译者前言:操作系统架构考古系列第一篇,原文标题 The UNIX Time-sharing System–A Retrospective。本文的一个版本曾于 1977 年 1 月在火奴鲁鲁举行的第十届夏威夷国际系统科学大会上报告,并于次年正式发表。本文回顾了七十年代以来 UNIX 的设计与发展。

此前动笔的机器之城这篇博客写着写着突然跑歪开始长篇大论计算机历史,又因为各种原因卡住迟迟无法继续,于是突发奇想要不要翻译一点历史文献来作为扩展阅读。本文便是这次冲动的结果,原文看着不长,结果翻译起来花了足足六天,一方面是因为英文底力不足,在尽力泡发原作者精炼又幽默的语调;另一方面是花了不少时间考证历史背景并注解,不然很多事情若贸然用现代视角来理解,多少会觉得莫名其妙。

封面图是一张家喻户晓的经典照片,大约摄于 1972 年。图中,Ken(坐着)和 Dennis(站着)在操作 PDP-11 型计算机。图片来源和图中设备的解释见此处

摘要

UNIX 是一个面向 DEC PDP-11 和 Interdata 8/32 型计算机的交互式通用分时操作系统。自 1971 年投入使用以来,它已得到颇为广泛的应用。本文讨论了 UNIX 的长处、短处,以及若干我们尚未投入精力的方向。本文涉及以下话题:

  • “文件”应该采用的结构:统一、可随机寻址的字节序列。不应在文件层面引入“record”概念的原因。文件寻址的效率。
  • 承载文件系统的存储设备及其组织方式;目录和文件。
  • 将 I/O 设备纳入文件系统。
  • 用户界面:shell 的基本机制、I/O 重定向、管道。
  • 进程的运行环境:系统调用、信号、地址空间。
  • 可靠性:系统崩溃与文件丢失。
  • 安全性:保护数据免遭损毁或未经授权的访问,保证系统免于停摆。
  • 使用高级编程语言:收益与代价。
  • 尚未实现的方面:“实时操作系统”、进程间通信、异步 I/O。
  • 对系统设计师的建议。

UNIX 是一个交互式通用分时操作系统,主要运行于 DEC PDP-11 系列计算机,最近也被移植到 Interdata 8/32 型计算机上。自从 1971 年问世以来,尽管我们没有专门推广过它,向外界提供时也没有承诺维护责任,它仍已被广泛采用。目前,UNIX 在贝尔系统内部已有超过 300 套部署;在外界的大学、中学、各类企业及政府部门中,部署数量则更多。本系统能适应相当广泛的硬件条件,从服务 48 个用户的大型 PDP-11/70 主机,到服务单用户的 LSI-11 微型计算机

总体看法

在很多方面,UNIX 是一个设计相当保守的系统,真正原创的做法寥寥无几。甚至可以说,它基本上就是 MIT 的 CTSS 分时系统 [1] 的一个现代实现——这里我们是在同时赞扬 UNIX 和 CTSS。CTSS 问世至今已有十五年有余。然而,据我们所知,少有其他交互式系统在易用性上胜过它;不少系统的基本设计甚至还不如它。

UNIX 从未是一个“项目”,它没有明确的预设目标。归根到底,这只是主要作者 Ken Thompson 和后来加入的本文作者为了给自己造一个舒适的编程和计算环境而做出来的。虽然在它成功之后找补一些成功因素不太容易,以下这些因素似乎最为重要:

  • 设计简单,易于理解;但功能又足够强大,能满足大部分用户需求。
  • 用户界面简洁,行为相对符合预期;同时又简练得近乎晦涩。
  • 运行在一个已经很流行的机器上。
  • 除操作系统和一些基本工具外,有不少有意思的软件可用,包括一个能处理复杂数学内容并输出到照排机或电传打字终端的排版系统 [2]、一个 LALR 语法分析器生成器(译者注:就是 YACC)。

这篇文章讨论了这个系统的长处和短处,以及一些我们尚且未投入精力的方向。我们只给出足以支撑下文讨论的实现细节,更详细的论述见 [3], [4]

然而,UNIX 没有统一的版本,所以讨论它的长短处比较困难。它不仅一直在进化,而且各路使用它的机构也加入了很多功能以满足各自的需求。现在有四个主要的版本:

  • 贝尔系统内部使用的标准版本,由贝尔实验室的 UNIX Support Group 维护
  • “程序员工作台”版本 [5], [6],在贝尔实验室内部也很流行,特别是在需要做文字处理、向其他机器提交作业的地方。最近它也被提供给外部机构使用。
  • “第六版 UNIX”(正式文档是这么叫的),主要通过 Western Electric 售卖的许可证,授权给贝尔系统之外的机构。
  • 贝尔实验室计算机科学研究中心(UNIX 的诞生地)自用的版本。贝尔实验室其他一些地方也在用。

这样版本林立的情况使本文的一些部分难以撰写,特别是涉及技术细节的地方(比如,单个文件的大小限制)。虽然给不同版本的 UNIX 做一个详细的对照表是个有益的练习,本文不会进行这样的对比,而是以作者当前使用的研究版本为准。

UNIX 内部的版本差异,不仅让本文这样的介绍文章难以撰写,也给用户和系统管理员造成了一些困扰。各版本的维护者当然也意识到了问题的严重性。事实上,他们正在积极推进整合,力图集各家所长,得到一个统一的系统。

译者注:牢肯、牢里:能赢吗?会赢的。

文件的结构

UNIX 文件模型非常简单,然而,它比许多复杂得多的系统更强大、更通用。在 UNIX 文件系统中,文件仅仅是一段没有内部结构、可以随机访问的字节序列。文件系统将下层存储设备的细节——诸如磁盘里一个磁道有多大——完全封装起来,对用户不可见。文件大小就是它所含的字节数,文件末尾由历次写入所达到的最高位字节决定。这样一来,文件空间无需预分配,系统也不提供这种接口。文件读、写系统调用分别只有一种形式:只需指定一个已经打开的文件局部名称(译者注:就是大家熟悉的文件描述符)、一个用作数据来源或去处的缓冲区,以及要读写的字节数。I/O 一般是顺序进行的,一次读写操作通常从上一次读写结束的位置继续。(译者注:内核为每个打开的文件维护一个读写游标)。随机访问则通过一个 seek 系统调用实现:它将游标设置到指定的位置,以便下次读写从该处开始。对程序而言,所有的 I/O 都表现为完全同步;至于预读、延迟写等优化,则由系统自动处理。

这种简洁的文件模型源于 MULTICS 的 I/O 系统 [7]

出于性能考虑,文件的寻址机制必须小心设计。文件需要可以变得很大(可达 字节之大)、可以在没有预分配的情况下增长,还得可以随机访问。为了能处理大量小文件,每个文件的固定开销必须很小。在用于撰写本文的机器上,用于存储绝大部分用户文件的磁盘有将近 27000 个文件,它们中的绝大部分都是小文件(80% 的文件只有不超过十个 512 字节的块,37% 只有一个块)。文件寻址的具体机制在文献 [4] 中给出。

我们并未进行完整的磁盘 I/O 性能测试,但是以下的简单实验表明 UNIX 的性能接近另外两个现存系统:DEC 面向 PDP-11 的 IAS,以及 Honeywell 面向 H6070 的 GCOS TSS。在 PDP-11 上,我们测量了拷贝一个有 480 个块(245760 字节)的文件的耗时。在 Honeywell 主机上的测试文件包含相同数量的字节(每个字节有 9 位而非 8 位),但是它每个块有 1280 字节。在被测主机几乎空闲的情况下,我们得到了以下测量结果:

system sec. msec./block
UNIX 21 21.8
IAS 19 19.8
H6070 9 23.4

