《UNIX编程艺术》

UNIX编程艺术

埃瑞克·S·理曼德

65个笔记

◆ 1 哲学

> 花哨的算法比简单算法更容易出bug、更难实现。尽量使用简单的算法配合简单的数据结构。

> 数据压倒一切。如果已经选择了正确的数据结构并且把一切都组织得井井有条,正确的算法也就不言自明。编程的核心是数据结构,而不是算法[7]。

> 1.模块原则:使用简洁的接口拼合简单的部件。2.清晰原则:清晰胜于机巧。3.组合原则:设计时考虑拼接组合。4.分离原则:策略同机制分离,接口同引擎分离。5.简洁原则:设计要简洁,复杂度能低则低。6.吝啬原则:除非确无它法,不要编写庞大的程序。7.透明性原则:设计要可见,以便审查和调试。8.健壮原则:健壮源于透明与简洁。9.表示原则:把知识叠入数据以求逻辑质朴而健壮。10.通俗原则:接口设计避免标新立异。11.缄默原则:如果一个程序没什么好说的,就沉默。12.补救原则:出现异常时,马上退出并给出足够错误信息。13.经济原则:宁花机器一分,不花程序员一秒。14.生成原则:避免手工hack,尽量编写程序去生成程序。15.优化原则:雕琢前先要有原型,跑之前先学会走。16.多样原则:决不相信所谓“不二法门”的断言。17.扩展原则:设计着眼未来,未来总比预想来得快。

> 维护如此重要而成本如此高昂;在写程序时,要想到你不是写给执行代码的计算机看的,而是给人——将来阅读维护源码的人,包括你自己——看的。

> 所以,把策略同机制揉成一团有两个负面影响:一来会使策略变得死板,难以适应用户需求的改变,二来也意味着任何策略的改变都极有可能动摇机制。

> 单个进程的整体实现方式来说,这种双端设计方式大大降低了整体复杂度,bug有望减少,从而降低程序的寿命周期成本。

> 大”有两重含义:体积大,复杂程度高。程序大了,维护起来就困难。由于人们对花费了大量精力才做出来的东西难以割舍,结果导致在庞大的程序中把投资浪费在注定要失败或者并非最佳的方案上。

> ● 只要可行,一切都应该做成与来源和目标无关的过滤器。● 数据流应尽可能文本化(这样可以使用标准工具来查看和过滤)。● 数据库部署和应用协议应尽可能文本化(让人可以阅读和编辑)。● 复杂的前端(用户界面)和后端应该泾渭分明。● 如果可能,用C编写前,先用解释性语言搭建原型。● 当且仅当只用一门语言编程会提高程序复杂度时,混用语言编程才比单一语言编程来得好。● 宽收严发(对接收的东西要包容,对输出的东西要严格)。● 过滤时,不需要丢弃的信息决不丢。● 小就是美。在确保完成任务的基础上,程序功能尽可能少。

> 要良好地运用Unix哲学,你应该珍惜你的时间决不浪费。一旦某人已经解决了某个问题,就直接拿来利用,不要让骄傲或偏见拽住你又去重做一遍。永远不要蛮干;要多用巧劲,省下力气到需要的时候再用,好钢用在刀刃上。善用工具,尽可能将一切都自动化。软件设计和实现应该是一门充满快乐的艺术,一种高水平的游戏。如果这种态度对你来说听起来有些荒谬,或者令你隐约感到有些困窘,那么请停下来,想一想,问问自己是不是已经把什么给遗忘了。如果只是为了赚钱或是打发时间,你为什么要搞软件设计而不是别的什么呢?你肯定曾经也认为软件设计值得你付出激情……

> 要良好地运用Unix哲学,你需要具备(或者找回)这种态度。你需要用心。你需要去游戏。你需要乐于探索。

我们希望你能带着这种态度来阅读本书的其它部分。或者,至少,我们希望本书能帮助你重拾这种态度。

[1] 教外别传。禅宗用语。不依文字、语言,直悟佛陀所悟之境界,即称为教外别传。又称单传。故禅宗又作别传宗,系教外别传宗之略称。据《祖庭事苑卷五怀禅师前》录载,禅宗传法诸祖亦以三藏教乘接引弟子,至达摩祖师时,始单传心印,破执显宗,即所谓教外别传,不立文字,直指人心,见性成佛。后四句英文为“A special transmission independent of the scriptures.Not founded on words or letters.By directly pointing to the mind.One’s nature is seen,enlightenment is attained”.—译者注

[2] 事实上,以太网已经两次被不同的技术所取代,只是名字没有变。第一次是双绞线取代了同轴电缆,第二次是千兆以太网的出现。

[3] 对,就是创立“汉明距离”和“汉明码”的那位汉明(Hamming)。

[4] 注:X的架构者之一Jim Gettys(也为本书撰写了部分内容)鞭辟入里地揭示了X的自由放纵主义才使得它有如此成就。无论就其专门建议,还是对Unix理念的表述,这篇小文都极其值得一读。

[5] 别的操作系统通常已经复制或者沿袭了Unix TCP/IP的实现,但是很遗憾,他们没学到Unix实现代码幕后的同僚复审这个强力传统,看看RFC1025(TCP and IP Bake Off——“TCP/IP不同实现大比拼”)就知道我是什么意思了。

[6] 语出Stephen C.Johnson对IBM MVS TSO机制发的牢骚,此人是yacc的作者。

[7] Pike 的原稿在这里补充了“(参考 Brooks p.102.)”。引用是来自于 The Mythical Man-Month【Brooks】早期的版本;引语为“给我看流程图而不让我看(数据)表,我仍会茫然不解;如果给我看(数据)表,通常就不需要流程图了;数据表是够说明问题了。”

[8] Jonathan Postel 是第一个互联网 RFC 系列标准的编纂者,也是互联网的主要架构者之一。

