操作系统导论

第2章 操作系统介绍

一个正在运行的程序会做一件非常简单的事情:执行指令。处理器从内存中获取(fetch)一条指令,对其进行解码(decode)(弄清楚这是哪条指令),然后执行(execute)它(做它应该做的事情,如两个数相加、访问内存、检查条件、跳转到函数等)。完成这条指令后,处理器继续执行下一条指令,依此类推,直到程序最终完成。

操作系统将物理(physical)资源(如处理器、内存或磁盘)转换为更通用、更强大且更易于使用的虚拟形式。因此,我们有时将操作系统称为虚拟机(virtual machine)。

实际上,典型的操作系统会提供几百个系统调用(system call),让应用程序调用。由于操作系统提供这些调用来运行程序、访问内存和设备,并进行其他相关操作,我们有时也会说操作系统为应用程序提供了一个标准库(standard library)。

最后,因为虚拟化让许多程序运行(从而共享CPU),让许多程序可以同时访问自己的指令和数据(从而共享内存),让许多程序访问设备(从而共享磁盘等),所以操作系统有时被称为资源管理器(resource manager)。每个CPU、内存和磁盘都是系统的资源(resource),因此操作系统扮演的主要角色就是管理(manage)这些资源,以做到高效或公平,或者实际上考虑其他许多可能的目标。

将单个CPU(或其中一小部分)转换为看似无限数量的CPU,从而让许多程序看似同时运行,这就是所谓的虚拟化CPU(virtualizing the CPU),这是本书第一大部分的关注点。

现代机器提供的物理内存(physical memory)模型非常简单。内存就是一个字节数组。要读取(read)内存,必须指定一个地址(address),才能访问存储在那里的数据。要写入(write)或更新(update)内存,还必须指定要写入给定地址的数据。

每个进程访问自己的私有虚拟地址空间(virtual address space)(有时称为地址空间,address space),操作系统以某种方式映射到机器的物理内存上。一个正在运行的程序中的内存引用不会影响其他进程(或操作系统本身)的地址空间。对于正在运行的程序,它完全拥有自己的物理内存。但实际情况是,物理内存是由操作系统管理的共享资源。

不像操作系统为CPU和内存提供的抽象,操作系统不会为每个应用程序创建专用的虚拟磁盘。相反,它假设用户经常需要共享(share)文件中的信息。

文件系统必须做很多工作:首先确定新数据将驻留在磁盘上的哪个位置,然后在文件系统所维护的各种结构中对其进行记录。这样做需要向底层存储设备发出I/O请求,以读取现有结构或更新(写入)它们。所有写过设备驱动程序(device driver)的人都知道,让设备代表你执行某项操作是一个复杂而详细的过程。它需要深入了解低级别设备接口及其确切的语义。幸运的是,操作系统提供了一种通过系统调用来访问设备的标准和简单的方法。因此,OS有时被视为标准库(standard library)。

出于性能方面的原因,大多数文件系统首先会延迟这些写操作一段时间,希望将其批量分组为较大的组。为了处理写入期间系统崩溃的问题,大多数文件系统都包含某种复杂的写入协议,如日志(journaling)或写时复制(copy-on-write),仔细排序写入磁盘的操作,以确保如果在写入序列期间发生故障,系统可以在之后恢复到合理的状态。为了使不同的通用操作更高效,文件系统采用了许多不同的数据结构和访问方法,从简单的列表到复杂的B树。

一个最基本的目标,是建立一些抽象(abstraction),让系统方便和易于使用。抽象对我们在计算机科学中做的每件事都很有帮助。抽象使得编写一个大型程序成为可能,将其划分为小而且容易理解的部分,用C这样的高级语言编写这样的程序不用考虑汇编,用汇编写代码不用考虑逻辑门,用逻辑门来构建处理器不用太多考虑晶体管。抽象是如此重要,有时我们会忘记它的重要性,但在这里我们不会忘记。

设计和实现操作系统的一个目标,是提供高性能(performance)。

另一个目标是在应用程序之间以及在OS和应用程序之间提供保护(protection)。

保护是操作系统基本原理之一的核心,这就是隔离(isolation)。让进程彼此隔离是保护的关键,因此决定了OS必须执行的大部分任务。

操作系统也必须不间断运行。当它失效时,系统上运行的所有应用程序也会失效。由于这种依赖性,操作系统往往力求提供高度的可靠性(reliability)。

其他目标也是有道理的:在我们日益增长的绿色世界中,能源效率(energy-efficiency)非常重要;安全性(security)(实际上是保护的扩展)对于恶意应用程序至关重要,特别是在这高度联网的时代。随着操作系统在越来越小的设备上运行,移动性(mobility)变得越来越重要。

一开始,操作系统并没有做太多事情。基本上,它只是一组常用函数库。

在超越常用服务的简单库的发展过程中,操作系统在管理机器方面扮演着更为重要的角色。其中一个重要方面是意识到代表操作系统运行的代码是特殊的。它控制了设备,因此对待它的方式应该与对待正常应用程序代码的方式不同。

因此,系统调用(system call)的概念诞生了,它是Atlas计算系统[K+61,L78]率先采用的。

系统调用和过程调用之间的关键区别在于,系统调用将控制转移(跳转)到OS中,同时提高硬件特权级别(hardware privilege level)。用户应用程序以所谓的用户模式(user mode)运行,这意味着硬件限制了应用程序的功能。例如,以用户模式运行的应用程序通常不能发起对磁盘的I/O请求,不能访问任何物理内存页或在网络上发送数据包。在发起系统调用时 [通常通过一个称为陷阱(trap)的特殊硬件指令],硬件将控制转移到预先指定的陷阱处理程序(trap handler)(即预先设置的操作系统),并同时将特权级别提升到内核模式(kernel mode)。在内核模式下,操作系统可以完全访问系统的硬件,因此可以执行诸如发起I/O请求或为程序提供更多内存等功能。当操作系统完成请求的服务时,它通过特殊的陷阱返回(return-from-trap)指令将控制权交还给用户,该指令返回到用户模式,同时将控制权交还给应用程序,回到应用离开的地方。

在I/O进行和任务中断时,要支持多道程序和重叠运行。这一愿望迫使操作系统创新,沿着多个方向进行概念发展。内存保护(memory protection)等问题变得重要。我们不希望一个程序能够访问另一个程序的内存。

遗憾的是,对于操作系统来说,个人计算机起初代表了一次巨大的倒退,因为早期的系统忘记了(或从未知道)小型机时代的经验教训。例如,早期的操作系统,如DOS(来自微软的磁盘操作系统),并不认为内存保护很重要。因此,恶意程序(或者只是一个编程不好的应用程序)可能会在整个内存中乱写乱七八糟的东西。第一代macOS(V9及更早版本)采取合作的方式进行作业调度。因此,意外陷入无限循环的线程可能会占用整个系统,从而导致重新启动。这一代系统中遗漏的操作系统功能造成的痛苦列表很长,太长了,因此无法在此进行全面的讨论。

在操作系统的历史中,UNIX的重要性举足轻重。受早期系统(特别是MIT著名的Multics系统)的影响,UNIX汇集了许多了不起的思想,创造了既简单又强大的系统。

最初的“贝尔实验室”UNIX的基础是统一的原则,即构建小而强大的程序,这些程序可以连接在一起形成更大的工作流。在你输入命令的地方,shell提供了诸如管道(pipe)之类的原语,来支持这样的元(meta-level)编程,因此很容易将程序串起来完成更大的任务。例如,要查找文本文件中包含单词“foo”的行,然后要计算存在多少行,请键入:grep foo file.txt | wc -l,从而使用grep和wc (单词计数)程序来实现你的任务。

第4章 抽象:进程

进程的非正式定义非常简单:进程就是运行中的程序。程序本身是没有生命周期的,它只是存在磁盘上面的一些指令(也可能是一些静态数据)。是操作系统让这些字节运行起来,让程序发挥作用。

操作系统通过虚拟化(virtualizing)CPU来提供这种假象。通过让一个进程只运行一个时间片,然后切换到其他进程,操作系统提供了存在多个虚拟CPU的假象。这就是时分共享(time sharing)CPU技术,允许用户如愿运行多个并发进程。潜在的开销就是性能损失,因为如果CPU必须共享,每个进程的运行就会慢一点。

时分共享(time sharing)是操作系统共享资源所使用的最基本的技术之一。通过允许资源由一个实体使用一小段时间,然后由另一个实体使用一小段时间,如此下去,所谓的资源(例如,CPU或网络链接)可以被许多人共享。时分共享的自然对应技术是空分共享,资源在空间上被划分给希望使用它的人。例如,磁盘空间自然是一个空分共享资源,因为一旦将块分配给文件,在用户删除文件之前,不可能将它分配给其他文件。

进程的机器状态有一个明显组成部分,就是它的内存。指令存在内存中。正在运行的程序读取和写入的数据也在内存中。因此进程可以访问的内存(称为地址空间,address space)是该进程的一部分。

进程的机器状态的另一部分是寄存器。许多指令明确地读取或更新寄存器,因此显然,它们对于执行该进程很重要。

请注意,有一些非常特殊的寄存器构成了该机器状态的一部分。例如,程序计数器(Program Counter,PC)(有时称为指令指针,Instruction Pointer或IP)告诉我们程序当前正在执行哪个指令;类似地,栈指针(stack pointer)和相关的帧指针(frame pointer)用于管理函数参数栈、局部变量和返回地址。

·创建(create):操作系统必须包含一些创建新进程的方法。

·销毁(destroy):由于存在创建进程的接口,因此系统还提供了一个强制销毁进程的接口。

·等待(wait):有时等待进程停止运行是有用的,因此经常提供某种等待接口。

·其他控制(miscellaneous control):除了杀死或等待进程外,有时还可能有其他控制。例如,大多数操作系统提供某种方法来暂停进程(停止运行一段时间),然后恢复(继续运行)。

·状态(statu):通常也有一些接口可以获得有关进程的状态信息,例如运行了多长时间,或者处于什么状态。

操作系统运行程序必须做的第一件事是将代码和所有静态数据(例如初始化变量)加载(load)到内存中,加载到进程的地址空间中。

在早期的(或简单的)操作系统中,加载过程尽早(eagerly)完成,即在运行程序之前全部完成。现代操作系统惰性(lazily)执行该过程,即仅在程序执行期间需要加载的代码或数据片段,才会加载。要真正理解代码和数据的惰性加载是如何工作的,必须更多地了解分页和交换的机制,这是我们将来讨论内存虚拟化时要涉及的主题。

将代码和静态数据加载到内存后,操作系统在运行此进程之前还需要执行其他一些操作。必须为程序的运行时栈(run-time stack或stack)分配一些内存。你可能已经知道,C程序使用栈存放局部变量、函数参数和返回地址。操作系统分配这些内存,并提供给进程。操作系统也可能会用参数初始化栈。具体来说,它会将参数填入main()函数,即argc和argv数组。

操作系统也可能为程序的堆(heap)分配一些内存。在C程序中,堆用于显式请求的动态分配数据。程序通过调用malloc()来请求这样的空间,并通过调用free()来明确地释放它。数据结构(如链表、散列表、树和其他有趣的数据结构)需要堆。起初堆会很小。随着程序运行,通过malloc()库API请求更多内存,操作系统可能会参与分配更多内存给进程,以满足这些调用。

操作系统还将执行一些其他初始化任务,特别是与输入/输出(I/O)相关的任务。例如,在UNIX系统中,默认情况下每个进程都有3个打开的文件描述符(file descriptor),用于标准输入、输出和错误。这些描述符让程序轻松读取来自终端的输入以及打印输出到屏幕。

通过将代码和静态数据加载到内存中,通过创建和初始化栈以及执行与I/O设置相关的其他工作,OS现在(终于)为程序执行搭好了舞台。然后它有最后一项任务:启动程序,在入口处运行,即main()。通过跳转到main()例程(第5章讨论的专门机制),OS将CPU的控制权转移到新创建的进程中,从而程序开始执行。

简而言之,进程可以处于以下3种状态之一。·运行(running):在运行状态下,进程正在处理器上运行。这意味着它正在执行指令。·就绪(ready):在就绪状态下,进程已准备好运行,但由于某种原因,操作系统选择不在此时运行。·阻塞(blocked):在阻塞状态下,一个进程执行了某种操作,直到发生其他事件时才会准备运行。一个常见的例子是,当进程向磁盘发起I/O请求时,它会被阻塞,因此其他进程可以使用处理器。

从就绪到运行意味着该进程已经被调度(scheduled)。从运行转移到就绪意味着该进程已经取消调度(descheduled)。一旦进程被阻塞(例如,通过发起I/O操作),OS将保持进程的这种状态,直到发生某种事件(例如,I/O完成)。此时,进程再次转入就绪状态(也可能立即再次运行,如果操作系统这样决定)。

为了跟踪每个进程的状态,操作系统可能会为所有就绪的进程保留某种进程列表(process list),以及跟踪当前正在运行的进程的一些附加信息。

有时候系统会有一个初始(initial)状态,表示进程在创建时处于的状态。另外,一个进程可以处于已退出但尚未清理的最终(final)状态(在基于UNIX的系统中,这称为僵尸状态)。这个最终状态非常有用,因为它允许其他进程(通常是创建进程的父进程)检查进程的返回代码,并查看刚刚完成的进程是否成功执行(通常,在基于UNIX的系统中,程序成功完成任务时返回零,否则返回非零)。完成后,父进程将进行最后一次调用(例如,wait()),以等待子进程的完成,并告诉操作系统它可以清理这个正在结束的进程的所有相关数据结构。

第5章 插叙:进程API

子进程并不是完全拷贝了父进程。具体来说,虽然它拥有自己的地址空间(即拥有自己的私有内存)、寄存器、程序计数器等,但是它从fork()返回的值是不同的。父进程获得的返回值是新创建子进程的PID,而子进程获得的返回值是0。这个差别非常重要,因为这样就很容易编写代码处理两种不同的情况(像上面那样)。

事实表明,有时候父进程需要等待子进程执行完毕,这很有用。这项任务由wait()系统调用(或者更完整的兄弟接口waitpid())。

最后是exec()系统调用,它也是创建进程API的一个重要部分。这个系统调用可以让子进程执行与父进程不同的程序。

对exec()的成功调用永远不会返回。

抽象和简化都不能替代做对事。”有时你必须做正确的事,当你这样做时,总是好过其他方案。有许多方式来设计创建进程的API,但fork()和exec()的组合既简单又极其强大。因此UNIX的设计师们做对了。

shell也是一个用户程序,它首先显示一个提示符(prompt),然后等待用户输入。你可以向它输入一个命令(一个可执行程序的名称及需要的参数),大多数情况下,shell可以在文件系统中找到这个可执行程序,调用fork()创建新进程,并调用exec()的某个变体来执行这个可执行程序,调用wait()等待该命令完成。子进程执行结束后,shell从wait()返回并再次输出一个提示符,等待用户输入下一条命令。

shell实现结果重定向的方式也很简单,当完成子进程的创建后,shell在调用exec()之前先关闭了标准输出(standard output),打开了文件newfile.txt。这样,即将运行的程序wc的输出结果就被发送到该文件,而不是打印在屏幕上。

UNIX管道也是用类似的方式实现的,但用的是pipe()系统调用。在这种情况下,一个进程的输出被链接到了一个内核管道(pipe)上(队列),另一个进程的输入也被连接到了同一个管道上。因此,前一个进程的输出无缝地作为后一个进程的输入,许多命令可以用这种方式串联在一起,共同完成某项任务。

第6章 机制:受限直接执行

为了虚拟化CPU,操作系统需要以某种方式让许多任务共享物理CPU,让它们看起来像是同时运行。基本思想很简单:运行一个进程一段时间,然后运行另一个进程,如此轮换。通过以这种方式时分共享(timesharing)CPU,就实现了虚拟化。然而,在构建这样的虚拟化机制时存在一些挑战。第一个是性能:如何在不增加系统开销的情况下实现虚拟化?第二个是控制权:如何有效地运行进程,同时保留对CPU的控制?控制权对于操作系统尤为重要,因为操作系统负责资源管理。如果没有控制权,一个进程可以简单地无限制运行并接管机器,或访问没有权限的信息。因此,在保持控制权的同时获得高性能,这是构建操作系统的主要挑战之一。

为了使程序尽可能快地运行,操作系统开发人员想出了一种技术——我们称之为受限的直接执行(limiteddirect execution)。这个概念的“直接执行”部分很简单:只需直接在CPU上运行程序即可。因此,当OS希望启动程序运行时,它会在进程列表中为其创建一个进程条目,为其分配一些内存,将程序代码(从磁盘)加载到内存中,找到入口点(main()函数或类似的),跳转到那里,并开始运行用户的代码。

要执行系统调用,程序必须执行特殊的陷阱(trap)指令。该指令同时跳入内核并将特权级别提升到内核模式。一旦进入内核,系统就可以执行任何需要的特权操作(如果允许),从而为调用进程执行所需的工作。完成后,操作系统调用一个特殊的从陷阱返回(return-from-trap)指令,如你期望的那样,该指令返回到发起调用的用户程序中,同时将特权级别降低,回到用户模式。

执行陷阱时,硬件需要小心,因为它必须确保存储足够的调用者寄存器,以便在操作系统发出从陷阱返回指令时能够正确返回。例如,在x86上,处理器会将程序计数器、标志和其他一些寄存器推送到每个进程的内核栈(kernel stack)上。从返回陷阱将从栈弹出这些值,并恢复执行用户模式程序(有关详细信息,请参阅英特尔系统手册[I11])。其他硬件系统使用不同的约定,但基本概念在各个平台上是相似的。

你可能想知道,为什么对系统调用的调用(如open()或read())看起来完全就像C中的典型过程调用。也就是说,如果它看起来像一个过程调用,系统如何知道这是一个系统调用,并做所有正确的事情?原因很简单:它是一个过程调用,但隐藏在过程调用内部的是著名的陷阱指令。更具体地说,当你调用open()(举个例子)时,你正在执行对C库的过程调用。其中,无论是对于open()还是提供的其他系统调用,库都使用与内核一致的调用约定来将参数放在众所周知的位置(例如,在栈中或特定的寄存器中),将系统调用号也放入一个众所周知的位置(同样,放在栈或寄存器中),然后执行上述的陷阱指令。库中陷阱之后的代码准备好返回值,并将控制权返回给发出系统调用的程序。因此,C库中进行系统调用的部分是用汇编手工编码的,因为它们需要仔细遵循约定,以便正确处理参数和返回值,以及执行硬件特定的陷阱指令。现在你知道为什么你自己不必写汇编代码来陷入操作系统了,因为有人已经为你写了这些汇编。

内核通过在启动时设置陷阱表(trap table)来实现。当机器启动时,它在特权(内核)模式下执行,因此可以根据需要自由配置机器硬件。操作系统做的第一件事,就是告诉硬件在发生某些异常事件时要运行哪些代码。例如,当发生硬盘中断,发生键盘中断或程序进行系统调用时,应该运行哪些代码?操作系统通常通过某种特殊的指令,通知硬件这些陷阱处理程序的位置。一旦硬件被通知,它就会记住这些处理程序的位置,直到下一次重新启动机器,并且硬件知道在发生系统调用和其他异常事件时要做什么(即跳转到哪段代码)。

LDE协议有两个阶段。第一个阶段(在系统引导时),内核初始化陷阱表,并且CPU记住它的位置以供随后使用。内核通过特权指令来执行此操作(所有特权指令均以粗体突出显示)。第二个阶段(运行进程时),在使用从陷阱返回指令开始执行进程之前,内核设置了一些内容(例如,在进程列表中分配一个节点,分配内存)。这会将CPU切换到用户模式并开始运行该进程。当进程希望发出系统调用时,它会重新陷入操作系统,然后再次通过从陷阱返回,将控制权还给进程。该进程然后完成它的工作,并从main()返回。这通常会返回到一些存根代码,它将正确退出该程序(例如,通过调用exit()系统调用,这将陷入OS中)。此时,OS清理干净,任务完成了。

协作方式:等待系统调用过去某些系统采用的一种方式(例如,早期版本的Macintosh操作系统[M11]或旧的Xerox Alto系统[A79])称为协作(cooperative)方式。在这种风格下,操作系统相信系统的进程会合理运行。运行时间过长的进程被假定会定期放弃CPU,以便操作系统可以决定运行其他任务。

因此,在协作调度系统中,OS通过等待系统调用,或某种非法操作发生,从而重新获得CPU的控制权。

非协作方式:操作系统进行控制事实证明,没有硬件的额外帮助,如果进程拒绝进行系统调用(也不出错),从而将控制权交还给操作系统,那么操作系统无法做任何事情。事实上,在协作方式中,当进程陷入无限循环时,唯一的办法就是使用古老的解决方案来解决计算机系统中的所有问题——重新启动计算机。因此,我们又遇到了请求获得CPU控制权的一个子问题。

答案很简单,许多年前构建计算机系统的许多人都发现了:时钟中断(timer interrupt)[M+63]。时钟设备可以编程为每隔几毫秒产生一次中断。产生中断时,当前正在运行的进程停止,操作系统中预先配置的中断处理程序(interrupt handler)会运行。此时,操作系统重新获得CPU的控制权,因此可以做它想做的事:停止当前进程,并启动另一个进程。

即使进程以非协作的方式运行,添加时钟中断(timer interrupt)也让操作系统能够在CPU上重新运行。因此,该硬件功能对于帮助操作系统维持机器的控制权至关重要。

首先,正如我们之前讨论过的系统调用一样,操作系统必须通知硬件哪些代码在发生时钟中断时运行。因此,在启动时,操作系统就是这样做的。其次,在启动过程中,操作系统也必须启动时钟,这当然是一项特权操作。一旦时钟开始运行,操作系统就感到安全了,因为控制权最终会归还给它,因此操作系统可以自由运行用户程序。时钟也可以关闭(也是特权操作),稍后更详细地理解并发时,我们会讨论。

请注意,硬件在发生中断时有一定的责任,尤其是在中断发生时,要为正在运行的程序保存足够的状态,以便随后从陷阱返回指令能够正确恢复正在运行的程序。这一组操作与硬件在显式系统调用陷入内核时的行为非常相似,其中各种寄存器因此被保存(进入内核栈),因此从陷阱返回指令可以容易地恢复。

既然操作系统已经重新获得了控制权,无论是通过系统调用协作,还是通过时钟中断更强制执行,都必须决定:是继续运行当前正在运行的进程,还是切换到另一个进程。这个决定是由调度程序(scheduler)做出的,它是操作系统的一部分。

上下文切换在概念上很简单:操作系统要做的就是为当前正在执行的进程保存一些寄存器的值(例如,到它的内核栈),并为即将执行的进程恢复一些寄存器的值(从它的内核栈)。这样一来,操作系统就可以确保最后执行从陷阱返回指令时,不是返回到之前运行的进程,而是继续执行另一个进程。

为了保存当前正在运行的进程的上下文,操作系统会执行一些底层汇编代码,来保存通用寄存器、程序计数器,以及当前正在运行的进程的内核栈指针,然后恢复寄存器、程序计数器,并切换内核栈,供即将运行的进程使用。通过切换栈,内核在进入切换代码调用时,是一个进程(被中断的进程)的上下文,在返回时,是另一进程(即将执行的进程)的上下文。当操作系统最终执行从陷阱返回指令时,即将执行的进程变成了当前运行的进程。至此上下文切换完成。

请注意,在此协议中,有两种类型的寄存器保存/恢复。第一种是发生时钟中断的时候。在这种情况下,运行进程的用户寄存器由硬件隐式保存,使用该进程的内核栈。第二种是当操作系统决定从A切换到B。在这种情况下,内核寄存器被软件(即OS)明确地保存,但这次被存储在该进程的进程结构的内存中。后一个操作让系统从好像刚刚由A陷入内核,变成好像刚刚由B陷入内核。

操作系统可能简单地决定,在中断处理期间禁止中断(disable interrupt)。这样做可以确保在处理一个中断时,不会将其他中断交给CPU。当然,操作系统这样做必须小心。禁用中断时间过长可能导致丢失中断,这(在技术上)是不好的。

操作系统还开发了许多复杂的加锁(locking)方案,以保护对内部数据结构的并发访问。这使得多个活动可以同时在内核中进行,特别适用于多处理器。

我们已经描述了一些实现CPU虚拟化的关键底层机制,并将其统称为受限直接执行(limited directexecution)。基本思路很简单:就让你想运行的程序在CPU上运行,但首先确保设置好硬件,以便在没有操作系统帮助的情况下限制进程可以执行的操作。

第7章 进程调度:介绍

任务的周转时间定义为任务完成时间减去任务到达系统的时间。

我们可以实现的最基本的算法,被称为先进先出(First In First Out或FIFO)调度,有时候也称为先到先服务(First Come First Served或FCFS)。

这个问题通常被称为护航效应(convoy effect)[B+79],一些耗时较少的潜在资源消费者被排在重量级的资源消费者之后。

事实证明,一个非常简单的方法解决了这个问题。实际上这是从运筹学中借鉴的一个想法[C54,PV56],然后应用到计算机系统的任务调度中。这个新的调度准则被称为最短任务优先(Shortest Job First,SJF),该名称应该很容易记住,因为它完全描述了这个策略:先运行最短的任务,然后是次短的任务,如此下去。

在过去的批处理计算中,开发了一些非抢占式(non-preemptive)调度程序。这样的系统会将每项工作做完,再考虑是否运行新工作。几乎所有现代化的调度程序都是抢占式的(preemptive),非常愿意停止一个进程以运行另一个进程。这意味着调度程序采用了我们之前学习的机制。特别是调度程序可以进行上下文切换,临时停止一个运行进程,并恢复(或启动)另一个进程。

幸运的是,有一个调度程序完全就是这样做的:向SJF添加抢占,称为最短完成时间优先(Shortest Time-to-Completion First,STCF)或抢占式最短作业优先(Preemptive Shortest Job First ,PSJF)调度程序[CK68]。每当新工作进入系统时,它就会确定剩余工作和新工作中,谁的剩余时间最少,然后调度该工作。

