try ai
科普
编辑
分享
反馈
  • 操作系统史

操作系统史

SciencePedia玻尔百科
核心要点
  • 操作系统创建了关键的幻象,例如虚拟内存和并发进程,以使复杂的硬件易于管理和编程。
  • 核心的操作系统设计涉及基本的权衡,例如进程间通信中的性能与安全性,或CPU调度中的公平性与响应性。
  • 并发控制是一个严峻的挑战,其解决方法涵盖了从防止优先级反转的锁定协议到解决无锁算法中ABA问题的带标签指针等多种技术。
  • 为操作系统开发的抽象、一致性和安全性原则在分布式系统、数据库甚至生物信息学等领域具有普适的应用。

引言

操作系统(OS)是作为计算机硬件与用户之间中介的基础软件,负责管理资源并创建有用的抽象。其主要意义在于能将硬件原始、有限和混乱的本质,转变为一个连贯、强大且易于使用的环境。本文通过探索操作系统核心原理的演变,来解答操作系统如何实现这一壮举。本文审视了定义该领域的历史问题和巧妙的解决方案,揭示了在性能、安全性和便利性之间不断的权衡协商。

通过这次探索,您将对驱动现代计算的大师级幻象有更深入的理解。第一章“原理与机制”,剖析了操作系统的内部工作原理,从虚拟内存的优雅技巧,到CPU调度的复杂舞蹈,再到内核内部并发的危险挑战。随后的“应用与跨学科联系”一章则展示了这些概念的普适性,说明了操作系统原理如何为解决分布式系统、数据库设计乃至生物信息学中的问题提供了语言,证明了从管理单台机器中学到的经验教训已经塑造了更广阔的技术世界。

原理与机制

操作系统是一位魔术大师。它将计算机硬件原始、混乱且有限的现实——一次只能做一件事的处理器、有限的内存池以及固执缓慢的外围设备——变幻出一个充满优美抽象的世界。在这个世界里,每个程序都享有一种幻象,仿佛它独占一个强大的处理器、一个广阔的私有内存空间,并且能毫不费力地访问文件和网络。操作系统的历史就是发明、改进和完善这些幻象的故事。这是一段由不懈追求两个目标所驱动的旅程:让计算机更易于使用,以及驯服共享资源的混乱。这段旅程并非一路坦途,而是一场对权衡的迷人探索,其中每一种问题的解决方案都会带来新的、更微妙的待解挑战。

无限私有内存的幻象

在计算的早期,内存是一个严酷的现实。程序被分配一块物理内存,仅此而已。如果你的程序大于可用内存,那么负担就完全落在你——程序员——的肩上。你必须手动将你的程序分解成称为​​覆盖(overlays)​​的片段,并编写明确的代码,在适当的时候将正确的片段从缓慢的磁盘加载到内存中。这听起来既繁琐又容易出错,事实也的确如此。一个错误就可能导致程序崩溃,或者更糟,悄无声息地损坏其数据。那是一个充满体力劳动的世界。

革命来自于一个惊人优雅的想法:​​虚拟内存​​。既然操作系统对硬件有特权视图,为什么还要让程序员管理内存,而不是让操作系统来代劳呢?操作系统与处理器的内存管理单元(MMU)合谋创造了一个宏大的幻象。每个程序都被告知它拥有自己巨大、原始且私有的地址空间,从地址零开始,延伸数千兆字节或数万亿字节。这是它的虚拟地址空间。实际上,物理RAM是由称为​​页帧​​的许多固定大小的小内存块组成的杂乱拼图。操作系统保留着一套秘密的映射表,称为​​页表​​,用以将程序使用的虚拟地址转换成这些页帧的真实物理地址。

当一个程序试图访问一个虚拟地址,而其对应的页面当前不在物理页帧中时,硬件会触发一种称为​​页错误(page fault)​​的特殊中断。这不是一个错误,而是给操作系统施展魔法的信号。操作系统暂停该程序,在磁盘(即​​后备存储​​)上找到所需的数据,将其加载到RAM中一个可用的页帧里,更新它的秘密映射表,然后无缝地从程序中断的地方继续执行。对程序而言,就好像那块内存一直都在那里。这种机制被称为​​按需分页​​。