两个系统在 PDP-11 上的拷贝速率几乎一样,Honeywell 计算机上,每块耗时也差不了太远。我们无法从这个简单的实验中得出具有普遍意义的统计结论。比如,磁盘寻道耗时主导了测量到的 I/O 耗时(因为 PDP-11 的磁盘读写很迅速,在磁头定位之后仅需 0.6 毫秒就能完成一个块的读写),我们也没去优化输入和输出文件在磁盘盘片上的存储位置。然而,这个结果确实表明:UNIX 这种非常抽象灵活的文件模型至少不会比其他两个系统引入更多的固定开销。

在 UNIX 下,我们测得每块 I/O 耗时大概 22 毫秒。每处理一个块,系统都有 6 毫秒的固定开销,其中绝大部分都可以和设备 I/O 重叠进行。因此,如果将块大小从 512 字节提高到 1024 字节,块复制操作的总体传输速率似乎有望近乎翻倍。然而,我们有理由不这么做。比如说,空间利用率将会显著降低:如果将块大小翻倍,在作者的机器上,文件总尺寸就会增大大概 15%——当磁盘空闲空间只剩下 5% 时,抠这个数字的重要性就体现出来了。增大块尺寸大概也会让系统只能缓存更少的块数、降低缓存击中率,但是对于这个负面效果,我们没有可靠估计。

此外,我们举的这个拷贝程序的场景是一个极端例子:它完全受 I/O 性能限制,没有计算负荷。大部分程序多多少少会在吞吐数据的时候看一眼经手的数据。因此,如果我们用一个程序将上述文件中各字节的数值相加,总共需要 10 秒,其中 5 秒是进程执行用户态代码、检查并累加这些字节的时间。如果我们让程序忽略文件内容、纯读,只需要 9 秒跑完,用户态耗时可以忽略。我们可以因此认为系统提供的预读机制非常有效,即使对于一个处理每个字节只需 50 微秒的程序,它也不会被 I/O 延迟拖累(当然,除非这个进程正在和别的进程抢磁盘)。

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,可以视作一种有益而且常见的高层结构,而非以前硬件限制的简单延续。

现代人:常见个锤子,有益个屁!来,我给你发个 JSON object 给你开开眼,让你们这老登看看什么才叫高层结构

然而,随着计算环境从批处理走出来,迈入分时系统和交互式计算的领域,计算机的应用日趋灵活。本文作者敏锐地指出:逻辑上的 record 和物理存储上的 record 绝非紧耦合;去除这层耦合、将使用任何存储数据结构的自由还给应用程序,不仅不会破坏效率,而且还能产生无与伦比的灵活性。

对于定长 record,大家很明显能感觉到再这么用下去就是有病了:数据短了浪费空间,数据长了装不下,许多接口还要求以整条记录为单位读写。都 8791 年了,还在用这个,高低得评一个穿孔卡仙人。

变长 record 和字节流用起来更接近,但是多了一些可以造成各种问题的冗余信息。想象两个基于变长 record 的文件,都写着“hello, world!”,但是一个是 [7, "hello, "];[6, "world"];,另一个是 [4, "hell"];[9, "o, world"];,那这俩到底算一样还是不算一样呢?!如果算一样,那么每个程序在写的时候就需要小心地处理变长 record,把它们变成一样的表示法再开始处理;如果不算一样,那么,好日子还在后面呢!

出于以上讨论的原因,UNIX 软件并不在文件模型里采用传统的 record 概念,尤其是在需要文本信息处理的情况下。当然,肯定会有应用程序适合并需要使用这样的概念。然而这并不是一个问题:一个程序或一套软件包,完全可以在这种通用、无结构的字节流文件模型上,构造任意自己需要的数据表示。对于一个维护被频繁查询的大型数据库的程序,按照数据库索引编号排序的定长 record 序列很适合它。当需求或数据访问约定变化时,别的数据表示方式可能更合适。在 UNIX 平台上,各种不同的数据表示法都很容易实现,因为 UNIX 文件只是没有预定结构的字节序列,系统不对用户程序强加某种特定的数据表示法。作为一个展现这样的设计有多强大的例子,INGRES [8] 是一个在 UNIX 上开发的关系型数据库管理软件,它支持足足五种落盘格式。(译者注:这就是大名鼎鼎的 PostgreSQL 的直系祖先)。

加州大学伯克利分校 Evans Hall 的外景
图 1  Evans Hall,UC Berkeley,摄于 2022 年。INGRES 就是在这座建筑里写成的,当年 Berkeley 的机房就在里面。如今它是 Berkeley 数学系的建筑。据说在几年之后就要拆除了(2022 年就说要拆除,现在 2026 年了,可能还能蹦跶几年)。译者有幸在这栋楼里待过。
加州大学伯克利分校 Evans Hall 的地下室
图 2  曾经作为 Berkeley 计算中心机房的地下室,内部装修由 Sam Davis Architecture 所设计,几经重新装修。

文件系统的结构

在每个作为文件系统的存储设备(例如一个磁盘)上,访问文件所需元信息按数组排列并存放在一个已知位置(译者注:早期 UNIX 文件系统集中存放 inode 的区域)。因此,每个文件都由其所属的存储设备和它在该设备内的索引号唯一确定。(译者注:这个索引号就是早期 UNIX 的 i-number,后称 inode 号。至于 i 是哪来的,根据原作者后来在别处的访谈,可能是 index 的意思。最初含义已不可考。)然而,用户和用户程序无需关心文件在设备上的内部名称。UNIX 文件系统以层级目录结构组织文件,每个目录存储一系列文件名(每个文件名是一个字符串),以及和每个文件名关联的文件的索引号,索引号指向的文件则隐含地理解为位于与目录相同的设备上。由于目录本身也是文件,这套命名规则原则上可以成为任意有向图。不过,系统管理约定将其限制为树状结构;例外是,非目录文件可以对应多个名字,也就是可以出现在多个目录中(译者注:硬链接)。

一个文件由一串以 / 分隔的名称来命名,在目录树里以从根到叶的顺序排列。由 / 开头的路径代表从根开始的路径,反之则代表从当前目录开始的路径。所以,单独一个 x 代表当前目录下的 x/usr/dmr/x 则先在根目录搜索子目录 usr,然后再在其中进一步搜索子目录 dmr,最后在 dmr 里找到 x

译者注:译者第一次知道 UNIX 风格文件系统,乃至后来 Plan 9 的 9p 文件协议,都采用这种“walk”的语义的时候,还是吃了一惊:为什么它不直接像个键值对数据库一样按路径取文件呢?不过仔细一想,这样的做法非常合理:UNIX 文件树由挂载拼凑而成,而且先后支持了硬链接和软链接这样引入逻辑环路的机制,再加上目录树权限检查是路径相关的,walk 便成了实现起来最简单优雅的选择;再加上当时机器羸弱的性能,walk 这样的局部搜索和检查机制拥有必要的性能优势。

后来 80 年代中期开始开发的更灵活更复杂的 Plan 9 家族也继承了这套 walk 的机制:Plan 9 不仅有传统挂载和链接,还有合并挂载、原生的远程挂载等一大堆极为灵活的机制,所有 walk 就显得更不可或缺了。然而,随着计算机技术的继续革新,这样的前提背景被不短蚕食。比如,在 00 年代之后,作为一个可能上公网的远程文件系统协议,9p 里的这一大堆 walk 就显得有些啰唆了。在某种意义上,Plan 9 可以视为现代使用 URI 来集中资源的 Web 计算环境的祖先;然而在路径的处理上,Web 采取了完全不同风格的做法:采用扁平的路径空间,将路径不透明地视作键值对的键。这点在对象存储和传统 UNIX/POSIX 文件系统存储的接口区别上,体现得很明显。