响应时间定义为从任务到达系统到首次运行的时间。

你可能会想,STCF和相关方法在响应时间上并不是很好。例如,如果3个工作同时到达,第三个工作必须等待前两个工作全部运行后才能运行。这种方法虽然有很好的周转时间,但对于响应时间和交互性是相当糟糕的。假设你在终端前输入,不得不等待10s才能看到系统的回应,只是因为其他一些工作已经在你之前被调度:你肯定不太开心。

为了解决这个问题,我们将介绍一种新的调度算法,通常被称为轮转(Round-Robin,RR)调度[K64]。基本思想很简单:RR在一个时间片(time slice,有时称为调度量子,scheduling quantum)内运行一个工作,然后切换到运行队列中的下一个任务,而不是运行一个任务直到结束。它反复执行,直到所有任务完成。因此,RR有时被称为时间切片(time-slicing)。请注意,时间片长度必须是时钟中断周期的倍数。因此,如果时钟中断是每10ms中断一次,则时间片可以是10ms、20ms或10ms的任何其他倍数。

如你所见,时间片长度对于RR是至关重要的。越短,RR在响应时间上表现越好。然而,时间片太短是有问题的:突然上下文切换的成本将影响整体性能。因此,系统设计者需要权衡时间片的长度,使其足够长,以便摊销(amortize)上下文切换成本,而又不会使系统不及时响应。

请注意,上下文切换的成本不仅仅来自保存和恢复少量寄存器的操作系统操作。程序运行时,它们在CPU高速缓存、TLB、分支预测器和其他片上硬件中建立了大量的状态。切换到另一个工作会导致此状态被刷新,且与当前运行的作业相关的新状态被引入,这可能导致显著的性能成本[MB91]。

RR所做的正是延伸每个工作,只运行每个工作一小段时间,就转向下一个工作。因为周转时间只关心作业何时完成,RR几乎是最差的,在很多情况下甚至比简单的FIFO更差。

更一般地说,任何公平(fair)的政策(如RR),即在小规模的时间内将CPU均匀分配到活动进程之间,在周转时间这类指标上表现不佳。事实上,这是固有的权衡:如果你愿意不公平,你可以运行较短的工作直到完成,但是要以响应时间为代价。如果你重视公平性,则响应时间会较短,但会以周转时间为代价。这种权衡在系统中很常见。你不能既拥有你的蛋糕,又吃它。

如有可能,重叠(overlap)操作可以最大限度地提高系统的利用率。重叠在许多不同的领域很有用,包括执行磁盘I/O或将消息发送到远程机器时。在任何一种情况下,开始操作然后切换到其他工作都是一个好主意,这也提高了系统的整体利用率和效率。

通过将每个CPU突发作为一项工作,调度程序确保“交互”的进程经常运行。当这些交互式作业正在执行I/O时,其他CPU密集型作业将运行,从而更好地利用处理器。

第8章 调度:多级反馈队列

1962年,Corbato首次提出多级反馈队列[C+62],应用于兼容时分共享系统(CTSS)。Corbato因在CTSS中的贡献和后来在Multics中的贡献,获得了ACM颁发的图灵奖(Turing Award)。该调度程序经过多年的一系列优化,出现在许多现代操作系统中。

多级反馈队列需要解决两方面的问题。首先,它要优化周转时间。在第7章中我们看到,这通过先执行短工作来实现。然而,操作系统通常不知道工作要运行多久,而这又是SJF(或STCF)等算法所必需的。其次,MLFQ希望给交互用户(如用户坐在屏幕前,等着进程结束)很好的交互体验,因此需要降低响应时间。然而,像轮转这样的算法虽然降低了响应时间,周转时间却很差。

多级反馈队列是用历史经验预测未来的一个典型的例子,操作系统中有很多地方采用了这种技术(同样存在于计算机科学领域的很多其他地方,比如硬件的分支预测及缓存算法)。如果工作有明显的阶段性行为,因此可以预测,那么这种方式会很有效。当然,必须十分小心地使用这种技术,因为它可能出错,让系统做出比一无所知的时候更糟的决定。

MLFQ中有许多独立的队列(queue),每个队列有不同的优先级(priority level)。任何时刻,一个工作只能存在于一个队列中。MLFQ总是优先执行较高优先级的工作(即在较高级队列中的工作)。当然,每个队列中可能会有多个工作,因此具有同样的优先级。在这种情况下,我们就对这些工作采用轮转调度。因此,MLFQ调度策略的关键在于如何设置优先级。MLFQ没有为每个工作指定不变的优先情绪而已,而是根据观察到的行为调整它的优先级。例如,如果一个工作不断放弃CPU去等待键盘输入,这是交互型进程的可能行为,MLFQ因此会让它保持高优先级。相反,如果一个工作长时间地占用CPU,MLFQ会降低其优先级。通过这种方式,MLFQ在进程运行过程中学习其行为,从而利用工作的历史来预测它未来的行为。至此,我们得到了MLFQ的两条基本规则。·规则1:如果A的优先级 > B的优先级,运行A(不运行B)。·规则2:如果A的优先级 = B的优先级,轮转运行A和B。

·规则3:工作进入系统时,放在最高优先级(最上层队列)。·规则4a:工作用完整个时间片后,降低其优先级(移入下一个队列)。·规则4b:如果工作在其时间片以内主动释放CPU,则优先级不变。

通过这个例子,你大概可以体会到这个算法的一个主要目标:如果不知道工作是短工作还是长工作,那么就在开始的时候假设其是短工作,并赋予最高优先级。如果确实是短工作,则很快会执行完毕,否则将被慢慢移入低优先级队列,而这时该工作也被认为是长工作了。通过这种方式,MLFQ近似于SJF。

看一个有I/O的例子。根据上述规则4b,如果进程在时间片用完之前主动放弃CPU,则保持它的优先级不变。这条规则的意图很简单:假设交互型工作中有大量的I/O操作(比如等待用户的键盘或鼠标输入),它会在时间片用完之前放弃CPU。在这种情况下,我们不想处罚它,只是保持它的优先级不变。

至此,我们有了基本的MLFQ。它看起来似乎相当不错,长工作之间可以公平地分享CPU,又能给短工作或交互型工作很好的响应时间。然而,这种算法有一些非常严重的缺点。你能想到吗?(暂停一下,尽量让脑筋转转弯)首先,会有饥饿(starvation)问题。如果系统有“太多”交互型工作,就会不断占用CPU,导致长工作永远无法得到CPU(它们饿死了)。即使在这种情况下,我们希望这些长工作也能有所进展。

其次,聪明的用户会重写程序,愚弄调度程序(game the scheduler)。愚弄调度程序指的是用一些卑鄙的手段欺骗调度程序,让它给你远超公平的资源。上述算法对如下的攻击束手无策:进程在时间片用完之前,调用一个I/O操作(比如访问一个无关的文件),从而主动释放CPU。如此便可以保持在高优先级,占用更多的CPU时间。做得好时(比如,每运行99%的时间片时间就主动放弃一次CPU),工作可以几乎独占CPU。

·规则5:经过一段时间S,就将系统中所有工作重新加入最高优先级队列。

新规则一下解决了两个问题。首先,进程不会饿死——在最高优先级队列中,它会以轮转的方式,与其他高优先级工作分享CPU,从而最终获得执行。其次,如果一个CPU密集型工作变成了交互型,当它优先级提升时,调度程序会正确对待它。

当然,添加时间段S导致了明显的问题:S的值应该如何设置?德高望重的系统研究员 John Ousterhout[O11]曾将这种值称为“巫毒常量(voo-doo constant)”,因为似乎需要一些黑魔法才能正确设置。如果S设置得太高,长工作会饥饿;如果设置得太低,交互型工作又得不到合适的CPU时间比例。

现在还有一个问题要解决:如何阻止调度程序被愚弄?可以看出,这里的元凶是规则4a和4b,导致工作在时间片以内释放CPU,就保留它的优先级。那么应该怎么做?这里的解决方案,是为MLFQ的每层队列提供更完善的CPU计时方式(accounting)。调度程序应该记录一个进程在某一层中消耗的总时间,而不是在调度时重新计时。只要进程用完了自己的配额,就将它降到低一优先级的队列中去。不论它是一次用完的,还是拆成很多次用完。因此,我们重写规则4a和4b。·规则4:一旦工作用完了其在某一层中的时间配额(无论中间主动放弃了多少次CPU),就降低其优先级(移入低一级队列)。

关于MLFQ调度算法还有一些问题。其中一个大问题是如何配置一个调度程序,例如,配置多少队列?每一层队列的时间片配置多大?为了避免饥饿问题以及进程行为改变,应该多久提升一次进程的优先级?这些问题都没有显而易见的答案,因此只有利用对工作负载的经验,以及后续对调度程序的调优,才会导致令人满意的平衡。

尽可能避免巫毒常量是个好主意。然而,从上面的例子可以看出,这通常很难。当然,我们也可以让系统自己去学习一个很优化的值,但这同样也不容易。因此,通常我们会有一个写满各种参数值默认值的配置文件,使得系统管理员可以方便地进行修改调整。然而,大多数使用者并不会去修改这些默认值,这时就寄希望于默认值合适了。这个提示是由资深的OS教授John Ousterhout提出的,因此称为Ousterhout定律(Ousterhout’s Law)。

Solaris的MLFQ实现(时分调度类TS)很容易配置。它提供了一组表来决定进程在其生命周期中如何调整优先级,每层的时间片多大,以及多久提升一个工作的优先级[AD00]。管理员可以通过这些表,让调度程序的行为方式不同。该表默认有60层队列,时间片长度从20ms(最高优先级),到几百ms(最低优先级),每一秒左右提升一次进程的优先级。

其他一些MLFQ调度程序没用表,甚至没用本章中讲到的规则,有些采用数学公式来调整优先级。例如,FreeBSD调度程序(4.3版本),会基于当前进程使用了多少CPU,通过公式计算某个工作的当前优先级[LM+89]。另外,使用量会随时间衰减,这提供了期望的优先级提升,但与这里描述方式不同。阅读Epema的论文,他漂亮地概括了这种使用量衰减(decay-usage)算法及其特征[E95]。

最后,许多调度程序有一些我们没有提到的特征。例如,有些调度程序将最高优先级队列留给操作系统使用,因此通常的用户工作是无法得到系统的最高优先级的。有些系统允许用户给出优先级设置的建议(advice),比如通过命令行工具nice,可以增加或降低工作的优先级(稍微),从而增加或降低它在某个时刻运行的机会。

·规则1:如果A的优先级 > B的优先级,运行A(不运行B)。·规则2:如果A的优先级 = B的优先级,轮转运行A和B。·规则3:工作进入系统时,放在最高优先级(最上层队列)。·规则 4:一旦工作用完了其在某一层中的时间配额(无论中间主动放弃了多少次CPU),就降低其优先级(移入低一级队列)。·规则5:经过一段时间S,就将系统中所有工作重新加入最高优先级队列。

MLFQ有趣的原因是:它不需要对工作的运行方式有先验知识,而是通过观察工作的运行来给出对应的优先级。通过这种方式,MLFQ可以同时满足各种工作的需求:对于短时间运行的交互型工作,获得类似于SJF/STCF的很好的全局性能,同时对长时间运行的CPU密集型负载也可以公平地、不断地稳步向前。因此,许多系统使用某种类型的MLFQ作为自己的基础调度程序,包括类BSD UNIX系统[LM+89,B86]、Solaris[M06]以及Windows NT和其后的Window系列操作系统。

第9章 调度:比例份额

比例份额(proportional-share)调度程序,有时也称为公平份额(fair-share)调度程序。比例份额算法基于一个简单的想法:调度程序的最终目标,是确保每个工作获得一定比例的CPU时间,而不是优化周转时间和响应时间。

彩票调度背后是一个非常基本的概念:彩票数(ticket)代表了进程(或用户或其他)占有某个资源的份额。一个进程拥有的彩票数占总彩票数的百分比,就是它占有资源的份额。

彩票调度最精彩的地方在于利用了随机性(randomness)。当你需要做出决定时,采用随机的方式常常是既可靠又简单的选择。随机方法相对于传统的决策方式,至少有3点优势。第一,随机方法常常可以避免奇怪的边角情况,较传统的算法可能在处理这些情况时遇到麻烦。例如LRU替换策略(稍后会在虚拟内存的章节详细介绍)。虽然LRU通常是很好的替换算法,但在有重复序列的负载时表现非常差。但随机方法就没有这种最差情况。第二,随机方法很轻量,几乎不需要记录任何状态。在传统的公平份额调度算法中,记录每个进程已经获得了多少的CPU时间,需要对每个进程计时,这必须在每次运行结束后更新。而采用随机方式后每个进程只需要非常少的状态(即每个进程拥有的彩票号码)。第三,随机方法很快。只要能很快地产生随机数,做出决策就很快。因此,随机方式在对运行速度要求高的场景非常适用。当然,越是需要快的计算速度,随机就会越倾向于伪随机。

彩票调度还提供了一些机制,以不同且有效的方式来调度彩票。一种方式是利用彩票货币(ticketcurrency)的概念。这种方式允许拥有一组彩票的用户以他们喜欢的某种货币,将彩票分给自己的不同工作。之后操作系统再自动将这种货币兑换为正确的全局彩票。

另一个有用的机制是彩票转让(ticket transfer)。通过转让,一个进程可以临时将自己的彩票交给另一个进程。这种机制在客户端/服务端交互的场景中尤其有用,在这种场景中,客户端进程向服务端发送消息,请求其按自己的需求执行工作,为了加速服务端的执行,客户端可以将自己的彩票转让给服务端,从而尽可能加速服务端执行自己请求的速度。服务端执行结束后会将这部分彩票归还给客户端。

最后,彩票通胀(ticket inflation)有时也很有用。利用通胀,一个进程可以临时提升或降低自己拥有的彩票数量。当然在竞争环境中,进程之间互相不信任,这种机制就没什么意义。一个贪婪的进程可能给自己非常多的彩票,从而接管机器。但是,通胀可以用于进程之间相互信任的环境。在这种情况下,如果一个进程知道它需要更多CPU时间,就可以增加自己的彩票,从而将自己的需求告知操作系统,这一切不需要与任何

彩票调度中最不可思议的,或许就是实现简单。只需要一个不错的随机数生成器来选择中奖彩票和一个记录系统中所有进程的数据结构(一个列表),以及所有彩票的总数。

彩票调度通过随机值,聪明地做到了按比例分配。步长调度算法能够确定的获得需要的比例。虽然两者都很有趣,但由于一些原因,并没有作为CPU调度程序被广泛使用。一个原因是这两种方式都不能很好地适合I/O[AC97];另一个原因是其中最难的票数分配问题并没有确定的解决方式,例如,如何知道浏览器进程应该拥有多少票数?通用调度程序(像前面讨论的MLFQ及其他类似的Linux调度程序)做得更好,因此得到了广泛的应用。

第10章 多处理器调度(高级)

为了理解多处理器调度带来的新问题,必须先知道它与单CPU之间的基本区别。区别的核心在于对硬件缓存(cache)的使用(见图10.1),以及多处理器之间共享数据的方式。

在单CPU系统中,存在多级的硬件缓存(hardware cache),一般来说会让处理器更快地执行程序。缓存是很小但很快的存储设备,通常拥有内存中最热的数据的备份。相比之下,内存很大且拥有所有的数据,但访问速度较慢。通过将频繁访问的数据放在缓存中,系统似乎拥有又大又快的内存。

缓存是基于局部性(locality)的概念,局部性有两种,即时间局部性和空间局部性。时间局部性是指当一个数据被访问后,它很有可能会在不久的将来被再次访问,比如循环代码中的数据或指令本身。而空间局部性指的是,当程序访问地址为x的数据时,很有可能会紧接着访问x周围的数据,比如遍历数组或指令的顺序执行。由于这两种局部性存在于大多数的程序中,硬件系统可以很好地预测哪些数据可以放入缓存,从而运行得很好。

事实证明,多CPU的情况下缓存要复杂得多。例如,假设一个运行在CPU 1上的程序从内存地址A读取数据。由于不在CPU 1的缓存中,所以系统直接访问内存,得到值D。程序然后修改了地址A处的值,只是将它的缓存更新为新值D'。将数据写回内存比较慢,因此系统(通常)会稍后再做。假设这时操作系统中断了该程序的运行,并将其交给CPU 2,重新读取地址A的数据,由于CPU 2的缓存中并没有该数据,所以会直接从内存中读取,得到了旧值D,而不是正确的值D'。哎呀!这一普遍的问题称为缓存一致性(cache coherence)问题,有大量的研究文献描述了解决这个问题时的微妙之处[SHW11]。这里我们会略过所有的细节,只提几个要点。选一门计算机体系结构课(或3门),你可以了解更多。

硬件提供了这个问题的基本解决方案:通过监控内存访问,硬件可以保证获得正确的数据,并保证共享内存的唯一性。在基于总线的系统中,一种方式是使用总线窥探(bus snooping)[G83]。每个缓存都通过监听链接所有缓存和内存的总线,来发现内存访问。如果CPU发现对它放在缓存中的数据的更新,会作废(invalidate)本地副本(从缓存中移除),或更新(update)它(修改为新值)。回写缓存,如上面提到的,让事情更复杂(由于对内存的写入稍后才会看到),你可以想想基本方案如何工作。

既然缓存已经做了这么多工作来提供一致性,应用程序(或操作系统)还需要关心共享数据的访问吗?依然需要!

跨CPU访问(尤其是写入)共享数据或数据结构时,需要使用互斥原语(比如锁),才能保证正确性(其他方法,如使用无锁(lock-free)数据结构,很复杂,偶尔才使用。详情参见并发部分关于死锁的章节)。例如,假设多CPU并发访问一个共享队列。如果没有锁,即使有底层一致性协议,并发地从队列增加或删除元素,依然不会得到预期结果。需要用锁来保证数据结构状态更新的原子性。

具体来说,随着CPU数量的增加,访问同步共享的数据结构会变得很慢。

在设计多处理器调度时遇到的最后一个问题,是所谓的缓存亲和度(cache affinity)。这个概念很简单:一个进程在某个CPU上运行时,会在该CPU的缓存中维护许多状态。下次该进程在相同CPU上运行时,由于缓存中的数据而执行得更快。相反,在不同的CPU上执行,会由于需要重新加载数据而很慢(好在硬件保证的缓存一致性可以保证正确执行)。因此多处理器调度应该考虑到这种缓存亲和性,并尽可能将进程保持在同一个CPU上。

上面介绍了一些背景,现在来讨论如何设计一个多处理器系统的调度程序。最基本的方式是简单地复用单处理器调度的基本架构,将所有需要调度的工作放入一个单独的队列中,我们称之为单队列多处理器调度(Single Queue Multiprocessor Scheduling,SQMS)。这个方法最大的优点是简单。它不需要太多修改,就可以将原有的策略用于多个CPU,选择最适合的工作来运行(例如,如果有两个CPU,它可能选择两个最合适的工作)。

然而,SQMS有几个明显的短板。第一个是缺乏可扩展性(scalability)。为了保证在多CPU上正常运行,调度程序的开发者需要在代码中通过加锁(locking)来保证原子性,如上所述。在SQMS访问单个队列时(如寻找下一个运行的工作),锁确保得到正确的结果。

SQMS的第二个主要问题是缓存亲和性。

正是由于单队列调度程序的这些问题,有些系统使用了多队列的方案,比如每个CPU一个队列。我们称之为多队列多处理器调度(Multi-Queue Multiprocessor Scheduling,MQMS)

在MQMS中,基本调度框架包含多个调度队列,每个队列可以使用不同的调度规则,比如轮转或其他任何可能的算法。当一个工作进入系统后,系统会依照一些启发性规则(如随机或选择较空的队列)将其放入某个调度队列。这样一来,每个CPU调度之间相互独立,就避免了单队列的方式中由于数据共享及同步带来的问题。

MQMS比SQMS有明显的优势,它天生更具有可扩展性。队列的数量会随着CPU的增加而增加,因此锁和缓存争用的开销不是大问题。此外,MQMS天生具有良好的缓存亲和度。所有工作都保持在固定的CPU上,因而可以很好地利用缓存数据。

但是,如果稍加注意,你可能会发现有一个新问题(这在多队列的方法中是根本的),即负载不均(loadimbalance)。

最明显的答案是让工作移动,这种技术我们称为迁移(migration)。通过工作的跨CPU迁移,可以真正实现负载均衡。

更棘手的情况是较早一些的例子,A独自留在CPU 0上,B和D在CPU 1上交替运行。

在这种情况下,单次迁移并不能解决问题。应该怎么做呢?答案是不断地迁移一个或多个工作。一种可能的解决方案是不断切换工作,如下面的时间线所示。可以看到,开始的时候A独享CPU 0,B和D在CPU 1。一些时间片后,B迁移到CPU 0与A竞争,D则独享CPU 1一段时间。这样就实现了负载均衡。

当然,还有其他不同的迁移模式。但现在是最棘手的部分:系统如何决定发起这样的迁移?一个基本的方法是采用一种技术,名为工作窃取(work stealing)[FLR98]。通过这种方法,工作量较少的(源)队列不定期地“偷看”其他(目标)队列是不是比自己的工作多。如果目标队列比源队列(显著地)更满,就从目标队列“窃取”一个或多个工作,实现负载均衡。

当然,这种方法也有让人抓狂的地方——如果太频繁地检查其他队列,就会带来较高的开销,可扩展性不好,而这是多队列调度最初的全部目标!相反,如果检查间隔太长,又可能会带来严重的负载不均。找到合适的阈值仍然是黑魔法,这在系统策略设计中很常见。

有趣的是,在构建多处理器调度程序方面,Linux社区一直没有达成共识。一直以来,存在3种不同的调度程序:O(1)调度程序、完全公平调度程序(CFS)以及BF调度程序(BFS)。从Meehean的论文中可以找到对这些不同调度程序优缺点的对比总结[M11]。这里我们只总结一些基本知识。

O(1) CFS采用多队列,而BFS采用单队列,这说明两种方法都可以成功。当然它们之间还有很多不同的细节。例如,O(1)调度程序是基于优先级的(类似于之前介绍的MLFQ),随时间推移改变进程的优先级,然后调度最高优先级进程,来实现各种调度目标。交互性得到了特别关注。与之不同,CFS是确定的比例调度方法(类似之前介绍的步长调度)。BFS作为三个算法中唯一采用单队列的算法,也基于比例调度,但采用了更复杂的方案,称为最早最合适虚拟截止时间优先算法(EEVEF)[SA96]读者可以自己去了解这些现代操作系统的调度算法,现在应该能够理解它们的工作原理了!

第12章 关于内存虚拟化的对话

用户程序生成的每个地址都是虚拟地址(everyaddress generated by a user program is a virtual address)。操作系统只是为每个进程提供一个假象,具体来说,就是它拥有自己的大量私有内存。在一些硬件帮助下,操作系统会将这些假的虚拟地址变成真实的物理地址,从而能够找到想要的信息。

第13章 抽象:地址空间

操作系统曾经是一组函数(实际上是一个库),在内存中(在本例中,从物理地址0开始),然后有一个正在运行的程序(进程),目前在物理内存中(在本例中,从物理地址64KB开始),并使用剩余的内存。这里几乎没有抽象,用户对操作系统的要求也不多。

因此操作系统需要提供一个易用(easy to use)的物理内存抽象。这个抽象叫作地址空间(address space),是运行的程序看到的系统中的内存。理解这个基本的操作系统内存抽象,是了解内存虚拟化的关键。

一个进程的地址空间包含运行的程序的所有内存状态。比如:程序的代码(code,指令)必须在内存中,因此它们在地址空间里。当程序在运行的时候,利用栈(stack)来保存当前的函数调用信息,分配空间给局部变量,传递参数和函数返回值。最后,堆(heap)用于管理动态分配的、用户管理的内存,就像你从C语言中调用malloc()或面向对象语言(如C ++或Java)中调用new获得内存。当然,还有其他的东西(例如,静态初始化的变量),但现在假设只有这3个部分:代码、栈和堆。

隔离是建立可靠系统的关键原则。如果两个实体相互隔离,这意味着一个实体的失败不会影响另一个实体。操作系统力求让进程彼此隔离,从而防止相互造成伤害。通过内存隔离,操作系统进一步确保运行程序不会影响底层操作系统的操作。一些现代操作系统通过将某些部分与操作系统的其他部分分离,实现进一步的隔离。这样的微内核(microkernel)[BH70,R+89,S+03] 可以比整体内核提供更大的可靠性。

虚拟内存(VM)系统的一个主要目标是透明(transparency)。操作系统实现虚拟内存的方式,应该让运行的程序看不见。因此,程序不应该感知到内存被虚拟化的事实,相反,程序的行为就好像它拥有自己的私有物理内存。在幕后,操作系统(和硬件)完成了所有的工作,让不同的工作复用内存,从而实现这个假象。

虚拟内存的另一个目标是效率(efficiency)。操作系统应该追求虚拟化尽可能高效(efficient),包括时间上(即不会使程序运行得更慢)和空间上(即不需要太多额外的内存来支持虚拟化)。在实现高效率虚拟化时,操作系统将不得不依靠硬件支持,包括TLB这样的硬件功能(我们将在适当的时候学习)。

最后,虚拟内存第三个目标是保护(protection)。操作系统应确保进程受到保护(protect),不会受其他进程影响,操作系统本身也不会受进程影响。当一个进程执行加载、存储或指令提取时,它不应该以任何方式访问或影响任何其他进程或操作系统本身的内存内容(即在它的地址空间之外的任何内容)。因此,保护让我们能够在进程之间提供隔离(isolation)的特性,每个进程都应该在自己的独立环境中运行,避免其他出错或恶意进程的影响。