但是,这种新的自动化魔法真的比旧的手动方法更好吗?一个思想实验揭示了权衡的核心。想象一个运行着多个作业的系统。在一个覆盖系统中,性能取决于程序员安排覆盖以最小化磁盘访问的技巧。在一个虚拟内存系统中,性能取决于操作系统将程序的​​工作集​​——它活跃需要的页面集合——保留在内存中的能力。如果分配给一个作业的内存 aia_iai​ 小于其工作集 WiW_iWi​,它就会遭遇页错误。关键的洞察在于磁盘访问的成本。在那个时代的磁盘上,服务一次随机页错误的时间 fff 主要由磁盘磁头的物理移动(寻道时间)决定。这个时间与处理器速度相比是巨大的。覆盖技术如果设计完美,可以顺序加载数据,这样更快(成本为 ggg)。然而,“完美设计”是一个沉重的负担,而且工作负载很少那么可预测。虚拟内存之所以胜出,是因为它为所有程序自动提供了一个“足够好”的解决方案,而不仅仅是为那些手工优化的程序。它之所以能蓬勃发展,是因为随机错误的成本如此之高,以至于即使存在一定的错误率,只要它能消除手动覆盖带来的巨大的人力投入和脆弱性,也是可以接受的。分析表明,只有当随机错误服务时间 fff 低于一个临界阈值 f⋆f^{\star}f⋆ 时,覆盖技术才具有竞争力。从历史上看,对于磁盘,fff 远高于这个阈值,这为虚拟内存的胜利一锤定音。

彼此对话:进程间通信的艺术

一旦操作系统能够同时处理多个程序,下一步自然就是让它们进行协作。这需要​​进程间通信(IPC)​​。IPC的两种基本哲学构成了一个美丽的二分法。你可以使用​​消息传递​​,就像两个人通过邮政服务(操作系统)寄信来通信。发送方写一封信,交给操作系统,操作系统再将信投递到接收方的邮箱。另一种方法是​​共享内存​​,这就像给两个人一块共享的白板。他们都可以直接在上面读写,但必须小心不要同时覆盖对方的工作。

每种方法都有其优雅之处和代价。消息传递安全而简单;操作系统充当裁判,确保消息正确传递,并且进程不能干涉彼此的私有内存。但这种安全性是有代价的。发送一条消息通常需要至少两次​​系统调用​​(一次发送,一次接收),每次都会产生固定的开销 σ\sigmaσ。此外,操作系统必须将消息数据从发送方的内存复制到内核内的一个临时缓冲区,然后再从内核复制到接收方的内存。

相比之下,共享内存可以快得惊人。经过一个初始设置阶段(这也涉及一次系统调用)后,进程可以以原生的内存速度读写共享区域。每次数据交换都没有额外的复制,也没有内核的参与。这通常被称为​​零拷贝​​通信。但问题在于,安全性消失了。进程本身现在要负责同步它们对共享白板的访问,而这是一项出了名的棘手任务。

那么,哪种更好?答案,正如在系统设计中经常出现的那样,是“视情况而定”。我们可以建立一个简单的模型来看看为什么。发送一条大小为 xxx 的消息的总时间(延迟)可以分解为一个固定成本(系统调用开销)和一个可变成本(数据复制时间)。

  • 对于消息传递(套接字):Tsocket(x)=nsσ+2xBkT_{\text{socket}}(x) = n_s \sigma + 2 \frac{x}{B_k}Tsocket​(x)=ns​σ+2Bk​x​ (其中 nsn_sns​ 是系统调用次数,BkB_kBk​ 是内核的内存复制带宽)。
  • 对于共享内存:Tshmem(x)=nshσ+xBccT_{\text{shmem}}(x) = n_{\text{sh}} \sigma + \frac{x}{B_{cc}}Tshmem​(x)=nsh​σ+Bcc​x​ (其中 nshn_{\text{sh}}nsh​ 是同步所需的系统调用次数,BccB_{cc}Bcc​ 是内存子系统的有效带宽)。

通过令这两个延迟相等,我们可以解出一个阈值消息大小 x⋆x^{\star}x⋆。对于小于 x⋆x^{\star}x⋆ 的消息,固定的系统调用开销在总时间中占主导地位。消息传递方法,对于简单的交换可能使用更少的系统调用,实际上可能更快。但对于大于 x⋆x^{\star}x⋆ 的消息,消息传递模型中两次复制数据的成本成为压倒性因素,零拷贝的共享内存方法则大获全胜。因此,一个现代操作系统不会二选一,而是两者都提供,让开发者为具体工作选择合适的工具。

进步的无形成本:架构及其后果

操作系统的演变与其所运行的硬件的演变密不可分。一个典型的例子是从​​32位到64位​​架构的巨大转变。其主要特性显而易见:能够寻址的内存量呈天文数字般增长(从4GB到16EB)。但这一进步也带来了不易察觉的隐性成本:内存膨胀。