◆ 2 历史——双流记

> Those who cannot remember the past are condemned to repeat it.

> 然而在六十年代晚期,分时系统还是个新鲜玩意儿。计算机科学家John McCarthy(Lisp语言的发明者[1])几乎是在十年前才首次发表了分时系统的构想,而直到Unix诞生前七年的1962年才第一次真正部署使用,因此当时的分时系统尚处实验阶段,像喜怒无常的野兽,性能极不稳定。

那个时代计算机硬件的原始程度,恐怕亲历者现在也很难以记清。那时最强大的机器所拥有的计算能力和内存还不如现在一个普通的手机。[2]视频显示终端才刚刚起步,六年以后才得到广泛应用。最早分时系统的标准交互设备就是 ASR-33 电传打字机——一个又慢又响的设备,只能在大卷的黄色纸张上打印大写字母。Unix命令简洁、少说多作的传统正是从ASR-33开始的。

当贝尔实验室(Bell Labs)从Multics研究联盟中退出时,Ken Thompson带着从Multics激发的灵感——如何创建一个文件系统——留了下来。

> Unix的整个发展进程中都能吸引那些不堪忍受其它操作系统局限性的程序员自愿为它进行开发,这也一直是Unix不断拓展其能力的模式。这种模式早在贝尔实验室时就已确立了。

Unix的轻装开发和方法上不拘一格的传统与生俱来。Multics是项庞大的工程,硬件开发出来前必须编写几千页的技术说明书,而第一份跑起来的Unix代码只是在三个人头脑风暴了一把,然后由Ken Thompson花了两天时间来实现罢了——还是在一台破烂机器上完成的,而那个机器本来只作为一台“真正”计算机的图形终端!

Unix的第一功,是1971年为贝尔实验室的专利部门进行“文字处理”的支持工作。首个Unix 应用程序是nroff(1)文本格式化程序的前身。这个项目也让他们名正言顺地购买了一台功能强大得多的PDP-11小型机。万幸的是,当时管理层还未意识到Thompson和其同事所编写的字处理系统就快孵化出一个操作系统。

> 外界的压力和纯粹出于对技艺的荣誉感,促使人们在有了更好更多的初步思路后,去重写或抛开已有的大量代码。从来没听说什么职业竞争和势力范围保护:好东西太多了,没有人需要把这些创新占为己有。”

> 这一点非常重要,不仅因为补丁要比整个文件小,更因为即使基础文件和补丁制作者拿到的版本之间变化很大,仍然可以很干净地应用补丁。运用这个工具,基于共有源码库的开发流可以分开、并行、最后合拢。patch程序比其它任何单一工具都更能促进Internet上的协作开发——这种方式在1990年后让Unix获得新生。

1985年,Intel的第一枚386芯片下线。它具有用平面地址空间寻址4G内存的能力。笨拙的8086和286的段寻址旋即废弃。这是条大新闻,因为这意味着占据主导地位的Intel家族终于有了一款无需作出痛苦妥协就能运行Unix的微处理器。对Sun公司和其它工作站厂商来说,这真是不祥之兆,可惜它们并未觉察到。

同样在1985年,Richard Stallman发表了GNU宣言(the GNU manifesto) [Stallman],并发起了自由软件基金会(Free Software Foundation)。

> 我们幡然醒悟:过去的IBM垄断让位于现在的微软垄断,而微软设计糟糕的软件像浊流一样,围着我们越涨越高。

> 2.1.4 反击帝国:1991—1995

1990年,William Jolitz把BSD移植到了386机器上,这是黑暗中的第一缕曙光。1991年起一系列杂志文章对此进行了报道。向386移植BSD的移植之所以可能,是由于伯克利黑客Keith Bostic一定程度上受Stallman影响,早在1988年他就开始努力从BSD码中清除AT&T专有代码。但是,Jolitz在1991年年底退出386-BSD项目,并毁掉了自己的成果,使该项目受到严重打击。对于此事的起因众说纷纭,不过公认的一点是Jolitz希望将其代码以源码形式无限制地发布,因此当项目的企业赞助商选择了更专有的授权模式时,他火了。

1991年8月,当时默默无闻的芬兰大学生Linus Torvalds宣布了Linux项目。据称Torvalds最主要的激励是学校里用的Sun Unix太贵了。Torvalds还说,要是早知道有BSD项目,他就会加入BSD组而不是自己做一个。但是386BSD直到1992年早些时候才下线,而此时Linux第一版已经发布好几个月了。

> 不回头看,人们无法发现这两个项目的重要性。那时,即使在Internet黑客文化内部也没有多少人关注它们,遑论更广大的Unix社区。当时Unix社区还在盯着比PC机性能更强大的机器,仍试图把Unix的特有品质与软件业的常规专有模式扯到一起。

又过了两年,经历了1993—1994年的互联网大爆炸,Linux和开源BSD的真正重要性才为整个Unix世界所了解。但不幸的是对BSD支持者来说,AT&T对BSDI(赞助Jolitz移植的创业公司)的诉讼消耗了大量时间,使一些关键的Berkeley开发者转向了Linux。

代码抄袭和窃取商业秘密的行为从未被证实。他们花了两年的时间也没找到确凿的侵权代码。要不是Novell从AT&T买下了USL、并达成协议,这场官司还会拖得更久。结果是从发布包中 18000 个组成文件中删掉了三个,对其它文件作了一些小修改。另外,伯克利大学也同意为约70个文件增加USL版权,但同时约定这些文件仍然可以自由重新分发。

> 协作式开发和源码共享是Unix程序员的法宝。然而,对于早期的ARRPNET黑客,这还不只是一种策略,它更像一种公众信仰,部分起源于“要么发表要么烂掉”的学术规则,并且(更极端地)几乎发展成为关于网络思想社区的夏尔丹式理想主义(Chardinist idealism)。这些黑客中最著名的Richard M.Stallman后来成了严守教义的苦行僧。