实际上,作为用户级程序的程序员,可以看到的任何地址都是虚拟地址。只有操作系统,通过精妙的虚拟化内存技术,知道这些指令和数据所在的物理内存的位置。所以永远不要忘记:如果你在一个程序中打印出一个地址,那就是一个虚拟的地址。虚拟地址只是提供地址如何在内存中分布的假象,只有操作系统(和硬件)才知道物理地址。

第14章 插叙:内存操作API

在运行一个C程序的时候,会分配两种类型的内存。第一种称为栈内存,它的申请和释放操作是编译器来隐式管理的,所以有时也称为自动(automatic)内存。

就是这种对长期内存的需求,所以我们才需要第二种类型的内存,即所谓的堆(heap)内存,其中所有的申请和释放操作都由程序员显式地完成。

malloc只需要一个size_t类型参数,该参数表示你需要多少个字节。然而,大多数程序员并不会直接传入数字(比如10)。实际上,这样做会被认为是不太好的形式。替代方案是使用各种函数和宏。

在C 中,这通常被认为是编译时操作符,意味着这个大小是在编译时就已知道,因此被替换成一个数(在本例中是8,对于double),作为malloc()的参数。出于这个原因,sizeof() 被正确地认为是一个操作符,而不是一个函数调用(函数调用在运行时发生)。

另一个需要注意的地方是使用字符串。如果为一个字符串声明空间,请使用以下习惯用法:malloc(strlen(s) + 1),它使用函数strlen()获取字符串的长度,并加上1,以便为字符串结束符留出空间。这里使用sizeof()可能会导致麻烦。

你也许还注意到malloc()返回一个指向void类型的指针。这样做只是C中传回地址的方式,让程序员决定如何处理它。程序员将进一步使用所谓的强制类型转换(cast),在我们上面的示例中,程序员将返回类型的malloc()强制转换为指向double的指针。强制类型转换实际上没干什么事,只是告诉编译器和其他可能正在读你的代码的程序员:“是的,我知道我在做什么。”通过强制转换malloc()的结果,程序员只是在给人一些信心,强制转换不是程序正确所必须的。

要释放不再使用的堆内存,程序员只需调用free():

另一个相关的错误是没有分配足够的内存,有时称为缓冲区溢出(buffer overflow)。

另一个常见错误称为内存泄露(memory leak),如果忘记释放内存,就会发生。在长时间运行的应用程序或系统(如操作系统本身)中,这是一个巨大的问题,因为缓慢泄露的内存会导致内存不足,此时需要重新启动。因此,一般来说,当你用完一段内存时,应该确保释放它。请注意,使用垃圾收集语言在这里没有什么帮助:如果你仍然拥有对某块内存的引用,那么垃圾收集器就不会释放它,因此即使在较现代的语言中,内存泄露仍然是一个问题。

有时候程序会在用完之前释放内存,这种错误称为悬挂指针(dangling pointer),正如你猜测的那样,这也是一件坏事。随后的使用可能会导致程序崩溃或覆盖有效的内存(例如,你调用了free(),但随后再次调用malloc()来分配其他内容,这重新利用了错误释放的内存)。

程序有时还会不止一次地释放内存,这被称为重复释放(double free)。这样做的结果是未定义的。正如你所能想象的那样,内存分配库可能会感到困惑,并且会做各种奇怪的事情,崩溃是常见的结果。

当你编写一个短时间运行的程序时,可能会使用malloc()分配一些空间。程序运行并即将完成:是否需要在退出前调用几次free()?虽然不释放似乎不对,但在真正的意义上,没有任何内存会“丢失”。原因很简单:系统中实际存在两级内存管理。

第一级是由操作系统执行的内存管理,操作系统在进程运行时将内存交给进程,并在进程退出(或以其他方式结束)时将其回收。第二级管理在每个进程中,例如在调用malloc()和free()时,在堆内管理。即使你没有调用free()(并因此泄露了堆中的内存),操作系统也会在程序结束运行时,收回进程的所有内存(包括用于代码、栈,以及相关堆的内存页)。无论地址空间中堆的状态如何,操作系统都会在进程终止时收回所有这些页面,从而确保即使没有释放内存,也不会丢失内存。

在讨论malloc()和free()时,我们没有讨论系统调用。原因很简单:它们不是系统调用,而是库调用。因此,malloc库管理虚拟地址空间内的空间,但是它本身是建立在一些系统调用之上的,这些系统调用会进入操作系统,来请求更多内存或者将一些内容释放回系统。

一个这样的系统调用叫作brk,它被用来改变程序分断(break)的位置:堆结束的位置。它需要一个参数(新分断的地址),从而根据新分断是大于还是小于当前分断,来增加或减小堆的大小。另一个调用sbrk要求传入一个增量,但目的是类似的。

最后,你还可以通过mmap()调用从操作系统获取内存。通过传入正确的参数,mmap()可以在程序中创建一个匿名(anonymous)内存区域——这个区域不与任何特定文件相关联,而是与交换空间(swap space)相关联,稍后我们将在虚拟内存中详细讨论。这种内存也可以像堆一样对待并管理。阅读mmap()的手册页以获取更多详细信息。

第15章 机制:地址转换

在实现虚拟内存时,我们将追求类似的战略,在实现高效和控制的同时,提供期望的虚拟化。高效决定了我们要利用硬件的支持,这在开始的时候非常初级(如使用一些寄存器),但会变得相当复杂(比如我们会讲到的TLB、页表等)。控制意味着操作系统要确保应用程序只能访问它自己的内存空间。因此,要保护应用程序不会相互影响,也不会影响操作系统,我们需要硬件的帮助。最后,我们对虚拟内存还有一点要求,即灵活性。具体来说,我们希望程序能以任何方式访问它自己的地址空间,从而让系统更容易编程。

我们利用了一种通用技术,有时被称为基于硬件的地址转换(hardware-based address translation),简称为地址转换(address translation)。它可以看成是受限直接执行这种一般方法的补充。利用地址转换,硬件对每次内存访问进行处理(即指令获取、数据读取或写入),将指令中的虚拟(virtual)地址转换为数据实际存储的物理(physical)地址。因此,在每次内存引用时,硬件都会进行地址转换,将应用程序的内存引用重定位到内存中实际的位置。

当然,仅仅依靠硬件不足以实现虚拟内存,因为它只是提供了底层机制来提高效率。操作系统必须在关键的位置介入,设置好硬件,以便完成正确的地址转换。因此它必须管理内存(manage memory),记录被占用和空闲的内存位置,并明智而谨慎地介入,保持对内存使用的控制。

介入是一种很常见又很有用的技术,计算机系统中使用介入常常能带来很好的效果。在虚拟内存中,硬件可以介入到每次内存访问中,将进程提供的虚拟地址转换为数据实际存储的物理地址。但是,一般化的介入技术有更广阔的应用空间,实际上几乎所有良好定义的接口都应该提供功能介入机制,以便增加功能或者在其他方面提升系统。这种方式最基本的优点是透明(transparency),介入完成时通常不需要改动接口的客户端,因此客户端不需要任何改动。

在20世纪50年代后期,它在首次出现的时分机器中引入,那时只是一个简单的思想,称为基址加界限机制(base and bound),有时又称为动态重定位(dynamicrelocation),我们将互换使用这两个术语[SS74]。具体来说,每个CPU需要两个硬件寄存器:基址(base)寄存器和界限(bound)寄存器,有时称为限制(limit)寄存器。这组基址和界限寄存器,让我们能够将地址空间放在物理内存的任何位置,同时又能确保进程只能访问自己的地址空间。

将虚拟地址转换为物理地址,这正是所谓的地址转换(address translation)技术。也就是说,硬件取得进程认为它要访问的地址,将它转换成数据实际位于的物理地址。由于这种重定位是在运行时发生的,而且我们甚至可以在进程开始运行后改变其地址空间,这种技术一般被称为动态重定位(dynamic relocation)[M65]。

在动态重定位的过程中,只有很少的硬件参与,但获得了很好的效果。一个基址寄存器将虚拟地址转换为物理地址,一个界限寄存器确保这个地址在进程地址空间的范围内。它们一起提供了既简单又高效的虚拟内存机制。

界限寄存器提供了访问保护。在上面的例子中,界限寄存器被置为16KB。如果进程需要访问超过这个界限或者为负数的虚拟地址,CPU将触发异常,进程最终可能被终止。界限寄存器的用处在于,它确保了进程产生的所有地址都在进程的地址“界限”中。

这种基址寄存器配合界限寄存器的硬件结构是芯片中的(每个CPU一对)。有时我们将CPU的这个负责地址转换的部分统称为内存管理单元(Memory Management Unit,MMU)。随着我们开发更复杂的内存管理技术,MMU也将有更复杂的电路和功能。

关于界限寄存器再补充一点,它通常有两种使用方式。在一种方式中(像上面那样),它记录地址空间的大小,硬件在将虚拟地址与基址寄存器内容求和前,就检查这个界限。另一种方式是界限寄存器中记录地址空间结束的物理地址,硬件在转化虚拟地址到物理地址之后才去检查这个界限。这两种方式在逻辑上是等价的。简单起见,我们这里假设采用第一种方式。

操作系统必须记录哪些空闲内存没有使用,以便能够为进程分配内存。很多不同的数据结构可以用于这项任务,其中最简单的(也是我们假定在这里采用的)是空闲列表(free list)。它就是一个列表,记录当前没有使用的物理内存的范围。

首先,正如在CPU虚拟化的章节中提到的,我们需要两种CPU模式。操作系统在特权模式(privileged mode,或内核模式,kernel mode),可以访问整个机器资源。应用程序在用户模式(usermode)运行,只能做有限的操作。只要一个位,也许保存在处理器状态字(processor status word)中,就能说明当前的CPU运行模式。在一些特殊的时刻(如系统调用、异常或中断),CPU会切换状态。

硬件还必须提供基址和界限寄存器(base and bounds register),因此每个CPU的内存管理单元(MemoryManagement Unit,MMU)都需要这两个额外的寄存器。用户程序运行时,硬件会转换每个地址,即将用户程序产生的虚拟地址加上基址寄存器的内容。硬件也必须能检查地址是否有用,通过界限寄存器和CPU内的一些电路来实现。

硬件应该提供一些特殊的指令,用于修改基址寄存器和界限寄存器,允许操作系统在切换进程时改变它们。这些指令是特权(privileged)指令,只有在内核模式下,才能修改这些寄存器。

最后,在用户程序尝试非法访问内存(越界访问)时,CPU必须能够产生异常(exception)。在这种情况下,CPU应该阻止用户程序的执行,并安排操作系统的“越界”异常处理程序(exception handler)去处理。操作系统的处理程序会做出正确的响应,比如在这种情况下终止进程。类似地,如果用户程序尝试修改基址或者界限寄存器时,CPU也应该产生异常,并调用“用户模式尝试执行特权指令”的异常处理程序。CPU还必须提供一种方法,来通知它这些处理程序的位置,因此又需要另一些特权指令。

第一,在进程创建时,操作系统必须采取行动,为进程的地址空间找到内存空间。由于我们假设每个进程的地址空间小于物理内存的大小,并且大小相同,这对操作系统来说很容易。它可以把整个物理内存看作一组槽块,标记了空闲或已用。当新进程创建时,操作系统检索这个数据结构(常被称为空闲列表,free list),为新地址空间找到位置,并将其标记为已用。

第二,在进程终止时(正常退出,或因行为不端被强制终止),操作系统也必须做一些工作,回收它的所有内存,给其他进程或者操作系统使用。在进程终止时,操作系统会将这些内存放回到空闲列表,并根据需要清除相关的数据结构。

第三,在上下文切换时,操作系统也必须执行一些额外的操作。每个CPU毕竟只有一个基址寄存器和一个界限寄存器,但对于每个运行的程序,它们的值都不同,因为每个程序被加载到内存中不同的物理地址。因此,在切换进程时,操作系统必须保存和恢复基础和界限寄存器。具体来说,当操作系统决定中止当前的运行进程时,它必须将当前基址和界限寄存器中的内容保存在内存中,放在某种每个进程都有的结构中,如进程结构(process structure)或进程控制块(ProcessControl Block,PCB)中。类似地,当操作系统恢复执行某个进程时(或第一次执行),也必须给基址和界限寄存器设置正确的值。

第四,操作系统必须提供异常处理程序(exception handler),或要一些调用的函数,像上面提到的那样。操作系统在启动时加载这些处理程序(通过特权命令)。例如,当一个进程试图越界访问内存时,CPU会触发异常。在这种异常产生时,操作系统必须准备采取行动。通常操作系统会做出充满敌意的反应:终止错误进程。

请注意,地址转换过程完全由硬件处理,没有操作系统的介入。

利用地址转换,操作系统可以控制进程的所有内存访问,确保访问在地址空间的界限内。这个技术高效的关键是硬件支持,硬件快速地将所有内存访问操作中的虚拟地址(进程自己看到的内存位置)转换为物理地址(实际位置)。所有的这一切对进程来说都是透明的,进程并不知道自己使用的内存引用已经被重定位,制造了美妙的假象。

遗憾的是,这个简单的动态重定位技术有效率低下的问题。例如,从图15.2中可以看到,重定位的进程使用了从32KB到48KB的物理内存,但由于该进程的栈区和堆区并不很大,导致这块内存区域中大量的空间被浪费。这种浪费通常称为内部碎片(internal fragmentation),指的是已经分配的内存单元内部有未使用的空间(即碎片),造成了浪费。在我们当前的方式中,即使有足够的物理内存容纳更多进程,但我们目前要求将地址空间放在固定大小的槽块中,因此会出现内部碎片。所以,我们需要更复杂的机制,以便更好地利用物理内存,避免内部碎片。

第16章 分段

如果我们将整个地址空间放入物理内存,那么栈和堆之间的空间并没有被进程使用,却依然占用了实际的物理内存。因此,简单的通过基址寄存器和界限寄存器实现的虚拟内存很浪费。另外,如果剩余物理内存无法提供连续区域来放置完整的地址空间,进程便无法运行。这种基址加界限的方式看来并不像我们期望的那样灵活。

为了解决这个问题,分段(segmentation)的概念应运而生。分段并不是一个新概念,它甚至可以追溯到20世纪60年代初期[H61, G62]。这个想法很简单,在MMU中引入不止一个基址和界限寄存器对,而是给地址空间内的每个逻辑段(segment)一对。一个段只是地址空间里的一个连续定长的区域,在典型的地址空间里有3个逻辑不同的段:代码、栈和堆。分段的机制使得操作系统能够将不同的段放到不同的物理内存区域,从而避免了虚拟地址空间中的未使用部分占用物理内存。

从图中可以看到,只有已用的内存才在物理内存中分配空间,因此可以容纳巨大的地址空间,其中包含大量未使用的地址空间(有时又称为稀疏地址空间,sparse address spaces)。

段错误指的是在支持分段的机器上发生了非法的内存访问。有趣的是,即使在不支持分段的机器上这个术语依然保留。

硬件在地址转换时使用段寄存器。它如何知道段内的偏移量,以及地址引用了哪个段?一种常见的方式,有时称为显式(explicit)方式,就是用虚拟地址的开头几位来标识不同的段,VAX/VMS系统使用了这种技术[LL82]。

从图中可以看到,前两位(01)告诉硬件我们引用哪个段。剩下的12位是段内偏移:0000 0110 1000(即十六进制0x068或十进制104)。因此,硬件就用前两位来决定使用哪个段寄存器,然后用后12位作为段内偏移。偏移量与基址寄存器相加,硬件就得到了最终的物理地址。请注意,偏移量也简化了对段边界的判断。我们只要检查偏移量是否小于界限,大于界限的为非法地址。

硬件还有其他方法来决定特定地址在哪个段。在隐式(implicit)方式中,硬件通过地址产生的方式来确定段。例如,如果地址由程序计数器产生(即它是指令获取),那么地址在代码段。如果基于栈或基址指针,它一定在栈段。其他地址则在堆段。

首先,我们需要一点硬件支持。除了基址和界限外,硬件还需要知道段的增长方向(用一位区分,比如1代表自小而大增长,0反之)。

为了支持共享,需要一些额外的硬件支持,这就是保护位(protection bit)。基本为每个段增加了几个位,标识程序是否能够读写该段,或执行其中的代码。通过将代码段标记为只读,同样的代码可以被多个进程共享,而不用担心破坏隔离。虽然每个进程都认为自己独占这块内存,但操作系统秘密地共享了内存,进程不能修改这些内存,所以假象得以保持。

到目前为止,我们的例子大多针对只有很少的几个段的系统(即代码、栈、堆)。我们可以认为这种分段是粗粒度的(coarse-grained),因为它将地址空间分成较大的、粗粒度的块。但是,一些早期系统(如Multics[CV65, DD68])更灵活,允许将地址空间划分为大量较小的段,这被称为细粒度(fine-grained)分段。

支持许多段需要进一步的硬件支持,并在内存中保存某种段表(segment table)。这种段表通常支持创建非常多的段,因此系统使用段的方式,可以比之前讨论的方式更灵活。例如,像Burroughs B5000这样的早期机器可以支持成千上万的段,有了操作系统和硬件的支持,编译器可以将代码段和数据段划分为许多不同的部分[RK68]。当时的考虑是,通过更细粒度的段,操作系统可以更好地了解哪些段在使用哪些没有,从而可以更高效地利用内存。

然而,分段也带来了一些新的问题。我们先介绍必须关注的操作系统新问题。第一个是老问题:操作系统在上下文切换时应该做什么?你可能已经猜到了:各个段寄存器中的内容必须保存和恢复。显然,每个进程都有自己独立的虚拟地址空间,操作系统必须在进程运行前,确保这些寄存器被正确地赋值。第二个问题更重要,即管理物理内存的空闲空间。新的地址空间被创建时,操作系统需要在物理内存中为它的段找到空间。之前,我们假设所有的地址空间大小相同,物理内存可以被认为是一些槽块,进程可以放进去。现在,每个进程都有一些段,每个段的大小也可能不同。一般会遇到的问题是,物理内存很快充满了许多空闲空间的小洞,因而很难分配给新的段,或扩大已有的段。这种问题被称为外部碎片(external fragmentation)[R69],如图16.3(左边)所示。

该问题的一种解决方案是紧凑(compact)物理内存,重新安排原有的段。例如,操作系统先终止运行的进程,将它们的数据复制到连续的内存区域中去,改变它们的段寄存器中的值,指向新的物理地址,从而得到了足够大的连续空闲空间。这样做,操作系统能让新的内存分配请求成功。但是,内存紧凑成本很高,因为拷贝段是内存密集型的,一般会占用大量的处理器时间。图16.3(右边)是紧凑后的物理内存。

一种更简单的做法是利用空闲列表管理算法,试图保留大的内存块用于分配。相关的算法可能有成百上千种,包括传统的最优匹配(best-fit,从空闲链表中找最接近需要分配空间的空闲块返回)、最坏匹配(worst-fit)、首次匹配(first-fit)以及像伙伴算法(buddy algorithm)[K68]这样更复杂的算法。

分段解决了一些问题,帮助我们实现了更高效的虚拟内存。不只是动态重定位,通过避免地址空间的逻辑段之间的大量潜在的内存浪费,分段能更好地支持稀疏地址空间。它还很快,因为分段要求的算法很容易,很适合硬件完成,地址转换的开销极小。分段还有一个附加的好处:代码共享。如果代码放在独立的段中,这样的段就可能被多个运行的程序共享。

但我们已经知道,在内存中分配不同大小的段会导致一些问题,我们希望克服。首先,是我们上面讨论的外部碎片。由于段的大小不同,空闲内存被割裂成各种奇怪的大小,因此满足内存分配请求可能会很难。用户可以尝试采用聪明的算法[W+95],或定期紧凑内存,但问题很根本,难以避免。第二个问题也许更重要,分段还是不足以支持更一般化的稀疏地址空间。例如,如果有一个很大但是稀疏的堆,都在一个逻辑段中,整个堆仍然必须完整地加载到内存中。换言之,如果使用地址空间的方式不能很好地匹配底层分段的设计目标,分段就不能很好地工作。因此我们需要找到新的解决方案。你准备好了吗?

第17章 空闲空间管理

如果要管理的空闲空间由大小不同的单元构成,管理就变得困难(而且有趣)。这种情况出现在用户级的内存分配库(如malloc()和free()),或者操作系统用分段(segmentation)的方式实现虚拟内存。在这两种情况下,出现了外部碎片(external fragmentation)的问题:空闲空间被分割成不同大小的小块,成为碎片,后续的请求可能失败,因为没有一块足够大的连续空闲空间,即使这时总的空闲空间超出了请求的大小。

该库管理的空间由于历史原因被称为堆,在堆上管理空闲空间的数据结构通常称为空闲列表(free list)。该结构包含了管理内存区域中所有空闲块的引用。当然,该数据结构不一定真的是列表,而只是某种可以追踪空闲空间的数据结构。

假设我们只申请一个字节的内存。这种情况下,分配程序会执行所谓的分割(splitting)动作:它找到一块可以满足请求的空闲空间,将其分割,第一块返回给用户,第二块留在空闲列表中。

在归还一块空闲内存时,仔细查看要归还的内存块的地址以及邻近的空闲空间块。如果新归还的空间与一个原有空闲块相邻(或两个,就像这个例子),就将它们合并为一个较大的空闲块。

你可能注意到,free(void *ptr)接口没有块大小的参数。因此它是假定,对于给定的指针,内存分配库可以很快确定要释放空间的大小,从而将它放回空闲列表。要完成这个任务,大多数分配程序都会在头块(header)中保存一点额外的信息,它在内存中,通常就在返回的内存块之前。

该头块中至少包含所分配空间的大小(这个例子中是20)。它也可能包含一些额外的指针来加速空间释放,包含一个幻数来提供完整性检查,以及其他信息。

获得头块的指针后,库可以很容易地确定幻数是否符合预期的值,作为正常性检查(assert(hptr->magic ==1234567)),并简单计算要释放的空间大小(即头块的大小加区域长度)。请注意前一句话中一个小但重要的细节:实际释放的是头块大小加上分配给用户的空间的大小。因此,如果用户请求N字节的内存,库不是寻找大小为N的空闲块,而是寻找N加上头块大小的空闲块。

在更典型的列表中,如果要分配新节点,你会调用malloc()来获取该节点所需的空间。遗憾的是,在内存分配库内,你无法这么做!你需要在空闲空间本身中建立空闲空间列表。虽然听起来有点奇怪,但别担心,这是可以做到的。

从图中可以看出,我们现在一团糟!为什么?简单,我们忘了合并(coalesce)列表项,虽然整个内存空间是空闲的,但却被分成了小段,因此形成了碎片化的内存空间。解决方案很简单:遍历列表,合并(merge)相邻块。完成之后,堆又成了一个整体。

我们应该讨论最后一个很多内存分配库中都有的机制。具体来说,如果堆中的内存空间耗尽,应该怎么办?最简单的方式就是返回失败。在某些情况下这也是唯一的选择,因此返回NULL也是一种体面的方式。

大多数传统的分配程序会从很小的堆开始,当空间耗尽时,再向操作系统申请更大的空间。通常,这意味着它们进行了某种系统调用(例如,大多数UNIX系统中的sbrk),让堆增长。操作系统在执行sbrk系统调用时,会找到空闲的物理内存页,将它们映射到请求进程的地址空间中去,并返回新的堆的末尾地址。这时,就有了更大的堆,请求就可以成功满足。

一直以来有一种很有趣的方式叫作分离空闲列表(segregated list)。基本想法很简单:如果某个应用程序经常申请一种(或几种)大小的内存空间,那就用一个独立的列表,只管理这样大小的对象。其他大小的请求都交给更通用的内存分配程序。

就像所有好主意一样,这种方式也为系统引入了新的复杂性。例如,应该拿出多少内存来专门为某种大小的请求服务,而将剩余的用来满足一般请求?超级工程师Jeff Bonwick为Solaris系统内核设计的厚块分配程序(slab allocator),很优雅地处理了这个问题[B94]。

具体来说,在内核启动时,它为可能频繁请求的内核对象创建一些对象缓存(object cache),如锁和文件系统inode等。这些的对象缓存每个分离了特定大小的空闲列表,因此能够很快地响应内存请求和释放。如果某个缓存中的空闲空间快耗尽时,它就向通用内存分配程序申请一些内存厚块(slab)(总量是页大小和对象大小的公倍数)。相反,如果给定厚块中对象的引用计数变为0,通用的内存分配程序可以从专门的分配程序中回收这些空间,这通常发生在虚拟内存系统需要更多的空间的时候。

厚块分配程序比大多数分离空闲列表做得更多,它将列表中的空闲对象保持在预初始化的状态。Bonwick指出,数据结构的初始化和销毁的开销很大[B94]。通过将空闲对象保持在初始化状态,厚块分配程序避免了频繁的初始化和销毁,从而显著降低了开销。

在这种系统中,空闲空间首先从概念上被看成大小为2N的大空间。当有一个内存分配请求时,空闲空间被递归地一分为二,直到刚好可以满足请求的大小(再一分为二就无法满足)。这时,请求的块被返回给用户。

伙伴系统的漂亮之处在于块被释放时。如果将这个8KB的块归还给空闲列表,分配程序会检查“伙伴”8KB是否空闲。如果是,就合二为一,变成16KB的块。然后会检查这个16KB块的伙伴是否空闲,如果是,就合并这两块。这个递归合并过程继续上溯,直到合并整个内存区域,或者某一个块的伙伴还没有被释放。

上面提到的众多方法都有一个重要的问题,缺乏可扩展性(scaling)。具体来说,就是查找列表可能很慢。因此,更先进的分配程序采用更复杂的数据结构来优化这个开销,牺牲简单性来换取性能。例子包括平衡二叉树、伸展树和偏序树[W+95]。

第18章 分页:介绍