在64位系统中,一个指针——即一个存储内存地址的变量——的大小翻倍,从4字节变为8字节。考虑一个包含许多指针的数据结构,比如树或链表。当为64位系统重新编译时,这个结构的大小会显著增加。这不仅意味着你需要更多的RAM;它还会通过内存层次结构,特别是​​CPU缓存​​,对性能产生更隐蔽的影响。

缓存是一个小型、极快的存储器,用于存放最近使用的数据,以避免对慢速主RAM的访问。数据在RAM和缓存之间以称为​​缓存行​​(例如64字节)的固定大小块进行移动。如果你需要的数据在缓存中,这就是一次“命中(hit)”—一次快速访问。如果不在,就是一次“未命中(miss)”—在从RAM取回新行时的一次慢速访问。

现在,考虑一个大小为 sss 的记录。如果它在内存中的存储方式恰好跨越了两个缓存行的边界,那么对该记录的单次访问可能需要从RAM中获取两个缓存行,可能导致两次未命中而不是一次。记录越大,它跨越缓存行边界的概率就越高。

让我们来量化这一点。如果一个记录在32位系统上的大小为 S32S_{32}S32​,那么它在64位系统上的新大小 S64S_{64}S64​ 将根据其字段中作为指针的百分比 ppp 而增加。在缓存行大小为 LLL 的系统上,访问大小为 sss 的记录所触及的缓存行数的期望值约为 1+s/L1 + s/L1+s/L。64位系统与32位系统上所触及的预期缓存行数的比率,我们可以称之为​​内存膨胀因子​​ β\betaβ,结果是 β=1+S32p100(L+S32)\beta = 1 + \frac{S_{32} p}{100(L + S_{32})}β=1+100(L+S32​)S32​p​。这个优美的小公式揭示了很多信息。性能损失并不仅仅是新旧大小的简单比率。它微妙地取决于原始大小、缓存行大小和指针密度。这完美地阐释了系统中的一个核心原则:天下没有免费的午餐,架构的进步往往会带来一系列新的优化挑战。

裁判的困境:调度、公平与饿死

当有多个进程准备运行时,操作系统必须扮演裁判的角色,决定在任何特定时刻哪个进程可以使用CPU。这就是​​CPU调度​​。理想的调度器应该是个通灵者,总是选择能最快完成的作业,以最小化总体等待时间。这就是​​最短作业优先(SJF)​​策略背后的思想,该策略在最小化平均等待时间方面被证明是最优的。唯一的问题是,它需要预知未来。

在现实世界中,调度器必须依赖预测。一种常用技术是​​指数平均法​​,即预测一个进程下一个CPU突发时间的长度是其之前实际突发时间长度的加权平均值。SJF的一个抢占式版本,称为​​最短剩余时间优先(SRTF)​​,会总是运行预测剩余时间最短的进程。但是,当一个我们没有任何历史记录的新进程到达时会发生什么?操作系统必须给出一个初始猜测值 TinitT_{\text{init}}Tinit​。一个糟糕的猜测可能导致极差的性能。

考虑一个长时间运行的计算作业,突然被一连串短的、交互式的作业(如文本编辑器中的按键操作)打断。如果操作系统对这些短作业的CPU突发长度做出了悲观的初始猜测——比如,预测它们会运行很长时间——那么SRTF调度器会看到它们庞大的预测时间,将其与长作业不断缩小的剩余时间相比较,然后拒绝抢占。结果呢?你的打字出现延迟,系统感觉迟钝。

现在来看一个意外情况。如果我们使用一个看似“不公平”的策略,如​​后进先出(LIFO)​​,即最新到达的作业总是获得CPU,会怎样?在这种情况下,每当一个新的交互式作业到达时,它会立即抢占任何正在运行的作业。它的响应时间基本上为零!系统感觉响应极其迅速。当然,代价是最初那个长作业以及任何更早的交互式作业被反复推到队列的末尾。LIFO为新任务的爆发提供了极佳的响应时间,但牺牲了公平性,并有​​饿死(starvation)​​的风险——即一个较早的作业可能永远无法运行。

这揭示了一个深刻的真理:没有单一的“最佳”调度策略。选择是在相互竞争的目标之间进行丰富的权衡。现实世界的调度器,比如Linux中的调度器,是复杂的混合体,试图兼顾两者的优点。它们可能使用类似LIFO的行为来偏爱交互式任务,但同时也整合了​​老化(aging)​​机制,即逐渐增加等待中作业的优先级,以确保即使是最老、被忽略的任务最终也能轮到。