2.2.2 互联网大融合与自由软件运动:1981—1991

1983年后,随着BSD植入了TCP/IP,Unix文化和ARPANET文化开始融合。既然两种文化都由同一类人(实际上,就有少数几位很有影响的人同属两种文化阵营)构成,一旦沟通环节到位,两种文化的融合就水到渠成。

> 如果有足够多眼睛的关注,所有的bug都无处藏身”。

> Mozilla源码的公布使各方意见更为集中。1998年3月,为了深入研究共同目标和策略,召开了一次空前的社团重要领导人峰会,与会者几乎代表了所有的主要部落。这次会议为所有派系的共同开发方式确立了一个新标记——开源。

六个月之内,黑客社区中几乎所有部落都接受了用“开源”的新旗帜。IETF和BSD开发组这种老团体更是把他们从过去到现在所作的东西都追加上了这一标记。实际上,到2000年,黑客文化不仅让“开源”这个辞令统一了当前实践和未来计划,而且也对自己的历史重新有了鲜活的认识。

Netscape开放源码的宣告和Linux的新近崛起产生的激励效应远远超越了Unix社区和黑客文化。从1995年开始,所有阻拦在微软Windows滚滚巨轮前的各种平台(MacOS;Amiga;OS/2;DOS;CP/M;较弱小的专有Unix;各类大型机小型机和过时的微型机操作系统)

◆ 3 对比:Unix哲学同其他哲学的比较

> Contrasts:Comparing the Unix Philosophy with Others

If you have any trouble sounding condescending,find a Unix user to show you how it's done.

如果你不知道怎样表现得高人一等,找个Unix用户,让他做给你看。

Dilbert newsletter 3.0,1994

呆伯通讯3.0,1994年

—Scott Adams

操作系统的设计,在明显和微妙两方面,造就了该系统下软件开发的风格。本书大部分内容描绘了此两者之间的联系:Unix操作系统设计,以及由此发展出的编程设计哲学。为了便于对照,我们不妨把经典的Unix方式和其它主要操作系统的设计和编程习俗作一番比较。

> 文件属性会很有用,但是(我们在第20章将发现)在面向字节流工具和管道的世界中,它可能引发一些棘手的语义问题。对文件属性的操作系统级支持会诱导程序员使用不透明的文件格式,让他们依靠文件属性将文件格式同对应的解读程序绑在一起。

彻头彻尾的反Unix系统,应用一套拙劣的记录结构,任何特定的工具能否像文件编写者希望的那样读懂文件,完全是靠运气。加入文件属性,并让系统依赖于这些文件属性,就无法通过查看文件内的数据来确定文件的语义。

3.1.6 二进制文件格式

如果你的操作系统使用二进制文件格式存放关键数据(如用户帐号记录),应用程序采用可读文本格式的传统就很可能无法形成。我们将在第 5 章详细解释为什么这是一个问题。现在只要注意,这种做法可能会带来以下后果就够了。

> “Unix”框包括所有的专有Unix,包括AT&T版本和早期的伯克利版本。“Linux”框包括所有的开源Unix(均在1991年开始)。这些开源Unix与早期的Unix有渊源关系,它建立在1993年诉讼协议后从AT&T专有控制下解放出来的代码的基础上。[7]

3.2.1 VMS

VMS 是一个专有操作系统,最初由数字设备公司(Digital Equipment Corporation)为VAX小型机开发。VMS于1978年面世,是二十世纪八十年代和九十年代早期一个非常重要的产业化操作系统产品,无论在 Compaq 并购 DEC,还是 Hewlett-Packard 并购Compaq之后,这个系统一直得到了维护。直到2003年中期,这个产品仍在销售和支持,尽管今天已经没有多少人用它搞新的开发了[8]。在这里提出VMS,是为了对比Unix和来自小型机时代的其它CLI操作系统。

> [插图]

图3.1 分时系统历史示意图

VMS 具有完全抢占式多任务处理能力,但是进程生成的开销极为昂贵。VMS 文件系统有复杂的记录类型(但还不是记录属性)概念。这些特性造成了我们此前描述的后果,尤其(在VMS中)是程序庞大、个体臃肿的倾向。

> Mac程序员和Unix程序员在设计上往往走截然相反的路,即,他们的设计是从界面向内进行,而不是从引擎向外进行(我们将在 20 章讨论这种方式的影响)。MacOS 的一切设计共同促成了这种做法。

Macintosh的目标是作为是服务非技术终端用户的客户端操作系统,这就意味着用户对界面复杂度的容忍度很低。Macintosh文化下的开发者于是非常非常擅长设计简洁的界面。

假设你已经有 Macintosh 机器,那么晋级为开发者的代价一向不高。因此,尽管界面相当复杂,Mac 很早也形成了一种浓厚的玩家文化。开发小工具、共享软件和用户支持软件的传统一直非常盛行。

经典的 MacOS 已经寿终正寝。MacOS 大多数功能已被引入 MacOS X,并同源自Berkeley传统的Unix架构结合在一起[9]。同时,像Linux这样的前沿Unix也开始从MacOS中借鉴一些理念,如文件属性(资源分支的泛化)。

> NT操作系统的目标用户主要是非技术型最终用户,意味着对界面复杂度的容忍度非常低。NT既可作客户端又可作服务器。

在其历史早期,微软依靠第三方开发商提供应用软件。起初,微软还公布 Windows API 的完整文档,并保持其开发工具的低价格。但是,随着时间的推移、竞争者的相继倒下,微软转而青睐内部开发的战略,开始向外界隐藏 API,开发工具也越来越昂贵。早在Windows 95时期,微软就要求将保密协议作为购买专业级开发工具的一个条件。