系统启动伊始,只知道根文件系统设备一个设备,其信息是直接配死在系统源码里的。为了添加存储空间,额外的存储设备被“挂载”进来,每个存储设备有一棵自己的目录树。设备挂载后,其根目录会挂靠到当前目录树的一个叶节点上。例如,假设某设备被挂载到了文件 /usr 上,/usr 的内容便会被隐藏;此时访问 /usr/... 时, 实际上访问的是新挂载设备中 /... 所指向的内容。

这样的文件系统设计既易于实现,又能灵活组合满足大多数需求,还有一系列优点:比如,每个设备各自的文件系统一致性自检非常简单直接。这样的系统也有一些灵车的地方,比如实时生效的存储空间配额,不论是给特定用户还是给特定目录,都不是很容易实现(有个大学的部署环境实现了这个功能)。一个更大的问题是,前述的同一文件拥有多个名字的机制(译者注:硬链接。UNIX 最早只有硬链接)虽然在单个设备上很好实现,但是无法横跨多个设备。也就是说,一个目录项无法指向另一个设备上的文件。本设计还有一个限制:同一目录中的文件不能任意分散到另一台设备。若只想将其中一部分移到另一个设备上,就必须先把它们归入一个子目录,再以该子目录为挂载边界。一个经常出现的场景是,用户文件太多,一个设备装不下。此时,所有用户各自的家目录就无法放到同一个目录下了(如 /usr),而是得分成几组(如 /usr1/usr2)。(译者注:guess what,/usr 一开始还真的是用户目录,后来为什么变成现在这个鬼样子我也是莫名其妙啊.webp)。这不是很方便,特别是在原来的设备逐渐空间耗尽,需要将一部分用户搬到新设备的新目录下的时候。这样的数据迁移本身可以很快完成,但是诸如 /usr1/.../usr2/... 这样的文件名前缀变化会有一些麻烦的后果。不仅用户需要重新熟悉新路径,写死了老路径的程序也都会坏掉。

译者注:看到这里估计很多折腾过运维的读者已经开始汗流浃背了。要把用户家目录从 /usr1 搬运到 /usr2,这么灵车漂移得 break 多少东西?更别说有的用户还跑着实验呢,要是一上来给别人家目录扬了,人家可能跑了一个月的批处理输出就不知道飞到哪里去了,然后头就被用户锤爆了。太倒闭了。后来大家的找补主要是两个方向:

  • 传统 UNIX 侧:一直摆烂没能从本质上解决问题,存储拆分边界还是目录,再配合软链、自动挂载、网络文件系统等机制来缝缝补补,再通过卷管理器、越来越大的磁盘和单体文件系统这些硬扛😅
  • 贝尔实验室侧:抛弃了全局目录树的概念,每个进程的树默认互相独立,可以控制子进程选择性继承;同时,真正贯彻“一切皆文件”的思路,把几乎所有资源都以模仿文件接口的形式置入树中:不仅有文件和设备,还有各种接口,比如说网络栈、进程表、认证服务,乃至任何用户程序提供的接口;最后,用绑定、挂载、联合目录等机制实现这棵树的灵活按需组合。这套新东西被叫作“命名空间”。在这么一大套灵活的树操作机制下,之前的问题被彻底理顺了:直接利用联合目录特性,将来自不同设备甚至不同远程主机的文件系统给绑定挂载进来,合成成一个目录就好了。嗯,可惜没人用😅

这个文件系统设计的早期版本使用 16 位的块地址,于是整个文件系统卷最多只能有 65536 个块。这并不意味着大容量存储设备剩下的块都被浪费了,因为我们可以在同一个设备上划分出几个不同的逻辑设备(译者注:这里谈论的是磁盘分区。这是分区这一技术在当时的主要动机之一:单一文件系统块寻址能力有限,所以把磁盘切成几个不同的文件系统)。然而,这样的限制的确也使前面提到的问题更加严重。最近几版系统提高了这个限制,可以处理最多大概 1600 万个块。

输入输出设备

UNIX 花了不少巧思来抹平磁盘文件和诸如终端、磁带机、行式打印机这样的 I/O 设备之间的差异。每个受操作系统支持的设备,文件系统目录树里都会有一个相应的目录项,与普通文件采用同样的路径名结构(译者注:也就是设备文件,即我们熟悉的 /dev/...。这些文件并非真的文件,是内核设备驱动的入口)。不管是 I/O 设备还是磁盘文件,它们都共享同一套读和写的系统调用。一些其他东西,比如说权限保护机制,也能无缝运用在两者身上。

译者注:这样的小巧思其实是一种早期的面向对象实践,很适合“驱动程序的统一用户界面”这一特殊领域。具体而言,每个设备都有共同的 read 和 write 这两个多态的界面方法,具体怎么 read 怎么 write 由该设备“对象”自行决定。这样的思想后来被 NeXTSTEP(以及后来的 MacOS X)发扬光大了,成为了大名鼎鼎的 Driver Kit,广受高级用户喜爱。随时能写点小程序自己用,多是一件美事啊!

除了上述终端、磁带机、行式打印机等传统设备外,系统还为文件系统之外的磁盘提供了相应的访问名称(译者注:这种设备文件绕过了文件系统的目录、inode、文件等抽象,直接把磁盘也当字节流,像文件一样 seek 到多少字节数的位置做修改,再由操作系统在背后将修改过的块写回磁盘;而且操作它的接口也不是一些设备相关魔法咒语,而是大家最熟悉的文件读写接口。这和当年其他系统比是标新立异的)。此外,系统也为绝对地址访问的物理内存设置了相应的设备名(译者注:早期 UNIX 中的 /dev/mem 可以视为物理内存的设备文件,文件偏移量直接对应物理地址,因而可以用普通的读写操作检查,甚至修改内存和设备寄存器)。在实际应用中,最重要的 I/O 设备莫过于用户的终端。因为在 UNIX 中,每个终端通道都被视作普通文件处理,我们能很容易地将命令的输入和输出从终端重定向到一个文件。下一节会展开讲这种操作。同时,用户之间的通信也很容易实现。

然而,有一些区别无法被抹平。例如,系统一般以行为单位对待终端输入,因为在一行输入完毕之前,字符删除和整行清除等编辑操作可能改变其内容,因而必须等一整行输入完毕之后,才能交给程序。因此,如果一个程序尝试从终端读取大量字节,系统调用会阻塞,直到用户输入完一整行;随后,调用返回实际读到的字节数,这个数字可能小于程序请求的数量。无论如何,所有程序都必须在进行 I/O 时准备好应对这种短读情况:读取磁盘文件时,如果读取过程中碰到了文件末尾,同样只会读到少于请求数量的字节。因此,通常而言,从终端和从文件读取数据使用完全兼容的接口。然而,如果一个程序先读取一些字节,并根据读到的一行文本,调用另一个程序来处理剩下的部分,上述行为会引发一种微妙的问题。当这个程序的输入是终端时,完全没有问题,因为输入是一行一行读取的;但是如果输入是个普通文件,那么第一个程序可能一口气读到了应当交给第二个程序的内容。(译者注:如果读者觉得这个问题很绕,不妨想象一个 shell。shell 从标准输入读取命令,执行命令时,子进程继承标准输入描述符,从同一输入读取后续内容。输入来自终端时这没问题,因为内核里的终端驱动一次读取至多交付一行;但如果喂给 shell 的是一个文件(脚本),就容易乱套,shell 的一次读取可能会消耗掉原本应留给子进程的内容。)目前最简单的解决方案是让第一个程序一个字符一个字符地读取。一个更通用但是尚未落地的解决方案是:系统向程序提供一种新的读取模式,每次至多返回一行,不管输入来自何处。