在​​实时操作系统(RTOS)​​中,这种平衡行为事关生死,正如1997年火星探路者(Mars Pathfinder)任务中著名的发现。该航天器的计算机因一个名为​​无界优先级反转​​的微妙调度错误而不断重启。想象三个任务:一个高优先级任务H,一个中优先级任务M,和一个低优先级任务L。任务L获得了一个共享资源(如数据总线)的锁。然后,任务H准备好运行。它抢占了L,但很快也需要同一个总线锁。H现在阻塞,等待L释放锁。致命之处在于:任务M,虽然与锁无关,但优先级高于L,此时也准备就绪。它抢占了L!现在,高优先级任务H实际上被中优先级任务M阻塞了,因为M阻止了低优先级任务L运行并释放锁。这种阻塞的持续时间是不可预测的,并且可能是无限的。

拯救了这次任务的解决方案是操作系统理论中一个优美的片段:​​优先级天花板协议(PCP)​​。其思想是为每个共享资源分配一个“优先级天花板”,即曾经使用该资源的最高优先级任务的优先级。核心规则是:一个任务只有在它的优先级严格高于其他任务当前持有的所有锁的天花板时,才被允许获取一个锁。这条规则的效果是,一个任务最多只会被一个较低优先级任务的一个临界区阻塞。优先级反转仍然可能发生,但它是受限且可预测的。通过计算所有资源的天花板,工程师可以分析系统并推导出任何高优先级任务可能需要等待的保证上限。这是原则性设计的一次胜利,将一个神秘的、灾难性的故障转变为一个可管理的、可量化的延迟。

内核中的堡垒:内核并发

随着计算机架构师将更多的处理器集成到单个芯片上,操作系统面临着一场新的内部战争。内核,曾经是一个宁静的、单线程的君主,现在必须能够在多个CPU上同时运行。这意味着内核自己的数据结构可能被多个CPU同时访问,打开了并发错误的潘多拉魔盒。

在这一转变中的一个关键步骤是使内核​​可抢占​​——允许一个正在运行内核代码的任务被中断并由另一个任务替换。虽然这提高了响应性,但也造成了极其微妙的竞争条件。例如:

  • 一个线程进入系统调用。内核代码首先读取当前时间以记录开始时间,但在它能设置标志 I_sys = true 以表明它现在处于内核模式之前,一个定时器中断发生了。中断处理程序检查该标志,看到的是 false,并错误地将这个时间片归因于用户。系统时间丢失了。
  • 一个线程在CPU 0上开始一个系统调用,从其本地时钟 C0()C_0()C0​() 记录了开始时间。它被抢占并迁移到CPU 1。当它退出系统调用时,它从CPU 1的时钟 C1()C_1()C1​() 读取结束时间。如果两个CPU上的时钟没有完美同步,计算出的持续时间 C1(texit)−C0(tentry)C_1(t_{exit}) - C_0(t_{entry})C1​(texit​)−C0​(tentry​) 就成了无意义的垃圾数据。
  • 操作系统可能使用两种方法来计算系统时间:定时器中断的周期性采样和框定系统调用的开始和结束。如果将框定持续时间加到总时间的这段代码是可抢占的,那么一个定时器滴答可能在此计算中间发生,导致两种机制都为同一时间间隔计时,从而出现​​重复计数​​。

这些挑战推动了大量的创新,从一个序列化整个内核的“大内核锁”发展到保护单个数据结构的大量细粒度锁。一个更前沿的领域是​​无锁​​算法的开发,它使用特殊的原子硬件指令来管理并发,而完全不使用传统的锁。

然而,无锁编程有其自身深层次的陷阱。最著名的是​​ABA问题​​。考虑一个用 head 指针和原子​​比较并交换(CAS)​​指令实现的无锁栈。要弹出一个元素,一个线程读取头指针 A,然后读取 A 的 next 指针 B。现在它准备执行 CAS(head, A, B),这个操作只有在头指针仍然指向 A 时才会成功。但在它执行之前,该线程被暂停了。在它暂停期间,另一个线程弹出了 A,然后第三个线程分配了一个新节点(恰好在刚刚被释放的同一内存地址 A 处),并将其推入栈中。栈的状态发生了巨大变化,但 head 指针再次指向 A。现在,第一个线程被唤醒并执行它的 CAS(head, A, B)。CAS成功了,因为 head 的值是 A,正如所料。但这是一场灾难;它破坏了栈,通常导致新推入的元素丢失。