围绕DOS 和Windows早期版本形成的玩家文化和轻松开发文化已经足够壮大,即使在微软日益加强的排挤(包括为了把业余开发者非法化而设立的各种认证计划)下也足以自我维系。共享软件从未消亡,而在2000 年后,迫于开源操作系统和 Java 的市场压力,微软的策略也略有转变。

> 自2000年以来,IBM以前所未有的力度在大型机上推广VM/CMS系统——作为能同时容纳成千上万虚拟Linux机的手段。

3.2.8 Linux

Linux由Linus Torvalds于1991年发明,是1990年后出现的新学派开源Unix阵营(也包括FreeBSD、NetBSD、OpenBSD和Darwin)的领头羊,代表了整个阵营的设计方向。Linux的技术趋势可视为整个阵营的典型。

Linux并不含任何来自原始Unix源码树的代码,但却是一个依照Unix标准设计、行为像Unix的操作系统。在本书的其余部分,我们重点强调的是Unix和Linux的延续性。无论从技术还是从关键开发者两个方面看,这种延续性都极其紧密——但此处,我们的重点是介绍Linux正在前进的几个方向,这些也正是Linux开始与“经典”Unix传统分道扬镳的标志。

> Linux社区的许多开发者和积极分子都有夺取足量桌面用户市场份额的雄心壮志。这就使Linux的目标受众比“旧学派”Unix广泛得多,后者主要瞄准服务器和技术型工作站市场。这一点影响了Linux黑客设计软件的方式。

最明显的变化就是首选界面风格的转变。最初,设计Unix是为了在电传打字机和低速打印终端上使用。Unix生涯的大多数时间被用在字符型视频显示终端上,没有图形和色彩能力。大多数Unix程序员仍然固执地坚持使用命令行,即使大型终端用户应用程序很早就已经移植到基于X的GUI中了。这种状况也一直体现在Unix操作系统及应用程序的设计中。

另一方面,Linux 的用户和开发者不断自我调整来消弭非技术用户对 CLI 的恐惧。他们比旧学派Unix、甚至同时代专有Unix更看重GUI及其工具的开发。其它开源Unix也在发生同样变化,变化虽小,但意义深远。

贴近终端用户的愿望使得Linux开发者比专有Unix更注重系统安装的平稳性和软件发布问题。

◆ 4 模块性:保持清晰,保持简洁

> 有一种很好的方式来验证API是否设计良好:如果试着用纯人类语言描述设计(不许摘录任何源代码),能否把事情说清楚?养成在编码前为API编写一段非正式书面描述的习惯,是一个非常好的办法

> 是不是因为缓存了某个计算或查找的中间结果而复制了数据?仔细考虑一下,这是不是一种过早优化;陈旧的缓存(以及保持缓存同步所必需的代码层)是滋生bug的温床,而且如果(实际经常是)缓存管理的开销比预想的要高,甚至可能降低整体性能[4]。

> 如果有大量重复的样板代码,是不是可以用单一的更高层表现形式生成这些代码、然后通过提供不同的细调选项生成不同个例呢?到此,读者应该能看出一个轮廓逐渐清晰的模式。

