UNIX 的基本系统界面隐藏了下层存储设备的细节,比如说块、磁道、柱面。同样,UNIX 系统内完全没有“record”的概念,其上的标准软件中也几乎没有这个概念。(“record”指一种有明确边界、可独立辨识的数据单元:其长度可以固定,也可以由长度计数和紧随其后的相应数量的字节组成。)比如说,文本文件是以字符序列的形式存储的,用换行符来分隔文本行。以这种方式组织存储不仅比定长 record 更节省空间——甚至比起携带长度数据的变长 record 也是这样,而且,对于绝大多数文本处理程序,这也是更方便使用的数据格式,因为它们的逻辑几乎全都是处理字符流的。然而,这样做更重要的益处是,文本文件只有一种表示形式。UNIX 最宝贵的特点之一是其上不同程序的组合能力,我们能通过这样的组合产生很多有用的用法。如果同样一段信息有多种表示方式,那么我们组合程序的能力就会大打折扣。
我们以看乐子的心态来回想这样一个系统:其 FORTRAN 编译器要求输入文件必须由恰好为 80 字节长的“变长” record 组成(译者注:草)。这种神必情况在现实中还不少,所以,在遇到一个新系统的时候,我们可以做以下有趣的软件灵活性测试(来自 M. D. McIlroy):写一个 FORTRAN(或者 PL/I,或者其他语言)的程序,让它将自己的源代码复制到一个新文件;执行这个程序后,尝试编译被拷出来的文件。绝大多数系统最终能通过这个测试,但是一般都得喊个专家来念一堆咒,将 FORTRAN 程序生成的数据文件转换成 FORTRAN 编译器接受的文件格式(译者注:即使它们都是文本!)。总之,在我们眼中,如果一个系统要求我们在处理每一个文件的时候,都明确区分 114514 种不同的存储方式,那真是这辈子有了。
译者注:现代的读者可能很难体会到这一段的笑点,问就是吃水忘了挖井人。
所谓 record 概念甚至早于电子计算机。早在 1890 年那次著名的美国人口普查中,它已经活在机电机械计算机所使用的打孔卡上了:每个人的信息形成一条原始的结构化记录,被打进一张穿孔卡;一摞卡片就是一个数据集,或者,一个“文件(file)”。“文件由一条条独立的数据记录组成”这套观念,在这个时代就已经根深蒂固了。这套技术和业务,经过一系列合并和重组之后,后来有了一个响亮的名字:IBM。它所使用的 80 列穿孔卡片也在接下来的 30 年间成为了行业标准。
在磁带作为存储设备取代卡片之后,这种 record 的概念并没有消失,反而同时得到了存储介质特性和业务需求的双重强化:磁带本身通过物理间隔划分数据,记录可以自然地采取变长形式;业务需求方面,工资、库存、账务以及科学计算等批处理程序是当时计算机业务的绝对主流,它们也大都是在读取、排序、归并和生成记录。到了磁盘和大型机时代,记录又成为了随机访问、按键检索、文件组织和数据库管理的基本单位。在本文撰写的年代,record 已经成为了许多计算机系统习以为常的基本机制。record 的支持者认为,record,特别是变长 record,可以视作一种有益而且常见的高层结构,而非以前硬件限制的简单延续。
后来 80 年代中期开始开发的更灵活更复杂的 Plan 9 家族也继承了这套 walk 的机制:Plan 9 不仅有传统挂载和链接,还有合并挂载、原生的远程挂载等一大堆极为灵活的机制,所有 walk 就显得更不可或缺了。然而,随着计算机技术的继续革新,这样的前提背景被不短蚕食。比如,在 00 年代之后,作为一个可能上公网的远程文件系统协议,9p 里的这一大堆 walk 就显得有些啰唆了。在某种意义上,Plan 9 可以视为现代使用 URI 来集中资源的 Web 计算环境的祖先;然而在路径的处理上,Web 采取了完全不同风格的做法:采用扁平的路径空间,将路径不透明地视作键值对的键。这点在对象存储和传统 UNIX/POSIX 文件系统存储的接口区别上,体现得很明显。
虽然 UNIX 程序惜字如金的输入输出算是一种设计风格,它也和程序之间的组合机制有关。我们可以用以下的简单例子展示这一点。命令 who 用于打印所有当前登录的用户的信息,每行一个人,输出用户名、终端名、登录时间。命令 wc(得名自“word count”)输出其输入的行数、单词数、字符数。于是,命令
who | wc
输出的行数字段能告诉我们目前有多少用户在线。如果 who 输出了多余的废话,那么这个计数就会有偏差。更糟糕的情况是,如果 wc 非得交互式从输入来确定到底是输出行数、单词数还是字符数,那它根本拼不进管线里。当然,不是所有输出表格的程序都得忽略表头。不过,我们有充分理由把“多余的废话”的范围理解得更宽泛一些(译者注:这里的意思是,大胆地扔掉非必要的程序输出,让输出的文本结构简单,易于组合)。
译者注:现代的 UNIX-C 环境已经比这复杂得多了。此处描述的早期进程内存布局,除只读、可共享的程序正文外,内核只向进程提供两种可写存储机制:自动栈存储,以及可显式扩容的连续数据区。前者就是一个可由程序操作的栈,汇编程序员可以借助栈指针和相关寻址方式实现压入、取出数据,再配合各类跳转指令,按约定手工组织子程序调用、局部数据的保存和恢复、结果返回等过程;后者并非现代 C 意义下的“静态变量区”或者“堆”,而是由程序自行布局、使用的连续可写内存。如果使用了 C 语言,那么编译器和链接器会自动将全局、静态对象安置其中;C 运行库又可以在数据区顶端通过 brk 系统调用获取更多空间,并在用户态将其切分、复用,这才逐渐形成我们熟悉的堆内存分配器(malloc 什么的)。
顺带一提,UNIX 和 C 的早期接口和实现是共同演化的,故而许多 UNIX-C 习惯看似通用,实则带有很深的 PDP-11 与 C 运行环境的痕迹。由此还产生了另一层常见误解:把 C 当成对机器透明、无损的描述。(译者暴论:C 不能被视为完全自包含的底层语言,某种意义上,UNIX 正可以被视为 C 的运行时!)到了今天,这样的误解深入人心,负面影响深远,感兴趣的读者可以阅读这篇文章。
另外,这里提到“控制块”是在和 IBM 风格的批处理大型机系统做对比。这类系统一般有一整套结构化的控制块,描述作业、任务、地址空间、文件、record、I/O 请求和设备状态,以组织资源分配、调度、I/O。进程需要按照约定好的方式,与这套复杂的控制结构体系交互。UNIX 则尽量把这些信息隐藏进内核,给进程一个极简的地址空间,不让进程直接操作,而是通过少数几个清晰的系统调用来访问这些资源。可想而知,这能给程序开发体验带来多大的提升。
译者注:从现代视角来看,这些机制无疑是有点简陋的,特别是返回状态码和信号,因为表达能力不足,后来不出意料地被大家添油加醋变成了一坨矢山。Plan 9 在设计的时候对这些机制做了史诗级加强,返回状态变成了可组合的错误链字符串(有写过 Go 的人 get 到没有?open /n/project/out: walk project: mount /mnt: attach: auth: dial authsrv: tcp: connection refused,详见……)
目前 UNIX 没有通用的进程间通信或者同步的机制。这是 UNIX 的一个短处,但是对于目前它的使用场景,我们觉得这没那么重要(当然,如后文所述,在另一些用途里,这个问题就十分重要了)。一些并发原语可以用 UNIX 的现有机制组合出来:比如说,为了实现信号量,可以通过创建、删除一个已知位置的文件来表达 P 和 V 操作的语义。把信号量实现成原语自然会比这么做更高效,但是我们的取舍是,既然它可以用既有机制组合出来,我们便不必引入新的内核原语。只有在这样做会引入严重的性能问题时,我们才会考虑将这个界面做得更复杂。
译者注:在之后数十年各种 UNIX 的大乱斗中,因为大家对 IPC 的需求高涨,这同样成为了巴别塔级别的矢山。这同样在续作 Plan 9 中得到了改正:Plan 9 提供了一套优雅的机制来支持异步 IPC 和灵活的进程间同步。现在这套机制的子孙活在 Go channel 里。
译者注:实际上,在 10 年后计算环境逐渐从单机向分布式系统发展时,超级用户带来的问题便无法被像上文一样容忍:一台主机上的超级用户,在另一台主机上,到底算什么用户?UNIX 的后继者 Plan 9 因而取消了权利无穷的全局超级用户概念,只为每台机器保留一个“主机所有者”。它有权管理本机内核所控制的进程、设备、网络接口等资源,但是在由众多机器与服务组成的 Plan 9 计算域中,它仍只是一个普通的身份;命名空间访问的认证由统一机制管理,授权仍由相应服务独立掌握。感兴趣的读者可以参阅这篇文章。
熟悉 Kubernetes 的读者可能会嗅到一丝熟悉的味道。
这个权限系统中一个不同寻常的地方是“设置用户 ID”位(后称 setuid 位)。当一个文件被设置了这个位,然后这个文件被作为程序执行,那么该程序用于文件权限检查的用户 ID 会变成文件所有者的 ID,而不是执行者本人的。在实践中,这样的标志位一般用在需要执行特权的程序上(比如创建目录、改变文件所有者等等。)
UNIX 及其绝大部分软件都是用 C 语言编写的 [10]。文献 [11] 介绍了这门语言。因为在 C 被发明前,UNIX 最早是用汇编写的,所以在使用高级语言编写操作系统这件事上,我们比较有发言权。简单来说,好处很多,代价相比起来可以忽略。具体的效果很难被量化,因为我们不用代码行数来衡量生产力。不过,很能说明问题的是,UNIX 平台上的确有不少有趣的软件,从语法分析器生成器(译者注:还是我,Y🎵A🎶CC~),到数学公式排版套件,很难想象如果我们还在倒腾汇编的话,能整出这么多好活。我们许多最具创造力的贡献者既不熟悉、也不愿学习特定机器的指令集。
在我们有了 C 语言之后,用 C 重写过的程序远比早先的汇编版本更易理解、修复、扩展。对于操作系统内核本身,这点尤为明显。最早的汇编版本的系统非常难以修改,特别是在需要添加新设备支持的时候;有时甚至做点小改动都比较困难。C 重写的版本则相对而言极易修改:不仅我们这么认为,不止一个用上 UNIX 的大学已经开始自己魔改了,比如彻底重写终端设备驱动,以满足自己的偏好。(颇为矛盾的是,易于修改这一优点也带来了一些麻烦:魔改版本层出不穷……整个生态乱糟糟的。)
和得到的益处比较,使用高级语言的代价几乎是可以忽略的。当然,编译器生成的目标程序会比匠心手搓的汇编程序大一点。平均到底大了多少比较难以估计,因为在我们重写的过程中,我们总是忍不住顺手改进原来的设计。一个典型的估计是,重写成 C 程序之后,程序的大小膨胀了 20% 至 40%。程序运行速度的下降幅度大致相当,但是有时也会更大。主要原因是,在 C 中,子程序调用的开销往往比汇编程序要高——其他高级语言也是如此。然而,现在软件工程界逐渐形成的共识是,一个软件中,大部分运行时间都是很少一部分代码所消耗的(译者注:八二原则,启动!),我们的实操经验也符合这个共识。为了让常用程序达到可接受的性能,我们整了一个性能剖析器,将程序员的关注点引导到程序中最值得优化的地方。
上述关于时空膨胀的估计并非来自一项完整的系统研究。虽然这样的研究会比较有趣,但是我们认为这并不重要,因为无论研究结果怎么样,尝到了高级语言细糠的我们肯定不愿回去苦哈哈地搓汇编了。现在,操作系统内核和它上面运行的主要程序的性能都够用了。当然,这并不是说继续提升 C 编译器的代码生成效率已经没有意义了。这确实意味着,如今我们已经开始认为:操作系统本身,外加各种“系统程序”,诸如编辑器、编译器还有其他基本工具,同样适合用高级语言表达,正如FORTRAN 之于数值计算、COBOL 之于商业计算一样。
在我们讨论使用 C 的代价的时候,把程序编译出来这件事本身的开销也需要纳入考虑。这一点我们也认为可以接受。比方说,在我们的 PDP-11/70 型小型机上,将整个操作系统编译和链接起来(sysgen)需要九分钟出头(其中七分钟是 CPU 时间);整个代码库包含 12500 行 C 代码,折算下来从源代码到可执行文件,吞吐量平均 22 行每秒。实际上,编译器的吞吐量比我们这样简单计算出来的还要高,因为我们广泛使用了 include 源码预处理机制,所以经过它泡发了的源码规模达到了 38000 行左右,编译器吞吐量约 65 行每秒。
[1] P. A. Crisman, 编, 《Compatible Time-Sharing System: A Programmers' Guide》. MIT Press, Cambridge, MA, USA, 1965年8月15日.
[2] B. W. Kernighan, M. E. Lesk, 和 J. F. Ossanna, 《UNIX time-sharing system: Document preparation》, The Bell System Technical Journal, 卷 57, 期 6, 页 2115~2135, 7月 1978, doi: 10.1002/j.1538-7305.1978.tb02145.x.
[3] D. M. Ritchie 和 K. Thompson, 《The UNIX time-sharing system》, Communications of the ACM, 卷 17, 期 7, 页 365~375, 7月 1974, doi: 10.1145/361011.361061.
[4] K. Thompson, 《UNIX time-sharing system: UNIX implementation》, The Bell System Technical Journal, 卷 57, 期 6, 页 1931~1946, 7月 1978, doi: 10.1002/j.1538-7305.1978.tb02137.x.
[5] T. A. Dolotta 和 J. R. Mashey, 《An introduction to the Programmer's Workbench》, 收入 Proceedings of the 2nd international conference on Software engineering, 收入 ICSE '76. Washington, DC, USA: IEEE Computer Society Press, 10月 1976, 页 164~168. 见于: 2026年8月30日. [在线].载于: https://dl.acm.org/doi/10.5555/800253.807669
[6] T. A. Dolotta, R. C. Haight, 和 J. R. Mashey, 《UNIX time-sharing system: The programmer's workbench》, The Bell System Technical Journal, 卷 57, 期 6, 页 2177~2200, 7月 1978, doi: 10.1002/j.1538-7305.1978.tb02148.x.
[7] R. J. Feiertag 和 E. I. Organick, 《The Multics Input/Output system》, 收入 Proceedings of the third ACM symposium on Operating systems principles, 收入 SOSP '71. New York, NY, USA: Association for Computing Machinery, 10月 1971, 页 35~41. doi: 10.1145/800212.806497.
[8] M. Stonebraker, G. Held, E. Wong, 和 P. Kreps, 《The design and implementation of INGRES》, ACM Transactions on Database Systems (TODS), 卷 1, 期 3, 页 189~222, 9月 1976, doi: 10.1145/320473.320476.
[9] S. R. Bourne, 《Unix time-sharing system: the unix shell》, The Bell System Technical Journal, 卷 57, 期 6, 页 1971~1990, 7月 1978, doi: 10.1002/j.1538-7305.1978.tb02139.x.
[10] B. W. Kernighan 和 D. M. Ritchie, The C programming language. USA: Prentice-Hall, Inc., 1978.
[11] D. M. Ritchie, S. C. Johnson, M. E. Lesk, 和 B. W. Kernighan, 《UNIX time-sharing system: The C programming language》, The Bell System Technical Journal, 卷 57, 期 6, 页 1991~2019, 7月 1978, doi: 10.1002/j.1538-7305.1978.tb02140.x.
[12] S. C. Johnson 和 D. M. Ritchie, 《UNIX time-sharing system: Portability of c programs and the UNIX system》, The Bell System Technical Journal, 卷 57, 期 6, 页 2021~2048, 7月 1978, doi: 10.1002/j.1538-7305.1978.tb02141.x.
[13] D. L. Bayer 和 H. Lycklama, 《MERT - a multi-environment real-time operating system》, 收入 Proceedings of the fifth ACM symposium on Operating systems principles, 收入 SOSP '75. New York, NY, USA: Association for Computing Machinery, 11月 1975, 页 33~42. doi: 10.1145/800213.806519.
[14] G. L. Chesson, 《The network Unix system》, 收入 Proceedings of the fifth ACM symposium on Operating systems principles, 收入 SOSP '75. New York, NY, USA: Association for Computing Machinery, 11月 1975, 页 60~66. doi: 10.1145/800213.806522.