有时候人们会说,操作系统有两种方法,来解决大多数空间管理问题。第一种是将空间分割成不同长度的分片,就像虚拟内存管理中的分段。遗憾的是,这个解决方法存在固有的问题。具体来说,将空间切成不同长度的分片以后,空间本身会碎片化(fragmented),随着时间推移,分配内存会变得比较困难。因此,值得考虑第二种方法:将空间分割成固定长度的分片。在虚拟内存中,我们称这种思想为分页,可以追溯到一个早期的重要系统,Atlas[KE+62, L78]。分页不是将一个进程的地址空间分割成几个不同长度的逻辑段(即代码、堆、段),而是分割成固定大小的单元,每个单元称为一页。相应地,我们把物理内存看成是定长槽块的阵列,叫作页帧(page frame)。每个这样的页帧包含一个虚拟内存页。

可以看到,与我们以前的方法相比,分页有许多优点。可能最大的改进就是灵活性:通过完善的分页方法,操作系统能够高效地提供地址空间的抽象,不管进程如何使用地址空间。例如,我们不会假定堆和栈的增长方向,以及它们如何使用。

另一个优点是分页提供的空闲空间管理的简单性。例如,如果操作系统希望将64字节的小地址空间放到8页的物理地址空间中,它只要找到4个空闲页。也许操作系统保存了一个所有空闲页的空闲列表(free list),只需要从这个列表中拿出4个空闲页。

为了记录地址空间的每个虚拟页放在物理内存中的位置,操作系统通常为每个进程保存一个数据结构,称为页表(pagetable)。页表的主要作用是为地址空间的每个虚拟页面保存地址转换(address translation),从而让我们知道每个页在物理内存中的位置。

重要的是要记住,这个页表是一个每进程的数据结构(我们讨论的大多数页表结构都是每进程的数据结构,我们将接触的一个例外是倒排页表,inverted page table)。如果在上面的示例中运行另一个进程,操作系统将不得不为它管理不同的页表,因为它的虚拟页显然映射到不同的物理页面(除了共享之外)。

为了转换(translate)该过程生成的虚拟地址,我们必须首先将它分成两个组件:虚拟页面号(virtual page number,VPN)和页内的偏移量(offset)。对于这个例子,因为进程的虚拟地址空间是64字节,我们的虚拟地址总共需要6位(26 = 64)。

页面大小为16字节,位于64字节的地址空间。因此我们需要能够选择4个页,地址的前2位就是做这件事的。因此,我们有一个2位的虚拟页号(VPN)。其余的位告诉我们,感兴趣该页的哪个字节,在这个例子中是4位,我们称之为偏移量。

当进程生成虚拟地址时,操作系统和硬件必须协作,将它转换为有意义的物理地址。

页表可以变得非常大,比我们之前讨论过的小段表或基址/界限对要大得多。

一个20位的VPN意味着,操作系统必须为每个进程管理220个地址转换(大约一百万)。假设每个页表格条目(PTE)需要4个字节,来保存物理地址转换和任何其他有用的东西,每个页表就需要巨大的4MB内存!这非常大。现在想象一下有100个进程在运行:这意味着操作系统会需要400MB内存,只是为了所有这些地址转换!即使是现在,机器拥有千兆字节的内存,将它的一大块仅用于地址转换,这似乎有点疯狂,不是吗?我们甚至不敢想64位地址空间的页表有多大。那太可怕了,也许把你吓坏了。

由于页表如此之大,我们没有在MMU中利用任何特殊的片上硬件,来存储当前正在运行的进程的页表,而是将每个进程的页表存储在内存中。

页表就是一种数据结构,用于将虚拟地址(或者实际上,是虚拟页号)映射到物理地址(物理帧号)。因此,任何数据结构都可以采用。最简单的形式称为线性页表(linear page table),就是一个数组。操作系统通过虚拟页号(VPN)检索该数组,并在该索引处查找页表项(PTE),以便找到期望的物理帧号(PFN)。

至于每个PTE的内容,我们在其中有许多不同的位,值得有所了解。有效位(valid bit)通常用于指示特定地址转换是否有效。例如,当一个程序开始运行时,它的代码和堆在其地址空间的一端,栈在另一端。所有未使用的中间空间都将被标记为无效(invalid),如果进程尝试访问这种内存,就会陷入操作系统,可能会导致该进程终止。因此,有效位对于支持稀疏地址空间至关重要。通过简单地将地址空间中所有未使用的页面标记为无效,我们不再需要为这些页面分配物理帧,从而节省大量内存。

我们还可能有保护位(protection bit),表明页是否可以读取、写入或执行。同样,以这些位不允许的方式访问页,会陷入操作系统。

还有其他一些重要的部分,但现在我们不会过多讨论。存在位(present bit)表示该页是在物理存储器还是在磁盘上(即它已被换出,swapped out)。当我们研究如何将部分地址空间交换(swap)到磁盘,从而支持大于物理内存的地址空间时,我们将进一步理解这一机制。交换允许操作系统将很少使用的页面移到磁盘,从而释放物理内存。脏位(dirtybit)也很常见,表明页面被带入内存后是否被修改过。

参考位(reference bit,也被称为访问位,accessed bit)有时用于追踪页是否被访问,也用于确定哪些页很受欢迎,因此应该保留在内存中。这些知识在页面替换(page replacement)时非常重要,我们将在随后的章节中详细研究这一主题。

内存中的页表,我们已经知道它们可能太大了。事实证明,它们也会让速度变慢。

总之,我们现在描述了在每个内存引用上发生的情况的初始协议。基本方法如图18.6所示。对于每个内存引用(无论是取指令还是显式加载或存储),分页都需要我们执行一个额外的内存引用,以便首先从页表中获取地址转换。工作量很大!额外的内存引用开销很大,在这种情况下,可能会使进程减慢两倍或更多。

现代操作系统的内存管理子系统中最重要的数据结构之一就是页表(page table)。通常,页表存储虚拟—物理地址转换(virtual-to-physical address translation),从而让系统知道地址空间的每个页实际驻留在物理内存中的哪个位置。由于每个地址空间都需要这种转换,因此一般来说,系统中每个进程都有一个页表。页表的确切结构要么由硬件(旧系统)确定,要么由OS(现代系统)更灵活地管理。

第19章 分页:快速地址转换(TLB)

想让某些东西更快,操作系统通常需要一些帮助。帮助常常来自操作系统的老朋友:硬件。我们要增加所谓的(由于历史原因[CP78])地址转换旁路缓冲存储器(translation-lookaside buffer,TLB[CG68,C95]),它就是频繁发生的虚拟到物理地址转换的硬件缓存(cache)。因此,更好的名称应该是地址转换缓存(address-translation cache)。对每次内存访问,硬件先检查TLB,看看其中是否有期望的转换映射,如果有,就完成转换(很快),不用访问页表(其中有全部的转换映射)。TLB带来了巨大的性能提升,实际上,因此它使得虚拟内存成为可能[C95]。

TLB和其他缓存相似,前提是在一般情况下,转换映射会在缓存中(即命中)。如果是这样,只增加了很少的开销,因为TLB处理器核心附近,设计的访问速度很快。如果TLB未命中,就会带来很大的分页开销。必须访问页表来查找转换映射,导致一次额外的内存引用(或者更多,如果页表更复杂)。如果这经常发生,程序的运行就会显著变慢。相对于大多数CPU指令,内存访问开销很大,TLB未命中导致更多内存访问。因此,我们希望尽可能避免TLB未命中。

关于TLB性能还有最后一点:如果在这次循环后不久,该程序再次访问该数组,我们会看到更好的结果,假设TLB足够大,能缓存所需的转换映射:命中、命中、命中、命中、命中、命中、命中、命中、命中、命中。在这种情况下,由于时间局部性(temporal locality),即在短时间内对内存项再次引用,所以TLB的命中率会很高。类似其他缓存,TLB的成功依赖于空间和时间局部性。如果某个程序表现出这样的局部性(许多程序是这样),TLB的命中率可能很高。

缓存是计算机系统中最基本的性能改进技术之一,一次又一次地用于让“常见的情况更快”[HP06]。硬件缓存背后的思想是利用指令和数据引用的局部性(locality)。通常有两种局部性:时间局部性(temporal locality)和空间局部性(spatial locality)。时间局部性是指,最近访问过的指令或数据项可能很快会再次访问。想想循环中的循环变量或指令,它们被多次反复访问。空间局部性是指,当程序访问内存地址x时,可能很快会访问邻近x的内存。想想遍历某种数组,访问一个接一个的元素。当然,这些性质取决于程序的特点,并不是绝对的定律,而更像是一种经验法则。

有一个问题我们必须回答:谁来处理TLB未命中?可能有两个答案:硬件或软件(操作系统)。以前的硬件有复杂的指令集(有时称为复杂指令集计算机,Complex-Instruction Set Computer,CISC),造硬件的人不太相信那些搞操作系统的人。因此,硬件全权处理TLB未命中。为了做到这一点,硬件必须知道页表在内存中的确切位置(通过页表基址寄存器,page-table base register,在图19.1的第11行使用),以及页表的确切格式。发生未命中时,硬件会“遍历”页表,找到正确的页表项,取出想要的转换映射,用它更新TLB,并重试该指令。这种“旧”体系结构有硬件管理的TLB,一个例子是x86架构,它采用固定的多级页表(multi-level page table,详见第20章),当前页表由CR3寄存器指出[I09]。

更现代的体系结构(例如,MIPS R10k[H93]、Sun公司的SPARC v9[WG00],都是精简指令集计算机,Reduced-Instruction Set Computer,RISC),有所谓的软件管理TLB(software- managed TLB)。发生TLB未命中时,硬件系统会抛出一个异常(见图19.3第11行),这会暂停当前的指令流,将特权级提升至内核模式,跳转至陷阱处理程序(trap handler)。接下来你可能已经猜到了,这个陷阱处理程序是操作系统的一段代码,用于处理TLB未命中。这段代码在运行时,会查找页表中的转换映射,然后用特别的“特权”指令更新TLB,并从陷阱返回。此时,硬件会重试该指令(导致TLB命中)。

首先,这里的从陷阱返回指令稍稍不同于之前提到的服务于系统调用的从陷阱返回。在后一种情况下,从陷阱返回应该继续执行陷入操作系统之后那条指令,就像从函数调用返回后,会继续执行此次调用之后的语句。在前一种情况下,在从TLB未命中的陷阱返回后,硬件必须从导致陷阱的指令继续执行。这次重试因此导致该指令再次执行,但这次会命中TLB。因此,根据陷阱或异常的原因,系统在陷入内核时必须保存不同的程序计数器,以便将来能够正确地继续执行。

第二,在运行TLB未命中处理代码时,操作系统需要格外小心避免引起TLB未命中的无限递归。有很多解决方案,例如,可以把TLB未命中陷阱处理程序直接放到物理内存中 [它们没有映射过(unmapped),不用经过地址转换]。或者在TLB中保留一些项,记录永久有效的地址转换,并将其中一些永久地址转换槽块留给处理代码本身,这些被监听的(wired)地址转换总是会命中TLB。

软件管理的方法,主要优势是灵活性:操作系统可以用任意数据结构来实现页表,不需要改变硬件。另一个优势是简单性。

CISC背后的思想是,指令应该是高级原语,这让汇编语言本身更易于使用,代码更紧凑。

RISC 指令集恰恰相反。RISC 背后的关键观点是,指令集实际上是编译器的最终目标,所有编译器实际上需要少量简单的原语,可以用于生成高性能的代码。因此,RISC倡导者们主张,尽可能从硬件中拿掉不必要的东西(尤其是微代码),让剩下的东西简单、统一、快速。

我们来详细看一下硬件TLB中的内容。典型的TLB有32项、64项或128项,并且是全相联的(fully associative)。基本上,这就意味着一条地址映射可能存在TLB中的任意位置,硬件会并行地查找TLB,找到期望的转换映射。一条TLB项内容可能像下面这样:VPN | PFN | 其他位注意,VPN和PFN同时存在于TLB中,因为一条地址映射可能出现在任意位置(用硬件的术语,TLB被称为全相联的(fully-associative)缓存)。硬件并行地查找这些项,看看是否有匹配。

常见的错误是混淆TLB的有效位和页表的有效位。在页表中,如果一个页表项(PTE)被标记为无效,就意味着该页并没有被进程申请使用,正常运行的程序不应该访问该地址。当程序试图访问这样的页时,就会陷入操作系统,操作系统会杀掉该进程。

TLB的有效位不同,只是指出TLB项是不是有效的地址映射。例如,系统启动时,所有的TLB项通常被初始化为无效状态,因为还没有地址转换映射被缓存在这里。一旦启用虚拟内存,当程序开始运行,访问自己的虚拟地址,TLB就会慢慢地被填满,因此有效的项很快会充满TLB。

TLB有效位在系统上下文切换时起到了很重要的作用,后面我们会进一步讨论。通过将所有TLB项设置为无效,系统可以确保将要运行的进程不会错误地使用前一个进程的虚拟到物理地址转换映射。

有了TLB,在进程间切换时(因此有地址空间切换),会面临一些新问题。具体来说,TLB中包含的虚拟到物理的地址映射只对当前进程有效,对其他进程是没有意义的。所以在发生进程切换时,硬件或操作系统(或二者)必须注意确保即将运行的进程不要误读了之前进程的地址映射。

一种方法是在上下文切换时,简单地清空(flush)TLB,这样在新进程运行前TLB就变成了空的。如果是软件管理TLB的系统,可以在发生上下文切换时,通过一条显式(特权)指令来完成。如果是硬件管理TLB,则可以在页表基址寄存器内容发生变化时清空TLB(注意,在上下文切换时,操作系统必须改变页表基址寄存器(PTBR)的值)。不论哪种情况,清空操作都是把全部有效位(valid)置为0,本质上清空了TLB。

上下文切换的时候清空TLB,这是一个可行的解决方案,进程不会再读到错误的地址映射。但是,有一定开销:每次进程运行,当它访问数据和代码页时,都会触发TLB未命中。如果操作系统频繁地切换进程,这种开销会很高。

为了减少这种开销,一些系统增加了硬件支持,实现跨上下文切换的TLB共享。比如有的系统在TLB中添加了一个地址空间标识符(Address Space Identifier,ASID)。可以把ASID看作是进程标识符(Process Identifier,PID),但通常比PID位数少(PID一般32位,ASID一般是8位)。

因此,有了地址空间标识符,TLB可以同时缓存不同进程的地址空间映射,没有任何冲突。当然,硬件也需要知道当前是哪个进程正在运行,以便进行地址转换,因此操作系统在上下文切换时,必须将某个特权寄存器设置为当前进程的ASID。

如果两个进程共享同一物理页(例如代码段的页),就可能出现这种情况。在上面的例子中,进程P1和进程P2共享101号物理页,但是P1将自己的10号虚拟页映射到该物理页,而P2将自己的50号虚拟页映射到该物理页。共享代码页(以二进制或共享库的方式)是有用的,因为它减少了物理页的使用,从而减少了内存开销。

在讨论页换出到磁盘的问题时,我们将详细研究这样的策略。这里我们先简单指出几个典型的策略。一种常见的策略是替换最近最少使用(least-recently-used,LRU)的项。LRU尝试利用内存引用流中的局部性,假定最近没有用过的项,可能是好的换出候选项。另一种典型策略就是随机(random)策略,即随机选择一项换出去。这种策略很简单,并且可以避免一种极端情况。例如,一个程序循环访问n+1个页,但TLB大小只能存放n个页。这时之前看似“合理”的LRU策略就会表现得不可理喻,因为每次访问内存都会触发TLB未命中,而随机策略在这种情况下就好很多。

随机存取存储器(Random-Access Memory,RAM)暗示你访问RAM的任意部分都一样快。虽然一般这样想RAM没错,但因为TLB这样的硬件/操作系统功能,访问某些内存页的开销较大,尤其是没有被TLB缓存的页。因此,最好记住这个实现的窍门:RAM不总是RAM。有时候随机访问地址空间,尤其是TLB没有缓存的页,可能导致严重的性能损失。因为我的一位导师David Culler过去常常指出TLB是许多性能问题的源头,所以我们以他来命名这个定律:Culler定律(Culler’s Law)。

我们了解了硬件如何让地址转换更快的方法。通过增加一个小的、芯片内的TLB作为地址转换的缓存,大多数内存引用就不用访问内存中的页表了。因此,在大多数情况下,程序的性能就像内存没有虚拟化一样,这是操作系统的杰出成就,当然对现代操作系统中的分页非常必要。

但是,TLB也不能满足所有的程序需求。具体来说,如果一个程序短时间内访问的页数超过了TLB中的页数,就会产生大量的TLB未命中,运行速度就会变慢。这种现象被称为超出TLB覆盖范围(TLB coverage),这对某些程序可能是相当严重的问题。解决这个问题的一种方案是支持更大的页,把关键数据结构放在程序地址空间的某些区域,这些区域被映射到更大的页,使TLB的有效覆盖率增加。对更大页的支持通常被数据库管理系统(Database Management System,DBMS)这样的程序利用,它们的数据结构比较大,而且是随机访问。

另一个TLB问题值得一提:访问TLB很容易成为CPU流水线的瓶颈,尤其是有所谓的物理地址索引缓存(physically-indexed cache)。有了这种缓存,地址转换必须发生在访问该缓存之前,这会让操作变慢。为了解决这个潜在的问题,人们研究了各种巧妙的方法,用虚拟地址直接访问缓存,从而在缓存命中时避免昂贵的地址转换步骤。像这种虚拟地址索引缓存(virtually-indexed cache)解决了一些性能问题,但也为硬件设计带来了新问题。

第20章 分页:较小的表

可以用一种简单的方法减小页表大小:使用更大的页。再以32位地址空间为例,但这次假设用16KB的页。因此,会有18位的VPN加上14位的偏移量。假设每个页表项(4字节)的大小相同,现在线性页表中有218个项,因此每个页表的总大小为1MB,页表缩到四分之一。

然而,这种方法的主要问题在于,大内存页会导致每页内的浪费,这被称为内部碎片(internal fragmentation)问题(因为浪费在分配单元内部)。因此,结果是应用程序会分配页,但只用每页的一小部分,而内存很快就会充满这些过大的页。因此,大多数系统在常见的情况下使用相对较小的页大小:4KB(如x86)或8KB(如SPARCv9)。问题不会如此简单地解决。

多年前,Multics的创造者(特别是Jack Dennis)在构建Multics虚拟内存系统时,偶然发现了这样的想法[M07]。具体来说,Dennis想到将分页和分段相结合,以减少页表的内存开销。更仔细地看看典型的线性页表,就可以理解为什么这可能有用。假设我们有一个地址空间,其中堆和栈的使用部分很小。例如,我们使用一个16KB的小地址空间和1KB的页(见图20.1)。

在杂合方案中,我们仍然在MMU中拥有这些结构。在这里,我们使用基址不是指向段本身,而是保存该段的页表的物理地址。界限寄存器用于指示页表的结尾(即它有多少有效页)。

杂合方案的关键区别在于,每个分段都有界限寄存器,每个界限寄存器保存了段中最大有效页的值。例如,如果代码段使用它的前3个页(0、1和2),则代码段页表将只有3个项分配给它,并且界限寄存器将被设置为3。内存访问超出段的末尾将产生一个异常,并可能导致进程终止。以这种方式,与线性页表相比,杂合方法实现了显著的内存节省。栈和堆之间未分配的页不再占用页表中的空间(仅将其标记为无效)。

但是,你可能会注意到,这种方法并非没有问题。首先,它仍然要求使用分段。正如我们讨论的那样,分段并不像我们需要的那样灵活,因为它假定地址空间有一定的使用模式。例如,如果有一个大而稀疏的堆,仍然可能导致大量的页表浪费。其次,这种杂合导致外部碎片再次出现。尽管大部分内存是以页面大小单位管理的,但页表现在可以是任意大小(是PTE的倍数)。因此,在内存中为它们寻找自由空间更为复杂。出于这些原因,人们继续寻找更好的方式来实现更小的页表。

另一种方法并不依赖于分段,但也试图解决相同的问题:如何去掉页表中的所有无效区域,而不是将它们全部保留在内存中?我们将这种方法称为多级页表(multi-level page table),因为它将线性页表变成了类似树的东西。这种方法非常有效,许多现代系统都用它(例如x86 [BOH10])。

多级页表的基本思想很简单。首先,将页表分成页大小的单元。然后,如果整页的页表项(PTE)无效,就完全不分配该页的页表。为了追踪页表的页是否有效(以及如果有效,它在内存中的位置),使用了名为页目录(page directory)的新结构。页目录因此可以告诉你页表的页在哪里,或者页表的整个页不包含有效页。

在一个简单的两级页表中,页目录为每页页表包含了一项。它由多个页目录项(Page Directory Entries,PDE)组成。PDE(至少)拥有有效位(valid bit)和页帧号(page frame number,PFN),类似于PTE。但是,正如上面所暗示的,这个有效位的含义稍有不同:如果PDE项是有效的,则意味着该项指向的页表(通过PFN)中至少有一页是有效的,即在该PDE所指向的页中,至少一个PTE,其有效位被设置为1。如果PDE项无效(即等于零),则PDE的其余部分没有定义。

与我们至今为止看到的方法相比,多级页表有一些明显的优势。首先,也许最明显的是,多级页表分配的页表空间,与你正在使用的地址空间内存量成比例。因此它通常很紧凑,并且支持稀疏的地址空间。

其次,如果仔细构建,页表的每个部分都可以整齐地放入一页中,从而更容易管理内存。操作系统可以在需要分配或增长页表时简单地获取下一个空闲页。

应该指出,多级页表是有成本的。在TLB未命中时,需要从内存加载两次,才能从页表中获取正确的地址转换信息(一次用于页目录,另一次用于PTE本身),而用线性页表只需要一次加载。因此,多级表是一个时间—空间折中(time-space trade-off)的小例子。我们想要更小的表(并得到了),但不是没代价。尽管在常见情况下(TLB命中),性能显然是相同的,但TLB未命中时,则会因较小的表而导致较高的成本。另一个明显的缺点是复杂性。无论是硬件还是操作系统来处理页表查找(在TLB未命中时),这样做无疑都比简单的线性页表查找更复杂。通常我们愿意增加复杂性以提高性能或降低管理费用。在多级表的情况下,为了节省宝贵的内存,我们使页表查找更加复杂。

系统设计者应该谨慎对待让系统增加复杂性。好的系统构建者所做的就是:实现最小复杂性的系统,来完成手上的任务。例如,如果磁盘空间非常大,则不应该设计一个尽可能少使用字节的文件系统。同样,如果处理器速度很快,建议在操作系统中编写一个干净、易于理解的模块,而不是CPU优化的、手写汇编的代码。注意过早优化的代码或其他形式的不必要的复杂性。这些方法会让系统难以理解、维护和调试。正如Antoine de Saint-Exupery的名言:“完美非无可增,乃不可减。”他没有写的是:“谈论完美易,真正实现难。

一旦从VPN中提取了页目录索引(简称PDIndex),我们就可以通过简单的计算来找到页目录项(PDE)的地址:PDEAddr = PageDirBase +(PDIndex×sizeof(PDE))。这就得到了页目录,现在我们来看它,在地址转换上取得进一步进展。

如果页目录项标记为无效,则我们知道访问无效,从而引发异常。但是,如果PDE有效,我们还有更多工作要做。具体来说,我们现在必须从页目录项指向的页表的页中获取页表项(PTE)。

在反向页表(inverted page table)中,可以看到页表世界中更极端的空间节省。在这里,我们保留了一个页表,其中的项代表系统的每个物理页,而不是有许多页表(系统的每个进程一个)。页表项告诉我们哪个进程正在使用此页,以及该进程的哪个虚拟页映射到此物理页。现在,要找到正确的项,就是要搜索这个数据结构。线性扫描是昂贵的,因此通常在此基础结构上建立散列表,以加速查找。PowerPC就是这种架构[JM98]的一个例子。更一般地说,反向页表说明了我们从一开始就说过的内容:页表只是数据结构。你可以对数据结构做很多疯狂的事情,让它们更小或更大,使它们变得更慢或更快。多层和反向页表只是人们可以做的很多事情的两个例子。

第21章 超越物理内存:机制

为了支持更大的地址空间,操作系统需要把当前没有在用的那部分地址空间找个地方存储起来。一般来说,这个地方有一个特点,那就是比内存有更大的容量。因此,一般来说也更慢(如果它足够快,我们就可以像使用内存一样使用,对吗?)。在现代系统中,硬盘(hard disk drive)通常能够满足这个需求。因此,在我们的存储层级结构中,大而慢的硬盘位于底层,内存之上。

我们要做的第一件事情就是,在硬盘上开辟一部分空间用于物理页的移入和移出。在操作系统中,一般这样的空间称为交换空间(swap space),因为我们将内存中的页交换到其中,并在需要的时候又交换回去。因此,我们会假设操作系统能够以页大小为单元读取或者写入交换空间。为了达到这个目的,操作系统需要记住给定页的硬盘地址(diskaddress)。

交换空间的大小是非常重要的,它决定了系统在某一时刻能够使用的最大内存页数。

我们需要注意,交换空间不是唯一的硬盘交换目的地。例如,假设运行一个二进制程序(如ls,或者你自己编译的main程序)。这个二进制程序的代码页最开始是在硬盘上,但程序运行的时候,它们被加载到内存中(要么在程序开始运行时全部加载,要么在现代操作系统中,按需要一页一页加载)。但是,如果系统需要在物理内存中腾出空间以满足其他需求,则可以安全地重新使用这些代码页的内存空间,因为稍后它又可以重新从硬盘上的二进制文件加载。

但是,如果希望允许页交换到硬盘,必须添加更多的机制。具体来说,当硬件在PTE中查找时,可能发现页不在物理内存中。硬件(或操作系统,在软件管理TLB时)判断是否在内存中的方法,是通过页表项中的一条新信息,即存在位(present bit)。如果存在位设置为1,则表示该页存在于物理内存中,并且所有内容都如上所述进行。如果存在位设置为零,则页不在内存中,而在硬盘上。访问不在物理内存中的页,这种行为通常被称为页错误(page fault)。