原作者注:这个想法可能和之前不使用 record 的立场相冲突。然而这其实不矛盾:这只影响如何读取信息,不影响如何存储信息。无论采用何种读取方式,程序最终都会得到同样的字节。
译者注:原作者设想的这种内核级行读取模式并未成为通用接口。直到今天,原来的内核行编辑功能(ldisc)仍然是默认行为,fgetsgetline 等标准库函数在此基础上,帮用户完成了每次读固定量字节直到换行、如果读多了自动缓冲的工作;前述“读多了”的问题在使用该模式时仍然存在。对于有需要“精确地读一行并转交 fd 读取状态”需求的程序,比如前述的 shell,通常显式进入某种“raw 模式”,绕过 ldisc 将逐行读取、编辑的工作交给用户态库(比如 GNU/Readline)或者自行处理。

用户界面

译者注:本节推荐配合操作视频食用,风味更佳。

命令解释程序(下称 shell),是用户和系统沟通的最重要的渠道。Shell 不是操作系统(内核)的一部分,也没有任何特权。登录程序所读取的口令文件中,每个用户的账号记录都有一项,用于指定登录后首先运行的程序;对于绝大多数用户而言,这个程序就是 shell。这样的可配置性已经在设计良好的系统里比较常见了,但远非无处不在。UNIX 的做法的优势之一是,在有内存压力的情况下,即使内核本身无法被换页换出,因为 shell 程序是分开的普通程序,也可以被独立换出。于是,shell 程序的尺寸便不再那么要紧,我们有余裕可以往里面塞更多功能;我们甚至能很容易地将 shell 换成各种别的程序,不管是为了测试新版本,还是为了提供某种非标准的用户界面。

UNIX 的 shell 所用的这套语言有点复杂,因为它提供了不少功能。详细的讨论见文献 [9]。然而,它对于单个命令的处理是比较简单标准的:一个命令是一串用空白字符(空格或制表符)分割的单词。第一个词是命令名;此处所谓的“命令”,可以是任意可执行文件。若该名称包含 /,shell 就按给出的路径定位文件;否则,便依次搜索若干约定的目录。系统提供的命令的唯一特殊性就是,它们对应的可执行文件被放在一个搜索路径上,供绝大多数用户使用。(只有很少一些命令是 shell 内置的。)剩下组成命令行的词分为以下三种类型:

  • 普通字符串
  • 一个带有 <> 或者 >> 前缀的文件名
  • 一个含有文件名通配符的字符串

普通参数会被以字符串数组的形式传给命令,再由程序解释。像这样让 shell 预先将参数解析成字符串序列再送给程序的做法,算是建立统一的参数格式约定的第一步。我们曾在各种系统里看见过五花八门的参数分隔符:有用逗号的,有用分号的,还有用括号的……如果不是博闻强识,或者正好手头有本手册,不然这根本没法用。

< 开头的参数用于指定一个文件,这个文件会被 shell 打开并作为程序的标准输入。程序可以在完全不知情的情况下照常读入字节流。如果没有提供这种参数,程序的标准输入会被直接接到终端。类似地,以 > 开头的参数代表将命令的标准输出接到参数指定的文件。它的一个变体是 >>,会让 shell 将命令的输出附加到文件末尾,而不是像 > 一样覆盖文件原有内容。要实现这样的机制,我们必须保证终端和文件的 I/O 接口互相兼容。注意,重定向是 shell 语言层面提供的一种方便又自然的机制,因此可以无一例外地用于所有命令。表示重定向的参数不会被传给被执行的命令;命令若想知道自己的输入输出是否发生过重定向,甚至还需要费一些奇技淫巧。也有其他系统支持 I/O 重定向(遗憾的是,这实在太少),但是据我们所知,没有哪个系统提供了如此方便的表示法。

译者注:原文特意说的是参数(argument),指 shell 命令中的一个项,而非传给程序的参数。输入输出重定向的“参数”会被 shell 捕获并解释,不会传给程序。

一个含有文件名通配符的参数会被展开成一串普通参数,分别是匹配到的文件名。例如,字符 * 表示零个或多个字符组成的序列,*.c 则会被展开成当前工作目录中所有以 .c 结尾的文件名(译者注:*.c 会被展开替换成 foo.c bar.c buzz.c 这样)。其他通配符还有可以表示文件名中的任意单个字符,或者某个字符范围(例如全体数字字符)。

将这些通配符机制搬到 shell 里有几点好处:这些功能只需实现一次,因此既不浪费空间,一般命令也无须专门适配;同时,还能保证展开算法在不同命令上行为一致。对于处理文件的命令,它们唯一需要遵循的规范是:即使这个命令一般一次只处理一个文件,也要能够接受一串文件名参数。比如说,删除文件的命令如果被实现成每次只接受一个文件名参数,那么通配符展开机制就没用了。实际上,这个命令可以接受一串文件名参数(不管这些参数是怎么生成的)并将这些文件全部删除。只有少数情况下会遇到麻烦。比如说,有个叫 save 的命令可以将参数指定的各个文件转移到某种离线存储。那么 save * 就可以将当前目录里的所有文件都存下来,非常好。然而,如果还有一个相反的命令 restore,会将指定的离线文件再恢复出来,那 restore * 在 shell 里的意思就变成了“恢复当前目录下已经存在的所有文件”,而不是“恢复离线存储里的所有文件”。

管道 的概念是 UNIX 在编程领域最重要的贡献之一,尤其是在 shell 里用管道的优雅写法。管道实际上相当于一个连接两个进程的已打开文件:写入一端的信息可以从另一端读出,同步、调度和缓冲均由系统自动处理。这样,一列线性串接的进程——也就是一条“管线”——便像一组协同程序那样,同时处理同一条 I/O 流。Shell 里的管道语法用一条竖线分割多个命令,比如说

anycommand | sort | pr

anycommand 的输出拿去排序,再将结果以分页形式打印出来。这样将程序串起来的能力从根本上改变了我们怎么看待和编写一般的程序,特别是文字处理程序。一个很典型的例子是,我们有三个程序,分别能转换字符(全部转小写)、将一个文件排序并去掉重复行、比较两个已排序的文件并输出只在第一个文件出现的行。把这些和我们的电子词典结合起来,就能形成一个管线,它能找到一个文档里所有字典里不存在的词并打印出来——也就是说,检查拼写错误。再补上一个能生成词形派生的小程序,整个拼写检查器就齐活儿了。

译者注:此处所谓电子词典就是一个文本文件,里面按字典序排列了一堆英语常用词。将要检查的文档里的词排序、去重之后,和这个文件对比,就能挑出不在字典中的词,找到可能拼错的词。这个操作在视频里有演示。

虽然操作系统允许管道将不同进程连接成任意的图,但是这个 shell 管道语法使得我们只能构造线性管线。这样的限制有几个原因。其中最重要的是,缺少一种像简单线性管道那样清晰的语法。其次,形成任意图的管线可能会因为有限的管道缓存被耗尽,互相等待消费然后死锁。最后,虽然有人提出过一种尚可接受、但颇为复杂的表示法,保证只能构造无死锁的“管道图”,仍然没有人认为这个需求足够紧迫,所以也没人去实现。

UNIX 中还有一些不依附于某个特定程序的设计的用户界面改善设计。比如,有一个似乎很简单,但是一旦用户习惯了之后,实际上体验改善很大的功能:带预读的全双工终端。虽然程序一般以行而不是单个字符为单位和用户交互,能够有全双工的终端 I/O 意味着用户可以在任意时刻输入,甚至包括系统正在输出到终端的时候,不用担心输入的内容丢失或者损坏。有了预读功能后,用户不再需要停下来等待每一行输入的响应。这对于文字工作者来说尤为重要:一个熟练的打字员可能会被每输入完一行就得停下来等待终端响应的体验整到红温。当一个人知道自己想输入什么的时候,如果不能一口气连续地把东西写下来,而是得一行一行停下来等响应,那心理上的挫折是相当可观的。(译者注:这是对于在低速主机上使用类似 ed 的行编辑器的体验而言的。)