解决方案与问题本身一样巧妙:​​带标签的指针​​。head 位置不只存储一个指针,而是存储一对:一个指针和一个版本计数器,或称“标签”。每次 head 被成功修改时,版本计数器就递增。现在的CAS会同时检查指针和版本:CAS(head, (A, v), (B, v+1))。在我们的ABA场景中,中间的操作会把状态变成 (A, v+2)。第一个线程过时的 CAS(head, (A, v), ...) 现在会正确地失败,因为版本号 v 不再匹配。这项技术需要确保版本计数器不会在线程停滞期间“回绕”并重复。这催生了一项优美的工程设计:给定最大更新速率 ρ\rhoρ 和最大停滞时间 Δ\DeltaΔ,可以计算出标签所需的最小位数 bbb,即 2b>ρΔ2^b > \rho \Delta2b>ρΔ,以保证安全。

永无止境的战斗:持久性与安全

除了提供便利的幻象和管理并发之外,操作系统也是一个守护者。它必须保护数据免受硬件故障和恶意攻击。

如果在你保存文件时电源突然中断会发生什么?像向文件追加内容这样的操作可能涉及多个、独立的磁盘写入:一次更新文件的数据块,另一次更新​​元数据​​(如文件的大小和块位置)。如果在这两次写入之间发生崩溃,文件系统可能会处于损坏、不一致的状态。

​​日志文件系统​​就是为了解决这个问题而发明的。核心思想很简单:在对活动的文件系统执行任何复杂、多部分的更新之前,首先在一个称为​​日志​​的特殊日志中写下你打算做的所有事情的描述。一旦整个事务安全地记录在日志中,你就可以将这些更改应用到文件系统本身(这个过程称为“检查点设置”)。如果发生崩溃,操作系统在重启时只需读取日志,看到未完成的事务,然后要么完成它,要么撤销它,从而将文件系统恢复到一致状态。

然而,这种安全性涉及与性能的权衡。不同的日志​​模式​​代表了这条权衡曲线上的不同点。

  • ​​data=journal 模式​​是最安全的:数据和元数据都首先写入日志。它可以在任何时刻的崩溃中幸存,但需要将所有东西写两次(一次到日志,一次到最终位置),因此速度较慢。
  • ​​writeback 模式​​是最快的:只有元数据更改被写入日志。实际的数据块被懒惰地写入其最终位置。这提供了元数据的一致性(文件系统结构不会损坏),但对数据本身不提供任何保证。一次崩溃可能导致一个大小正确但填充着旧数据或垃圾数据的文件。
  • ​​ordered 模式​​是一个巧妙的折中:它确保数据块在它们相应的元数据提交到日志之前被写入其最终位置。这个简单的顺序规则避免了writeback模式中最明显的异常情况,同时又避免了完全数据日志记录的双重写入惩罚。 在它们之间做出选择是一种风险管理行为。一个形式化的效用函数 U(R,P)=R⋅(1−P)U(R, P) = R \cdot (1-P)U(R,P)=R⋅(1−P),其中 RRR 是吞吐量,PPP 是数据丢失的概率,可以帮助量化这一选择。对于一个崩溃非常罕见的系统,writeback 的高吞吐量可能是理性的选择。而对于一个数据完整性至关重要的系统,则必须选择更安全的模式。

一场类似的概率之战也发生在安全领域。一种常见的攻击手段是诱骗程序跳转到一段恶意代码。为此,攻击者需要知道程序内存中各个部分的位置。​​地址空间布局随机化(ASLR)​​是一种防御措施,它通过在每次程序运行时随机放置栈、堆和共享库等关键内存区域来挫败这种攻击。

ASLR将确定性攻击变成了猜谜游戏。它的强度可以用​​熵​​的比特数 HHH 来衡量。有 HHH 比特的熵,就有 2H2^H2H 个可能的随机位置。然而,攻击者可能不需要猜中那个唯一正确的位置。如果他们可以运行或攻击同一个程序的许多实例,他们可以简单地玩概率游戏,希望两个实例会因纯粹的巧合而最终拥有相同的随机化布局。这是一个经典的​​生日悖论​​场景。在 nnn 个进程中发生碰撞的概率在 nnn 远小于 2H2^H2H 之前是不可忽略的。近似的碰撞概率由 P(collision)≈1−exp⁡(−n(n−1)/(2⋅2H))P(\text{collision}) \approx 1 - \exp(-n(n-1) / (2 \cdot 2^H))P(collision)≈1−exp(−n(n−1)/(2⋅2H)) 给出。这表明ASLR并非万能药,而是一种概率性防御,其有效性取决于拥有足够的熵来抵御攻击者可以观察到的并发实例数量。