回想一下,在TLB未命中的情况下,我们有两种类型的系统:硬件管理的TLB(硬件在页表中找到需要的转换映射)和软件管理的TLB(操作系统执行查找过程)。不论在哪种系统中,如果页不存在,都由操作系统负责处理页错误。操作系统的页错误处理程序(page-fault handler)确定要做什么。几乎所有的系统都在软件中处理页错误。即使是硬件管理的TLB,硬件也信任操作系统来管理这个重要的任务。

如果一个页不存在,它已被交换到硬盘,在处理页错误的时候,操作系统需要将该页交换到内存中。那么,问题来了:操作系统如何知道所需的页在哪儿?在许多系统中,页表是存储这些信息最自然的地方。因此,操作系统可以用PTE中的某些位来存储硬盘地址,这些位通常用来存储像页的PFN这样的数据。当操作系统接收到页错误时,它会在PTE中查找地址,并将请求发送到硬盘,将页读取到内存中。

当硬盘I/O完成时,操作系统会更新页表,将此页标记为存在,更新页表项(PTE)的PFN字段以记录新获取页的内存位置,并重试指令。下一次重新访问TLB还是未命中,然而这次因为页在内存中,因此会将页表中的地址更新到TLB 中(也可以在处理页错误时更新TLB以避免此步骤)。最后的重试操作会在TLB 中找到转换映射,从已转换的内存物理地址,获取所需的数据或指令。

请注意,当I/O在运行时,进程将处于阻塞(blocked)状态。因此,当页错误正常处理时,操作系统可以自由地运行其他可执行的进程。因为I/O操作是昂贵的,一个进程进行I/O(页错误)时会执行另一个进程,这种交叠(overlap)是多道程序系统充分利用硬件的一种方式。

内存可能已满(或接近满了)。因此,操作系统可能希望先交换出(page out)一个或多个页,以便为操作系统即将交换入的新页留出空间。选择哪些页被交换出或被替换(replace)的过程,被称为页交换策略(page-replacement policy)。

为了保证有少量的空闲内存,大多数操作系统会设置高水位线(High Watermark,HW)和低水位线(LowWatermark,LW),来帮助决定何时从内存中清除页。原理是这样:当操作系统发现有少于LW个页可用时,后台负责释放内存的线程会开始运行,直到有HW个可用的物理页。这个后台线程有时称为交换守护进程(swap daemon)或页守护进程(page daemon),它然后会很开心地进入休眠状态,因为它毕竟为操作系统释放了一些内存。

通过同时执行多个交换过程,我们可以进行一些性能优化。例如,许多系统会把多个要写入的页聚集(cluster)或分组(group),同时写入到交换区间,从而提高硬盘的效率[LL82]。我们稍后在讨论硬盘时将会看到,这种合并操作减少了硬盘的寻道和旋转开销,从而显著提高了性能。

交换算法需要先简单检查是否有空闲页,而不是直接执行替换。如果没有空闲页,会通知后台分页线程按需要释放页。当线程释放一定数目的页时,它会重新唤醒原来的线程,然后就可以把需要的页交换进内存,继续它的工作。

对进程而言,它只是访问自己私有的、连续的虚拟内存。在后台,物理页被放置在物理内存中的任意(非连续)位置,有时它们甚至不在内存中,需要从硬盘取回。虽然我们希望在一般情况下内存访问速度很快,但在某些情况下,它需要多个硬盘操作的时间。像执行单条指令这样简单的事情,在最坏的情况下,可能需要很多毫秒才能完成。

第22章 超越物理内存:策略

遗憾的是,当内存不够时事情会变得更有趣。在这种情况下,由于内存压力(memory pressure)迫使操作系统换出(paging out)一些页,为常用的页腾出空间。确定要踢出(evict)哪个页(或哪些页)封装在操作系统的替换策略(replacement policy)中。历史上,这是早期的虚拟内存系统要做的最重要的决定之一,因为旧系统的物理内存非常小。

知道了缓存命中和未命中的次数,就可以计算程序的平均内存访问时间(Average Memory Access Time,AMAT,计算机架构师衡量硬件缓存的指标 [HP06])。具体来说,给定这些值,可以按照如下公式计算AMAT:AMAT = (PHit·TM) + (PMiss·TD)其中TM表示访问内存的成本,TD表示访问磁盘的成本,PHit表示在缓存中找到数据的概率(命中),PMiss表示在缓存中找不到数据的概率(未命中)。PHit和PMiss从0.0变化到1.0,并且PMiss + PHit = 1.0。

许多早期的系统避免了尝试达到最优的复杂性,采用了非常简单的替换策略。例如,一些系统使用FIFO(先入先出)替换策略。页在进入系统时,简单地放入一个队列。当发生替换时,队列尾部的页(“先入”页)被踢出。FIFO有一个很大的优势:实现相当简单。

Belady(最优策略发明者)及其同事发现了一个有意思的引用序列[BNS69]。内存引用顺序是:1,2,3,4,1,2,5,1,2,3,4,5。他们正在研究的替换策略是FIFO。有趣的问题:当缓存大小从3变成4时,缓存命中率如何变化?一般来说,当缓存变大时,缓存命中率是会提高的(变好)。但在这个例子,采用FIFO,命中率反而下降了!你可以自己计算一下缓存命中和未命中次数。这种奇怪的现象被称为Belady的异常(Belady’s Anomaly)。其他一些策略,比如LRU,不会遇到这个问题。可以猜猜为什么?事实证明,LRU具有所谓的栈特性(stackproperty)[M+70]。对于具有这个性质的算法,大小为N + 1的缓存自然包括大小为N的缓存的内容。因此,当增加缓存大小时,缓存命中率至少保证不变,有可能提高。先进先出(FIFO)和随机(Random)等显然没有栈特性,因此容易出现异常行为。

程序倾向于表现出两种类型的局部。第一种是空间局部性(spatial locality),它指出如果页P被访问,可能围绕它的页(比如P−1或P + 1)也会被访问。第二种是时间局部性(temporal locality),它指出近期访问过的页面很可能在不久的将来再次访问。假设存在这些类型的局部性,对硬件系统的缓存层次结构起着重要作用,硬件系统部署了许多级别的指令、数据和地址转换缓存,以便在存在此类局部性时,能帮助程序快速运行。

首先,当工作负载不存在局部性时,使用的策略区别不大。LRU、FIFO和随机都执行相同的操作,命中率完全由缓存的大小决定。其次,当缓存足够大到可以容纳所有的数据时,使用哪种策略也无关紧要,所有的策略(甚至是随机的)都有100%的命中率。最后,你可以看到,最优策略的表现明显好于实际的策略。如果有可能的话,偷窥未来,就能做到更好的替换。

如果每次未命中代价非常大(并不罕见),那么即使小幅提高命中率(降低未命中率)也会对性能产生巨大的影响。如果未命中的代价不那么大,那么LRU带来的好处就不会那么重要。

遗憾的是,由于工作负载的循环性质,这些较旧的页将比因为策略决定保存在缓存中的页更早被访问。事实上,即使缓存的大小是49页,50个页面的循环连续工作负载也会导致0%的命中率。有趣的是,随机策略明显更好,虽然距离最优策略还有距离,但至少达到了非零的命中率。可以看出随机策略有一些不错的属性,比如不会出现特殊情况下奇怪的结果。

为了记录哪些页是最少和最近被使用,系统必须对每次内存引用做一些记录工作。显然,如果不十分小心,这样的记录反而会极大地影响性能。

从计算开销的角度来看,近似LRU更为可行,实际上这也是许多现代系统的做法。这个想法需要硬件增加一个使用位(use bit,有时称为引用位,reference bit),这种做法在第一个支持分页的系统Atlas one-level store[KE + 62]中实现。系统的每个页有一个使用位,然后这些使用位存储在某个地方(例如,它们可能在每个进程的页表中,或者只在某个数组中)。每当页被引用(即读或写)时,硬件将使用位设置为1。但是,硬件不会清除该位(即将其设置为0),这由操作系统负责。

操作系统如何利用使用位来实现近似LRU?可以有很多方法,有一个简单的方法称作时钟算法(clock algorithm)[C69]。想象一下,系统中的所有页都放在一个循环列表中。时钟指针(clock hand)开始时指向某个特定的页(哪个页不重要)。当必须进行页替换时,操作系统检查当前指向的页P的使用位是1还是0。如果是1,则意味着页面P最近被使用,因此不适合被替换。然后,P的使用位设置为0,时钟指针递增到下一页(P + 1)。该算法一直持续到找到一个使用位为0的页,使用位为0意味着这个页最近没有被使用过(在最坏的情况下,所有的页都已经被使用了,那么就将所有页的使用位都设置为0)。

请注意,这种方法不是通过使用位来实现近似LRU的唯一方法。实际上,任何周期性地清除使用位,然后通过区分使用位是1和0来判定该替换哪个页的方法都是可以的。Corbato的时钟算法只是一个早期成熟的算法,并且具有不重复扫描内存来寻找未使用页的特点,也就是它在最差情况下,只会遍历一次所有内存。

时钟算法的一个小修改(最初也由Corbato [C69]提出),是对内存中的页是否被修改的额外考虑。这样做的原因是:如果页已被修改(modified)并因此变脏(dirty),则踢出它就必须将它写回磁盘,这很昂贵。如果它没有被修改(因此是干净的,clean),踢出就没成本。物理帧可以简单地重用于其他目的而无须额外的I/O。因此,一些虚拟机系统更倾向于踢出干净页,而不是脏页。为了支持这种行为,硬件应该包括一个修改位(modified bit,又名脏位,dirty bit)。每次写入页时都会设置此位,因此可以将其合并到页面替换算法中。例如,时钟算法可以被改变,以扫描既未使用又干净的页先踢出。无法找到这种页时,再查找脏的未使用页面,等等。

页面替换不是虚拟内存子系统采用的唯一策略(尽管它可能是最重要的)。例如,操作系统还必须决定何时将页载入内存。该策略有时称为页选择(page selection)策略(因为Denning这样命名[D70]),它向操作系统提供了一些不同的选项。

当然,操作系统可能会猜测一个页面即将被使用,从而提前载入。这种行为被称为预取(prefetching),只有在有合理的成功机会时才应该这样做。

另一个策略决定了操作系统如何将页面写入磁盘。当然,它们可以简单地一次写出一个。然而,许多系统会在内存中收集一些待完成写入,并以一种(更高效)的写入方式将它们写入硬盘。这种行为通常称为聚集(clustering)写入,或者就是分组写入(grouping),这样做有效是因为硬盘驱动器的性质,执行单次大的写操作,比许多小的写操作更有效。

当内存就是被超额请求时,操作系统应该做什么,这组正在运行的进程的内存需求是否超出了可用物理内存?在这种情况下,系统将不断地进行换页,这种情况有时被称为抖动(thrashing)[D70]。

一些早期的操作系统有一组相当复杂的机制,以便在抖动发生时检测并应对。例如,给定一组进程,系统可以决定不运行部分进程,希望减少的进程工作集(它们活跃使用的页面)能放入内存,从而能够取得进展。这种方法通常被称为准入控制(admission control),它表明,少做工作有时比尝试一下子做好所有事情更好,这是我们在现实生活中以及在现代计算机系统中经常遇到的情况(令人遗憾)。

目前的一些系统采用更严格的方法处理内存过载。例如,当内存超额请求时,某些版本的Linux会运行“内存不足的杀手程序(out-of-memory killer)”。这个守护进程选择一个内存密集型进程并杀死它,从而以不怎么委婉的方式减少内存。虽然成功地减轻了内存压力,但这种方法可能会遇到问题,例如,如果它杀死X服务器,就会导致所有需要显示的应用程序不可用。

现代系统增加了对时钟等简单LRU近似值的一些调整。例如,扫描抗性(scan resistance)是许多现代算法的重要组成部分,如ARC [MM03]。扫描抗性算法通常是类似LRU的,但也试图避免LRU的最坏情况行为,我们曾在循环顺序工作负载中看到这种情况。因此,页替换算法的发展仍在继续。

第23章 VAX/VMS虚拟内存系统

代码段永远不会从第0页开始。相反,该页被标记为不可访问,以便为检测空指针(null-pointer)访问提供一些支持。因此,设计地址空间时需要考虑的一个问题是对调试的支持,这正是无法访问的零页所提供的。

正如我们前面看到的,纯粹的FIFO并不是特别好。为了提高FIFO的性能,VMS引入了两个二次机会列表(second-chance list),页在从内存中被踢出之前被放在其中。具体来说,是全局的干净页空闲列表和脏页列表。当进程P超过其RSS时,将从其每个进程的FIFO中移除一个页。如果干净(未修改),则将其放在干净页列表的末尾。如果脏(已修改),则将其放在脏页列表的末尾。如果另一个进程Q需要一个空闲页,它会从全局干净列表中取出第一个空闲页。但是,如果原来的进程P在回收之前在该页上出现页错误,则P会从空闲(或脏)列表中回收,从而避免昂贵的磁盘访问。这些全局二次机会列表越大,分段的FIFO算法越接近LRU [RL81]。

为了让交换I/O更有效,VMS增加了一些优化,但最重要的是聚集(clustering)。通过聚集,VMS将大批量的页从全局脏列表中分组到一起,并将它们一举写入磁盘(从而使它们变干净)。聚集用于大多数现代系统,因为可以在交换空间的任意位置放置页,所以操作系统对页分组,执行更少和更大的写入,从而提高性能。

VMS有另外两个现在成为标准的技巧:按需置零和写入时复制。我们现在描述这些惰性(lazy)优化。

VMS(以及大多数现代系统)中的一种懒惰形式是页的按需置零(demand zeroing)。为了更好地理解这一点,我们来考虑一下在你的地址空间中添加一个页的例子。在一个初级实现中,操作系统响应一个请求,在物理内存中找到页,将该页添加到你的堆中,并将其置零(安全起见,这是必需的。否则,你可以看到其他进程使用该页时的内容。),然后将其映射到你的地址空间(设置页表以根据需要引用该物理页)。但是初级实现可能是昂贵的,特别是如果页没有被进程使用。利用按需置零,当页添加到你的地址空间时,操作系统的工作很少。它会在页表中放入一个标记页不可访问的条目。如果进程读取或写入页,则会向操作系统发送陷阱。在处理陷阱时,操作系统注意到(通常通过页表项中“保留的操作系统字段”部分标记的一些位),这实际上是一个按需置零页。此时,操作系统会完成寻找物理页的必要工作,将它置零,并映射到进程的地址空间。如果该进程从不访问该页,则所有这些工作都可以避免,从而体现按需置零的好处。

VMS有另一个很酷的优化(几乎每个现代操作系统都是这样),写时复制(copy-on-write,COW)。这个想法至少可以回溯到TENEX操作系统[BB+72],它很简单:如果操作系统需要将一个页面从一个地址空间复制到另一个地址空间,不是实际复制它,而是将其映射到目标地址空间,并在两个地址空间中将其标记为只读。如果两个地址空间都只读取页面,则不会采取进一步的操作,因此操作系统已经实现了快速复制而不实际移动任何数据。但是,如果其中一个地址空间确实尝试写入页面,就会陷入操作系统。操作系统会注意到该页面是一个COW页面,因此(惰性地)分配一个新页,填充数据,并将这个新页映射到错误处理的地址空间。该进程然后继续,现在有了该页的私人副本。

COW有用有一些原因。当然,任何类型的共享库都可以通过写时复制,映射到许多进程的地址空间中,从而节省宝贵的内存空间。在UNIX系统中,由于fork()和exec()的语义,COW更加关键。你可能还记得,fork()会创建调用者地址空间的精确副本。对于大的地址空间,这样的复制过程很慢,并且是数据密集的。更糟糕的是,大部分地址空间会被随后的exec()调用立即覆盖,它用即将执行的程序覆盖调用进程的地址空间。通过改为执行写时复制的fork(),操作系统避免了大量不必要的复制,从而保留了正确的语义,同时提高了性能。

第26章 并发:介绍

经典观点是一个程序只有一个执行点(一个程序计数器,用来存放要执行的指令),但多线程(multi-threaded)程序会有多个执行点(多个程序计数器,每个都用于取指令和执行)。换一个角度来看,每个线程类似于独立的进程,只有一点区别:它们共享地址空间,从而能够访问相同的数据。

因此,单个线程的状态与进程状态非常类似。线程有一个程序计数器(PC),记录程序从哪里获取指令。每个线程有自己的一组用于计算的寄存器。所以,如果有两个线程运行在一个处理器上,从运行一个线程(T1)切换到另一个线程(T2)时,必定发生上下文切换(context switch)。线程之间的上下文切换类似于进程间的上下文切换。对于进程,我们将状态保存到进程控制块(Process Control Block,PCB)。现在,我们需要一个或多个线程控制块(ThreadControl Block,TCB),保存每个线程的状态。但是,与进程相比,线程之间的上下文切换有一点主要区别:地址空间保持不变(即不需要切换当前使用的页表)。

线程和进程之间的另一个主要区别在于栈。

然而,在多线程的进程中,每个线程独立运行,当然可以调用各种例程来完成正在执行的任何工作。不是地址空间中只有一个栈,而是每个线程都有一个栈。

因此,所有位于栈上的变量、参数、返回值和其他放在栈上的东西,将被放置在有时称为线程本地(thread-local)存储的地方,即相关线程的栈。

以前,堆和栈可以互不影响地增长,直到空间耗尽。多个栈就没有这么简单了。幸运的是,通常栈不会很大(除了大量使用递归的程序)。

设想我们的两个线程之一(线程1)进入这个代码区域,并且因此将要增加一个计数器。它将counter的值(假设它这时是50)加载到它的寄存器eax中。因此,线程1的eax = 50。然后它向寄存器加1,因此eax = 51。现在,一件不幸的事情发生了:时钟中断发生。因此,操作系统将当前正在运行的线程(它的程序计数器、寄存器,包括eax等)的状态保存到线程的TCB。现在更糟的事发生了:线程2被选中运行,并进入同一段代码。它也执行了第一条指令,获取计数器的值并将其放入其eax中 [请记住:运行时每个线程都有自己的专用寄存器。上下文切换代码将寄存器虚拟化(virtualized),保存并恢复它们的值]。此时counter的值仍为50,因此线程2的eax = 50。假设线程2执行接下来的两条指令,将eax递增1(因此eax =51),然后将eax的内容保存到counter(地址0x8049a1c)中。因此,全局变量counter现在的值是51。最后,又发生一次上下文切换,线程1恢复运行。还记得它已经执行过mov和add指令,现在准备执行最后一条mov指令。回忆一下,eax=51。因此,最后的mov指令执行,将值保存到内存,counter再次被设置为51。

因此,我们要做的是要求硬件提供一些有用的指令,可以在这些指令上构建一个通用的集合,即所谓的同步原语(synchronization primitive)。通过使用这些硬件同步原语,加上操作系统的一些帮助,我们将能够构建多线程代码,以同步和受控的方式访问临界区,从而可靠地产生正确的结果—— 尽管有并发执行的挑战。

·临界区(critical section)是访问共享资源的一段代码,资源通常是一个变量或数据结构。·竞态条件(race condition)出现在多个执行线程大致同时进入临界区时,它们都试图更新共享的数据结构,导致了令人惊讶的(也许是不希望的)结果。·不确定性(indeterminate)程序由一个或多个竞态条件组成,程序的输出因运行而异,具体取决于哪些线程在何时运行。这导致结果不是确定的(deterministic),而我们通常期望计算机系统给出确定的结果。·为了避免这些问题,线程应该使用某种互斥(mutual exclusion)原语。这样做可以保证只有一个线程进入临界区,从而避免出现竞态,并产生确定的程序输出。

例如,设想有两个进程在运行。假设它们都调用write()来写入文件,并且都希望将数据追加到文件中(即将数据添加到文件的末尾,从而增加文件的长度)。为此,这两个进程都必须分配一个新块,记录在该块所在文件的inode中,并更改文件的大小以反映新的、增加的大小(插一句,在本书的第3部分,我们将更多地了解文件)。因为中断可能随时发生,所以更新这些共享结构的代码(例如,分配的位图或文件的inode)是临界区。因此,从引入中断的一开始,OS设计人员就不得不担心操作系统如何更新内部结构。不合时宜的中断会导致上述所有问题。毫不奇怪,页表、进程列表、文件系统结构以及几乎每个内核数据结构都必须小心地访问,并使用正确的同步原语才能正常工作。

原子操作是构建计算机系统的最强大的基础技术之一,从计算机体系结构到并行代码(我们在这里研究的内容)、文件系统(我们将很快研究)、数据库管理系统,甚至分布式系统[L+93]。

将一系列动作原子化(atomic)背后的想法可以简单用一个短语表达:“全部或没有”。看上去,要么你希望组合在一起的所有活动都发生了,要么它们都没有发生。不会看到中间状态。有时,将许多行为组合为单个原子动作称为事务(transaction),这是一个在数据库和事务处理世界中非常详细地发展的概念[GR92]。

第27章 插叙:线程API

再次,我们应该注意,必须非常小心如何从线程返回值。特别是,永远不要返回一个指针,并让它指向线程调用栈上分配的东西。

最后,你可能会注意到,使用pthread_create()创建线程,然后立即调用pthread_join(),这是创建线程的一种非常奇怪的方式。事实上,有一个更简单的方法来完成这个任务,它被称为过程调用(procedure call)。显然,我们通常会创建不止一个线程并等待它完成,否则根本没有太多的用途。

我们应该注意,并非所有多线程代码都使用join函数。例如,多线程Web服务器可能会创建大量工作线程,然后使用主线程接受请求,并将其无限期地传递给工作线程。因此这样的长期程序可能不需要join。然而,创建线程来(并行)执行特定任务的并行程序,很可能会使用join来确保在退出或进入下一阶段计算之前完成所有这些工作。

除了线程创建和join之外,POSIX线程库提供的最有用的函数集,可能是通过锁(lock)来提供互斥进入临界区的那些函数。

函数应该易于理解和使用。如果你意识到有一段代码是一个临界区,就需要通过锁来保护,以便像需要的那样运行。

如果在调用pthread_mutex_lock()时没有其他线程持有锁,线程将获取该锁并进入临界区。如果另一个线程确实持有该锁,那么尝试获取该锁的线程将不会从该调用返回,直到获得该锁(意味着持有该锁的线程通过解锁调用释放该锁)。当然,在给定的时间内,许多线程可能会卡住,在获取锁的函数内部等待。然而,只有获得锁的线程才应该调用解锁。

对于POSIX线程,有两种方法来初始化锁。一种方法是使用PTHREADMUTEX INITIALIZER

初始化的动态方法(即在运行时)是调用pthread_mutex_init()

无论哪种方式都有效,但我们通常使用动态(后者)方法。请注意,当你用完锁时,还应该相应地调用pthread_mutex_destroy(),所有细节请参阅手册。

所有线程库还有一个主要组件(当然POSIX线程也是如此),就是存在一个条件变量(condition variable)。当线程之间必须发生某种信号时,如果一个线程在等待另一个线程继续执行某些操作,条件变量就很有用。

第一个函数pthread_cond_wait()使调用线程进入休眠状态,因此等待其他线程发出信号,通常当程序中的某些内容发生变化时,现在正在休眠的线程可能会关心它。

首先,在发出信号时(以及修改全局变量ready时),我们始终确保持有锁。这确保我们不会在代码中意外引入竞态条件。

其次,你可能会注意到等待调用将锁作为其第二个参数,而信号调用仅需要一个条件。造成这种差异的原因在于,等待调用除了使调用线程进入睡眠状态外,还会让调用者睡眠时释放锁。想象一下,如果不是这样:其他线程如何获得锁并将其唤醒?但是,在被唤醒之后返回之前,pthread_cond_wait()会重新获取该锁,从而确保等待线程在等待序列开始时获取锁与结束时释放锁之间运行的任何时间,它持有锁。

最后一点需要注意:等待线程在while循环中重新检查条件,而不是简单的if语句。在后续章节中研究条件变量时,我们会详细讨论这个问题,但是通常使用while循环是一件简单而安全的事情。虽然它重新检查了这种情况(可能会增加一点开销),但有一些pthread实现可能会错误地唤醒等待的线程。在这种情况下,没有重新检查,等待的线程会继续认为条件已经改变。因此,将唤醒视为某种事物可能已经发生变化的暗示,而不是绝对的事实,这样更安全。

千万不要这么做。首先,多数情况下性能差(长时间的自旋浪费CPU)。其次,容易出错。最近的研究[X+10]显示,线程之间通过标志同步(像上面那样),出错的可能性让人吃惊。在那项研究中,这些不正规的同步方法半数以上都是有问题的。不要偷懒,就算你想到可以不用条件变量,还是用吧。

·保持简洁。最重要的一点,线程之间的锁和信号的代码应该尽可能简洁。复杂的线程交互容易产生缺陷。·让线程交互减到最少。尽量减少线程之间的交互。每次交互都应该想清楚,并用验证过的、正确的方法来实现(很多方法会在后续章节中学习)。·初始化锁和条件变量。未初始化的代码有时工作正常,有时失败,会产生奇怪的结果。·检查返回值。当然,任何C和UNIX的程序,都应该检查返回值,这里也是一样。否则会导致古怪而难以理解的行为,让你尖叫,或者痛苦地揪自己的头发。·注意传给线程的参数和返回值。具体来说,如果传递在栈上分配的变量的引用,可能就是在犯错误。·每个线程都有自己的栈。类似于上一条,记住每一个线程都有自己的栈。因此,线程局部变量应该是线程私有的,其他线程不应该访问。线程之间共享数据,值要在堆(heap)或者其他全局可访问的位置。·线程之间总是通过条件变量发送信号。切记不要用标记变量来同步。·多查手册。尤其是Linux的pthread手册,有更多的细节、更丰富的内容。请仔细阅读!

第28章 锁