译者注:这段现代人比较难以理解……大家可能会想,什么叫 UNIX 有全双工终端,即使终端正在输出,用户打的也不会损坏或丢失?好像的确在程序输出的时候我输入的字符会留下来,但是回显不就是会被冲烂么?

难道 Dennis 他们习惯不看命令回显盲打?这么自信的么?

你别说,还真是……讲道理在电传打字机上那个体验,也和盲打差不多了……

这其实和下面要提到的 UNIX 惜字如金的命令输出也有关系:在等待 shell 命令运行期间,命令一般很少说话。所以大多数情况下这个体验是有效的:想象你可以在等 cc 编译的几分钟里输入下一个命令,这些输入能被内核缓冲不会丢失,也不会有乱七八糟的输出来冲烂你的回显。这个“内核缓冲”就是所谓的预读。然而,因为现在的计算机速度很快,所以一般大家也体验不到这个功能的好。

如果想体验一下这个功能,可以跑个 sleep 10 然后在阻塞期间输入点东西。

UNIX 程序与用户之间的交互通常极为精炼。这可能有点难以理解,特别是对于初学者来说。比方说,文本编辑器(译者注:就是 ed)只有一种错误信息,在输入有问题的时候直接打个问号(译者注:我还真用过 ed,命令打错了给你缓缓打出一个“?”,这个体验我真是气笑了)。虽然有些人一开始可能会很迷惑,但是当用户熟悉了这个编辑器,错误出在哪里通常一目了然,此时这种精炼的输出就成为了一个大家喜欢的优点。即使对于那些偶尔需要更完整的诊断输出的情景,我们也不会希望每次使用命令都输出一大堆信息,给我们制造噪音。命令行解释器不会大声宣告被执行的程序正常结束,默认也不会输出命令占了多少时间或空间资源。对于前者,一个低调出现的命令提示符就能很好地告诉用户,程序正常跑完了;对于后者,用户可以在需要的时候显式要求。

同样地,命令很少会具体提示用户缺少了什么参数。如果真的缺了必要参数,命令会输出一个最多一行的命令用法速查,然后退出。我们知道有些系统引以为豪的功能是,命令采用交互式的界面,而且还是强制提供的,不管用户想不想要。这里我们想说的是,“是否提示用户缺失的参数”还算一个可以讨论的设计品味问题;但如果程序非要摁着你的头回答一些问题,走完它那个交互式向导,那恐怕板砖就要飞过来了。

虽然 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 则尽量把这些信息隐藏进内核,给进程一个极简的地址空间,不让进程直接操作,而是通过少数几个清晰的系统调用来访问这些资源。可想而知,这能给程序开发体验带来多大的提升。

新进程通过 fork 操作创建。这个操作创建出一个子进程,其代码和数据都拷贝自父进程。子进程继承父进程打开的文件。除非父进程专门等待子进程结束,子进程是异步运行的。fork 是系统工作的核心机制,因为 shell 执行的每个命令都跑在单独的进程里。这个设计让一些系统服务很容易实现。尤其是 I/O 重定向,做起来非常简单。它完全在执行命令的子进程中完成,所以父进程 shell 无须记住并在命令结束后撤销对标准输入输出的改动。后台进程也不需要任何额外的机制来实现:只需要让 shell 不等待命令结束即可。最后,在 shell 里嵌套启动 shell 来解释执行一个文件里的一系列命令也不需要任何特殊设计。

进程和外界沟通的手段被限制到了为数不多的几个渠道上。其中,最常见的是显式系统调用,主要用于执行 I/O 操作。其次,一个新启动的程序会从它的调用者收到一系列字符串参数,然后在结束后返回一个字节的状态信息。此外,进程可以接收“信号”,在默认情况下迫使进程终止,但是进程也可以选择捕获信号并采取特定行动,比如说忽略信号,或者把它当成某种软件模拟的硬件中断来处理。比如说,终端收到代表“中断”控制字符之后,系统会向连接到该终端上的进程发射一个信号。诸如寻址错误这样的故障也会以信号的形式通知进程。尚未被赋予特殊含义的信号,也可以用于协作进程之间的通信。最后,一个比较特定的机制是,父进程可以追踪子进程的行为、接收故障通知、访问子进程的内存。这是给调试用的。

译者注:从现代视角来看,这些机制无疑是有点简陋的,特别是返回状态码和信号,因为表达能力不足,后来不出意料地被大家添油加醋变成了一坨矢山。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 里。

译者注:从这里可以看见,大胆的系统设计取舍以及灵活迭代开发是 UNIX 的一大亮点。

战后早期,西方工程界普遍笼罩在一种对系统复杂度的迷信中:喜欢在实际应用之前纸上谈兵考虑到所有可能情况,再设计一个复杂到爆的系统,然后意淫它能大获成功。从机电到基建,从生产到行政,体系设计叠床架屋,堆不死就往死里堆。

这经常导致项目不断超支、膨胀,最后烂尾。这算是早期乐观现代主义的一个笑话:人们相信复杂度可以被更多复杂系统管理,却很少认真考虑复杂度本身引入的代价。

六十年代末、七十年代初在 MULTICS 的废墟中成长起来的 UNIX,作为对这种工程文化的一次反动,不为未经实践验证的需求预先堆砌机制,不把所有可能的情况提前纳入设计,更不试图对整个领域的问题作出完整建模。它所引领的这种亚文化,就是后来四十年开发者圈子内,大家时常津津乐道的 hacking 文化的起源。

现在大家所看到的 UNIX 世界的各种灵车 quirk,很多正是这条路线留下的历史包袱:先让系统跑起来、让用户用起来,怎么符合当前的需求怎么来,即使这样会让系统有点晦涩;没有紧迫的需求,就不急着增加机制、完善抽象。

后来被人奉为圭臬的所谓 UNIX 哲学,也并非推崇抱残守缺,死抱着这些简陋而有强烈时代性的“特性”不放。这里特意摆出《Unix Hater’s Handbook》以鼓励这种批判性思维。60 年了!UNIX 这块又老又硬的东西早该被送进 CHM 了!

伯克利最著名的两个发明莫过于 LSD 和 Unix(BSD)。我不觉得这是个巧合。

——佚名

Two of the most famous products of Berkeley are LSD and Unix. I don’t think that is a coincidence.

—Anonymous

Dennis Ritchie 吐舌头表示不满的漫画肖像
图 3  dmr:这帮人叽里咕噜说什么呢?灵车是代价不是目标! 取自《The UNIX-HATERS Handbook》的 “Anti-foreword” 部分;插图:John Klossner。

可靠性

译者注:翻到这里我就想笑,《笑谈之灵车可靠性》,《Unix 最著名的 feature 是 kernel panic》。
腾讯 QQ 的企鹅形象惊恐地瑟瑟发抖,下方写着“Linux 内核恐慌”和“Linux Kernel Panic”,以及“高清版”
图 4  Linux Kernel Panic(迫真)

一个系统的可靠性可从三方面衡量:是否避免计划外停机、是否能可靠保存存入系统的信息,以及软件是否正确运行。

第一,操作系统不应崩溃。虽然并非完美无缺,UNIX 系统总体上有不错的可靠性记录。连续运行期间,相邻两次因软件故障造成的崩溃之间的间隔远超两周。当然,这个数字取决于最近对系统的灵车魔改有多厉害。