操作系统的未来:操作系统正在消失吗?

将操作系统视为管理单台机器的单体内核的传统观点正在演变。今天,应用程序通常被打包在容器中,作为云中的“无服务器”函数运行,或者完全生活在网络浏览器的沙箱内。操作系统开始“消失”在基础设施中。但它所解决的基本问题并没有消失。

让我们基于这一趋势进行一个推测性的“如果”设想。想象一个另类的未来,操作系统的主要接口——它的应用程序二进制接口(ABI)——不是原始的系统调用表,而是一个高级的、沙箱化的运行时,比如​​WebAssembly(WASM)​​。在这个世界里,所有程序都被编译成WASM。当一个程序需要操作系统服务时,它调用一个“宿主函数”,WASM运行时会验证这个调用,根据一个能力列表进行检查,然后才将其翻译成一个真正的内核系统调用。

权衡会是什么?

  • ​​性能:​​这个翻译层增加了开销。即使有即时(JIT)编译来优化热点路径,与传统的、直接到内核的ABI相比,额外的验证、编组和能力检查都会给每个系统调用增加周期。性能被用来换取安全性和可移植性。
  • ​​安全:​​这个模型提出了一个有趣的安全权衡。WASM运行时本身成为可信计算基(TCB)的一部分,代表了一个新的攻击面(prp_rpr​)。然而,WASM在设计上是内存安全的。它可以消除一整类源于内存不安全的用户代码(pmp_mpm​)的常见漏洞,如缓冲区溢出。正如分析所示,如果运行时设计良好,消除用户内存错误所带来的风险降低,可能远远超过运行时本身带来的新风险,从而实现净安全增益。

这个假设的场景优美地概括了操作系统的整个历史。它是在便利性、性能和安全性之间进行权衡管理的循环中又一个转折点。具体的技术在变——从覆盖到分页,从套接字到WASM——但基本原则依然存在。操作系统,无论是以一个清晰的实体出现,还是消融在云的结构中,都将继续其本质工作:在硬件原始、无情的逻辑之上,构建优雅、健壮且有用的幻象。

应用与跨学科联系

操作系统的原理,诞生于管理单台计算机内部电子精妙舞蹈的实际需求,但它们并未被禁锢在那个盒子里。如同物理学的基本定律,它们已被证明是管理复杂性、确保一致性以及在任何规模上构建可靠系统的通用模式。操作系统的历史不是一本尘封的书;它是一份活的文件,其章节如今正在分布式数据库、生物信息学和计算机安全等不同领域被书写。审视这些应用,就如同看到古老而根本的问题的回响,以崭新而优美的方式被解决。

抽象的艺术与信息的本质

从本质上讲,操作系统是一个宏大的叙事者。它将硬件混乱、肮脏的现实——旋转的磁盘、闪烁的内存单元、原始的比特流——编织成一个优美、连贯的叙事。这些故事中最熟悉的就是文件系统。整洁的文件夹和文件层次结构是一个强大的幻象,一个将我们从追踪磁盘上物理块地址的繁琐工作中解放出来的心智模型。然而,即便是这个简单的故事也包含了深刻的设计选择。

考虑一下表示父目录的那个不起眼的 .. 符号。在一个简单的树结构中,每个子节点只有一个父节点,.. 的含义是明确的。但如果我们想更高效,在两个不同的项目间共享一个子目录呢?目录结构就不再是树,而是一个更通用的有向无环图(DAG)。现在,一个共享目录有两个父节点。当我们在里面输入 cd .. 时,应该去哪里?这个看似微不足道的问题揭示了一个根本性的张力。我们是回到刚刚来的那个目录,以保持用户直观的导航感?还是选择一个单一的、“规范的”父节点,以确保询问“我在哪里?”(用像 getcwd() 这样的命令)总是得到一个单一、确定的答案?最优雅的解决方案通常是混合型的:系统记住走过的路径以实现直观导航,但当不存在这样的历史时(例如,对于直接在该共享目录中启动的进程),则回退到指定的首要父节点。我们日常计算机使用的简洁性,就是建立在这样深思熟虑的妥协之上。

将文件系统表示为图的想法可以更进一步。如果我们能捕捉它在某一时刻的状态,我们能否捕捉它的整个历史?想象一个审计日志,需要重构过去任何一个时间点的目录结构确切状态。一种幼稚的方法是在每次更改后都做一个完整的快照,但这极其浪费。一个更优美的解决方案源于关注每个独立链接的生命周期。我们不记录整个图,而只是记录每个父子链接存在的时间区间——一个链接被创建时的“诞生”时间,以及它被移除时的“死亡”时间。要查看时间 ttt 的文件系统,我们只需收集所有在那个时刻“存活”的链接。这种视角上的转变,从记录状态到记录状态变迁,是一个强大的思想,构成了时态数据库和高级版本控制系统的基础。