锁就是一个变量,因此我们需要声明一个某种类型的锁变量(lock variable,如上面的mutex),才能使用。这个锁变量(简称锁)保存了锁在某一时刻的状态。它要么是可用的(available,或unlocked,或free),表示没有线程持有锁,要么是被占用的(acquired,或locked,或held),表示有一个线程持有锁,正处于临界区。我们也可以保存其他的信息,比如持有锁的线程,或请求获取锁的线程队列,但这些信息会隐藏起来,锁的使用者不会发现。

lock()和unlock()函数的语义很简单。调用lock()尝试获取锁,如果没有其他线程持有锁(即它是可用的),该线程会获得锁,进入临界区。这个线程有时被称为锁的持有者(owner)。如果另外一个线程对相同的锁变量(本例中的mutex)调用lock(),因为锁被另一线程持有,该调用不会返回。这样,当持有锁的线程在临界区时,其他线程就无法进入临界区。

锁的持有者一旦调用unlock(),锁就变成可用了。如果没有其他等待线程(即没有其他线程调用过lock()并卡在那里),锁的状态就变成可用了。如果有等待线程(卡在lock()里),其中一个会(最终)注意到(或收到通知)锁状态的变化,获取该锁,进入临界区。

你可能还会注意到,POSIX的lock和unlock函数会传入一个变量,因为我们可能用不同的锁来保护不同的变量。这样可以增加并发:不同于任何临界区都使用同一个大锁(粗粒度的锁策略),通常大家会用不同的锁保护不同的数据和结构,从而允许更多的线程进入临界区(细粒度的方案)。

在实现锁之前,我们应该首先明确目标,因此我们要问,如何评价一种锁实现的效果。为了评价锁是否能工作(并工作得好),我们应该先设立一些标准。第一是锁是否能完成它的基本任务,即提供互斥(mutual exclusion)。最基本的,锁是否有效,能够阻止多个线程进入临界区?第二是公平性(fairness)。当锁可用时,是否每一个竞争线程有公平的机会抢到锁?用另一个方式来看这个问题是检查更极端的情况:是否有竞争锁的线程会饿死(starve),一直无法获得锁?最后是性能(performance),具体来说,是使用锁之后增加的时间开销。有几种场景需要考虑。一种是没有竞争的情况,即只有一个线程抢锁、释放锁的开支如何?另外一种是一个CPU上多个线程竞争,性能如何?最后一种是多个CPU、多个线程竞争时的性能。通过比较不同的场景,我们能够更好地理解不同的锁技术对性能的影响,下面会进行介绍。

最早提供的互斥解决方案之一,就是在临界区关闭中断。这个解决方案是为单处理器系统开发的。

假设我们运行在这样一个单处理器系统上。通过在进入临界区之前关闭中断(使用特殊的硬件指令),可以保证临界区的代码不会被中断,从而原子地执行。结束之后,我们重新打开中断(同样通过硬件指令),程序正常运行。

这个方法的主要优点就是简单。

首先,这种方法要求我们允许所有调用线程执行特权操作(打开关闭中断),即信任这种机制不会被滥用。

第二,这种方案不支持多处理器。如果多个线程运行在不同的CPU上,每个线程都试图进入同一个临界区,关闭中断也没有作用。线程可以运行在其他处理器上,因此能够进入临界区。

第三,关闭中断导致中断丢失,可能会导致严重的系统问题。假如磁盘设备完成了读取请求,但CPU错失了这一事实,那么,操作系统如何知道去唤醒等待读取的进程?

最后一个不太重要的原因就是效率低。与正常指令执行相比,现代CPU对于关闭和打开中断的代码执行得较慢。

一段时间以来,出于某种原因,大家都热衷于研究不依赖硬件支持的锁机制。后来这些工作都没有太多意义,因为只需要很少的硬件支持,实现锁就会容易很多(实际在多处理器的早期,就有这些硬件支持)。而且上面提到的方法无法运行在现代硬件(应为松散内存一致性模型),导致它们更加没有用处。

最简单的硬件支持是测试并设置指令(test-and-set instruction),也叫作原子交换(atomic exchange)。

当第一个线程正处于临界区时,如果另一个线程调用lock(),它会在while循环中自旋等待(spin-wait),直到第一个线程调用unlock()清空标志。然后等待的线程会退出while循环,设置标志,执行临界区代码。

遗憾的是,这段代码有两个问题:正确性和性能。这个正确性问题在并发编程中很常见。

通过适时的(不合时宜的?)中断,我们很容易构造出两个线程都将标志设置为1,都能进入临界区的场景。这种行为就是专家所说的“不好”,我们显然没有满足最基本的要求:互斥。

性能问题(稍后会有更多讨论)主要是线程在等待已经被持有的锁时,采用了自旋等待(spin-waiting)的技术,就是不停地检查标志的值。自旋等待在等待其他线程释放锁的时候会浪费时间。尤其是在单处理器上,一个等待线程等待的目标线程甚至无法运行(至少在上下文切换之前)!

幸运的是,一些系统提供了这一指令,支持基于这种概念创建简单的锁。这个更强大的指令有不同的名字:在SPARC上,这个指令叫ldstub(load/store unsigned byte,加载/保存无符号字节);在x86上,是xchg(atomic exchange,原子交换)指令。但它们基本上在不同的平台上做同样的事,通常称为测试并设置指令(test-and-set)。

测试并设置指令做了下述事情。它返回old_ptr指向的旧值,同时更新为new的新值。当然,关键是这些代码是原子地(atomically)执行。因为既可以测试旧值,又可以设置新值,所以我们把这条指令叫作“测试并设置”。这一条指令完全可以实现一个简单的自旋锁(spin lock),如图28.2所示。

首先假设一个线程在运行,调用lock(),没有其他线程持有锁,所以flag是0。当调用TestAndSet(flag, 1)方法,返回0,线程会跳出while循环,获取锁。同时也会原子的设置flag为1,标志锁已经被持有。当线程离开临界区,调用unlock()将flag清理为0。

第二种场景是,当某一个线程已经持有锁(即flag为1)。本线程调用lock(),然后调用TestAndSet(flag, 1),这一次返回1。只要另一个线程一直持有锁,TestAndSet()会重复返回1,本线程会一直自旋。当flag终于被改为0,本线程会调用TestAndSet(),返回0并且原子地设置为1,从而获得锁,进入临界区。

将测试(test旧的锁值)和设置(set新的值)合并为一个原子操作之后,我们保证了只有一个线程能获取锁。这就实现了一个有效的互斥原语!

你现在可能也理解了为什么这种锁被称为自旋锁(spin lock)。这是最简单的一种锁,一直自旋,利用CPU周期,直到锁可用。在单处理器上,需要抢占式的调度器(preemptive scheduler,即不断通过时钟中断一个线程,运行其他线程)。否则,自旋锁在单CPU上无法使用,因为一个自旋的线程永远不会放弃CPU。

锁最重要的一点是正确性(correctness):能够互斥吗?答案是可以的:自旋锁一次只允许一个线程进入临界区。因此,这是正确的锁。

下一个标准是公平性(fairness)。自旋锁对于等待线程的公平性如何呢?能够保证一个等待线程会进入临界区吗?答案是自旋锁不提供任何公平性保证。实际上,自旋的线程在竞争条件下可能会永远自旋。自旋锁没有公平性,可能会导致饿死。

对于自旋锁,在单CPU的情况下,性能开销相当大。假设一个线程持有锁进入临界区时被抢占。调度器可能会运行其他每一个线程(假设有N−1个这种线程)。而其他线程都在竞争锁,都会在放弃CPU之前,自旋一个时间片,浪费CPU周期。

但是,在多CPU上,自旋锁性能不错(如果线程数大致等于CPU数)。假设线程A在CPU 1,线程B在CPU 2竞争同一个锁。线程A(CPU 1)占有锁时,线程B竞争锁就会自旋(在CPU 2上)。然而,临界区一般都很短,因此很快锁就可用,然后线程B获得锁。自旋等待其他处理器上的锁,并没有浪费很多CPU周期,因此效果不错。

某些系统提供了另一个硬件原语,即比较并交换指令(SPARC系统中是compare-and-swap,x86系统是compare-and-exchange)。

比较并交换的基本思路是检测ptr指向的值是否和expected相等;如果是,更新ptr所指的值为新值。否则,什么也不做。不论哪种情况,都返回该内存地址的实际值,让调用者能够知道执行是否成功。

比较并交换指令比测试并设置更强大。当我们在将来简单探讨无等待同步(wait-freesynchronization)[H91]时,会用到这条指令的强大之处。然而,如果只用它实现一个简单的自旋锁,它的行为等价于上面分析的自旋锁。

一些平台提供了实现临界区的一对指令。例如MIPS架构[H93]中,链接的加载(load-linked)和条件式存储(store-conditional)可以用来配合使用,实现其他并发结构。

链接的加载指令和典型加载指令类似,都是从内存中取出值存入一个寄存器。关键区别来自条件式存储(store-conditional)指令,只有上一次加载的地址在期间都没有更新时,才会成功,(同时更新刚才链接的加载的地址的值)。成功时,条件存储返回1,并将ptr指的值更新为value。失败时,返回0,并且不会更新值。

程序员倾向于吹嘘自己使用大量的代码实现某功能。这样做本质上是不对的。我们应该吹嘘以很少的代码实现给定的任务。简洁的代码更易懂,缺陷更少。正如Hugh Lauer在讨论构建一个飞行员操作系统时说:“如果给同样这些人两倍的时间,他们可以只用一半的代码来实现”[L81]。我们称之为劳尔定律(Lauer’s Law),很值得记住。

最后一个硬件原语是获取并增加(fetch-and-add)指令,它能原子地返回特定地址的旧值,并且让该值自增一。

不是用一个值,这个解决方案使用了ticket和turn变量来构建锁。基本操作也很简单:如果线程希望获取锁,首先对一个ticket值执行一个原子的获取并相加指令。这个值作为该线程的“turn”(顺位,即myturn)。根据全局共享的lock->turn变量,当某一个线程的(myturn == turn)时,则轮到这个线程进入临界区。unlock则是增加turn,从而下一个等待线程可以进入临界区。

不同于之前的方法:本方法能够保证所有线程都能抢到锁。只要一个线程获得了ticket值,它最终会被调度。之前的方法则不会保证。比如基于测试并设置的方法,一个线程有可能一直自旋,即使其他线程在获取和释放锁。

基于硬件的锁简单(只有几行代码)而且有效(如果高兴,你甚至可以写一些代码来验证),这也是任何好的系统或者代码的特点。但是,某些场景下,这些解决方案会效率低下。以两个线程运行在单处理器上为例,当一个线程(线程0)持有锁时,被中断。第二个线程(线程1)去获取锁,发现锁已经被持有。因此,它就开始自旋。接着自旋。

然后它继续自旋。最后,时钟中断产生,线程0重新运行,它释放锁。最后(比如下次它运行时),线程1不需要继续自旋了,它获取了锁。因此,类似的场景下,一个线程会一直自旋检查一个不会改变的值,浪费掉整个时间片!如果有N个线程去竞争一个锁,情况会更糟糕。同样的场景下,会浪费N−1个时间片,只是自旋并等待一个线程释放该锁。

硬件支持让我们有了很大的进展:我们已经实现了有效、公平(通过ticket锁)的锁。但是,问题仍然存在:如果临界区的线程发生上下文切换,其他线程只能一直自旋,等待被中断的(持有锁的)进程重新运行。

第一种简单友好的方法就是,在要自旋的时候,放弃CPU。

在这种方法中,我们假定操作系统提供原语yield(),线程可以调用它主动放弃CPU,让其他线程运行。线程可以处于3种状态之一(运行、就绪和阻塞)。yield()系统调用能够让运行(running)态变为就绪(ready)态,从而允许其他线程运行。因此,让出线程本质上取消调度(deschedules)了它自己。

现在来考虑许多线程(例如100个)反复竞争一把锁的情况。在这种情况下,一个线程持有锁,在释放锁之前被抢占,其他99个线程分别调用lock(),发现锁被抢占,然后让出CPU。假定采用某种轮转调度程序,这99个线程会一直处于运行—让出这种模式,直到持有锁的线程再次运行。虽然比原来的浪费99个时间片的自旋方案要好,但这种方法仍然成本很高,上下文切换的成本是实实在在的,因此浪费很大。

更糟的是,我们还没有考虑饿死的问题。一个线程可能一直处于让出的循环,而其他线程反复进出临界区。很显然,我们需要一种方法来解决这个问题。

因此,我们必须显式地施加某种控制,决定锁释放时,谁能抢到锁。为了做到这一点,我们需要操作系统的更多支持,并需要一个队列来保存等待锁的线程。

guard基本上起到了自旋锁的作用,围绕着flag和队列操作。因此,这个方法并没有完全避免自旋等待。线程在获取锁或者释放锁时可能被中断,从而导致其他线程自旋等待。但是,这个自旋等待时间是很有限的(不是用户定义的临界区,只是在lock和unlock代码中的几个指令),因此,这种方法也许是合理的。

第二点,你可能注意到在lock()函数中,如果线程不能获取锁(它已被持有),线程会把自己加入队列(通过调用gettid()获得当前的线程ID),将guard设置为0,然后让出CPU。

最后,你可能注意到解决方案中的竞争条件,就在park()调用之前。如果不凑巧,一个线程将要park,假定它应该睡到锁可用时。这时切换到另一个线程(比如持有锁的线程),这可能会导致麻烦。比如,如果该线程随后释放了锁。接下来第一个线程的park会永远睡下去(可能)。这种问题有时称为唤醒/等待竞争(wakeup/waiting race)。为了避免这种情况,我们需要额外的工作。

Solaris通过增加了第三个系统调用separk()来解决这一问题。通过setpark(),一个线程表明自己马上要park。如果刚好另一个线程被调度,并且调用了unpark,那么后续的park调用就会直接返回,而不是一直睡眠。

Linux提供了futex,它类似于Solaris的接口,但提供了更多内核功能。具体来说,每个futex都关联一个特定的物理内存位置,也有一个事先建好的内核队列。

具体来说,有两个调用。调用futex_wait(address, expected)时,如果address处的值等于expected,就会让调线程睡眠。否则,调用立刻返回。调用futex_wake(address)唤醒等待队列中的一个线程。

这段代码来自nptl库(gnu libc库的一部分)[L09]中lowlevellock.h,它很有趣。基本上,它利用一个整数,同时记录锁是否被持有(整数的最高位),以及等待者的个数(整数的其余所有位)。因此,如果锁是负的,它就被持有(因为最高位被设置,该位决定了整数的符号)。这段代码的有趣之处还在于,它展示了如何优化常见的情况,即没有竞争时:只有一个线程获取和释放锁,所做的工作很少(获取锁时测试和设置的原子位运算,释放锁时原子的加法)。你可以看看这个“真实世界”的锁的其余部分,是否能理解其工作原理。

最后一点:Linux采用的是一种古老的锁方案,多年来不断被采用,可以追溯到20世纪60年代早期的Dahm锁[M82],现在也称为两阶段锁(two-phase lock)。两阶段锁意识到自旋可能很有用,尤其是在很快就要释放锁的场景。因此,两阶段锁的第一阶段会先自旋一段时间,希望它可以获取锁。但是,如果第一个自旋阶段没有获得锁,第二阶段调用者会睡眠,直到锁可用。上文的Linux锁就是这种锁,不过只自旋一次;更常见的方式是在循环中自旋固定的次数,然后使用futex睡眠。

第29章 基于锁的并发数据结构

理想情况下,你会看到多处理上运行的多线程就像单线程一样快。达到这种状态称为完美扩展(perfect scaling)。虽然总工作量增多,但是并行执行后,完成任务的时间并没有增加。

令人吃惊的是,关于如何实现可扩展的计数器,研究人员已经研究了多年[MS04]。更令人吃惊的是,最近的操作系统性能分析研究[B+10]表明,可扩展的计数器很重要。没有可扩展的计数,一些运行在Linux上的工作在多核机器上将遇到严重的扩展性问题。

尽管人们已经开发了多种技术来解决这一问题,我们将介绍一种特定的方法。这个方法是最近的研究提出的,称为懒惰计数器(sloppy counter)[B+10]。

懒惰计数器的基本思想是这样的。如果一个核心上的线程想增加计数器,那就增加它的局部计数器,访问这个局部计数器是通过对应的局部锁同步的。因为每个CPU有自己的局部计数器,不同CPU上的线程不会竞争,所以计数器的更新操作可扩展性好。但是,为了保持全局计数器更新(以防某个线程要读取该值),局部值会定期转移给全局计数器,方法是获取全局锁,让全局计数器加上局部计数器的值,然后将局部计数器置零。这种局部转全局的频度,取决于一个阈值,这里称为S(表示sloppiness)。S越小,懒惰计数器则越趋近于非扩展的计数器。S越大,扩展性越强,但是全局计数器与实际计数的偏差越大。我们可以抢占所有的局部锁和全局锁(以特定的顺序,避免死锁),以获得精确值,但这种方法没有扩展性。

从代码中可以看出,代码插入函数入口处获取锁,结束时释放锁。如果malloc失败(在极少的时候),会有一点小问题,在这种情况下,代码在插入失败之前,必须释放锁。事实表明,这种异常控制流容易产生错误。最近一个Linux内核补丁的研究表明,有40%都是这种很少发生的代码路径(实际上,这个发现启发了我们自己的一些研究,我们从Linux文件系统中移除了所有内存失败的路径,得到了更健壮的系统[S+11])。

尽管我们有了基本的并发链表,但又遇到了这个链表扩展性不好的问题。研究人员发现的增加链表并发的技术中,有一种叫作过手锁(hand-over-hand locking,也叫作锁耦合,lock coupling)[MS04]。原理也很简单。每个节点都有一个锁,替代之前整个链表一个锁。遍历链表的时候,首先抢占下一个节点的锁,然后释放当前节点的锁。从概念上说,过手锁链表有点道理,它增加了链表操作的并发程度。但是实际上,在遍历的时候,每个节点获取锁、释放锁的开销巨大,很难比单锁的方法快。即使有大量的线程和很大的链表,这种并发的方案也不一定会比单锁的方案快。也许某种杂合的方案(一定数量的节点用一个锁)值得去研究。

有一个通用建议,对并发代码和其他代码都有用,即注意控制流的变化导致函数返回和退出,或其他错误情况导致函数停止执行。因为很多函数开始就会获得锁,分配内存,或者进行其他一些改变状态的操作,如果错误发生,代码需要在返回前恢复各种状态,这容易出错。因此,最好组织好代码,减少这种模式。

第30章 条件变量

在很多情况下,线程需要检查某一条件(condition)满足之后,才会继续运行。

多线程程序中,一个线程等待某些条件是很常见的。简单的方案是自旋直到条件满足,这是极其低效的,某些情况下甚至是错误的。

线程可以使用条件变量(condition variable),来等待一个条件变成真。条件变量是一个显式队列,当某些执行状态(即条件,condition)不满足时,线程可以把自己加入队列,等待(waiting)该条件。另外某个线程,当它改变了上述状态时,就可以唤醒一个或者多个等待线程(通过在该条件上发信号),让它们继续执行。Dijkstra最早在“私有信号量”[D01]中提出这种思想。Hoare后来在关于观察者的工作中,将类似的思想称为条件变量[H74]。

条件变量有两种相关操作:wait()和signal()。线程要睡眠的时候,调用wait()。当线程想唤醒等待在某个条件变量上的睡眠线程时,调用signal()。

我们常简称为wait()和signal()。你可能注意到一点,wait()调用有一个参数,它是互斥量。它假定在wait()调用时,这个互斥量是已上锁状态。wait()的职责是释放锁,并让调用线程休眠(原子地)。当线程被唤醒时(在另外某个线程发信号给它后),它必须重新获取锁,再返回调用者。这样复杂的步骤也是为了避免在线程陷入休眠时,产生一些竞态条件。

有两种情况需要考虑。第一种情况是父线程创建出子线程,但自己继续运行(假设只有一个处理器),然后马上调用thr_join()等待子线程。在这种情况下,它会先获取锁,检查子进程是否完成(还没有完成),然后调用wait(),让自己休眠。子线程最终得以运行,打印出“child”,并调用thr_exit()函数唤醒父进程,这段代码会在获得锁后设置状态变量done,然后向父线程发信号唤醒它。最后,父线程会运行(从wait()调用返回并持有锁),释放锁,打印出“parent:end”。第二种情况是,子线程在创建后,立刻运行,设置变量done为1,调用signal函数唤醒其他线程(这里没有其他线程),然后结束。父线程运行后,调用thr_join()时,发现done已经是1了,就直接返回。最后一点说明:你可能看到父线程使用了一个while循环,而不是if语句来判断是否需要等待。虽然从逻辑上来说没有必要使用循环语句,但这样做总是好的(后面我们会加以说明)。

尽管并不是所有情况下都严格需要,但有效且简单的做法,还是在使用条件变量发送信号时持有锁。虽然上面的例子是必须加锁的情况,但也有一些情况可以不加锁,而这可能是你应该避免的。因此,为了简单,请在调用signal时持有锁(hold the lock when calling signal)。这个提示的反面,即调用wait时持有锁,不只是建议,而是wait的语义强制要求的。因为wait调用总是假设你调用它时已经持有锁、调用者睡眠之前会释放锁以及返回前重新持有锁。因此,这个提示的一般化形式是正确的:调用signal和wait时要持有锁(hold the lock when calling signal or wait),你会保持身心健康的

因为有界缓冲区是共享资源,所以我们必须通过同步机制来访问它,以免产生竞态条件。

c1被生产者唤醒后,但在它运行之前,缓冲区的状态改变了(由于Tc2)。发信号给线程只是唤醒它们,暗示状态发生了变化(在这个例子中,就是值已被放入缓冲区),但并不会保证在它运行之前状态一直是期望的情况。信号的这种释义常称为Mesa语义(Mesa semantic),为了纪念以这种方式建立条件变量的首次研究[LR80]。另一种释义是Hoare语义(Hoare semantic),虽然实现难度大,但是会保证被唤醒线程立刻执行[H74]。实际上,几乎所有系统都采用了Mesa语义。

由于Mesa语义,我们要记住一条关于条件变量的简单规则:总是使用while循环(always use while loop)。虽然有时候不需要重新检查条件,但这样做总是安全的,做了就开心了。

信号显然需要,但必须更有指向性。消费者不应该唤醒消费者,而应该只唤醒生产者,反之亦然。

使用两个条件变量,而不是一个,以便正确地发出信号,在系统状态改变时,哪类线程应该唤醒。

多线程程序在检查条件变量时,使用while循环总是对的。if语句可能会对,这取决于发信号的语义。因此,总是使用while,代码就会符合预期。对条件变量使用while循环,这也解决了假唤醒(spurious wakeup)的情况。某些线程库中,由于实现的细节,有可能出现一个信号唤醒两个线程的情况[L11]。再次检查线程的等待条件,假唤醒是另一个原因。

Lampson和Redell的解决方案也很直接:用pthread_cond_broadcast()代替上述代码中的pthread_cond_signal(),唤醒所有的等待线程。这样做,确保了所有应该唤醒的线程都被唤醒。当然,不利的一面是可能会影响性能,因为不必要地唤醒了其他许多等待的线程,它们本来(还)不应该被唤醒。这些线程被唤醒后,重新检查条件,马上再次睡眠。Lampson和Redell把这种条件变量叫作覆盖条件(covering condition),因为它能覆盖所有需要唤醒线程的场景(保守策略)。成本如上所述,就是太多线程被唤醒。聪明的读者可能发现,在单个条件变量的生产者/消费者问题中,也可以使用这种方法。但是,在这个例子中,我们有更好的方法,因此用了它。一般来说,如果你发现程序只有改成广播信号时才能工作(但你认为不需要),可能是程序有缺陷,修复它!但在上述内存分配的例子中,广播可能是最直接有效的方案。

第31章 信号量

信号量是有一个整数值的对象,可以用两个函数来操作它。在POSIX标准中,是sem_wait()和sem_post()。因为信号量的初始值能够决定其行为,所以首先要初始化信号量,才能调用其他函数与之交互,如图31.1所示。

其中申明了一个信号量s,通过第三个参数,将它的值初始化为1。sem_init()的第二个参数,在我们看到的所有例子中都设置为0,表示信号量是在同一进程的多个线程共享的。

首先,sem_wait()要么立刻返回(调用sem_wait()时,信号量的值大于等于1),要么会让调用线程挂起,直到之后的一个post操作。当然,也可能多个调用线程都调用sem_wait(),因此都在队列中等待被唤醒。

其次,sem_post()并没有等待某些条件满足。它直接增加信号量的值,如果有等待线程,唤醒其中一个。

最后,当信号量的值为负数时,这个值就是等待线程的个数[D68b]。虽然这个值通常不会暴露给信号量的使用者,但这个恒定的关系值得了解,可能有助于记住信号量的工作原理。

我们可以用信号量来实现锁了。因为锁只有两个状态(持有和没持有),所以这种用法有时也叫作二值信号量(binarysemaphore)。事实上这种信号量也有一些更简单的实现,我们这里使用了更为通用的信号量作为锁。

信号量也可以用在一个线程暂停执行,等待某一条件成立的场景。例如,一个线程要等待一个链表非空,然后才能删除一个元素。在这种场景下,通常一个线程等待条件成立,另外一个线程修改条件并发信号给等待线程,从而唤醒等待线程。因为等待线程在等待某些条件(condition)发生变化,所以我们将信号量作为条件变量(condition variable)。

当然,答案是信号量初始值应该是0。有两种情况需要考虑。第一种,父线程创建了子线程,但是子线程并没有运行。这种情况下(见表31.3),父线程调用sem_wait()会先于子线程调用sem_post()。我们希望父线程等待子线程运行。为此,唯一的办法是让信号量的值不大于0。因此,0为初值。父线程运行,将信号量减为−1,然后睡眠等待;子线程运行的时候,调用sem_post(),信号量增加为0,唤醒父线程,父线程然后从sem_wait()返回,完成该程序。