译者注:操作系统不应崩溃,但是操作系统不崩溃不太可能。

两种情况会让系统“自愿”崩溃:换页空间用完了,或者在换页的时候出现了不可恢复的 I/O 故障。这种情况下的崩溃不被视为软件故障。要让一个无法换出的进程优雅退出事实上相当棘手。然而,换页空间耗竭的可能性可以通过任意强的钞能力变得任意小;当前系统也只有在换页空间足以容纳新进程最大可能增长的大小时,才允许创建它;换页时遇到的不可恢复的 I/O 更是重量级,说明换页盘已经挫骨扬灰了,洗洗睡了吧。因为这些缘故,我们并没有尝试去应对这些理论上存在的情形。

仿“Keep Calm and Carry On”海报样式,上方为 Erlang 标志,下方写着“KEEP CALM AND LET IT CRASH”
图 5  明确穷举每一种故障和错误模式,并逐一实现故障恢复,被后来的工程实践证明是极其困难、不可持续的

下文的讨论会指出,换页空间以外的其他资源耗竭的确会发生,但是一般这不会导致系统崩溃,可以慢慢等它缓过来。这一般很难有通用解决办法。比如说,假设有人执意要去耗尽存储空间,那配额系统也大概帮不上多少忙,因为存储空间几乎都会是过度分配的。一个自动生效的文件大小配额机制能提供一些帮助,但是仅限于用户不小心 创建了一个雷霆大文件,或者雷霆数量的小文件的情况。

到目前为止,硬件错误是崩溃最常见的原因;而在还算健康的机器里,供电不稳算是头号杀手了,它会让磁盘掉盘,还会让处理器卡进没有文档描述的奥妙重重状态里(译者注:70 年代牢美的雷霆供电……现在对大家来说已经很陌生了。各位上一次听到“brownout”这个词是什么时候呢?另外,彼时计算机的雷霆大电源体积大、效率低、控制和保护功能有限,难以抵抗这种瞬态电源波动。于是那个年头的计算机就是三天两头挂。现代的开关电源凭借高频闭环控制、宽电压输入范围和完善的保护机制,让这种故障变得陌生)。其他故障发生得不是很频繁。不过,PDP-11 系列似乎有个坏毛病,它的 Unibus 总线喜欢爆出事后难以排查的瞬态故障,特别是在更高配的型号里。我们的确得承认,UNIX 不是很善于应对各类硬件错误;也不是很擅长提供完整信息来诊断硬件错误。

然而,不管发生什么故障,一个可靠的系统绝不应该丢失或者损坏用户文件。UNIX 在这方面没有采取什么特别措施:脏数据在被刷入硬盘之前,最多在内存缓存里停留 15 秒。即便如此,如果不算在创建文件的时候就崩溃的情况,作者的机器在过去的一年内也只写坏过三到四个文件。和用户意外删除和覆盖的文件相比,因系统崩溃而损坏的文件屈指可数。然而,我们现在这个文件系统仍然在冗余性上差了点意思,无法从供电波动、崩溃、暂时的硬件故障中自动恢复。我们用频繁的文件系统转储(译者注:贝尔实验室风味雷霆大 dump,见段末)来抵抗灾难性事件(真的发生过:有一次小磁头暴风摧毁大硬盘,把字刻在盘片上(物理);还有一次一个磁盘控制器喝高了,在硬盘上疯狂随机写入垃圾数据)。

续本达教授在 TUNA 活动中演讲,身后的屏幕上写着“硬盘阵列不是备份”,并展示倒地的硬盘柜和散落在地的硬盘
图 6  续老师教导我们,备份不可或缺,无论多完善的系统都可能遭到灾难性损失

译者注:这里的 dump 比较有意思,这不是一般的备份,是特殊的 Unix 风味雷霆大 dump:每天凌晨运行一次,扫一遍文件系统,用一些草台启发式规则猜一猜变动的块,就往磁带上灌,简单粗暴大力出奇迹,号称存储降价比我用得快,问?问就是数据无价。可靠恢复和校验机制……没有,恢复的时候就自己进去捞块拼文件吧。不过有备份总比没有好。

这个机制的残留一直保持到了今天:您知道 /etc/fstab 的第五列是干什么用的么?

后来 80 年代的 Plan 9 组也继承了这个机制,最开始是磁盘做热缓存、光盘库(自动光盘库!)做持久存储、每天凌晨把磁盘里的脏块倒腾进光盘;最后在 90 年代末的 Fossil/Venti 系统登峰造极,成为了现代快照文件系统、不可变备份和内容寻址归档存储的早期雏形。读者可以猜猜这套系统最后去哪里了。

安全性

“安全性”意味着保护数据免受未授权访问或破坏,并防止有人通过例如故意引发崩溃以妨碍其他人使用服务。UNIX 和它的绝大部分软件是在一个比较开放的环境里编写的,并没有投入维护一个高度安全的系统所需的大量工作(译者注:因为大家都是在自己写自己用,不像当时的传统大型研发机构(可以脑补一个 NASA.gov),每个程序简直要做出流程图、打印在纸上,开了半天会论证各种故障语义,才能编写;编写完了还得多人交叉审查才能加入系统并运行);结果便产生了一些安全问题。

最脆弱的部分是保护系统免于崩溃或者瘫痪。绝大多数 UNIX 版本缺乏检查过度资源消耗的机制,包括文件存储空间、文件数量、进程数量(新一点的版本引入了以用户为单位的数量限制)。耗尽这类资源不会导致系统崩溃,但是会让系统在一段时间内不可用。当资源耗尽发生时,耗尽原因和罪魁祸首一般是比较明确的。所以如果这是恶意行为造成的,顺着摸过去锤人是可行的;真正棘手的是意外写出的程序漏洞跑飞了。

理论上,信息保护方面的事情要好办一些。每个文件都被标记了所有者以及所有者所在的用户组。此外,文件还有九个权限位,分成三组,每组内三个位分别是能否读取、写入,以及把该文件作为程序执行。这三组权限位代表的权限分别应用到文件的所有者、所有者所在的用户组,以及其他所有人。

对于目录,这些权限位的含义有轻微不同:读权限指读取目录文件的权限,或者说查询该目录下有哪些文件及子目录的权限;执行权限决定了能否直接通过路径访问目录内的某个特定子条目;写权限决定了能否在目录中添加或者删除子条目,和能否读写目录中的子条目没有关系。

这样的权限分类自然无法细到满足所有场景的需求,但是对于绝大多数情况足够了。实际上,大多数部署环境甚至没有用到用户组的功能(所有用户都在一个组里);即使那些真的在用用户组功能的安装场景一般希望系统能提供更多用户 ID,而用户组 ID 的数量可以少一些。(老版本的系统里,两者分别都只支持 256 个;新一点的版本支持 65536 个,这应该够用了。)

一个特殊的用户(“超级用户”)可以无视权限访问任何文件。这个用户也是唯一一个被允许执行特权操作的用户。不管是从理论上,还是经常从实践上,我们都能体会到,超级用户的概念是任何权限保护方案在理论上、且往往也在实践中的一处瑕疵。

译者注:实际上,在 10 年后计算环境逐渐从单机向分布式系统发展时,超级用户带来的问题便无法被像上文一样容忍:一台主机上的超级用户,在另一台主机上,到底算什么用户?UNIX 的后继者 Plan 9 因而取消了权利无穷的全局超级用户概念,只为每台机器保留一个“主机所有者”。它有权管理本机内核所控制的进程、设备、网络接口等资源,但是在由众多机器与服务组成的 Plan 9 计算域中,它仍只是一个普通的身份;命名空间访问的认证由统一机制管理,授权仍由相应服务独立掌握。感兴趣的读者可以参阅这篇文章