当然,没有抽象是完美的。操作系统不知疲倦地为五花八门的硬件设备提供统一的接口,但有时底层的现实会渗透出来。当你插入一个外部硬盘时,操作系统可能需要充当实时翻译,将USB接口使用的协议(如SCSI)的命令转换成磁盘的本地语言(如ATA)。通常,这能完美工作。但如果你试图用一个高级诊断工具来读取驱动器的详细健康报告(其SMART数据),请求可能会失败。USB-SATA桥接芯片——那个硬件翻译器——可能不理解那个高级功能的特定命令,请求就在翻译中丢失了。这是一个令人谦卑的提醒:我们优雅的软件抽象终究是与物理现实的一场对话,而这场对话并不总是完美的。

一致性的巨大挑战

整个计算领域最深刻的挑战之一,是在多件事情同时发生时保持对世界的一致看法。这个问题即便在单台机器上也存在。假设你需要对一个文件系统运行检查(fsck)以验证其完整性。你不能在一个移动的目标上执行这个检查;在另一个进程同时修改文件系统元数据时读取它,只会导致混乱。操作系统必须提供一种机制来创造一个静止的瞬间。它通过一个精心编排的“冻结”操作来实现这一点:新的写入请求被暂时暂停,内存中所有待处理的更改都被一丝不苟地刷新到磁盘,只有到那时,当磁盘上的映像完全静态和一致时,检查才开始。这是一场静止与同步的优美舞蹈,是整个一致性问题的缩影。

当我们从一台计算机转向多台计算机时,这个挑战的复杂性呈爆炸式增长。想象一个存储在中央服务器上的文件,被不同机器上的客户端访问。为防止损坏,客户端使用一个分布式锁服务。客户端 C1C_1C1​ 获取了写锁,开始写入,然后灾难发生——网络故障将它与锁服务切断。锁服务注意到 C1C_1C1​ 已经失联,最终宣布其锁过期,并将一个新的写锁授予了客户端 C2C_2C2​。我们现在面临一个“裂脑(split-brain)”悖论:C1C_1C1​ 和 C2C_2C2​ 都认为自己拥有独占的写入权限。由于两者都仍然连接到存储服务器(它对这场高层戏剧毫不知情),它们的写入可能会交错并损坏文件。

解决方案是深刻的,并阐明了分布式系统的一个关键原则:你不能信任客户端。存储服务器本身必须是真理的最终仲裁者。锁服务不仅要授予许可,还必须为每个锁授予一个唯一的、单调递增的“隔离令牌(fencing token)”——就像一个按顺序编号的密码。然后,存储服务器执行一条简单而铁板一块的规则:它只接受带有最新令牌的写操作。来自过时客户端 C1C_1C1​ 的、携带旧令牌的任何写入都会被直接拒绝。这就“隔离”了流氓客户端,在最后一刻确保了数据的完整性。

同样对一致性的追求,是另一个完整领域——数据库系统——的灵魂。数据库事务提供了一个称为可串行化的强大承诺——即每个事务都像是完全独立地、以某种串行顺序运行的幻象,不受任何干扰。一个在许多并行处理器上运行的数据库引擎如何为成千上万的并发用户维持这种幻象?它使用高度复杂的协议,这些协议是操作系统并发原语的直接思想后代。一种方法是严格两阶段锁定,其中对数据的锁按需获取并一直持有到事务结束。另一种更现代的方法是可串行化快照隔离(SSI),这是一种乐观方法,每个事务都在数据的一致快照上工作。在提交时,系统会巧妙地检查此事务与其他并发事务之间的依赖关系是否可能形成一个悖论(串行化图中的一个环)。如果存在,它会中止一个事务以打破循环,为所有其他事务保留完美的串行幻象。

系统与应用的微妙舞蹈

操作系统通常是我们计算中的沉默伙伴,但它看不见的工作可能会产生深远而出人意料的影响。考虑透明内存压缩,这是一个旨在通过压缩最近未使用的数据来节省内存的操作系统特性。任何压缩算法的效率都取决于它所处理数据的规律性;一个充满零的页面远比一个充满随机噪声的页面更易于压缩。