最后,应该指出,读者-写者锁还有一些注意点。它们通常加入了更多开锁(尤其是更复杂的实现),因此和其他一些简单快速的锁相比,读者写者锁在性能方面没有优势[CB08]。无论哪种方式,它们都再次展示了如何以有趣、有用的方式来使用信号量。

解决上述问题最简单的方法,就是修改某个或者某些哲学家的取餐叉顺序。事实上,Dijkstra自己也是这样解决的。具体来说,假定哲学家4(编写最大的一个)取餐叉的顺序不同。

我们可以把信号量当作锁和条件变量的泛化。但这种泛化有必要吗?考虑基于信号量去实现条件变量的难度,可能这种泛化并没有你想的那么通用。

很奇怪,利用信号量来实现锁和条件变量,是棘手得多的问题。某些富有经验的并发程序员曾经在Windows环境下尝试过,随之而来的是很多缺陷[B04]。

第32章 常见并发问题

根据Lu等人,更正式的违反原子性的定义是:“违反了多次内存访问中预期的可串行性(即代码段本意是原子的,但在执行中并没有强制实现原子性)”。

在这个方案中,我们只要给共享变量的访问加锁,确保每个线程访问proc_info字段时,都持有锁(proc_info_lock)。当然,访问这个结构的所有其他代码,也应该先获取锁。

违反顺序更正式的定义是:“两个内存访问的预期顺序被打破了(即A应该在B之前执行,但是实际运行中却不是这个顺序)”[L+08]。

我们通过强制顺序来修复这种缺陷。正如之前详细讨论的,条件变量(condition variables)就是一种简单可靠的方式,在现代代码集中加入这种同步。

Lu等人的研究中,大部分(97%)的非死锁问题是违反原子性和违反顺序这两种。因此,程序员仔细研究这些错误模式,应该能够更好地避免它们。此外,随着更自动化的代码检查工具的发展,它们也应该关注这两种错误,因为开发中发现的非死锁问题大部分都是这两种。

那么,死锁为什么还会发生?其中一个原因是在大型的代码库里,组件之间会有复杂的依赖。以操作系统为例。虚拟内存系统在需要访问文件系统才能从磁盘读到内存页;文件系统随后又要和虚拟内存交互,去申请一页内存,以便存放读到的块。因此,在设计大型系统的锁机制时,你必须要仔细地去避免循环依赖导致的死锁。另一个原因是封装(encapsulation)。软件开发者一直倾向于隐藏实现细节,以模块化的方式让软件开发更容易。然而,模块化和锁不是很契合。Jula等人指出[J+08],某些看起来没有关系的接口可能会导致死锁。

死锁的产生需要如下4个条件[C+71]。·互斥:线程对于需要的资源进行互斥的访问(例如一个线程抢到锁)。·持有并等待:线程持有了资源(例如已将持有的锁),同时又在等待其他资源(例如,需要获得的锁)。·非抢占:线程获得的资源(例如锁),不能被抢占。·循环等待:线程之间存在一个环路,环路上每个线程都额外持有一个资源,而这个资源又是下一个线程要申请的。如果这4个条件的任何一个没有满足,死锁就不会产生。

也许最实用的预防技术(当然也是经常采用的),就是让代码不会产生循环等待。最直接的方法就是获取锁时提供一个全序(total ordering)。假如系统共有两个锁(L1和L2),那么我们每次都先申请L1然后申请L2,就可以避免死锁。这样严格的顺序避免了循环等待,也就不会产生死锁。当然,更复杂的系统中不会只有两个锁,锁的全序可能很难做到。因此,偏序(partial ordering)可能是一种有用的方法,安排锁的获取并避免死锁。Linux中的内存映射代码就是一个偏序锁的好例子[T+94]。代码开头的注释表明了10组不同的加锁顺序,包括简单的关系,比如i_mutex早于i_mmap_mutex,也包括复杂的关系,比如i_mmap_mutex早于private_lock,早于swap_lock,早于mapping->tree_lock。

当一个函数要抢多个锁时,我们需要注意死锁。比如有一个函数:do_something(mutex t *m1, mutex t *m2),如果函数总是先抢m1,然后m2,那么当一个线程调用do_something(L1, L2),而另一个线程调用do_something(L2, L1)时,就可能会产生死锁。为了避免这种特殊问题,聪明的程序员根据锁的地址作为获取锁的顺序。按照地址从高到低,或者从低到高的顺序加锁,do_something()函数就可以保证不论传入参数是什么顺序,函数都会用固定的顺序加锁。

死锁的持有并等待条件,可以通过原子地抢锁来避免。

在调用unlock之前,都认为锁是被占有的,多个抢锁操作通常会带来麻烦,因为我们等待一个锁时,同时持有另一个锁。很多线程库提供更为灵活的接口来避免这种情况。具体来说,trylock()函数会尝试获得锁,或者返回−1,表示锁已经被占有。

注意,另一个线程可以使用相同的加锁方式,但是不同的加锁顺序(L2然后L1),程序仍然不会产生死锁。但是会引来一个新的问题:活锁(livelock)。两个线程有可能一直重复这一序列,又同时都抢锁失败。这种情况下,系统一直在运行这段代码(因此不是死锁),但是又不会有进展,因此名为活锁。也有活锁的解决方法:例如,可以在循环结束的时候,先随机等待一个时间,然后再重复整个动作,这样可以降低线程之间的重复互相干扰。

最后的预防方法是完全避免互斥。通常来说,代码都会存在临界区,因此很难避免互斥。那么我们应该怎么做呢?Herlihy提出了设计各种无等待(wait-free)数据结构的思想[H91]。想法很简单:通过强大的硬件指令,我们可以构造出不需要锁的数据结构。

了死锁预防,某些场景更适合死锁避免(avoidance)。我们需要了解全局的信息,包括不同线程在运行中对锁的需求情况,从而使得后续的调度能够避免产生死锁。

很多数据库系统使用了死锁检测和恢复技术。死锁检测器会定期运行,通过构建资源图来检查循环。当循环(死锁)发生时,系统需要重启。如果还需要更复杂的数据结构相关的修复,那么需要人工参与。

无等待的方案也很有希望,在一些通用库和系统中,包括Linux,都已经有了一些无等待的实现。然而,这种方案不够通用,并且设计一个新的无等待的数据结构极其复杂,以至于不够实用。也许,最好的解决方案是开发一种新的并发编程模型:在类似MapReduce(来自Google)[GD02]这样的系统中,程序员可以完成一些类型的并行计算,无须任何锁。锁必然带来各种困难,也许我们应该尽可能地避免使用锁,除非确信必须使用。

第33章 基于事件的并发(进阶)

基于事件的并发针对两方面的问题。一方面是多线程应用中,正确处理并发很有难度。正如我们讨论的,忘加锁、死锁和其他烦人的问题会发生。另一方面,开发者无法控制多线程在某一时刻的调度。程序员只是创建了线程,然后就依赖操作系统能够合理地调度线程。要实现一个在各种不同负载下,都能够良好运行的通用调度程序,是极有难度的。因此,某些时候操作系统的调度并不是最优的。

我们使用的基本方法就是基于事件的并发(event-based concurrency)。该方法很简单:我们等待某事(即“事件”)发生;当它发生时,检查事件类型,然后做少量的相应工作(可能是I/O请求,或者调度其他事件准备后续处理)。这就好了!

阻塞(或同步,synchronous)接口在返回给调用者之前完成所有工作。非阻塞(或异步,asynchronous)接口开始一些工作,但立即返回,从而让所有需要完成的工作都在后台完成。通常阻塞调用的主犯是某种I/O。例如,如果一个调用必须从磁盘读取才能完成,它可能会阻塞,等待发送到磁盘的I /O请求返回。非阻塞接口可用于任何类型的编程(例如,使用线程),但在基于事件的方法中非常重要,因为阻塞的调用会阻止所有进展。

使用单个CPU和基于事件的应用程序,并发程序中发现的问题不再存在。具体来说,因为一次只处理一个事件,所以不需要获取或释放锁。基于事件的服务器不能被另一个线程中断,因为它确实是单线程的。因此,线程化程序中常见的并发性错误并没有出现在基本的基于事件的方法中。

基于事件的服务器可以对任务调度进行细粒度的控制。但是,为了保持这种控制,不可以有阻止调用者执行的调用。

使用基于事件的方法时,没有其他线程可以运行:只是主事件循环。这意味着如果一个事件处理程序发出一个阻塞的调用,整个服务器就会这样做:阻塞直到调用完成。当事件循环阻塞时,系统处于闲置状态,因此是潜在的巨大资源浪费。因此,我们在基于事件的系统中必须遵守一条规则:不允许阻塞调用。

为了克服这个限制,许多现代操作系统已经引入了新的方法来向磁盘系统发出I/O请求,一般称为异步I/O(asynchronous I/O)。这些接口使应用程序能够发出I/O请求,并在I/O完成之前立即将控制权返回给调用者,另外的接口让应用程序能够确定各种I/O是否已完成。

基于事件的方法的另一个问题是,这种代码通常比传统的基于线程的代码更复杂。原因如下:当事件处理程序发出异步I/O时,它必须打包一些程序状态,以便下一个事件处理程序在I/O最终完成时使用。这个额外的工作在基于线程的程序中是不需要的,因为程序需要的状态在线程栈中。Adya等人称之为手工栈管理(manual stack management),这是基于事件编程的基础[A + 02]。

基于事件的方法还有其他一些困难,我们应该指出。例如,当系统从单个CPU转向多个CPU时,基于事件的方法的一些简单性就消失了。具体来说,为了利用多个CPU,事件服务器必须并行运行多个事件处理程序。发生这种情况时,就会出现常见的同步问题(例如临界区),并且必须采用通常的解决方案(例如锁定)。因此,在现代多核系统上,无锁的简单事件处理已不再可能。

基于事件的方法的另一个问题是,它不能很好地与某些类型的系统活动集成,如分页(paging)。例如,如果事件处理程序发生页错误,它将被阻塞,并且因此服务器在页错误完成之前不会有进展。尽管服务器的结构可以避免显式阻塞,但由于页错误导致的这种隐式阻塞很难避免,因此在频繁发生时可能会导致较大的性能问题。

还有一个问题是随着时间的推移,基于事件的代码可能很难管理,因为各种函数的确切语义发生了变化[A+02]。例如,如果函数从非阻塞变为阻塞,调用该例程的事件处理程序也必须更改以适应其新性质,方法是将其自身分解为两部分。由于阻塞对于基于事件的服务器而言是灾难性的,因此程序员必须始终注意每个事件使用的API语义的这种变化。

最后,虽然异步磁盘I/O现在可以在大多数平台上使用,但是花了很长时间才做到这一点[PDZ99],而且与异步网络I/O集成不会像你想象的那样有简单和统一的方式。例如,虽然人们只想使用select()接口来管理所有未完成的I/O,但通常需要组合用于网络的select()和用于磁盘I/O的AIO调用。

第36章 I/O设备

开始讨论之前,我们先看一个典型系统的架构(见图36.1)。其中,CPU通过某种内存总线(memory bus)或互连电缆连接到系统内存。图像或者其他高性能I/O设备通过常规的I/O总线(I/O bus)连接到系统,在许多现代系统中会是PCI或它的衍生形式。最后,更下面是外围总线(peripheral bus),比如SCSI、SATA或者USB。它们将最慢的设备连接到系统,包括磁盘、鼠标及其他类似设备。

你可能会问:为什么要用这样的分层架构?简单回答:因为物理布局及造价成本。越快的总线越短,因此高性能的内存总线没有足够的空间连接太多设备。另外,在工程上高性能总线的造价非常高。所以,系统的设计采用了这种分层的方式,这样可以让要求高性能的设备(比如显卡)离CPU更近一些,低性能的设备离CPU远一些。将磁盘和其他低速设备连到外围总线的好处很多,其中较为突出的好处就是你可以在外围总线上连接大量的设备。

同软件一样,硬件也需要一些接口,让系统软件来控制它的操作。因此,所有设备都有自己的特定接口以及典型交互的协议。

一个(简化的)设备接口包含3个寄存器:一个状态(status)寄存器,可以读取并查看设备的当前状态;一个命令(command)寄存器,用于通知设备执行某个具体任务;一个数据(data)寄存器,将数据传给设备或从设备接收数据。通过读写这些寄存器,操作系统可以控制设备的行为。

多年前,工程师们发明了我们目前已经很常见的中断(interrupt)来减少CPU开销。有了中断后,CPU 不再需要不断轮询设备,而是向设备发出一个请求,然后就可以让对应进程睡眠,切换执行其他任务。当设备完成了自身操作,会抛出一个硬件中断,引发CPU跳转执行操作系统预先定义好的中断服务例程(Interrupt Service Routine,ISR),或更为简单的中断处理程序(interrupt handler)。中断处理程序是一小段操作系统代码,它会结束之前的请求(比如从设备读取到了数据或者错误码)并且唤醒等待I/O的进程继续执行。因此,中断允许计算与I/O重叠(overlap),这是提高CPU利用率的关键。

其中,进程1在CPU上运行一段时间(对应CPU那一行上重复的1),然后发出一个读取数据的I/O请求给磁盘。如果没有中断,那么操作系统就会简单自旋,不断轮询设备状态,直到设备完成I/O操作(对应其中的p)。当设备完成请求的操作后,进程1又可以继续运行。如果我们利用中断并允许重叠,操作系统就可以在等待磁盘操作时做其他事情

注意,使用中断并非总是最佳方案。假如有一个非常高性能的设备,它处理请求很快:通常在CPU第一次轮询时就可以返回结果。此时如果使用中断,反而会使系统变慢:切换到其他进程,处理中断,再切换回之前的进程代价不小。因此,如果设备非常快,那么最好的办法反而是轮询。如果设备比较慢,那么采用允许发生重叠的中断更好。如果设备的速度未知,或者时快时慢,可以考虑使用混合(hybrid)策略,先尝试轮询一小段时间,如果设备没有完成操作,此时再使用中断。这种两阶段(two-phased)的办法可以实现两种方法的好处。

另一个最好不要使用中断的场景是网络。网络端收到大量数据包,如果每一个包都发生一次中断,那么有可能导致操作系统发生活锁(livelock),即不断处理中断而无法处理用户层的请求。例如,假设一个Web服务器因为“点杠效应”而突然承受很重的负载。这种情况下,偶尔使用轮询的方式可以更好地控制系统的行为,并允许Web服务器先服务一些用户请求,再回去检查网卡设备是否有更多数据包到达。

另一个基于中断的优化就是合并(coalescing)。设备在抛出中断之前往往会等待一小段时间,在此期间,其他请求可能很快完成,因此多次中断可以合并为一次中断抛出,从而降低处理中断的代价。

DMA引擎是系统中的一个特殊设备,它可以协调完成内存和设备间的数据传递,不需要CPU介入。

DMA工作过程如下。为了能够将数据传送给设备,操作系统会通过编程告诉DMA引擎数据在内存的位置,要拷贝的大小以及要拷贝到哪个设备。在此之后,操作系统就可以处理其他请求了。当DMA的任务完成后,DMA控制器会抛出一个中断来告诉操作系统自己已经完成数据传输。

随着技术的不断发展,主要有两种方式来实现与设备的交互。第一种办法相对老一些(在IBM主机中使用了多年),就是用明确的I/O指令。这些指令规定了操作系统将数据发送到特定设备寄存器的方法,从而允许构造上文提到的协议。

这些指令通常是特权指令(privileged)。操作系统是唯一可以直接与设备交互的实体。例如,设想如果任意程序都可以直接读写磁盘:完全混乱(总是会这样),因为任何用户程序都可以利用这个漏洞来取得计算机的全部控制权。

第二种方法是内存映射I/O(memory- mapped I/O)。通过这种方式,硬件将设备寄存器作为内存地址提供。当需要访问设备寄存器时,操作系统装载(读取)或者存入(写入)到该内存地址;然后硬件会将装载/存入转移到设备上,而不是物理内存。

两种方法没有一种具备极大的优势。内存映射I/O的好处是不需要引入新指令来实现设备交互,但两种方法今天都在使用。

文件系统(当然也包括在其之上的应用程序)完全不清楚它使用的是什么类型的磁盘。它只需要简单地向通用块设备层发送读写请求即可,块设备层会将这些请求路由给对应的设备驱动,然后设备驱动来完成真正的底层操作。

注意,这种封装也有不足的地方。例如,如果有一个设备可以提供很多特殊的功能,但为了兼容大多数操作系统它不得不提供一个通用的接口,这样就使得自身的特殊功能无法使用。这种情况在使用SCSI设备的Linux中就发生了。SCSI设备提供非常丰富的报告错误信息,但其他的块设备(比如ATA/IDE)只提供非常简单的报错处理,这样上层的所有软件只能在出错时收到一个通用的EIO错误码(一般IO错误),SCSI可能提供的所有附加信息都不能报告给文件系统[G08]。

有趣的是,因为所有需要插入系统的设备都需要安装对应的驱动程序,所以久而久之,驱动程序的代码在整个内核代码中的占比越来越大。查看Linux内核代码会发现,超过70%的代码都是各种驱动程序。在Windows系统中,这样的比例同样很高。因此,如果有人跟你说操作系统包含上百万行代码,实际的意思是包含上百万行驱动程序代码。当然,任何安装进操作系统的驱动程序,大部分默认都不是激活状态(只有一小部分设备是在系统刚开启时就需要连接)。更加令人沮丧的是,因为驱动程序的开发者大部分是“业余的”(不是全职内核开发者),所以他们更容易写出缺陷,因此是内核崩溃的主要贡献者[S03]。

第37章 磁盘驱动器

最后,任何现代磁盘驱动器都有一个重要组成部分,即它的缓存(cache),由于历史原因有时称为磁道缓冲区(trackbuffer)。该缓存只是少量的内存(通常大约8MB或16MB),驱动器可以使用这些内存来保存从磁盘读取或写入磁盘的数据。例如,当从磁盘读取扇区时,驱动器可能决定读取该磁道上的所有扇区并将其缓存在其存储器中。这样做可以让驱动器快速响应所有后续对同一磁道的请求。

第39章 插叙:文件和目录

随着时间的推移,存储虚拟化形成了两个关键的抽象。第一个是文件(file)。文件就是一个线性字节数组,每个字节都可以读取或写入。每个文件都有某种低级名称(low-level name),通常是某种数字。用户通常不知道这个名字(我们稍后会看到)。由于历史原因,文件的低级名称通常称为inode号(inode number)。

第二个抽象是目录(directory)。一个目录,像一个文件一样,也有一个低级名字(即inode号),但是它的内容非常具体:它包含一个(用户可读名字,低级名字)对的列表。

名称在系统中很重要,因为访问任何资源的第一步是能够命名它。在UNIX系统中,文件系统提供了一种统一的方式来访问磁盘、U盘、CD-ROM、许多其他设备上的文件,事实上还有很多其他的东西,都位于单一目录树下。

open()的一个重要方面是它的返回值:文件描述符(file descriptor)。文件描述符只是一个整数,是每个进程私有的,在UNIX系统中用于访问文件。因此,一旦文件被打开,你就可以使用文件描述符来读取或写入文件,假定你有权这样做。这样,一个文件描述符就是一种权限(capability)[L84],即一个不透明的句柄,它可以让你执行某些操作。另一种看待文件描述符的方法,是将它作为指向文件类型对象的指针。一旦你有这样的对象,就可以调用其他“方法”来访问文件,如read()和write()。

大多数情况下,当程序调用write()时,它只是告诉文件系统:请在将来的某个时刻,将此数据写入持久存储。出于性能的原因,文件系统会将这些写入在内存中缓冲(buffer)一段时间(例如5s或30s)。在稍后的时间点,写入将实际发送到存储设备。从调用应用程序的角度来看,写入似乎很快完成,并且只有在极少数情况下(例如,在write()调用之后但写入磁盘之前,机器崩溃)数据会丢失。但是,有些应用程序需要的不只是这种保证。例如,在数据库管理系统(DBMS)中,开发正确的恢复协议要求能够经常强制写入磁盘。为了支持这些类型的应用程序,大多数文件系统都提供了一些额外的控制API。在UNIX中,提供给应用程序的接口被称为fsync(int fd)。当进程针对特定文件描述符调用fsync()时,文件系统通过强制将所有脏(dirty)数据(即尚未写入的)写入磁盘来响应,针对指定文件描述符引用的文件。一旦所有这些写入完成,fsync()例程就会返回。

rename()调用提供了一个有趣的保证:它(通常)是一个原子(atomic)调用,不论系统是否崩溃。如果系统在重命名期间崩溃,文件将被命名为旧名称或新名称,不会出现奇怪的中间状态。因此,对于支持某些需要对文件状态进行原子更新的应用程序,rename()非常重要。

创建一个文件时,实际上做了两件事。首先,要构建一个结构(inode),它将跟踪几乎所有关于文件的信息,包括其大小、文件块在磁盘上的位置等等。其次,将人类可读的名称链接到该文件,并将该链接放入目录中。

在创建文件的硬链接之后,在文件系统中,原有文件名(file)和新创建的文件名(file2)之间没有区别。

这样的结果是因为当文件系统取消链接文件时,它检查inode号中的引用计数(reference count)。该引用计数(有时称为链接计数,link count)允许文件系统跟踪有多少不同的文件名已链接到这个inode。调用unlink()时,会删除人类可读的名称(正在删除的文件)与给定inode号之间的“链接”,并减少引用计数。只有当引用计数达到零时,文件系统才会释放inode和相关数据块,从而真正“删除”该文件。

还有一种非常有用的链接类型,称为符号链接(symbolic link),有时称为软链接(soft link)。事实表明,硬链接有点局限:你不能创建目录的硬链接(因为担心会在目录树中创建一个环)。你不能硬链接到其他磁盘分区中的文件(因为inode号在特定文件系统中是唯一的,而不是跨文件系统),等等。因此,人们创建了一种称为符号链接的新型链接。

为了创建一个文件系统,大多数文件系统提供了一个工具,通常名为mkfs(发音为“make fs”),它就是完成这个任务的。思路如下:作为输入,为该工具提供一个设备(例如磁盘分区,例如/dev/sda1),一种文件系统类型(例如ext3),它就在该磁盘分区上写入一个空文件系统,从根目录开始。mkfs说,要有文件系统!

但是,一旦创建了这样的文件系统,就需要在统一的文件系统树中进行访问。这个任务是通过mount程序实现的(它使底层系统调用mount()完成实际工作)。mount的作用很简单:以现有目录作为目标挂载点(mount point),本质上是将新的文件系统粘贴到目录树的这个点上。

第40章 文件系统实现

文件系统是纯软件。与CPU和内存虚拟化的开发不同,我们不会添加硬件功能来使文件系统的某些方面更好地工作(但我们需要注意设备特性,以确保文件系统运行良好)。由于在构建文件系统方面具有很大的灵活性,因此人们构建了许多不同的文件系统,从AFS(Andrew文件系统)[H+88]到ZFS(Sun的Zettabyte文件系统)[B07]。所有这些文件系统都有不同的数据结构,在某些方面优于或逊于同类系统。

考虑文件系统时,我们通常建议考虑它们的两个不同方面。如果你理解了这两个方面,可能就理解了文件系统基本工作原理。

第一个方面是文件系统的数据结构(data structure)。换言之,文件系统在磁盘上使用哪些类型的结构来组织其数据和元数据?我们即将看到的第一个文件系统(包括下面的VSFS)使用简单的结构,如块或其他对象的数组,而更复杂的文件系统(如SGI的XFS)使用更复杂的基于树的结构[S+96]。

正如我们之前讨论的那样,心智模型就是你在学习系统时真正想要发展的东西。对于文件系统,你的心智模型最终应该包含以下问题的答案:磁盘上的哪些结构存储文件系统的数据和元数据?当一个进程打开一个文件时会发生什么?在读取或写入期间访问哪些磁盘结构?通过研究和改进心智模型,你可以对发生的事情有一个抽象的理解,而不是试图理解某些文件系统代码的细节(当然这也是有用的!)。

文件系统的第二个方面是访问方法(access method)。如何将进程发出的调用,如open()、read()、write()等,映射到它的结构上?在执行特定系统调用期间读取哪些结构?改写哪些结构?所有这些步骤的执行效率如何?

我们需要做的第一件事是将磁盘分成块(block)。简单的文件系统只使用一种块大小,这里正是这样做的。我们选择常用的4KB。

现在让我们考虑一下,为了构建文件系统,需要在这些块中存储什么。当然,首先想到的是用户数据。实际上,任何文件系统中的大多数空间都是(并且应该是)用户数据。

正如我们在第39章中了解到的,文件系统必须记录每个文件的信息。该信息是元数据(metadata)的关键部分,并且记录诸如文件包含哪些数据块(在数据区域中)、文件的大小,其所有者和访问权限、访问和修改时间以及其他类似信息的事情。为了存储这些信息,文件系统通常有一个名为inode的结构(后面会详细介绍inode)。

为了存放inode,我们还需要在磁盘上留出一些空间。我们将这部分磁盘称为inode表(inode table),它只是保存了一个磁盘上inode的数组。

建立在更大磁盘上的相同文件系统可以简单地分配更大的inode表,从而容纳更多文件。

我们的文件系统有了数据块(D)和inode(I),但还缺一些东西。你可能已经猜到,还需要某种方法来记录inode或数据块是空闲还是已分配。因此,这种分配结构(allocation structure)是所有文件系统中必需的部分。

当然,可能有许多分配记录方法。例如,我们可以用一个空闲列表(free list),指向第一个空闲块,然后它又指向下一个空闲块,依此类推。我们选择一种简单而流行的结构,称为位图(bitmap),一种用于数据区域(数据位图,databitmap),另一种用于inode表(inode位图,inode bitmap)。位图是一种简单的结构:每个位用于指示相应的对象/块是空闲(0)还是正在使用(1)。