熟悉 Kubernetes 的读者可能会嗅到一丝熟悉的味道。

这个权限系统中一个不同寻常的地方是“设置用户 ID”位(后称 setuid 位)。当一个文件被设置了这个位,然后这个文件被作为程序执行,那么该程序用于文件权限检查的用户 ID 会变成文件所有者的 ID,而不是执行者本人的。在实践中,这样的标志位一般用在需要执行特权的程序上(比如创建目录、改变文件所有者等等。)

理论上这个权限系统已经够用了,但是在实践中,灵车时有发生。最常见的操作失误是给文件设置了错误的权限。我们的软件倾向于创建对任何人可读,甚至可写的文件。这并非偶然疏忽,而是我们所处的开放环境的体现。然而,工作在更加不可信的环境的人,可能需要更加频繁地调整权限。这很容易搞忘,导致在一段时间内,文件会拥有不合适的权限。一个可行的改进是,让软件以一种用户指定的默认权限来创建文件。系统管理员可能需要比用户更谨慎一些。比如,我们可以轻易编写一个用户程序,读取物理磁盘的内容,并将其解释为文件系统卷。那么,除非这个磁盘的设备文件设置了足够的权限保护,只要有人能访问这个设备文件,那么这人就能无视该文件系统的权限,访问其中的文件。此外,如果一个带 setuid 位的文件是可写的,那另一个用户便可以把自己的程序覆盖上去。

当然,攻击者也可以利用有 setuid 位的程序。比如说,用来给同一主机别的用户送信的程序可能被赋予把邮件投递到该用户原无权写入的目录等能力。对于这样一个程序,编写者必须小心确认这个程序无法被攻击者利用,将别人的私有文件传给自己。

综上,维护一个完全安全的系统,在实践中是相当困难的。尽管如此,UNIX 本身似乎足以保护数据安全。我们这里只能说“似乎”,因为这个系统未经形式化验证;不过,除了前述系统资源耗尽的问题,目前尚未发现任何和安全有关的缺陷。事实上,在某些方面,UNIX 本质上比许多其他系统更安全。例如,所有 I/O 操作都只能针对一个打开的文件,而打开的文件在进程内仅以局部对象指代。权限检查在打开文件时进行;其后 I/O 系统调用只接受两个东西:该已打开文件的局部名称,以及用户提供的读写缓冲区的位置和大小。实际的物理 I/O 一律对系统缓冲区进行,然后由同一段内核代码负责将数据搬入搬出各个进程的地址空间。这样一来,系统便无需去审查用户提供的设备指令和通道程序(译者注:即 channel program,在旧时大型机上常见。它是由 I/O 指令流组成的 DMA 脚本,由用户提供但高度受限,独立于主处理器异步运行,描述设备如何进行读写、数据如何传送)的安全性——这样的检查机制通常极度复杂,而且容易出错。同样,用户地址空间中不存在用户的“数据控制块”或者其他控制块;用户进程和系统之间的接口因此较易审查,因为双方完全通过明确给出的参数交互。

译者注:这有点 capability 的味道了:权限不再系于一个全局名字或者裸地址,而是体现在一个仅进程本地可用、已通过授权检查的句柄上。相比之下,当时有些系统的内核接口更像是在传递野指针;有的甚至允许程序自行拼装磁盘 I/O 的设备指令链,再交给专门的 I/O 通道执行。UNIX 则把这些危险行为全部托管给内核,用户只需描述“对谁读写”、“读写什么”。

当然,大型机采用这类复杂设计,也有极强的性能动机:让独立的 I/O 通道异步驱动外设、直接搬运内存数据,可以尽量节约昂贵的主处理器的时间,并充分榨干磁盘、磁带、长途专线等 I/O 设备的吞吐。这样的 DMA 思路后来以受限、隔离的方式,在现代系统中不断复活:网卡和 NVMe 的描述符队列、RDMA 等机制,多少都带着一点“主机预先描述 I/O、副计算机异步执行”的影子。

使用高级编程语言

译者注:感兴趣的读者可以配合 Research UNIX V6 源代码食用本段,风味更佳。如果出现急性食物中毒,请寻求流浪猫协助。本批注不是医疗建议。

UNIX 及其绝大部分软件都是用 C 语言编写的 [10]。文献 [11] 介绍了这门语言。因为在 C 被发明前,UNIX 最早是用汇编写的,所以在使用高级语言编写操作系统这件事上,我们比较有发言权。简单来说,好处很多,代价相比起来可以忽略。具体的效果很难被量化,因为我们不用代码行数来衡量生产力。不过,很能说明问题的是,UNIX 平台上的确有不少有趣的软件,从语法分析器生成器(译者注:还是我,Y🎵A🎶CC~),到数学公式排版套件,很难想象如果我们还在倒腾汇编的话,能整出这么多好活。我们许多最具创造力的贡献者既不熟悉、也不愿学习特定机器的指令集。

在我们有了 C 语言之后,用 C 重写过的程序远比早先的汇编版本更易理解、修复、扩展。对于操作系统内核本身,这点尤为明显。最早的汇编版本的系统非常难以修改,特别是在需要添加新设备支持的时候;有时甚至做点小改动都比较困难。C 重写的版本则相对而言极易修改:不仅我们这么认为,不止一个用上 UNIX 的大学已经开始自己魔改了,比如彻底重写终端设备驱动,以满足自己的偏好。(颇为矛盾的是,易于修改这一优点也带来了一些麻烦:魔改版本层出不穷……整个生态乱糟糟的。)

换用 C 之后,我们意外得到了一个极为宝贵的好处:这个系统变得易于移植。我们在另一篇文章 [12] 中讨论了将 UNIX 从 PDP-11 移植到 Interdata 8/32 的过程。我们可以用高级语言编写从系统内核到其上的一整套软件,然后将完全相同的源代码编译到不同的机器上运行,只有少数几个模块需要修改。不管是对于部署了多种计算机硬件的组织,还是生产多种计算机产品线的厂家,这种做法的回报是显而易见的。

和得到的益处比较,使用高级语言的代价几乎是可以忽略的。当然,编译器生成的目标程序会比匠心手搓的汇编程序大一点。平均到底大了多少比较难以估计,因为在我们重写的过程中,我们总是忍不住顺手改进原来的设计。一个典型的估计是,重写成 C 程序之后,程序的大小膨胀了 20% 至 40%。程序运行速度的下降幅度大致相当,但是有时也会更大。主要原因是,在 C 中,子程序调用的开销往往比汇编程序要高——其他高级语言也是如此。然而,现在软件工程界逐渐形成的共识是,一个软件中,大部分运行时间都是很少一部分代码所消耗的(译者注:八二原则,启动!),我们的实操经验也符合这个共识。为了让常用程序达到可接受的性能,我们整了一个性能剖析器,将程序员的关注点引导到程序中最值得优化的地方。

上述关于时空膨胀的估计并非来自一项完整的系统研究。虽然这样的研究会比较有趣,但是我们认为这并不重要,因为无论研究结果怎么样,尝到了高级语言细糠的我们肯定不愿回去苦哈哈地搓汇编了。现在,操作系统内核和它上面运行的主要程序的性能都够用了。当然,这并不是说继续提升 C 编译器的代码生成效率已经没有意义了。这确实意味着,如今我们已经开始认为:操作系统本身,外加各种“系统程序”,诸如编辑器、编译器还有其他基本工具,同样适合用高级语言表达,正如FORTRAN 之于数值计算、COBOL 之于商业计算一样。