现在,想象一个科学程序正在遍历一个大的数字矩阵。在像C这样的语言中,矩阵通常以“行主序”存储,这意味着同一行的元素在内存中是连续的。如果程序逐行遍历,它会顺序访问内存。但如果算法需要逐列遍历,它的内存访问将以大步幅跳跃。如果这个程序正在执行浮点运算——这种运算并非完全满足结合律——那么不同的操作顺序可能导致微小的舍入差异,从而在矩阵中产生略微不同的最终值。这反过来意味着内存页面上的字节模式是不同的。完全有可能,由列式迭代产生的字节模式规律性较差,因此操作系统对其的压缩效果也较差。在这里我们看到一个有趣的级联效应:一个应用程序的算法选择影响了其内存访问模式,这又影响了其数值结果,进而影响了底层操作系统内存节省特性的性能。这是一个引人注目的例子,说明了计算机系统的整体性、相互关联的本质。

在安全领域,操作系统与世界之间的相互作用比任何地方都更为关键。我们依赖操作系统作为引用监视器,即执行访问控制规则的守护者。一个操作系统可能提供使文件append-only(只追加)或完全immutable(不可变)的功能。但是,如果操作系统内核本身被获得root权限的攻击者攻破了呢?这些软件强制的规则就变得毫无意义。一个权限足够高的攻击者可以绕过文件系统抽象,直接向存储设备的原始块写入,随意修改或删除日志文件。

要构建一个真正可防篡改的日志,我们必须从一个连被攻破的操作系统都无法触及的锚点建立一个信任链。这就是像可信平台模块(TPM)这样的专用硬件所扮演的角色。正确的方法是创建一个加密哈希链:每个新的日志条目都与前一个条目的哈希值一起进行哈希。关键步骤是将最新的哈希值和一个记录条目数的单调计数器存储在TPM受保护的非易失性内存中,而不是磁盘上。攻击者可以篡改磁盘上的日志,但他们无法篡改TPM中的可信值。一个离线验证器可以从磁盘日志中重新计算哈希链,并与TPM的可信哈希值和条目计数进行核对。任何不匹配都是篡改的无可否认的证据。这代表了安全领域的一种范式转变,从信任守门人(操作系统)转向用一个无法被腐蚀的硬件公证人来验证账本。

操作系统的通用语言

也许操作系统最令人惊奇的方面是,为解决其问题而发展的概念如何成为一种通用语言,用于理解各种复杂、演进的系统。

以跨一大群服务器同步配置文件的挑战为例,其中一些服务器可能会与网络断开一段时间。如果两个不同的管理员在两个断开连接的节点上进行了更改,当节点重新连接时,我们如何合并他们的工作?这个问题催生了无冲突复制数据类型(CRDTs)的发明。通过精心设计数据结构(如集合或计数器),使其更新操作满足结合律、交换律和幂等律,我们可以保证即使更新在不同副本上以不同顺序应用,它们最终都会收敛到完全相同的最终状态,而无需任何中央协调。这种“最终一致性”的强大思想现在驱动着像Google Docs这样的协作编辑器、多人游戏和大规模分布式数据库。它也阐明了这种模型在何种情况下是不够的:要维持一个必须“始终”成立的不变量(比如“这个集群中只有一个领导节点”),CRDT的最终收敛是不够的。必须使用一个更强的、涉及协调和共识的模型来建立一个单一、线性的事件历史。

这种普适性最惊人的例子可能来自生物信息学领域。想象一下注释人类基因组的宏伟任务,这是一个涉及数百名科学家和自动化算法并行工作的协作努力。不同的团队不断地添加、删除和修改关于基因、外显子和调控元件的注释。这从本质上讲,是一个版本控制问题。像Git这样的现代版本控制系统的架构——它本身就是诞生于操作系统开发需求的工具——提供了完美的概念模型。注释的历史是一个由不可变提交组成的有向无环图(DAG)。但像用于源代码那样的简单文本合并是不够的。我们需要一个语义合并,它能理解基因组学的语言——染色体坐标、链和特征类型。我们甚至可以定义自定义的、基于证据的合并策略:例如,一个来自人类策展员的手动注释优先于一个来自自动化流程的注释,除非自动化注释提供了压倒性的更强证据。在这里,我们看到了为管理操作系统源代码的复杂性而锻造出的思想,被应用于管理我们对生命本身不断演进的集体知识。

因此,操作系统的故事不仅仅是关于计算机的。它是关于我们如何学会思考复杂性、管理并发,以及用不可靠的部件构建可靠、演进的系统的故事。这些是永恒的挑战,而为应对这些挑战而创造的智力工具,已成为21世纪科学和工程基本语言的一部分。