细心的读者可能已经注意到,在极简文件系统的磁盘结构设计中,还有一块。我们将它保留给超级块(superblock),在下图中用S表示。超级块包含关于该特定文件系统的信息,包括例如文件系统中有多少个inode和数据块(在这个例子中分别为80和56)、inode表的开始位置(块3)等等。它可能还包括一些幻数,来标识文件系统类型(在本例中为VSFS)。

因此,在挂载文件系统时,操作系统将首先读取超级块,初始化各种参数,然后将该卷添加到文件系统树中。当卷中的文件被访问时,系统就会知道在哪里查找所需的磁盘上的结构。

文件系统最重要的磁盘结构之一是inode,几乎所有的文件系统都有类似的结构。名称inode是index node(索引节点)的缩写,它是由UNIX开发人员Ken Thompson [RT74]给出的历史性名称,因为这些节点最初放在一个数组中,在访问特定inode时会用到该数组的索引。

每个inode都由一个数字(称为inumber)隐式引用,我们之前称之为文件的低级名称(low-level name)。在VSFS(和其他简单的文件系统)中,给定一个inumber,你应该能够直接计算磁盘上相应节点的位置。

在每个inode中,实际上是所有关于文件的信息:文件类型(例如,常规文件、目录等)、大小、分配给它的块数、保护信息(如谁拥有该文件以及谁可以访问它)、一些时间信息(包括文件创建、修改或上次访问的时间文件下),以及有关其数据块驻留在磁盘上的位置的信息(如某种类型的指针)。我们将所有关于文件的信息称为元数据(metadata)。实际上,文件系统中除了纯粹的用户数据外,其他任何信息通常都称为元数据。

设计inode时,最重要的决定之一是它如何引用数据块的位置。一种简单的方法是在inode中有一个或多个直接指针(磁盘地址)。每个指针指向属于该文件的一个磁盘块。这种方法有局限:例如,如果你想要一个非常大的文件(例如,大于块的大小乘以直接指针数),那就不走运了。

为了支持更大的文件,文件系统设计者必须在inode中引入不同的结构。一个常见的思路是有一个称为间接指针(indirectpointer)的特殊指针。它不是指向包含用户数据的块,而是指向包含更多指针的块,每个指针指向用户数据。因此,inode可以有一些固定数量(例如12个)的直接指针和一个间接指针。如果文件变得足够大,则会分配一个间接块(来自磁盘的数据块区域),并将inode的间接指针设置为指向它。假设一个块是4KB,磁盘地址是4字节,那就增加了1024个指针。文件可以增长到(12 + 1024)×4KB,即4144KB。

另一种方法是使用范围(extent)而不是指针。范围就是一个磁盘指针加一个长度(以块为单位)。因此,不需要指向文件的每个块的指针,只需要指针和长度来指定文件的磁盘位置。只有一个范围是有局限的,因为分配文件时可能无法找到连续的磁盘可用空间块。因此,基于范围的文件系统通常允许多个范围,从而在文件分配期间给予文件系统更多的自由。这两种方法相比较,基于指针的方法是最灵活的,但是每个文件使用大量元数据(尤其是大文件)。基于范围的方法不够灵活但更紧凑。特别是,如果磁盘上有足够的可用空间并且文件可以连续布局(无论如何,这实际上是所有文件分配策略的目标),基于范围的方法都能正常工作。

总之,这种不平衡树被称为指向文件块的多级索引(multi-level index)方法。我们来看一个例子,它有12个直接指针,以及一个间接块和一个双重间接块。假设块大小为4KB,并且指针为4字节,则该结构可以容纳一个刚好超过4GB的文件,即(12 + 1024 + 10242)×4KB。增加一个三重间接块,你是否能弄清楚支持多大的文件?

你可能已经猜到,链接式文件分配对于某些工作负载表现不佳。例如,考虑读取文件的最后一个块,或者就是进行随机访问。因此,为了使链接式分配更好地工作,一些系统在内存中保留链接信息表,而不是将下一个指针与数据块本身一起存储。该表用数据块D的地址来索引,一个条目的内容就是D的下一个指针,即D后面的文件中的下一个块的地址。那里也可以是空值(表示文件结束),或用其他标记来表示一个特定的块是空闲的。拥有这样的下一块指针表,使得链接分配机制可以有效地进行随机文件访问,只需首先扫描(在内存中)表来查找所需的块,然后直接访问(在磁盘上)。这样的表听起来很熟悉吗?我们描述的是所谓的文件分配表(File Allocation Table,FAT)——文件系统的基本结构。是的,在NTFS [C94]之前,这款经典的旧Windows文件系统基于简单的基于链接的分配方案。它与标准UNIX文件系统还有其他不同之处。例如,本身没有inode,而是存储关于文件的元数据的目录条目,并且直接指向所述文件的第一个块,这导致不可能创建硬链接。参见Brouwer的著作 [B02],了解更多不够优雅的细节。

在这个简单的例子中,让我们先假设你只是想打开一个文件(例如/foo/bar,读取它,然后关闭它)。对于这个简单的例子,假设文件的大小只有4KB(即1块)。当你发出一个open("/foo/bar", O_RDONLY)调用时,文件系统首先需要找到文件bar的inode,从而获取关于该文件的一些基本信息(权限信息、文件大小等等)。为此,文件系统必须能够找到inode,但它现在只有完整的路径名。文件系统必须遍历(traverse)路径名,从而找到所需的inode。

所有遍历都从文件系统的根开始,即根目录(root directory),它就记为/。因此,文件系统的第一次磁盘读取是根目录的inode。但是这个inode在哪里?要找到inode,我们必须知道它的i-number。通常,我们在其父目录中找到文件或目录的i-number。根没有父目录(根据定义)。因此,根的inode号必须是“众所周知的”。在挂载文件系统时,文件系统必须知道它是什么。在大多数UNIX文件系统中,根的inode号为2。因此,要开始该过程,文件系统会读入inode号2的块(第一个inode块)。一旦inode被读入,文件系统可以在其中查找指向数据块的指针,数据块包含根目录的内容。因此,文件系统将使用这些磁盘上的指针来读取目录,在这个例子中,寻找foo的条目。通过读入一个或多个目录数据块,它将找到foo的条目。一旦找到,文件系统也会找到下一个需要的foo的inode号(假定是44)。

下一步是递归遍历路径名,直到找到所需的inode。在这个例子中,文件系统读取包含foo的inode及其目录数据的块,最后找到bar的inode号。open()的最后一步是将bar的inode读入内存。然后文件系统进行最后的权限检查,在每个进程的打开文件表中,为此进程分配一个文件描述符,并将它返回给用户。打开后,程序可以发出read()系统调用,从文件中读取。第一次读取(除非lseek()已被调用,则在偏移量0处)将在文件的第一个块中读取,查阅inode以查找这个块的位置。它也会用新的最后访问时间更新inode。读取将进一步更新此文件描述符在内存中的打开文件表,更新文件偏移量,以便下一次读取会读取第二个文件块,等等。

写入文件是一个类似的过程。首先,文件必须打开(如上所述)。其次,应用程序可以发出write()调用以用新内容更新文件。最后,关闭该文件。与读取不同,写入文件也可能会分配(allocate)一个块(除非块被覆写)。当写入一个新文件时,每次写入操作不仅需要将数据写入磁盘,还必须首先决定将哪个块分配给文件,从而相应地更新磁盘的其他结构(例如数据位图和inode)。因此,每次写入文件在逻辑上会导致5个I/O:一个读取数据位图(然后更新以标记新分配的块被使用),一个写入位图(将它的新状态存入磁盘),再是两次读取,然后写入inode(用新块的位置更新),最后一次写入真正的数据块本身。考虑简单和常见的操作(例如文件创建),写入的工作量更大。要创建一个文件,文件系统不仅要分配一个inode,还要在包含新文件的目录中分配空间。这样做的I/O工作总量非常大:一个读取inode位图(查找空闲inode),一个写入inode位图(将其标记为已分配),一个写入新的inode本身(初始化它),一个写入目录的数据(将文件的高级名称链接到它的inode号),以及一个读写目录inode以便更新它。如果目录需要增长以容纳新条目,则还需要额外的I/O(即数据位图和新目录块)。所有这些只是为了创建一个文件!

早期的文件系统因此引入了一个固定大小的缓存(fixed-size cache)来保存常用的块。正如我们在讨论虚拟内存时一样,LRU及不同变体策略会决定哪些块保留在缓存中。这个固定大小的缓存通常会在启动时分配,大约占总内存的10%。然而,这种静态的内存划分(static partitioning)可能导致浪费。如果文件系统在给定的时间点不需要10%的内存,该怎么办?使用上述固定大小的方法,文件高速缓存中的未使用页面不能被重新用于其他一些用途,因此导致浪费。相比之下,现代系统采用动态划分(dynamic partitioning)方法。具体来说,许多现代操作系统将虚拟内存页面和文件系统页面集成到统一页面缓存中(unified page cache)[S00]。通过这种方式,可以在虚拟内存和文件系统之间更灵活地分配内存,具体取决于在给定时间哪种内存需要更多的内存。现在想象一下有缓存的文件打开的例子。第一次打开可能会产生很多I/O流量,来读取目录的inode和数据,但是随后文件打开的同一文件(或同一目录中的文件),大部分会命中缓存,因此不需要I/O。

我们也考虑一下缓存对写入的影响。尽管可以通过足够大的缓存完全避免读取I/O,但写入流量必须进入磁盘,才能实现持久。因此,高速缓存不能减少写入流量,像对读取那样。虽然这么说,写缓冲(write buffering,人们有时这么说)肯定有许多优点。首先,通过延迟写入,文件系统可以将一些更新编成一批(batch),放入一组较小的I/O中。例如,如果在创建一个文件时,inode位图被更新,稍后在创建另一个文件时又被更新,则文件系统会在第一次更新后延迟写入,从而节省一次I/O。其次,通过将一些写入缓冲在内存中,系统可以调度(schedule)后续的I/O,从而提高性能。最后,一些写入可以通过拖延来完全避免。例如,如果应用程序创建文件并将其删除,则将文件创建延迟写入磁盘,可以完全避免(avoid)写入。在这种情况下,懒惰(在将块写入磁盘时)是一种美德。

如果系统在更新传递到磁盘之前崩溃,更新就会丢失。但是,将内存写入时间延长,则可以通过批处理、调度甚至避免写入,提高性能。

第41章 局部性和快速文件系统

主要问题是老UNIX文件系统将磁盘当成随机存取内存。数据遍布各处,而不考虑保存数据的介质是磁盘的事实,因此具有实实在在的、昂贵的定位成本。例如,文件的数据块通常离其inode非常远,因此每当第一次读取inode然后读取文件的数据块(非常常见的操作)时,就会导致昂贵的寻道。更糟糕的是,文件系统最终会变得非常碎片化(fragmented),因为空闲空间没有得到精心管理。空闲列表最终会指向遍布磁盘的一堆块,并且随着文件的分配,它们只会占用下一个空闲块。结果是在磁盘上来回访问逻辑上连续的文件,从而大大降低了性能。

伯克利的一个小组决定建立一个更好、更快的文件系统,他们聪明地称之为快速文件系统(Fast File System,FFS)。思路是让文件系统的结构和分配策略具有“磁盘意识”,从而提高性能,这正是他们所做的。因此,FFS进入了文件系统研究的新时代。通过保持与文件系统相同的接口(相同的API,包括open()、read()、write()、close()和其他文件系统调用),但改变内部实现,作者为新文件系统的构建铺平了道路,这方面的工作今天仍在继续。事实上,所有现代文件系统都遵循现有的接口(从而保持与应用程序的兼容性),同时为了性能、可靠性或其他原因,改变其内部实现。

第一步是更改磁盘上的结构。FFS将磁盘划分为一些分组,称为柱面组(cylinder group,而一些现代文件系统,如Linuxext2和ext3,就称它们为块组,即block group)。

我们现在描述一个柱面组的构成。出于可靠性原因,每个组中都有超级块(super block)的一个副本(例如,如果一个被损坏或划伤,你仍然可以通过使用其中一个副本来挂载和访问文件系统)。

在每个组中,我们需要记录该组的inode和数据块是否已分配。每组的inode位图(inode bitmap,ib)和数据位图(databitmap,db)起到了这个作用,分别针对每组中的inode和数据块。位图是管理文件系统中可用空间的绝佳方法,因为很容易找到大块可用空间并将其分配给文件,这可能会避免旧文件系统中空闲列表的某些碎片问题。

首先是目录的放置。FFS采用了一种简单的方法:找到分配数量少的柱面组(因为我们希望跨组平衡目录)和大量的自由inode(因为我们希望随后能够分配一堆文件),并将目录数据和inode放在该分组中。当然,这里可以使用其他推断方法(例如,考虑空闲数据块的数量)。

对于文件,FFS做两件事。首先,它确保(在一般情况下)将文件的数据块分配到与其inode相同的组中,从而防止inode和数据之间的长时间寻道(如在老文件系统中)。其次,它将位于同一目录中的所有文件,放在它们所在目录的柱面组中。因此,如果用户创建了4个文件,/dir1/1.txt、/dir1/2.txt、/dir1/3.txt和/dir99/4.txt,FFS会尝试将前3个放在一起(同一组),与第四个远离(它在另外某个组中)。

在FFS中,文件放置的一般策略有一个重要的例外,它出现在大文件中。如果没有不同的规则,大文件将填满它首先放入的块组(也可能填满其他组)。以这种方式填充块组是不符合需要的,因为它妨碍了随后的“相关”文件放置在该块组内,因此可能破坏文件访问的局部性。因此,对于大文件,FFS执行以下操作。在将一定数量的块分配到第一个块组(例如,12个块,或inode中可用的直接指针的数量)之后,FFS将文件的下一个“大”块(即第一个间接块指向的那些部分)放在另一个块组中(可能因为它的利用率低而选择)。然后,文件的下一个块放在另一个不同的块组中,依此类推。

但是,FFS没有使用这种类型的计算来跨组分布大文件。相反,它采用了一种简单的方法,基于inode本身的结构。前12个直接块与inode放在同一组中。每个后续的间接块,以及它指向的所有块都放在不同的组中。如果块大小为4KB,磁盘地址是32位,则此策略意味着文件的每1024个块(4MB)放在单独的组中,唯一的例外是直接指针所指向的文件的前48KB。

FFS设计人员采用很简单的解决方案解决了这个问题。他们决定引入子块(sub-block),这些子块有512字节,文件系统可以将它们分配给文件。因此,如果你创建了一个小文件(比如大小为1KB),它将占用两个子块,因此不会浪费整个4KB块。随着文件的增长,文件系统将继续为其分配512字节的子块,直到它达到完整的4KB数据。此时,FFS将找到一个4KB块,将子块复制到其中,并释放子块以备将来使用。

你可能会发现这个过程效率低下,文件系统需要大量的额外工作(特别是执行复制的大量额外I/O)。你又对了!因此,FFS通常通过修改libc库来避免这种异常行为。该库将缓冲写入,然后以4KB块的形式将它们发送到文件系统,从而在大多数情况下完全避免子块的特殊情况。

FFS引入的第二个巧妙方法,是针对性能进行优化的磁盘布局。那时候(在SCSI和其他更现代的设备接口之前),磁盘不太复杂,需要主机CPU以更加亲力亲为的方式来控制它们的操作。当文件放在磁盘的连续扇区上时,FFS遇到了问题,如图41.3左图所示。

FFS还增加了另一些可用性改进。FFS是允许长文件名的第一个文件系统之一,因此在文件系统中实现了更具表现力的名称,而不是传统的固定大小方法(例如,8个字符)。此外,引入了一种称为符号链接的新概念。正如第40章所讨论的那样,硬链接的局限性在于它们都不能指向目录(因为害怕引入文件系统层次结构中的循环),并且它们只能指向同一卷内的文件(即inode号必须仍然有意义)。符号链接允许用户为系统上的任何其他文件或目录创建“别名”,因此更加灵活。FFS还引入了一个原子rename()操作,用于重命名文件。除了基本技术之外,可用性的改进也可能让FFS拥有更强大的用户群。

第42章 崩溃一致性:FSCK和日志

文件系统面临的一个主要挑战在于,如何在出现断电(power loss)或系统崩溃(system crash)的情况下,更新持久数据结构。具体来说,如果在更新磁盘结构的过程中,有人绊到电源线并且机器断电,会发生什么?或者操作系统遇到错误并崩溃?由于断电和崩溃,更新持久性数据结构可能非常棘手,并导致了文件系统实现中一个有趣的新问题,称为崩溃一致性问题(crash-consistency problem)。

在文件系统数据结构中可能存在不一致性。可能有空间泄露,可能将垃圾数据返回给用户,等等。理想的做法是将文件系统从一个一致状态(在文件被追加之前),原子地(atomically)移动到另一个状态(在inode、位图和新数据块被写入磁盘之后)。遗憾的是,做到这一点不容易,因为磁盘一次只提交一次写入,而这些更新之间可能会发生崩溃或断电。我们将这个一般问题称为崩溃一致性问题(crash-consistency problem,也可以称为一致性更新问题,consistent-update problem)。

对于一致更新问题,最流行的解决方案可能是从数据库管理系统的世界中借鉴的一个想法。这种名为预写日志(write-ahead logging)的想法,是为了解决这类问题而发明的。在文件系统中,出于历史原因,我们通常将预写日志称为日志(journaling)。第一个实现它的文件系统是Cedar [H87],但许多现代文件系统都使用这个想法,包括Linux ext3和ext4、reiserfs、IBM的JFS、SGI的XFS和Windows NTFS。

现在来了解文件系统如何利用日志内容从崩溃中恢复(recover)。在这个更新序列期间,任何时候都可能发生崩溃。如果崩溃发生在事务被安全地写入日志之前(在上面的步骤2完成之前),那么我们的工作很简单:简单地跳过待执行的更新。如果在事务已提交到日志之后但在加检查点完成之前发生崩溃,则文件系统可以按如下方式恢复(recover)更新。系统引导时,文件系统恢复过程将扫描日志,并查找已提交到磁盘的事务。然后,这些事务被重放(replayed,按顺序),文件系统再次尝试将事务中的块写入它们最终的磁盘位置。这种形式的日志是最简单的形式之一,称为重做日志(redo logging)。通过在日志中恢复已提交的事务,文件系统确保磁盘上的结构是一致的,因此可以继续工作,挂载文件系统并为新请求做好准备。

为了解决这个问题,一些文件系统不会一次一个地向磁盘提交每个更新(例如,Linux ext3)。与此不同,可以将所有更新缓冲到全局事务中。在上面的示例中,当创建两个文件时,文件系统只将内存中的inode位图、文件的inode、目录数据和目录inode标记为脏,并将它们添加到块列表中,形成当前的事务。当最后应该将这些块写入磁盘时(例如,在超时5s之后),会提交包含上述所有更新的单个全局事务。因此,通过缓冲更新,文件系统在许多情况下可以避免对磁盘的过多的写入流量。

日志满时会出现两个问题。第一个问题比较简单,但不太重要:日志越大,恢复时间越长,因为恢复过程必须重放日志中的所有事务(按顺序)才能恢复。第二个问题更重要:当日志已满(或接近满)时,不能向磁盘提交进一步的事务,从而使文件系统“不太有用”(即无用)。为了解决这些问题,日志文件系统将日志视为循环数据结构,一遍又一遍地重复使用。这就是为什么日志有时被称为循环日志(circular log)。为此,文件系统必须在加检查点之后的某个时间执行操作。具体来说,一旦事务被加检查点,文件系统应释放它在日志中占用的空间,允许重用日志空间。有很多方法可以达到这个目的。例如,你只需在日志超级块(journal superblock)中标记日志中最旧和最新的事务。所有其他空间都是空闲的。

1.数据写入:将数据写入最终位置,等待完成(等待是可选的,详见下文)。2.日志元数据写入:将开始块和元数据写入日志,等待写入完成。3.日志提交:将事务提交块(包括TxE)写入日志,等待写完成,现在认为事务(包括数据)已提交(committed)。4.加检查点元数据:将元数据更新的内容写入文件系统中的最终位置。5.释放:稍后,在日志超级块中将事务标记为空闲。

另一种方法称为写时复制(Copy-On-Write,COW),并且在许多流行的文件系统中使用,包括Sun的ZFS [B07]。这种技术永远不会覆写文件或目录。相反,它会对磁盘上以前未使用的位置进行新的更新。在完成许多更新后,COW文件系统会翻转文件系统的根结构,以包含指向刚更新结构的指针。这样做可以使文件系统保持一致。

第43章 日志结构文件系统

LFS引入了一种更新磁盘的新方法。LFS总是写入磁盘的未使用部分,然后通过清理回收旧空间,而不是在原来的位置覆盖文件。这种方法在数据库系统中称为影子分页(shadow paging)[L77],在文件系统中有时称为写时复制(copy-on-write),可以实现高效写入,因为LFS可以将所有更新收集到内存的段中,然后按顺序一起写入。

这种方法的缺点是它会产生垃圾。旧数据的副本分散在整个磁盘中,如果想要为后续使用回收这样的空间,则必须定期清理旧的数据段。清理成为LFS争议的焦点,对清理成本的担忧[SS+95]可能限制了LFS开始对该领域的影响。然而,一些现代商业文件系统,包括NetApp的WAFL [HLM94]、Sun的ZFS [B07]和Linux btrfs [M07],采用了类似的写时复制方法来写入磁盘,因此LFS的知识遗产继续存在于这些现代文件系统中。特别是,WAFL通过将清理问题转化为特征来解决问题。通过快照(snapshots)提供旧版本的文件系统,用户可以在意外删除当前文件时,访问到旧文件。

第44章 数据完整性和保护

在结束之前,讨论一下使用校验和进行数据保护的一些开销。有两种不同的开销,在计算机系统中很常见:空间和时间。空间开销有两种形式。第一种是磁盘(或其他存储介质)本身。每个存储的校验和占用磁盘空间,不能再用于用户数据。典型的比率可能是每4KB数据块的8字节校验和,磁盘空间开销为0.19%。第二种空间开销来自系统的内存。访问数据时,内存中必须有足够的空间用于校验和以及数据本身。但是,如果系统只是检查校验和,然后在完成后将其丢弃,则这种开销是短暂的,并不是很重要。只有将校验和保存在内存中(为了增加内存讹误防护级别[Z+13]),才能观察到这种小开销。

虽然空间开销很小,但校验和引起的时间开销可能非常明显。至少,CPU必须计算每个块的校验和,包括存储数据时(确定存储的校验和的值),以及访问时(再次计算校验和,并将其与存储的校验和进行比较)。许多使用校验和的系统(包括网络栈)采用了一种降低CPU开销的方法,将数据复制和校验和组合成一个简化的活动。因为无论如何都需要拷贝(例如,将数据从内核页面缓存复制到用户缓冲区中),组合的复制/校验和可能非常有效。除了CPU开销之外,一些校验和方案可能会导致外部I/O开销,特别是当校验和与数据分开存储时(因此需要额外的I/O来访问它们),以及后台擦净所需的所有额外I/O。前者可以通过设计减少,后者影响有限,因为可以调整,也许通过控制何时进行这种擦净活动。半夜,当大多数(不是全部)努力工作的人们上床睡觉时,可能是进行这种擦净活动、增加存储系统健壮性的好时机。

第47章 分布式系统

UDP是不可靠通信层的一个很好的例子。如果你使用它,就会遇到数据包丢失(丢弃),从而无法到达目的地的情况。发送方永远不会被告知丢失。但是,这并不意味着UDP根本不能防止任何故障。例如,UDP包含校验和(checksum),以检测某些形式的数据包损坏。

在内部,客户端存根中的每个函数都执行远程过程调用所需的所有工作。对于客户端,代码只是作为函数调用出现(例如,客户端调用func1(x))。在内部,func1()的客户端存根中的代码执行此操作:·创建消息缓冲区。消息缓冲区通常只是某种大小的连续字节数组。·将所需信息打包到消息缓冲区中。该信息包括要调用的函数的某种标识符,以及函数所需的所有参数(例如,在上面的示例中,func1需要一个整数)。将所有这些信息放入单个连续缓冲区的过程,有时被称为参数的封送处理(marshaling)或消息的序列化(serialization)。·将消息发送到目标RPC服务器。与RPC服务器的通信,以及使其正常运行所需的所有细节,都由RPC运行时库处理,如下所述。·等待回复。由于函数调用通常是同步的(synchronous),因此调用将等待其完成。·解包返回代码和其他参数。如果函数只返回一个返回码,那么这个过程很简单。但是,较复杂的函数可能会返回更复杂的结果(例如,列表),因此存根可能也需要对它们解包。此步骤也称为解封送处理(unmarshaling)或反序列化(deserialization)。·返回调用者。最后,只需从客户端存根返回到客户端代码。

对于服务器,也会生成代码。在服务器上执行的步骤如下:·解包消息。此步骤称为解封送处理(unmarshaling)或反序列化(deserialization),将信息从传入消息中取出。提取函数标识符和参数。·调用实际函数。终于,我们到了实际执行远程函数的地方。RPC运行时调用ID指定的函数,并传入所需的参数。·打包结果。返回参数被封送处理,放入一个回复缓冲区。·发送回复。回复最终被发送给调用者。

遗憾的是,在可靠的通信层之上构建RPC可能会导致性能的低效率。回顾上面的讨论,可靠的通信层如何工作:确认和超时/重试。因此,当客户端向服务器发送RPC请求时,服务器以确认响应,以便调用者知道收到了请求。类似地,当服务器将回复发送到客户端时,客户端会对其进行确认,以便服务器知道它已被接收。在可靠的通信层之上构建请求/响应协议(例如RPC),必须发送两个“额外”消息。因此,许多RPC软件包都建立在不可靠的通信层之上,例如UDP。这样做可以实现更高效的RPC层,但确实增加了为RPC系统提供可靠性的责任。RPC层通过使用超时/重试和确认来实现所需的责任级别,就像我们上面描述的那样。通过使用某种形式的序列编号,通信层可以保证每个RPC只发生一次(在没有故障的情况下),或者最多只发生一次(在发生故障的情况下)。