在我们讨论使用 C 的代价的时候,把程序编译出来这件事本身的开销也需要纳入考虑。这一点我们也认为可以接受。比方说,在我们的 PDP-11/70 型小型机上,将整个操作系统编译和链接起来(sysgen)需要九分钟出头(其中七分钟是 CPU 时间);整个代码库包含 12500 行 C 代码,折算下来从源代码到可执行文件,吞吐量平均 22 行每秒。实际上,编译器的吞吐量比我们这样简单计算出来的还要高,因为我们广泛使用了 include 源码预处理机制,所以经过它泡发了的源码规模达到了 38000 行左右,编译器吞吐量约 65 行每秒。

译者注:UNIX 开发组在这里为高级语言站台,的确很有时代特色。70 年代的高级语言大多还处于衣不蔽体的状况:后来大名鼎鼎的 FORTRAN 最初也只是个围绕数值计算需求打造的散装翻译器,方便程序员凹数学表达式,至于后来大家习以为常的:完整的结构化程序、复杂的语法语义数据抽象、执行和内存模型标准化约定,一概没有(但是这的确是 killer feature,把一个算式心算凹成汇编的编程体验的确没人绷得住),还有一堆吓死人的灵车小约定。鉴于这些雷霆玩意儿珠玉在前,业界不乏怀疑高级语言能否成为软件工程主力的声音。UNIX 这帮人直接从头写了一好用的平台外加一套异常给力的程序,还能以狂暴速度移植到多个计算机平台,代码绝大部分不用改,震撼亲妈,大家用了都说好,给出了业界无法忽视的成功证明,开创了一个时代。

现在,业内的各路权威人士都在主张使用高级语言。因此,UNIX 岂敢自称革命的推动者。即便如此,不是所有从事系统研制的机构都愿意上这条贼船。也许 UNIX 可以推他们最后一把。在规模最大的 PDP-11 配置上,UNIX 可以支持 48 个用户同时使用,约为硬件厂商最接近的可比系统所支持用户数的两倍;换成一个精简版但是仍然用 C 写成、仍然被视作同一个系统的 UNIX,它能在 LSI-11 微型计算机上用 8K 词的内存支撑单个用户使用。

译者注:事后看来,UNIX 被称为高级语言革命的推动者,无疑是当之无愧的。

UNIX 所未提供的功能

有些其他系统提供的机制,在 UNIX 中尚未实现。其中不少对某些应用场景有用,甚至不可或缺——以至于现在已经存在若干 UNIX 变体,每一种都实现了下文所举的可能添加的机制的一个子集。这些变体的存在本身就是应该整一个统一版本、在其中纳入这些扩展、将它们做得更一般化的有力理由。然而,我们也必须能说服自己:拟议的扩展并非某个构想过窄、孤立无援、无法与系统其余部分良好配合的“功能”。我们还必须认识到,作为 UNIX 最常见的运行平台,PDP-11 系列计算机有限的地址空间对系统规模施加了严苛限制。

UNIX 不是一个实时操作系统。它无法将进程锁在内存里不准换页换出,也无法允许进程直接操作 I/O 设备。MERT [13] 作为一个 UNIX 的扩展版本,则允许这些操作。它是一个三层结构的系统,包括一个内核、一个或多个监管进程、其他用户进程。其中一个监管进程是一个 UNIX 模拟器,所以所有常见 UNIX 软件都能用,就是性能有所降级。

UNIX 也没有通用的进程间消息传递机制,甚至连信号量这种受限的机制也没有。事实证明之前提到的管道已经足够实现密切相关、合作的进程之间的通信了。这里所谓“密切相关”,是指这些进程拥有共同的祖先进程,并由祖先进程为它们建立通信连接。然而,管道并不适用于和服务多个用户的守护进程之间的沟通。在有些地方,一种叫“命名管道”的机制(译者注:待补充)被用来做这种事:指定一个具名文件,由一个进程负责读取。任何有权限写入该文件的人都可以把消息写入该文件中,而该进程则一直等待,直到消息进来。

对程序而言,输入输出通畅表现为同步操作,程序在 I/O 完成之前会阻塞等待。对于磁盘上的文件,预读和延迟写由操作系统在背后完成。这些系统机制足够高效,对用户代码的简化作用显著,所以我们毫不犹豫地选择这样设计。在一些特殊应用场景里,用户会希望启动多个 I/O 流并等待,直到其中有一个操作完成。如果流的数量比较小,我们可以用多个进程来模拟这种行为。然而,负责编写 UNIX 的 ARPANET 接口 [14] ——NCP(“网络控制程序”)——的作者认为真正的异步 I/O 会给他们的实现带来巨大的性能提升。

译者注:当然,谁又会知道,ARPANET 这样一个高大上的前瞻军用研究项目,会变得如此重要、如此无处不在?UNIX 在异步 I/O 机制上的缺乏成为了它后来在网络时代的一个大坑。不过,原作者选择在当时不去折腾异步 I/O 也是合理的,因为这本身就是一个高度非平凡的问题,贯穿了系统设计、编程语言设计以及当时刚刚起步的计算机网络领域,硬究起来深不见底。

进程之间不共享内存,只有程序代码是以只读方式共享的。受控的共享可写内存对于一部分情形是有价值的:一部分是为了适应 PDP-11 虚拟内存过小的限制,一部分是为了简化异步但是紧耦合的进程之间的通信。PDP-11 捉襟见肘的虚拟内存空间是这么做的一个尤为重要的原因。许多以 UNIX 为平台开展的项目都希望拥有更好的进程间通信机制——不管是消息传递还是共享内存——因为它们不得不因地址空间限制,把一个逻辑上本应由单一进程完成的任务,拆成多个进程。贝尔系统内部的若干应用是如此,INGRES [8] 也是如此。

UNIX 不会试图将不可共享的设备分配给特定用户。一些设备一次只能被一个进程打开,但是系统没有提供机制将设备在一段时间或者几个命令的执行期间预留给某个用户。很少有地方觉得这样是一个问题。比如说,行式打印机通常专供一个缓冲打印队列的假脱机程序管理;直接使用它要么是被禁止的,要么需要通过非正式的约定来协调。磁带机则一向采用非正式的协调分配。然而,即使真的有实现预留设备的需要,也可以在完全不修改操作系统的情况下实现分配、释放设备的命令。这是因为设备文件与普通文件一样,遵循同一套权限机制,所以这样的 assign 命令可以通过在使用期间,临时将设备的拥有者改成调用者来实现。

给操作系统设计人员的建议

以下是我们强烈推荐给操作系统设计人员的一些建议:

  • 我们没有任何借口不提供一个层级化文件系统。它不仅可以将相关的文件分类到目录中方便使用;查找文件也很高效,因为查找只须沿路径逐级搜索,而不必在一个全局名字表中遍历全部文件,因此搜索范围受到路径深度和各级目录大小的约束;此外,它还很容易实现。
  • “record”的概念似乎只是历史遗留,来自使用 80 列打孔卡的时代。文件本身应该只被视为字节序列。
  • 我们应当竭力保证,操作系统对文件只提供一种统一的抽象(即上述字节序列)。这是让程序能组合协作的根本。
  • 操作系统应该以有利于移植的高级语言编写。有的厂商既生产多个机器系列,又为每个系列另造不同的操作系统和实用工具,这纯粹是浪费钱。

致谢

UNIX 的绝大部分设计和实现都出自 Ken Thompson 之手。本文使用“我们”一词时,也包括他在内。我希望自己在本文中没有曲解他的见解。

译后记:真是折腾死我了,这是迄今为止我做过的规模最大的翻译,考据工作量更是远超预期,甚至远长于写作本身。

感谢 Seraph JACK3TUSK 帮忙校读草稿并提供宝贵建议。

🔏 Jack Wang @ Santa Cruz, CA, PST 2026/09/05

参考文献

  • [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.