> 这不仅是为了追求风格上的异国情调,而是因为Unix的核心概念一向都有清瘦如禅般的简洁性,在围绕这些核心概念发生的历史事件中如影随形,熠熠生辉。这种特性也反映在Unix的基础性著作中,如《C程序设计语言》(C Programming Language)[Kernighan-Ritchiel]和向世人介绍Unix的1974年CACM论文。文中最常被人引用的一句话是这样的:“……限制不仅提倡了经济性,而且某种程度上提倡了设计的优雅”。要达到这种简洁性,尽量不要去想一种语言或操作系统最多能做多少事情,而是尽量去想这种语言或操作系统最少能做的事情——不是带着假想行动,而是从零开始(禅称为“初心”(beginner's mind)或者叫“虚心”(empty mind))。

要达到紧凑、正交的的设计,就从零开始。禅教导我们:依附导致痛苦;软件设计的经验教导我们:依附于被人忽略的假定将导致非正交、不紧凑的设计,项目不是失败就是成为维护的梦魇。

> 要达到紧凑、正交的的设计,就从零开始。禅教导我们:依附导致痛苦;软件设计的经验教导我们:依附于被人忽略的假定将导致非正交、不紧凑的设计,项目不是失败就是成为维护的梦魇。

> Unix程序员继承了一个居于系统程序设计核心的传统,在这一传统中,底层的原语是硬件层操作,后者特性固定且极其重要。因此,出于后天学得的本能,Unix程序员更倾向于自底向上的编程方式。

无论是否是系统程序员,当你用一种探索的方式编程,想尽量领会你还没有完全理解的软件、硬件抑或真实世界的现象时,自底向上法看起来也会更有吸引力。它给你时间和空间去细化含糊的规范,同时也迎合了程序员身上人类通有的懒惰天性——当必须丢弃和重建代码时,与之相比,如果用自顶向下的设计,需要抛弃的代码往往更多。

因此实际代码往往是自顶向下和自底向上的综合产物。同一个项目中经常同时兼有自顶向下的代码和自底向上的代码。这就导致了“胶合层”的出现。

4.3.2 胶合层

当自顶向下和自底向上发生冲突时,其结果往往是一团糟。

> 薄胶合层原则可以看作是分离原则的升华。策略(应用逻辑)应该与机制(域原语集)清晰地分离。如果有许多代码既不属于策略又不属于机制,就很有可能除了增加系统的整体复杂度之外,没有任何其它用

◆ 5 文本化:好协议产生好实践

> Textuality:Good Protocols Make Good Practice

It's a well-known fact that computing devices such as the abacus were invented thousands of years ago.But it's not well known that the first use of a common computer protocol occurred in the Old Testament.This,of course,was when Moses aborted the Egyptians'process with a control-sea.

众所周知,人类几千年前就已经发明了算盘之类的计算设备。但很少有人知道,人类第一次使用通用计算机协议是在《旧约》中——当时摩西用控制海中止了埃及人的进程。

rec.arts.comics,1992年2月

—Tom Galloway

我们将在本章分析Unix传统所教导的两种不同而又紧密相关的设计:设计将应用数据存储在永久存储器中的文件格式,和在协作程序中(可能会通过网络)

> 从目前的X框架来看,我们确实没有设计出足够好的结构,使得对协议微小的扩展仍会对它造成影响;当然,有时我们可以做到这一点,但如果有一个更好的框架会更好。

—Jim Gettys

当认为找到一种极端情况,有足够理由使用二进制文件格式或协议时,需仔细考虑扩展性,并在设计中为以后发展留出余地。

5.1.1 实例分析:Unix口令文件格式

在许多操作系统中,验证用户登录并开始用户会话所必需的用户数据都是不透明的二进制数据库。相反,在Unix中,这种数据是文本文件,采用一行一条、字段用冒号分隔的记录格式。

例5.1是几行随机选择的文件行:

例5.1 口令文件实例

[插图]

> [插图]

IMAP 对有效载荷部分的分隔方法略有不同,它不是用一个点号来结束,而是将有效载荷的长度直接放在有效载荷之前发送。这稍稍增加了服务器的负担(消息必须提前完成组合,无法在初始化后流转),但使客户端工作更容易了——客户端可以提前知道需要分配多少存储空间作为整个处理消息的缓冲区。

同时,应注意,每个响应都标上了由请求提供的序列标签,本例中这个标签的形式为A000n,但客户端也可以在这个位置上生成任何其它标记。

◆ 6 透明性:来点儿光

> 6.1 研究实例

本书的通常做法是将实例分析贯穿在基本原理中。但我们在本章首先将分析几个展示透明性和可显性的Unix设计,在介绍全部实例后才尝试从中总结经验。本章后半部分的每个分析点都要用到这些实例,这种编排方式会避免在论述中引用读者还没有见到的实例。

6.1.1 实例分析:audacity

首先,我们看一看UI设计中展示透明性的一个例子。这就是audacity,运行在Unix系统、Mac OS和Windows上开源的声音文件编辑器。源码、可下载的二进制文件、文档和屏幕截图可以在该项目的站点<http://audacity.sourceforge.net/>处获得。

> 我们可以从这个例子中学到些东西。教训是:不要让调试工具仅仅成为一种事后追加或者用过就束之高阁的东西。它们是通往代码的窗口:不要只在墙上凿出粗糙的洞,要修整这些洞并装上窗。如果打算让代码一直可被维护,就始终必须让光照进去

> 这个例子揭示的设计模式是驱动程序应该具备监控开关,这些监控开关仅仅(但足够了)揭示组件间的文本数据流。如同fetchmail的-v选项一样,这些选项也不是事后追加,而是为了可显性才设计进来的。

6.1.4 实例分析:kmail

kmail是随KDE环境一起发布的GUI邮件阅读程序。kmail的用户界面非常优雅,设计得很好,包括很多优良特性,如自动显示MIME multipart内嵌图像和支持PGP密钥加密/解密等。对最终用户相当友好——我心爱但不懂技术的妻子就喜欢使用它。

许多邮件用户代理只是向可显性做了个姿态,用一个命令来切换显示邮件头的所有信息,而不是显示From(发件人)和Subject(主题)等选择性信息。但kmail的用户界面在这个方向上走得更远。

> 这可能会使整个系统都无法使用。即使系统没有瘫痪,但如果破坏本身干扰了专用的注册表编辑工具,恢复工作就会很困难。

我们的Unix实例表明,为透明性设计可以防止这类问题的发生。因为terminfo数据库不是单一的大文件,修补一条terminfo记录不会使整个terminfo数据集都不能用。像termcap这样纯文本化的单一大文件格式的解析通常都使用(与二进制结构转储的块读取不同)可以从单点错误恢复的方法。SNG文件中的语法错误人工就可以解决,不需要专用编辑器,那些专用编辑器可能拒绝载入受损的PNG图像。

回到kmail案例,这个程序使得故障诊断更加容易是因为遵循了补救原则:SMTP错误信息太扰人,这就非常有用。根本不必解析 kmail 自身产生的混乱信息层来了解同SMTP服务器的交互情况。所需做的一切就是到该去的地方查看,因为kmail就是透明的,而且不抛弃错误状态的信息(SMTP 本身也是文本化的,并且在其事务处理中包括人可读的状态信息,这也很有用)。

◆ 7 多道程序设计:分离进程为独立的功能

> 总的来说,线程不是降低而是提高了全局复杂度,因此,除非万不得已,尽量避免使用线程。

> 本章剩余部分将大致按照编程技术复杂度由低到高的顺序介绍各种IPC技法。在使用更晚出现、更复杂的技法前,应该通过实证——用原型和基准检测结果——所有更早出现、更简单的技法都不管用。经常,你会把自己吓一跳。

7.2.1 把任务转给专门程序

廉价的进程生成使程序间的协作变为可能,其中最简单的形式就是一个程序调用另一个程序来完成专门任务。被调用的程序经常通过system(3)的调用被指定为一个Unix Shell 命令,因此这通常称做对被调用程序“shell out”(外壳执行)。被调用的程序在运行完毕之前接管用户的键盘和显示,退出后,调用程序重新控制键盘和显示并继续运行[4]。因为在被调用程序的执行过程中,调用程序并不与之通讯,所以协议设计在这种协作类型中并不成问题,除非从调用程序可能向被调用程序传递命令行参数以改变其行为这层微不足道的意义上来说。

> 经典的Unix shellout实例是在邮件或新闻组程序中调用文本编辑器。Unix的传统中,不要求使用常规文本编辑输入的程序捆绑专门用途的编辑器。相反,允许用户在需要编辑时指定自选的文本编辑器以供调用。

专门程序通常借由文件系统与父进程进行通信,方法是在指定位置读取或修改文件;编辑器或邮件器的shellout就是这样工作的。

这种模式的常见变形是专门程序可以接受标准输入,用C库的popen(…,"w")或作为shell脚本的一部分进行调用。或者专门程序也可以有标准输出,用popen(…,"r")或作为shell脚本的部分加以调用(如果它既从标准输入读入又向标准输出写出,则可以通过批量方式完成,即完成全部读操作之后才进行写操作)。通常不把这种子进程称为shellout;目前还没有一个标准术语称呼这个子进程,但完全可以称其为“bolt-on”(栓合)。

> 管道操作把一个程序的标准输出连接到另一个程序的标准输入。用这种方式连接起来的一系列程序被称为管线。如果我们键入:

ls|wc

我们可以看到当前目录列表的字符数/字数/行数(在这种情况下,只有行数有真正意义)。

一个讨人喜欢的管道线是"bc|speak"——一个能说话的桌面计算器。这个计算器知道1×1063以下数字的叫法。

—Doug McIlroy

管道线中所有阶段的程序是并发运行的,注意到这一点很重要。每一段等待前一段的输出作为输入,但在下一段能够运行前没有哪个段必须退出。这个特性在我们接下来分析管道系统的交互作用时非常重要,例如把某个命令的超长输出发送给more(1)。

人们很容易低估管道和重定向的组合能力。

> 管线有很多用处。举一个例子来说,Unix 的进程列举程序 ps(1)在标准输出上列举进程时,并不关心长列表在用户的滚屏速度太快会造成用户无法看清。Unix还有另外一个程序 more(1),它按屏幕大小显示标准输入,每次满屏显示后等待用户按键显示下一满屏。

这样,如果用户键入“ps|more”,把ps(1)的输出管道连接至more(1)的输入,则每次按键后可以继续显示一整屏的进程列表。

如此组合程序的能力极为有用。但此处真正的成果并不是这个漂亮的组合,而是由于管道和more(1)两者的存在,其它程序可以变得更简单一些。管道意味着ls(1)(以及其它写标准输出的程序)之类的程序无需开发自己的分页程序——我们得以从一个到处都是内置分页程序(自然,每个分页程序都有不同的观感)的世界中解救出来。这就避免了代码的臃肿,降低了全局复杂度。

> 一个额外好处是,如果需要定制分页程序行为,可以只在一个地方、改变一个程序就行了。确实,可以存在多个分页程序,并且这些分页程序对每一个写标准输出的应用程序都非常有用。

实际上,这确实已经发生了。现代Unix中,more(1)基本上已被less(1)取代。less(1)对显示的文件增加了向后滚屏的能力,不再只能向前滚屏[6]。因为less(1)独立于使用它的程序,所以只需在shell中简单地将“less”指定“more”的alias,把环境变量 PAGER(分页器)设置成“less”(参考第 10 章),然后就可以在所有正确编写的Unix程序中享受到更好分页程序所带来的全部好处了。

7.2.2.2 实例分析:制作单词表

一个更加有趣的例子是通过管道相连的程序来协作完成某种数据变换。在没有这么灵活的环境中,要实现这点就必须定制代码。

> 考虑以下管线:

[插图]

第一个命令把标准输入中非字母和数字的字符在标准输出上转换为新行。第二个命令对标准输入的行进行排序,对于所有重复的相邻行只保留一个,然后把排好序的数据写到标准输出。第三个命令去掉所有只含数字的行。合起来,这些操作把标准输入的文本生成了经过排序的单词表送到标准输出。

7.2.2.3 实例分析:pic2graph

程序pic2graph的shell源码和自由软件基金会的groff文本格式化工具套件一起发布。它把用PIC语言编制的图表转换为位图图像。例7.1展示了这个代码核心部分的管线。

[插图]

pic2graph(1)实现展示了仅仅依靠调用现有工具的管线能够完成多少任务。管线首先把其输入揉制成适当的形式,然后将其传入groff(1)

> 以生成PostScript,最后从PostScript转换成位图。它对用户隐藏了所有细节,用户只看到PIC源从一端进入,另一端产生了可包含在网页中的位图。

这是一个有趣的例子,因为它说明了管道和过滤器怎样使程序适应非预期的用法。解释PIC的程序pic(1),最初只是为了在排版文档中嵌入图表设计的。在包含pic(1)程序的一套工具中,绝大多数程序都行将过时。但是PIC在新用法中仍然十分便利,比如描述内嵌在HTML中的图表等。把pic(1)的输出转换为更现代格式所需要的全部机制,可以由pic2graph(1)这类工具捆绑在一起,所以它也获得了新生。

作为微型语言的例子,我们将在第8章进一步分析pic(1)。

7.2.2.4 实例分析:bc(1)和dc(1)

开始于版本7的经典Unix工具包包括一对计算器程序:dc(1)程序是一个简单的计算器程序,从标准输入端接受逆波兰标记法(RPN)的文本行,并向标准输出发送计算结果;bc(1)

> 大多数专用化包装器都相当简单,但非常有用。

和shellout一样,由于被调用程序执行过程中程序之间并不通信,因此也不存在相关协议;但是,包装器之所以存在,常常源于要指定参数来修改被调用程序的行为。

7.2.3.1 实例分析:备份脚本

专用化包装器是Unix shell和其它脚本语言的经典用途。既常用又典型的一类特化包装器是备份脚本,它可能简单到只有这样的一行:

[插图]

这是一个为 tar(1)磁带归档程序编写的包装器,这个包装器只简单地提供一个固定参数(磁带设备/dev/st0),并把用户提供的其它参数("$@")[7]传递给tar。

7.2.4 安全性包装器和Bernstein链

包装器脚本的常见用法是安全性包装器。安全性包装器可调用守门程序检查某类凭证,然后根据返回的状态值有条件地执行另一个程序。

> Bernstein 链(Bernstein chaining)是一个专用化的安全性包装器技法,由 Daniel J.Bernstein首先发明。Bernstein在他的许多程序包中都使用了安全包装器(在nohup(1)和su(1)之类命令中使用类似的手法,但是无法检查条件值)。从概念上来说,Bernstein链和管线类似,只不过每个继发阶段的程序取代了前一阶段的程序,而不是与之并行。

通常的应用是把较高安全级别的应用程序限制在某类门卫程序中,由这个程序把状态传递给一个权限较低的程序。这种技法使用一组exec,或者综合fork和exec把几个程序粘在一起。所有程序名都在一个命令行中得到指定。每个程序执行某个功能,(如果成功的话)用命令行剩余部分调用exec(2)。

Bernstein的rblsmtpd包就是一个原型例子。它用于在邮件滥用防御系统(MailAbuse Prevention System)

> 的垃圾邮件 DNS 域中查询主机。方法是通过载入环境变量“TCPREMOTEIP”的值取得IP地址,然后对其进行DNS查询。如果查询成功,rblsmtpd就运行自己的SMTP以丢弃这个邮件。如果不成功,它就假定剩余的命令行参数构成一个知道SMTP协议的邮件传输代理,交给exec(2)来运行。

Bernstein的qmail包是另外一个例子。qmail包含有condredirect程序。第一个参数是个邮件地址,剩余部分是门卫程序及其参数。condredirect首先fork,然后对门卫程序带参数进行exec。如果门卫程序成功退出,condredirect就把在标准输入端(stdin)的待处理邮件发送到指定的邮件地址。在本例子中,与rblsmtpd相反,安全策略由子进程决定。这种情况更像经典的shellout。

更精巧的例子是 qmail 的 POP3 服务器。它由三个程序构成,即 qmail-popup、checkpassword 和 qmail-pop3d。

> 7.2.5.1 实例分析:scp和ssh

两者间通信协议的确无足轻重的一个常见情况是进度显示程序。scp(1)安全拷贝命令把 ssh(1)作为从进程调用,从 ssh 的标准输出中截取足够的信息,然后把报告重新组织成为ASCII动画形式的进度条。[9]

7.2.6 对等进程间通信

迄今我们已经讨论的各种通讯方法都存在隐含的层次关系,即一个程序实际上控制或驱动另一个程序,而在反方向却没有或仅有有限的反馈。在通信和网络中,我们常常需要对等的通道,通常(但不一定)需要数据能自由地双向流动。接下来,我们将分析Unix中的对等通信方法,并在以后几章中逐步展开一些实例分析。

7.2.6.1 临时文件

把临时文件作为协作程序之间的通信中转站使用,是最古老的IPC技法。

> 举例来说,SIGHUP信号在会话结束时被发送给每一个从该指定终端会话启动的程序。SIGINT 信号,是在用户键入当前定义的中断字符(通常是control-C)时发送给当前每一个连接键盘的程序。然而,信号对一些IPC情形也很有用(而POSIX标准信号集就是为了这个使用目的才包含SIGUSR1和SIGUSR2两个信号的)。它们经常用作守护程序(在后台不间断运行而且不可见的程序)的控制通道,操作者或另一个程序用来通知守护程序重新初始化自身,或醒来执行工作,或向已知位置写入内部状态/调试信息的方法。

我坚持认为SIGUSR1和SIGUSR2是为BSD发明的。人们抓走了一些系统信号,使之成为他们所希望的IPC含义。这样一来,(例如),由于SIGSEGV已被挪用,所以出现分段错误的程序就不会进行核心转储(coredump)了。

这是一个普遍原则——人们总想挪用你写的任何工具,所以必须把它们设计成要么根本无法挪用要么总是可以干净地挪用。

> —Ken Arnold

在创建套接字的时候,可以指定协议族来告诉网络层如何解释套接字的名称。人们通常认为套接字和互联网有关,是一种在不同主机上运行的程序之间传递数据的方法,这是AF_INET套接字族。在这个套接字族中,地址被解释为主机地址和服务编号对。然而,AF_UNIX(也称为AF_LOCAL)协议族支持同样的套接字抽象,作为在同一台机器上(名字被解析为特殊文件的位置,与双向命名管道类似)两个进程之间的通信方式。举个例子,使用X window系统的客户端程序和服务器程序通常就使用AF_LOCAL套接字进行通信。

所有现代Unix都支持BSD风格的套接字,一个设计事实是,无论协作进程在何处安置,这些套接字通常都是用于双向IPC的正确方法。性能压力可能会促使你使用共享内存、临时文件或其它要求更多局部性条件的技法,但是在现代的情况下,最好设想代码需要增加分布式操作。

> 在所有已打开状态的文件句柄都关闭之前,这个文件不会被删除,但是一些老的Unix版本把连接计数降到零,作为可以停止更新文件在磁盘数据的提示。不利的方面是,存储回填用的是文件系统而不是交换设备,被删除文件所在的文件系统在使用该文件的程序关闭之前不能卸摘,而且把新进程附到(attach)已存在的、按这种方式模拟的共享内存段中极为复杂。

在版本7以及BSD和System V系列分开后,Unix的进程间通信朝两个不同的方向发展。BSD方向产生了套接字;另一方面,AT&T系列发展了命名管道(见前面讨论)和一种基于共享内存双向信息队列、为传输二进制数据而专门设计的IPC功能。这被称为“System V IPC”——或者,在那些老前辈当中,被称为“Indian Hill”IPC,以AT&T的实验室命名,这是其最初编写的地方。

System V IPC传递消息的上层已经逐渐被废弃了。而由共享内存和信号量构成的底层,在同一台机器上运行的进程间需要完成互斥锁定和全局数据共享的情况下,仍旧具有非常重要的应用。

> 这些System V的共享内存功能发展成了POSIX的共享内存API,Linux、BSD、MacOS X和Windows都支持,但是经典的MacOS不支持。

使用这些共享内存和信号量功能(shmget(2)、semget(2)及其类似机能)可以避免通过网络栈复制数据的开销。大型商业数据库(包括Oracle、DB2、Sybase和Informix)大量使用这种技术。

7.3 要避免的问题和方法

尽管基于TCP/IP的BSD风格套接字已经成为主流的Unix IPC方法,但是人们对于如何通过多道程序达到正确的划分仍然争论不休。一些过时的方法还没有完全消亡,而一些从其它操作系统中移植过来的(通常与图形或GUI编程有关)技术值得怀疑。下面我们将在危险的沼泽中游历:小心鳄鱼。

7.3.1 废弃的Unix IPC方法

> 7.3.3 线程——恐吓或威胁

尽管Unix开发者早就已经习惯于通过多个协作进程进行计算,他们仍然没有使用线程(共享整个地址空间的进程)的自发传统。线程最近才从其它地方移植过来,而Unix程序员通常不喜欢线程这件事,决不仅仅只是意外或历史的偶然。

从复杂度控制的角度来看,相对拥有独立地址空间的轻量级进程,线程是个糟糕的替代;线程是那些进程生成昂贵、IPC功能薄弱的操作系统所特有的概念。

从定义上看,尽管进程的子线程通常具有独立的局部变量栈,它们却共享同一全局内存。在这个共享地址空间管理竞争和临界区的任务相当困难,而且成为增加整体复杂度和滋生 bug 的温床。可以这样去做,但是随着锁定机制复杂度的增加,意外交互作用所造成的竞争和死锁机会也相应增加。

线程成为滋生 bug 温床源于它们太容易知道过多彼此的内部状态。

> X server的执行速度能够达到数百万次/秒(ops/second),但不是基于线程实现的;它使用poll/select循环。创造多线程实现的种种努力都没有产生好结果。对于图形服务器这种对性能敏感的程序来说,锁定和解锁操作的成本太高了。

—Jim Gettys

这个问题是根本性的,也一直是Unix内核的对称多处理设计的长期问题。由于资源锁定变得越来越琐细,锁定导致的延迟也迅速增加,足以超过只锁定更少的核心内存所获得的收益。

线程的最终难题在于,直到2003年年中,线程的标准仍然很薄弱而且规范不够明确。Unix 标准在理论上已经一致的程序库,如 POSIX 线程(1003.1c),仍然在不同的平台展示了惊人的行为差异,尤其是在信号、同其它IPC方法的交互和资源清理时间方面。Windows和传统MacOS自带的线程模型和中断功能与Unix差别非常大,即使很简单的线程程序也常常需要相当大的精力才能移植。

> 结果是根本不要指望线程程序可移植。

更多的讨论以及和事件驱动编程的鲜明对照请参考《为什么线程是个馊主意》(Why Threads Are a Bad Idea)[Ousterhout96]。

7.4 在设计层次上的进程划分

现在万事俱备,我们应该怎么办呢?

第一个要注意的是,临时文件、交互性更强的主/从进程关系、套接字、RPC和其它一些双向IPC方法在某种程度上是等价的——它们都只不过是程序在生命期内交换数据的方法。我们通过使用套接字或共享内存这种复杂的方法所完成的任务,大多数都可以通过使用临时文件作为信箱和通知信号这种简单的方法来完成。差别很小,主要体现在程序如何建立通信、何时何地完成信息的列集和散集、可能产生何种缓冲问题,以及如何保证获取信息的原子性(也就是说,在何种程度上可以知道来自一边的单个发送行为会在另一边成为单个的接收事件)。

> 我们已经从PostgreSQL实例中看到,降低复杂度的有效方法是把程序划分成客户端/服务器对。PostgreSQL的客户端和服务器的通信通过基于套接字的应用协议来实现,但是既便使用其它双向IPC方法,设计模式也并不会有多大改变。

在应用程序的多个实例必须管理共享资源访问的情况下,这种划分特别有效。一个简单的服务器进程可以管理所有的资源争用,或者协作的每个对等程序端都可以掌管某个关键资源。

客户端/服务器划分也有助于在多个主机上分布高时效要求的(cycle-hungry)应用程序。或者可以使它们适应互联网的分布计算(如 Freeciv)。我们将在第 11 章讨论相关的CLI服务器模式。

由于所有这些对等进程间的IPC技法在某种程度上都很像,所以我们应该主要评估它们引起的程序复杂度开销,以及给设计造成的不透明度。

> 归根结底,这就是为什么BSD套接字会在Unix IPC中胜出,以及为什么RPC根本无法获得更多支持的原因。

线程有着根本性不同。线程支持的并非不同程序之间的通讯,而是单个程序的一个实例内的某种分时形式。线程并不是把大程序分解成行为简单的小程序的方法,实际上是一种性能调整(performance hack)问题。但线程不仅具有此类问题的通病,而且还有自身的特殊症结。

因此,当我们寻找方法,把大程序分解成更简单的协作进程时,在进程内使用线程应该是最后一招而不是第一招。通常,你可以发现避免使用线程是可能的。如果能够使用有限的共享内存和信号量、使用SIGIO的异步I/O,或poll(2)/select(2),而不是使用线程,就这样做吧,保持简洁,在本章所列的技法中优先使用位置更前、复杂度更低的方法。

把线程、远程过程调用接口和重量级的面向对象设计结合使用特别危险。如果使用地非常谨慎和优雅,这些技术中的任何一个技术可能都非常有价值——但是如果你被邀请加入要使用这三者的项目,逃之夭夭并不丢面子。

◆ 点评

推荐
希望有一天能向linux kernel提交patch.

打赏作者