《操作系统导论》 - 书摘

前言

在真实系统上运行真实代码是了解操作系统的最佳方式,因此建议你尽可能这样做。

根据我们近15年来教授本课程的经验,学生很难理解并发问题是如何产生的,或者很难理解人们试图解决它的原因。那是因为他们还不了解地址空间是什么、进程是什么,或者为什么上下文切换可以在任意时间点发生。然而,一旦他们理解了这些概念,那么再引入线程的概念和由此产生的问题就变得相当容易,或者至少比较容易。

第1章 关于本书的对话

他讲的是物理学,而我们将探讨的主题是操作系统的3个简单部分。这很合适,因为操作系统的难度差不多是物理学的一半。

第2章 操作系统介绍

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

每个CPU、内存和磁盘都是系统的资源(resource),因此操作系统扮演的主要角色就是管理(manage)这些资源,以做到高效或公平,或者实际上考虑其他许多可能的目标。

遗憾的是,上面的程序中的关键部分是增加共享计数器的地方,它需要3条指令:一条将计数器的值从内存加载到寄存器,一条将其递增,另一条将其保存回内存。

你可能想知道操作系统为了实际写入磁盘而做了什么。我们会告诉你,但你必须答应先闭上眼睛。这是不愉快的。文件系统必须做很多工作:首先确定新数据将驻留在磁盘上的哪个位置,然后在文件系统所维护的各种结构中对其进行记录。这样做需要向底层存储设备发出I/O请求,以读取现有结构或更新(写入)它们。

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

系统调用和过程调用之间的关键区别在于,系统调用将控制转移(跳转)到OS中,同时提高硬件特权级别(hardware privilege level)。用户应用程序以所谓的用户模式(user mode)运行,这意味着硬件限制了应用程序的功能。例如,以用户模式运行的应用程序通常不能发起对磁盘的I/O请求,不能访问任何物理内存页或在网络上发送数据包。在发起系统调用时 [通常通过一个称为陷阱(trap)的特殊硬件指令],硬件将控制转移到预先指定的陷阱处理程序(trap handler)(即预先设置的操作系统),并同时将特权级别提升到内核模式(kernel mode)。

这种切换非常重要,因为I/O设备很慢。在处理I/O时让程序占着CPU,浪费了CPU时间。那么,为什么不切换到另一份工作并运行一段时间?

Windows在计算历史中同样采用了许多伟大的思想,特别是从Windows NT开始,这是微软操作系统技术的一次巨大飞跃。

UNIX环境对于程序员和开发人员都很友好,并为新的C编程语言提供了编译器。程序员很容易编写自己的程序并分享它们,这使得UNIX非常受欢迎。

幸运的是,对于UNIX来说,一位名叫Linus Torvalds的年轻芬兰黑客决定编写他自己的UNIX版本,该版本严重依赖最初系统背后的原则和思想,但没有借用原来的代码集,从而避免了合法性问题。他征集了世界各地许多其他人的帮助,不久,Linux就诞生了(同时也开启了现代开源软件运动)。

史蒂夫·乔布斯将他的基于UNIX的NeXTStep操作环境带到了苹果公司,从而使得UNIX在台式机上非常流行(尽管很多苹果技术用户可能都不知道这一事实)。因此,UNIX今天比以往任何时候都更加重要。如果你相信有计算之神,那么应该感谢这个美妙的结果。

第1部分 虚拟化

但吃的人多才有这样的问题。多数时间他们都在打盹或者做其他事情,所以,你可以在他们打盹的时候把他手中的桃子拿过来分给其他人,这样我们就创造了有许多虚拟桃子的假象,每人一个桃子!

这个CPU虚拟成多个虚拟CPU并分给每一个进程使用,因此,每个应用都以为自己在独占CPU,但实际上只有一个CPU。这样操作系统就创造了美丽的假象——它虚拟化了CPU。

第4章 抽象:进程

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

操作系统为正在运行的程序提供的抽象,就是所谓的进程(process)。

你可以将机制看成为系统的“如何(how)”问题提供答案。

操作系统运行程序必须做的第一件事是将代码和所有静态数据(例如初始化变量)加载(load)到内存中,加载到进程的地址空间中。程序最初以某种可执行格式驻留在磁盘上(disk,或者在某些现代系统中,在基于闪存的SSD上)。因此,将程序和静态数据加载到内存中的过程,需要操作系统从磁盘读取这些字节,并将它们放在内存中的某处(见图4.1)。

操作系统也可能会用参数初始化栈。具体来说,它会将参数填入main()函数,即argc和argv数组。

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

运行(running):在运行状态下,进程正在处理器上运行。这意味着它正在执行指令。·就绪(ready):在就绪状态下,进程已准备好运行,但由于某种原因,操作系统选择不在此时运行。·阻塞(blocked):在阻塞状态下,一个进程执行了某种操作,直到发生其他事件时才会准备运行。

操作系统还必须以某种方式跟踪被阻塞的进程。当I/O事件完成时,操作系统应确保唤醒正确的进程,让它准备好再次运行。

完成后,父进程将进行最后一次调用(例如,wait()),以等待子进程的完成,并告诉操作系统它可以清理这个正在结束的进程的所有相关数据结构。

这是比较简单的一种,但是,任何能够同时运行多个程序的操作系统当然都会有类似这种结构的东西,以便跟踪系统中正在运行的所有程序。有时候人们会将存储关于进程的信息的个体结构称为进程控制块(Process Control Block,PCB),这是谈论包含每个进程信息的C结构的一种方式。

第5章 插叙:进程API

该系统调用会在子进程运行结束后才返回[SPAN]。因此,即使父进程先运行,它也会礼貌地等待子进程运行完毕,然后wait()返回,接着父进程才输出自己的信息。

fork()系统调用很奇怪,它的伙伴exec()也不一般。给定可执行程序的名称(如wc)及需要的参数(如p3.c)后,exec()会从可执行程序中加载代码和静态数据,并用它覆写自己的代码段(以及静态数据),堆、栈及其他内存空间也会被重新初始化。然后操作系统就执行该程序,将参数通过argv传递给该进程。因此,它并没有创建新进程,而是直接将当前运行的程序(以前的p3)替换为不同的运行程序(wc)。

现在,知道fork()和exec()组合在创建和操作进程时非常强大就足够了。

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

为了虚拟化CPU,操作系统需要以某种方式让许多任务共享物理CPU,让它们看起来像是同时运行。基本思想很简单:运行一个进程一段时间,然后运行另一个进程,如此轮换。通过以这种方式时分共享(time sharing)CPU,就实现了虚拟化。

如果没有控制权,一个进程可以简单地无限制运行并接管机器,或访问没有权限的信息。因此,在保持控制权的同时获得高性能,这是构建操作系统的主要挑战之一。

操作系统通常会明智地利用硬件支持,以便高效地实现其工作。

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

一个进程必须能够执行I/O和其他一些受限制的操作,但又不能让进程完全控制系统。操作系统和硬件如何协作实现这一点?

在用户模式(user mode)下,应用程序不能完全访问硬件资源。在内核模式(kernel mode)下,操作系统可以访问机器的全部资源。还提供了陷入(trap)内核和从陷阱返回(return-from-trap)到用户模式程序的特别说明,以及一些指令,让操作系统告诉硬件陷阱表(trap table)在内存中的位置。

但是,我们仍然面临着一个挑战——如果用户希望执行某种特权操作(如从磁盘读取),应该怎么做?为了实现这一点,几乎所有的现代硬件都提供了用户程序执行系统调用的能力。系统调用是在Atlas [K+61,L78]等古老机器上开创的,它允许内核小心地向用户程序暴露某些关键功能,例如访问文件系统、创建和销毁进程、与其他进程通信,以及分配更多内存。大多数操作系统提供几百个调用(详见POSIX标准[P10])。

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

关键问题:如何在没有协作的情况下获得控制权即使进程不协作,操作系统如何获得CPU的控制权?操作系统可以做什么来确保流氓进程不会占用机器?答案很简单,许多年前构建计算机系统的许多人都发现了:时钟中断(timer interrupt)[M+63]。时钟设备可以编程为每隔几毫秒产生一次中断。

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

你可能有一个很自然的问题:上下文切换需要多长时间?甚至系统调用要多长时间?如果感到好奇,有一种称为lmbench [MS96]的工具,可以准确衡量这些事情,并提供其他一些可能相关的性能指标。

之前我们指出,在协作式抢占时,无限循环(以及类似行为)的唯一解决方案是重启(reboot)机器。虽然你可能会嘲笑这种粗暴的做法,但研究表明,重启(或在通常意义上说,重新开始运行一些软件)可能是构建强大系统的一个非常有用的工具[C+04]。

第7章 进程调度:介绍

许多人类活动也需要调度,而且许多关注点是一样的,包括像激光一样清楚的对效率的渴望。因此,我们的问题如下。

公平在调度系统中往往是矛盾的。例如,调度程序可以优化性能,但代价是以阻止一些任务运行,这就降低了公平。这个难题也告诉我们,生活并不总是完美的。

我们一起看一个简单的例子。想象一下,3个工作A、B和C在大致相同的时间(T到达时间 = 0)到达系统。因为FIFO必须将某个工作放在前面,所以我们假设当它们都同时到达时,A比B早一点点,然后B比C早到达一点点。假设每个工作运行10s。这些工作的平均周转时间(average turnaround time)是多少?从图7.1可以看出,A在10s时完成,B在20s时完成,C在30s时完成。因此,这3个任务的平均周转时间就是(10 + 20 + 30)/ 3 = 20

如图7.2所示,A先运行100s,B或C才有机会运行。因此,系统的平均周转时间是比较高的:令人不快的110s((100 + 110 + 120)/3 = 110)。

这个问题通常被称为护航效应(convoy effect)[B+79],一些耗时较少的潜在资源消费者被排在重量级的资源消费者之后。这个调度方案可能让你想起在杂货店只有一个排队队伍的时候,如果看到前面的人装满3辆购物车食品并且掏出了支票本,你感觉如何?这会等很长时间

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

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

然而,引入分时系统改变了这一切。现在,用户将会坐在终端前面,同时也要求系统的交互性好。因此,一个新的度量标准诞生了:响应时间(response time)。

为了解决这个问题,我们将介绍一种新的调度算法,通常被称为轮转(Round-Robin,RR)调度[K64]。基本思想很简单:RR在一个时间片(time slice,有时称为调度量子,scheduling quantum)内运行一个工作,然后切换到运行队列中的下一个任务,而不是运行一个任务直到结束。它反复执行,直到所有任务完成。因此,RR有时被称为时间切片(time-slicing)。

这并不奇怪,如果周转时间是我们的指标,那么RR确实是最糟糕的策略之一。直观地说,这应该是有意义的:RR所做的正是延伸每个工作,只运行每个工作一小段时间,就转向下一个工作。因为周转时间只关心作业何时完成,RR几乎是最差的,在很多情况下甚至比简单的FIFO更差。

,但是要以响应时间为代价。如果你重视公平性,则响应时间会较短,但会以周转时间为代价。这种权衡在系统中很常见。你不能既拥有你的蛋糕,又吃它

第9章 调度:比例份额

很简单:每隔一段时间,都会举行一次彩票抽奖,以确定接下来应该运行哪个进程。越是应该频繁运行的进程,越是应该拥有更多地赢得彩票的机会。

彩票调度最精彩的地方在于利用了随机性(randomness)。当你需要做出决定时,采用随机的方式常常是既可靠又简单的选择。

在这种情况下,如果一个进程知道它需要更多CPU时间,就可以增加自己的彩票,从而将自己的需求告知操作系统,这一切不需要与任何其他进程通信。

彩票调度中最不可思议的,或许就是实现简单。

将列表项按照彩票数递减排序。这个顺序并不会影响算法的正确性,但能保证用最小的迭代次数找到需要的节点,尤其当大多数彩票被少数进程掌握时。

可以看出,当工作执行时间很短时,平均不公平度非常糟糕。只有当工作执行非常多的时间片时,彩票调度算法才能得到期望的结果。

假设用户自己知道如何分配,因此可以给每个用户一定量的彩票,由用户按照需要自主分配给自己的工作。然而这种方案似乎什么也没有解决——还是没有给出具体的分配策略。因此对于给定的一组工作,彩票分配的问题依然没有最佳答案。

步长调度(stride scheduling),一个确定性的公平分配算法

之后,调度程序使用进程的步长及行程值来确定调度哪个进程。基本思路很简单:当需要进行调度时,选择目前拥有最小行程值的进程,并且在运行之后将该进程的行程值增加一个步长。

可以看出,C运行了5次、A运行了2次,B一次,正好是票数的比例——200、100和50。彩票调度算法只能一段时间后,在概率上实现比例,而步长调度算法可以在每个调度周期后做到完全正确。

彩票调度有一个步长调度没有的优势——不需要全局状态

因此彩票调度算法能够更合理地处理新加入的进程。

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

本章将介绍多处理器调度(multiprocessor scheduling)的基础知识。由于本章内容相对较深,建议认真学习并发相关的内容后再读。

为了解决这个问题,不得不重写这些应用程序,使之能并行(parallel)执行,也许使用多线程

因此,建议不按顺序学习这些高级章节。对于本章,建议在本书第2部分之后学习。

除了应用程序,操作系统遇到的一个新的问题是(不奇怪!)多处理器调度(multiprocessor scheduling)。

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

程序第一次读取数据时,数据在内存中,因此需要花费较长的时间(可能数十或数百纳秒)。处理器判断该数据很可能会被再次使用,因此将其放入CPU缓存中。如果之后程序再次需要使用同样的数据,CPU会先查找缓存。因为在缓存中找到了数据,所以取数据快得多(比如几纳秒),程序也就运行更快。

缓存是基于局部性(locality)的概念,局部性有两种,即时间局部性和空间局部性

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

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

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

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

跨CPU访问(尤其是写入)共享数据或数据结构时,需要使用互斥原语(比如锁),才能保证正确性(其他方法,如使用无锁(lock-free)数据结构,很复杂,偶尔才使用。

例如,假设多CPU并发访问一个共享队列。如果没有锁,即使有底层一致性协议,并发地从队列增加或删除元素,依然不会得到预期结果。需要用锁来保证数据结构状态更新的原子性。

这里只需要一个互斥锁(即pthread_mutex_t m;),然后在函数开始时调用lock(&m),在结束时调用unlock(&m),确保代码的执行如预期。我们会看到,这里依然有问题,尤其是性能方面。具体来说,随着CPU数量的增加,访问同步共享的数据结构会变得很慢。

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

由于每个CPU都简单地从全局共享的队列中选取下一个工作执行,因此每个工作都不断在不同CPU之间转移,这与缓存亲和的目标背道而驰。

我们看到,SQMS调度方式有优势也有不足。优势是能够从单CPU调度程序很简单地发展而来,根据定义,它只有一个队列。然而,它的扩展性不好(由于同步开销有限),并且不能很好地保证缓存亲和度。

我们称之为多队列多处理器调度(Multi-Queue Multiprocessor Scheduling,MQMS)

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

所以可怜的多队列多处理器调度程序应该怎么办呢?怎样才能克服潜伏的负载不均问题,打败邪恶的……霸天虎军团?如何才能不要问这些与这本好书几乎无关的问题?

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

当然,还有其他不同的迁移模式。但现在是最棘手的部分:系统如何决定发起这样的迁移?

工作窃取(work stealing)

工作量较少

找到合适的阈值仍然是黑魔法,这在系统策略设计中很常见。

有趣的是,在构建多处理器调度程序方面,Linux社区一直没有达成共识。一直以来,存在3种不同的调度程序:O(1)调度程序、完全公平调度程序(CFS)以及BF调度程序(BFS)[插图]。

本章介绍了多处理器调度的不同方法。其中单队列的方式(SQMS)比较容易构建,负载均衡较好,但在扩展性和缓存亲和度方面有着固有的缺陷。多队列的方式(MQMS)有很好的扩展性和缓存亲和度,但实现负载均衡却很困难,也更复杂。无论采用哪种方式,都没有简单的答案:构建一个通用的调度程序仍是一项令人生畏的任务

第11章 关于CPU虚拟化的总结对话

CPU虚拟化

似乎操作系统相当偏执。它希望确保控制机器。虽然它希望程序能够尽可能高效地运行 [因此也是受限直接执行(limited directexecution)背后的全部逻辑],但操作系统也希望能够对错误或恶意的程序说“啊!别那么快,我的朋友”。偏执狂全天控制,并且确保操作系统控制机器。也许这就是我们将操作系统视为资源管理器的原因。

也许有点明显,但明显也可以是很好。比如将短工作提升到队列前面的想法:自从有一次我在商店买一些口香糖,我就知道这是一个好主意,而且我面前的那个人有一张无法支付的信用卡。我要说的是,他不是“短工作”。

毕竟,即使我们自己的度量指标也不一致。如果你的调度程序周转时间好,那么在响应时间就会很糟糕,反之亦然。正如Lampson说的,也许目标不是找到最好的解决方案,而是为了避免灾难。

但你不就是这样想的吗?让我们对某件事感到兴奋,这样我们就会自己对它进行研究?点燃火,仅此而已?

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

内存虚拟化

虚拟内存很复杂,需要我们理解关于硬件和操作系统交互方式的更多复杂细节。

TLB

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

操作系统会让每个程序觉得,它有一个很大的连续地址空间(address space)来放入其代码和数据。因此,作为一名程序员,您不必担心诸如“我应该在哪里存储这个变量?”这样的事情,因为程序的虚拟地址空间很大,有很多空间可以存代码和数据。对于程序员来说,如果必须操心将所有的代码数据放入一个小而拥挤的内存,那么生活会变得痛苦得多。

隔离(isolation)和保护(protection)也是大事。我们不希望一个错误的程序能够读取或者覆写其他程序的内存,对吗?

除非它是由你不喜欢的人编写的程序。

但请记住,不是我对大家说,对于错误的进程行为,正确的操作系统反应是要“杀死”违规进程!

第13章 抽象:地址空间

然而一些烦人的用户提出要“易于使用”“高性能”“可靠性”等,这导致了所有这些令人头痛的问题。下次你见到这些用户的时候,应该感谢他们,他们是这些问题的根源。

随着时分共享变得更流行,人们对操作系统又有了新的要求。特别是多个程序同时驻留在内存中,使保护(protection)成为重要问题。人们不希望一个进程可以读取其他进程的内存,更别说修改了。

然而,我们必须将这些烦人的用户的需求放在心上。因此操作系统需要提供一个易用(easy to use)的物理内存抽象。这个抽象叫作地址空间(address space),是运行的程序看到的系统中的内存。理解这个基本的操作系统内存抽象,是了解内存虚拟化的关键。

一个进程的地址空间包含运行的程序的所有内存状态。

当程序在运行的时候,利用栈(stack)来保存当前的函数调用信息,分配空间给局部变量,传递参数和函数返回值。最后,堆(heap)用于管理动态分配的、用户管理的内存,就像你从C语言中调用malloc()或面向对象语言(如C ++或Java)中调用new获得内存。

接下来,在程序运行时,地址空间有两个区域可能增长(或者收缩)。它们就是堆(在顶部)和栈(在底部)。把它们放在那里,是因为它们都希望能够增长。通过将它们放在地址空间的两端,我们可以允许这样的增长:它们只需要在相反的方向增长。因此堆在代码(1KB)之下开始并向下增长(当用户通过malloc()请求更多内存时),栈从16KB开始并向上增长(当用户进行程序调用时)。然而,堆栈和堆的这种放置方法只是一种约定,如果你愿意,可以用不同的方式安排地址空间 [稍后我们会看到,当多个线程(threads)在地址空间中共存时,就没有像这样分配空间的好办法了]。

当然,当我们描述地址空间时,所描述的是操作系统提供给运行程序的抽象(abstract)。程序不在物理地址0~16KB的内存中,而是加载在任意的物理地址。回顾图13.2中的进程A、B和C,你可以看到每个进程如何加载到内存中的不同地址。

操作系统如何在单一的物理内存上为多个运行的进程(所有进程共享内存)构建一个私有的、可能很大的地址空间的抽象?

当操作系统这样做时,我们说操作系统在虚拟化内存(virtualizing memory),因为运行的程序认为它被加载到特定地址(例如0)的内存中,并且具有非常大的地址空间(例如32位或64位)。现实很不一样。

这是内存虚拟化的关键,这是世界上每一个现代计算机系统的基础。

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

虚拟内存(VM)系统的一个主要目标是透明(transparency)。

虚拟内存的另一个目标是效率(efficiency)。

最后,虚拟内存第三个目标是保护(protection)。操作系统应确保进程受到保护(protect),不会受其他进程影响,操作系统本身也不会受进程影响

实际上,作为用户级程序的程序员,可以看到的任何地址都是虚拟地址。只有操作系统,通过精妙的虚拟化内存技术,知道这些指令和数据所在的物理内存的位置。

虚拟地址只是提供地址如何在内存中分布的假象,只有操作系统(和硬件)才知道物理地址。

所有这些地址都是虚拟的,并且将由操作系统和硬件翻译成物理地址,以便从真实的物理位置获取该地址的值。

虚拟化内存所需的基本机制(mechanism),包括硬件和操作系统的支持。我们还将研究一些较相关的策略(polic

我们介绍了操作系统的一个重要子系统:虚拟内存。虚拟内存系统负责为程序提供一个巨大的、稀疏的、私有的地址空间的假象,其中保存了程序的所有指令和数据。操作系统在专门硬件的帮助下,通过每一个虚拟内存的索引,将其转换为物理地址,物理内存根据获得的物理地址去获取所需的信息。操作系统会同时对许多进程执行此操作,并且确保程序之间互相不会受到影响,也不会影响操作系统。

第14章 插叙:内存操作API

关键问题:如何分配和管理内存
在UNIX/C程序中,理解如何分配和管理内存是构建健壮和可靠软件的重要基础。通常使用哪些接口?哪些错误需要避免?

第一种称为栈内存,它的申请和释放操作是编译器来隐式管理的,所以有时也称为自动(automatic)内存。

当你从该函数退出时,编译器释放内存。因此,如果你希望某些信息存在于函数调用之外,建议不要将它们放在栈上。

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

malloc函数非常简单:传入要申请的堆空间的大小,它成功就返回一个指向新申请空间的指针,失败就返回NULL。

从这段信息可以看到,只需要包含头文件 stdlib.h就可以使用malloc了。但实际上,甚至都不需这样做,因为C库是C程序默认链接的,其中就有mallock()的代码,加上这个头文件只是让编译器检查你是否正确调用了malloc()(即传入参数的数目正确且类型正确)。

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

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

在这种情况下,编译器有足够的静态信息,知道已经分配了40个字节。

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

强制类型转换实际上没干什么事,只是告诉编译器和其他可能正在读你的代码的程序员:“是的,我知道我在做什么。”通过强制转换malloc()的结果,程序员只是在给人一些信心,强制转换不是程序正确所必须的。

事实证明,分配内存是等式的简单部分。知道何时、如何以及是否释放内存是困难的部分。要释放不再使用的堆内存,程序员只需调用free():

因此,你可能会注意到,分配区域的大小不会被用户传入,必须由内存分配库本身记录追踪。

在这样的语言中,当你调用类似malloc()的机制来分配内存时(通常用new或类似的东西来分配一个新对象),你永远不需要调用某些东西来释放空间。实际上,垃圾收集器(garbage collector)会运行,找出你不再引用的内存,替你释放它。

提示:它编译过了或它运行了!=它对了
仅仅因为程序编译过了甚至正确运行了一次或多次,并不意味着程序是正确的。许多事件可能会让你相信它能工作,但是之后有些事情会发生变化,它停止了。学生常见的反应是说(或者叫喊)“但它以前是好的!”,然后责怪编译器、操作系统、硬件,甚至是(我们敢说)教授。但是,问题通常就像你认为的那样,在你的代码中。在指责别人之前,先撸起袖子调试一下。

奇怪的是,这个程序通常看起来会正确运行,这取决于如何实现malloc和许多其他细节。在某些情况下,当字符串拷贝执行时,它会在超过分配空间的末尾处写入一个字节,但在某些情况下,这是无害的,可能会覆盖不再使用的变量。在某些情况下,这些溢出可能具有令人难以置信的危害,实际上是系统中许多安全漏洞的来源[W06]。

请注意,使用垃圾收集语言在这里没有什么帮助:如果你仍然拥有对某块内存的引用,那么垃圾收集器就不会释放它,因此即使在较现代的语言中,内存泄露仍然是一个问题。

虽然这肯定“有效”(请参阅后面的补充),但这可能是一个坏习惯,所以请谨慎选择这样的策略。

长远来看,作为程序员的目标之一是养成良好的习惯。其中一个习惯是理解如何管理内存,并在C这样的语言中,释放分配的内存块。即使你不这样做也可以逃脱惩罚,建议还是养成习惯,释放显式分配的每个字节。

有时候程序会在用完之前释放内存,这种错误称为悬挂指针(dangling pointer)

free()期望你只传入之前从malloc()得到的一个指针。如果传入一些其他的值,坏事就可能发生(并且会发生)。

程序运行并即将完成:是否需要在退出前调用几次free()?虽然不释放似乎不对,但在真正的意义上,没有任何内存会“丢失”。原因很简单:系统中实际存在两级内存管理。
第一级是由操作系统执行的内存管理,操作系统在进程运行时将内存交给进程,并在进程退出(或以其他方式结束)时将其回收。第二级管理在每个进程中,例如在调用malloc()和free()时,在堆内管理。即使你没有调用free()(并因此泄露了堆中的内存),操作系统也会在程序结束运行时,收回进程的所有内存(包括用于代码、栈,以及相关堆的内存页)。无论地址空间中堆的状态如何,操作系统都会在进程终止时收回所有这些页面,从而确保即使没有释放内存,也不会丢失内存。

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

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

你要使用的第一个工具是调试器gdb。关于这个调试器有很多需要了解的知识,在这里,我们只是浅尝辄止。
你要使用的第二个工具是valgrind [SN05]。该工具可以帮助查找程序中的内存泄露和其他隐藏的内存问题。如果你的系统上没有安装,请访问valgrind网站并安装它。

第15章 机制:地址转换

在实现CPU虚拟化时,我们遵循的一般准则被称为受限直接访问(Limited Direct Execution,LDE)。LDE背后的想法很简单:让程序运行的大部分指令直接访问硬件,只在一些关键点(如进程发起系统调用或发生时钟中断)由操作系统介入来确保“在正确时间,正确的地点,做正确的事”。

高效和控制是现代操作系统的两个主要目标。

要保护应用程序不会相互影响,也不会影响操作系统,我们需要硬件的帮助。

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

当然,仅仅依靠硬件不足以实现虚拟内存,因为它只是提供了底层机制来提高效率。操作系统必须在关键的位置介入,设置好硬件,以便完成正确的地址转换。

同样,所有这些工作都是为了创造一种美丽的假象:每个程序都拥有私有的内存,那里存放着它自己的代码和数据。虚拟现实的背后是丑陋的物理事实:许多程序其实是在同一时间共享着内存,就像CPU(或多个CPU)在不同的程序间切换运行。通过虚拟化,操作系统(在硬件的帮助下)将丑陋的机器现实转化成一种有用的、强大的、易于使用的抽象。

具体来说,我们先假设用户的地址空间必须连续地放在物理内存中。同时,为了简单,我们假设地址空间不是很大,具体来说,小于物理内存的大小。最后,假设每个地址空间的大小完全一样。别担心这些假设听起来不切实际,我们会逐步地放宽这些假设,从而得到现实的内存虚拟化。

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

然而,对虚拟内存来说,操作系统希望将这个进程地址空间放在物理内存的其他位置,并不一定从地址0开始。因此我们遇到了如下问题:怎样在内存中重定位这个进程,同时对该进程透明(transparent)?怎么样提供一种虚拟地址空间从0开始的假象,而实际上地址空间位于另外某个物理地址?

具体来说,每个CPU需要两个硬件寄存器:基址(base)寄存器和界限(bound)寄存器,有时称为限制(limit)寄存器。这组基址和界限寄存器,让我们能够将地址空间放在物理内存的任何位置,同时又能确保进程只能访问自己的地址空间。

在早期,在硬件支持重定位之前,一些系统曾经采用纯软件的重定位方式。基本技术被称为静态重定位(static relocation),其中一个名为加载程序(loader)的软件接手将要运行的可执行程序,将它的地址重写到物理内存中期望的偏移位置。

然而,静态重定位有许多问题,首先也是最重要的是不提供访问保护,进程中的错误地址可能导致对其他进程或操作系统内存的非法访问,一般来说,需要硬件支持来实现真正的访问保护[WL+93]。静态重定位的另一个缺点是一旦完成,稍后很难将内存空间重定位到其他位置 [M65]。

进程中使用的内存引用都是虚拟地址(virtual address),硬件接下来将虚拟地址加上基址寄存器中的内容,得到物理地址(physical address),再发给内存系统。

128: movl 0x0(%ebx), %eax
程序计数器(PC)首先被设置为128。当硬件需要获取这条指令时,它先将这个值加上基址寄存器中的32KB(32768),得到实际的物理地址32896,然后硬件从这个物理地址获取指令。接下来,处理器开始执行该指令。这时,进程发起从虚拟地址15KB的加载,处理器同样将虚拟地址加上基址寄存器内容(32KB),得到最终的物理地址47KB,从而获得需要的数据

将虚拟地址转换为物理地址,这正是所谓的地址转换(address translation)技术

由于这种重定位是在运行时发生的,而且我们甚至可以在进程开始运行后改变其地址空间,这种技术一般被称为动态重定位

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

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

这种基址寄存器配合界限寄存器的硬件结构是芯片中的(每个CPU一对)。有时我们将CPU的这个负责地址转换的部分统称为内存管理单元(Memory Management Unit,MMU)。

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

硬件还必须提供基址和界限寄存器(base and bounds register),因此每个CPU的内存管理单元(Memory Management Unit,MMU)都需要这两个额外的寄存器。

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

最后,在用户程序尝试非法访问内存(越界访问)时,CPU必须能够产生异常(exception)。在这种情况下,CPU应该阻止用户程序的执行,并安排操作系统的“越界”异常处理程序(exception handler)去处理。操作系统的处理程序会做出正确的响应,比如在这种情况下终止进程。

第一,在进程创建时,操作系统必须采取行动,为进程的地址空间找到内存空间。

第二,在进程终止时(正常退出,或因行为不端被强制终止),操作系统也必须做一些工作,回收它的所有内存,给其他进程或者操作系统使用。

第三,在上下文切换时,操作系统也必须执行一些额外的操作

需要注意,当进程停止时(即没有运行),操作系统可以改变其地址空间的物理位置,这很容易。要移动进程的地址空间,操作系统首先让进程停止运行,然后将地址空间拷贝到新位置,最后更新保存的基址寄存器(在进程结构中),指向新位置。当该进程恢复执行时,它的(新)基址寄存器会被恢复,它再次开始运行,显然它的指令和数据都在新的内存位置了。

第四,操作系统必须提供异常处理程序(exception handler),或要一些调用的函数,像上面提到的那样。

再见了,行为不端的进程,很高兴认识你。

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

从表中可以看出,我们仍然遵循受限直接访问(limited direct execution)的基本方法,大多数情况下,操作系统正确设置硬件后,就任凭进程直接运行在CPU上,只有进程行为不端时才介入。

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

没有保护,操作系统不可能控制机器(如果进程可以随意修改内存,它们就可以轻松地做出可怕的事情,比如重写陷阱表并完全接管系统)。

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

第16章 分段

栈和堆之间,有一大块“空闲”空间。

关键问题:怎样支持大地址空间
怎样支持大地址空间,同时栈和堆之间(可能)有大量空闲空间?在之前的例子里,地址空间非常小,所以这种浪费并不明显。但设想一个32位(4GB)的地址空间,通常的程序只会使用几兆的内存,但需要整个地址空间都放在内存中。

这个想法很简单,在MMU中引入不止一个基址和界限寄存器对,而是给地址空间内的每个逻辑段(segment)一对。一个段只是地址空间里的一个连续定长的区域,在典型的地址空间里有3个逻辑不同的段:代码、栈和堆。分段的机制使得操作系统能够将不同的段放到不同的物理内存区域,从而避免了虚拟地址空间中的未使用部分占用物理内存。

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

利用图16.1中的地址空间,我们来看一个地址转换的例子。假设现在要引用虚拟地址100(在代码段中),MMU将基址值加上偏移量(100)得到实际的物理地址:100 + 32KB = 32868。然后它会检查该地址是否在界限内(100小于2KB),发现是的,于是发起对物理地址32868的引用。

补充:段错误
段错误指的是在支持分段的机器上发生了非法的内存访问。有趣的是,即使在不支持分段的机器上这个术语依然保留。但如果你弄不清楚为什么代码老是出错,就没那么有趣了。

因为堆从虚拟地址4K(4096)开始,4200的偏移量实际上是4200减去4096,即104,然后用这个偏移量(104)加上基址寄存器中的物理地址(34KB),得到真正的物理地址34920

在我们之前的例子中,有3个段,因此需要两位来标识。如果我们用14位虚拟地址的前两位来标识,那么虚拟地址如下所示:

你或许已经注意到,上面使用两位来区分段,但实际只有3个段(代码、堆、栈),因此有一个段的地址空间被浪费。因此有些系统中会将堆和栈当作同一个段,因此只需要一位来做标识[LL82]。

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

到目前为止,我们一直没有讲地址空间中的一个重要部分:栈。在表16.1中,栈被重定位到物理地址28KB。但有一点关键区别,它反向增长。在物理内存中,它始于28KB,增长回到26KB,相应虚拟地址从16KB到14KB。地址转换必须有所不同。

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

在这个例子中,假设要访问虚拟地址15KB,它应该映射到物理地址27KB。该虚拟地址的二进制形式是:11 1100 0000 0000(十六进制0x3C00)。硬件利用前两位(11)来指定段,但然后我们要处理偏移量3KB。为了得到正确的反向偏移,我们必须从3KB中减去最大的段地址:在这个例子中,段可以是4KB,因此正确的偏移量是3KB减去4KB,即−1KB。只要用这个反向偏移量(−1KB)加上基址(28KB),就得到了正确的物理地址27KB。用户可以进行界限检查,确保反向偏移量的绝对值小于段的大小。

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

可以看到,代码段的权限是可读和可执行,因此物理内存中的一个段可以映射到多个虚拟地址空间。

支持许多段需要进一步的硬件支持,并在内存中保存某种段表(segment table)。

然而,分段也带来了一些新的问题。我们先介绍必须关注的操作系统新问题。第一个是老问题:操作系统在上下文切换时应该做什么?你可能已经猜到了:各个段寄存器中的内容必须保存和恢复。显然,每个进程都有自己独立的虚拟地址空间,操作系统必须在进程运行前,确保这些寄存器被正确地赋值。

一般会遇到的问题是,物理内存很快充满了许多空闲空间的小洞,因而很难分配给新的段,或扩大已有的段。这种问题被称为外部碎片(external fragmentation)[R69],如图16.3(左边)所示。

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

一种更简单的做法是利用空闲列表管理算法,试图保留大的内存块用于分配。

Wilson等人做过一个很好的调查[W+95],如果你想对这些算法了解更多,可以从它开始,或者等到第17 章,我们将介绍一些基本知识。

无论算法多么精妙,都无法完全消除外部碎片,因此,好的算法只是试图减小它。

唯一真正的解决办法就是(我们会在后续章节看到),完全避免这个问题,永远不要分配不同大小的内存块。

第17章 空闲空间管理

空闲空间管理(free-space management)

管理空闲空间当然可以很容易,我们会在讨论分页概念时看到。如果需要管理的空间被划分为固定大小的单元,就很容易。在这种情况下,只需要维护这些大小固定的单元的列表,如果有请求,就返回列表中的第一项。

如果要管理的空闲空间由大小不同的单元构成,管理就变得困难(而且有趣)。这种情况出现在用户级的内存分配库(如malloc()和free()),或者操作系统用分段(segmentation)的方式实现虚拟内存。

关键问题:如何管理空闲空间要满足变长的分配请求,应该如何管理空闲空间?什么策略可以让碎片最小化?不同方法的时间和空间开销如何?

void *malloc(size t size)需要一个参数size,它是应用程序请求的字节数。函数返回一个指针(没有具体的类型,在C语言的术语中是void类型),指向这样大小(或较大一点)的一块空间。

请注意该接口的隐含意义,在释放空间时,用户不需告知库这块空间的大小。因此,在只传入一个指针的情况下,库必须能够弄清楚这块内存的大小。

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

当然,分配程序也可能有内部碎片(internal fragmentation)的问题。如果分配程序给出的内存块超出请求的大小,在这种块中超出请求的空间(因此而未使用)就被认为是内部碎片(因为浪费发生在已分配单元的内部),这是另一种形式的空间浪费。

在一些情况下,分配程序可以要求这块区域增长。例如,一个用户级的内存分配库在空间快用完时,可以向内核申请增加堆空间(通过sbrk这样的系统调用),但是,简单起见,我们假设这块区域在整个生命周期内大小固定。

空闲列表包含一组元素,记录了堆中的哪些空间还没有分配。假设有下面的30字节的堆:[插图]这个堆对应的空闲列表会有两个元素,一个描述第一个10字节的空闲区域(字节0~9),一个描述另一个空闲区域(字节20~29)

通过上面的介绍可以看出,任何大于10字节的分配请求都会失败(返回NULL),因为没有足够的连续可用空间。而恰好10字节的需求可以由两个空闲块中的任何一个满足。但是,如果申请小于10字节空间,会发生什么?假设我们只申请一个字节的内存。这种情况下,分配程序会执行所谓的分割(splitting)动作:它找到一块可以满足请求的空闲空间,将其分割,第一块返回给用户,第二块留在空闲列表中。在我们的例子中,假设这时遇到申请一个字节的请求,分配程序选择使用第二块空闲空间,对malloc()的调用会返回20(1字节分配区域的地址),空闲列表会变成这样

许多分配程序中因此也有一种机制,名为合并(coalescing)。

为了避免这个问题,分配程序会在释放一块内存时合并可用空间。想法很简单:在归还一块空闲内存时,仔细查看要归还的内存块的地址以及邻近的空闲空间块。如果新归还的空间与一个原有空闲块相邻(或两个,就像这个例子),就将它们合并为一个较大的空闲块。

请注意前一句话中一个小但重要的细节:实际释放的是头块大小加上分配给用户的空间的大小。因此,如果用户请求N字节的内存,库不是寻找大小为N的空闲块,而是寻找N加上头块大小的空闲块。

你需要在空闲空间本身中建立空闲空间列表。虽然听起来有点奇怪,但别担心,这是可以做到的。

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

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

在阅读之前试试,你是否能想出所有的选择(也许还有新策略!)。

最优匹配最优匹配(best fit)策略非常简单:首先遍历整个空闲列表,找到和请求大小一样或更大的空闲块,然后返回这组候选者中最小的一块。这就是所谓的最优匹配(也可以称为最小匹配)。只需要遍历一次空闲列表,就足以找到正确的块并返回。

最差匹配最差匹配(worst fit)方法与最优匹配相反,它尝试找最大的空闲块,分割并满足用户需求后,将剩余的块(很大)加入空闲列表。最差匹配尝试在空闲列表中保留较大的块,而不是向最优匹配那样可能剩下很多难以利用的小块。但是,最差匹配同样需要遍历整个空闲列表。

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

补充:了不起的工程师真的了不起像Jeff Bonwick这样的工程师(Jeff Bonwick不仅写了上面提到的厚块分配程序,还是令人惊叹的文件系统ZFS的负责人),是硅谷的灵魂。在每一个伟大的产品或技术后面都有这样一个人(或一小群人),他们的天赋、能力和奉献精神远超众人。Facebook的Mark Zuckerberg曾经说过:“那些在自己的领域中超凡脱俗的人,比那些相当优秀的人强得不是一点点。”这就是为什么,会有人成立自己的公司,然后永远地改变了这个世界(想想Google、Apple和Facebook)。努力工作,你也可能成为这种“以一当百”的人。做不到的话,就和这样的人一起工作,你会明白什么是“听君一席话,胜读十年书”。如果都做不到,那就太难过了。

因此,更先进的分配程序采用更复杂的数据结构来优化这个开销,牺牲简单性来换取性能。例子包括平衡二叉树、伸展树和偏序树[W+95]。

第18章 分页:介绍

第一种是将空间分割成不同长度的分片,就像虚拟内存管理中的分段。遗憾的是,这个解决方法存在固有的问题。具体来说,将空间切成不同长度的分片以后,空间本身会碎片化(fragmented),随着时间推移,分配内存会变得比较困难。

因此,值得考虑第二种方法:将空间分割成固定长度的分片。在虚拟内存中,我们称这种思想为分页,可以追溯到一个早期的重要系统,Atlas[KE+62, L78]。分页不是将一个进程的地址空间分割成几个不同长度的逻辑段(即代码、堆、段),而是分割成固定大小的单元,每个单元称为一页。相应地,我们把物理内存看成是定长槽块的阵列,叫作页帧(page frame)。

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

为了记录地址空间的每个虚拟页放在物理内存中的位置,操作系统通常为每个进程保存一个数据结构,称为页表(page table)。页表的主要作用是为地址空间的每个虚拟页面保存地址转换(address translation),从而让我们知道每个页在物理内存中的位置。对于我们的简单示例(见图18.2),页表因此具有以下4个条目:(虚拟页0→物理帧3)、(VP 1→PF 7)、(VP 2→PF 5)和(VP 3→PF 2)。

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

因此,虚拟地址“21”在虚拟页“01”(或1)的第5个(“0101”)字节处。通过虚拟页号,我们现在可以检索页表,找到虚拟页1所在的物理页面。在上面的页表中,物理帧号(PFN)(有时也称为物理页号,physical page number或PPN)是7(二进制111)。因此,我们可以通过用PFN替换VPN来转换此虚拟地址,然后将载入发送给物理内存(见图18.3)。

例如,想象一个典型的32位地址空间,带有4KB的页。这个虚拟地址分成20位的VPN和12位的偏移量(回想一下,1KB的页面大小需要10位,只需增加两位即可达到4KB)。

一个20位的VPN意味着,操作系统必须为每个进程管理220个地址转换(大约一百万)。假设每个页表格条目(PTE)需要4个字节,来保存物理地址转换和任何其他有用的东西,每个页表就需要巨大的4MB内存!这非常大。现在想象一下有100个进程在运行:这意味着操作系统会需要400MB内存,只是为了所有这些地址转换!

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

现在你应该可以看到,有两个必须解决的实际问题。如果不仔细设计硬件和软件,页表会导致系统运行速度过慢,并占用太多内存。虽然看起来是内存虚拟化需求的一个很好的解决方案,但这两个关键问题必须先克服。

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

我们现在准备好跟踪程序的内存引用了。

别担心:它肯定会变得更糟,因为我们即将引入的机制只会使这个已经很复杂的机器更复杂。

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

因为这些映射信息一般存储在物理内存中,所以在转换虚拟地址时,分页逻辑上需要一次额外的内存访问。

关键问题:如何加速地址转换如何才能加速虚拟地址转换,尽量避免额外的内存访问?需要什么样的硬件支持?操作系统该如何支持?

想让某些东西更快,操作系统通常需要一些帮助。帮助常常来自操作系统的老朋友:硬件。我们要增加所谓的(由于历史原因[CP78])地址转换旁路缓冲存储器(translation-lookaside buffer,TLB[CG68,C95]),它就是频繁发生的虚拟到物理地址转换的硬件缓存(cache)。因此,更好的名称应该是地址转换缓存(address-translation cache)。

TLB带来了巨大的性能提升,实际上,因此它使得虚拟内存成为可能[C95]。

硬件算法的大体流程如下:首先从虚拟地址中提取页号(VPN)(见图19.1第1行),然后检查TLB是否有该VPN的转换映射(第2行)。如果有,我们有了TLB命中(TLB hit),这意味着TLB有该页的转换映射。成功!接下来我们就可以从相关的TLB项中取出页帧号(PFN),与原来虚拟地址中的偏移量组合形成期望的物理地址(PA),并访问内存(第5~7行),假定保护检查没有失败(第4行)。

如果这经常发生,程序的运行就会显著变慢。相对于大多数CPU指令,内存访问开销很大,TLB未命中导致更多内存访问。因此,我们希望尽可能避免TLB未命中。

在本例中,假设有一个由10个4字节整型数组成的数组,起始虚地址是100。进一步假定,有一个8位的小虚地址空间,页大小为16B。我们可以把虚地址划分为4位的VPN(有16个虚拟内存页)和4位的偏移量(每个页中有16个字节)。

因为数组的第二个元素在第一个元素之后,它们在同一页。因为我们之前访问数组的第一个元素时,已经访问了这一页,所以TLB中缓存了该页的转换映射。因此成功命中。访问a[2]同样成功(再次命中),因为它和a[0]、a[1]位于同一页。

即使这是程序首次访问该数组,但得益于空间局部性(spatial locality),TLB还是提高了性能。

典型页的大小一般为4KB,这种情况下,密集的、基于数组的访问会实现极好的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未命中的无限递归。

补充:RISC与CISC
在20世纪80年代,计算机体系结构领域曾发生过一场激烈的讨论。一方是CISC阵营,即复杂指令集计算(Complex Instruction Set Computing),另一方是RISC,即精简指令集计算(Reduced Instruction Set Computing)[PS81]。

CISC 指令集倾向于拥有许多指令,每条指令比较强大。例如,你可能看到一个字符串拷贝,它接受两个指针和一个长度,将一些字节从源拷贝到目标。CISC背后的思想是,指令应该是高级原语,这让汇编语言本身更易于使用,代码更紧凑。
RISC 指令集恰恰相反。RISC 背后的关键观点是,指令集实际上是编译器的最终目标,所有编译器实际上需要少量简单的原语,可以用于生成高性能的代码。因此,RISC倡导者们主张,尽可能从硬件中拿掉不必要的东西(尤其是微代码),让剩下的东西简单、统一、快速。

这些创新,加上每个芯片中晶体管数量的增长,让CISC保持了竞争力。争论最后平息了,现在两种类型的处理器都可以跑得很快。

当程序试图访问这样的页时,就会陷入操作系统,操作系统会杀掉该进程。

通过将所有TLB项设置为无效,系统可以确保将要运行的进程不会错误地使用前一个进程的虚拟到物理地址转换映射。

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

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

为了减少这种开销,一些系统增加了硬件支持,实现跨上下文切换的TLB共享。比如有的系统在TLB中添加了一个地址空间标识符(Address Space Identifier,ASID)。

如果仍以上面的TLB为例,加上 ASID,很清楚不同进程可以共享TLB了:只要ASID字段来区分原来无法区分的地址映射。

共享代码页(以二进制或共享库的方式)是有用的,因为它减少了物理页的使用,从而减少了内存开销。

具体来说,向TLB中插入新项时,会替换(replace)一个旧项,这样问题就来了:应该替换那一个?

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

VPN转换成最大24位的物理帧号(PFN),因此可以支持最多有64GB物理内存(224个4KB内存页)的系统。

MIPS TLB还有一些有趣的标识位。比如全局位(Global,G),用来指示这个页是不是所有进程全局共享的。因此,如果全局位置为1,就会忽略ASID。我们也看到了8位的ASID,操作系统用它来区分进程空间(像上面介绍的一样)。

如果正在运行的进程数超过256(28)个怎么办?

由于MIPS的TLB是软件管理的,所以系统需要提供一些更新TLB的指令。MIPS提供了4个这样的指令:TLBP,用来查找指定的转换映射是否在TLB中;TLBR,用来将TLB中的内容读取到指定寄存器中;TLBWI,用来替换指定的TLB项;TLBWR,用来随机替换一个TLB项。操作系统可以用这些指令管理TLB的内容。当然这些指令是特权指令,这很关键。如果用户程序可以任意修改TLB的内容,你可以想象一下会发生什么可怕的事情。

随机存取存储器(Random-Access Memory,RAM)

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

具体来说,如果一个程序短时间内访问的页数超过了TLB中的页数,就会产生大量的TLB未命中,运行速度就会变慢。这种现象被称为超出TLB覆盖范围(TLB coverage),这对某些程序可能是相当严重的问题。

对更大页的支持通常被数据库管理系统(Database Management System,DBMS)这样的程序利用,它们的数据结构比较大,而且是随机访问。

第20章 分页:较小的表

关键问题:如何让页表更小?简单的基于数组的页表(通常称为线性页表)太大,在典型系统上占用太多内存。如何让页表更小?关键的思路是什么?由于这些新的数据结构,会出现什么效率影响?

但是,如果一个“聪明的”应用程序请求它,则可以为地址空间的特定部分使用一个大型页(例如,大小为4MB),从而让这些应用程序可以将常用的(大型的)数据结构放入这样的空间,同时只占用一个TLB项。这种类型的大页在数据库管理系统和其他高端商业应用程序中很常见。

然而,正如研究人员已经说明[N+02]一样,采用多种页大小,使操作系统虚拟内存管理程序显得更复杂,因此,有时只需向应用程序暴露一个新接口,让它们直接请求大内存页,这样最容易。

在生活中,每当有两种合理但不同的方法时,你应该总是研究两者的结合,看看能否两全其美。我们称这种组合为杂合(hybrid)

例如,为什么只吃巧克力或简单的花生酱,而不是将两者结合起来,就像可爱的花生酱巧克力杯[M28]?

杂合方案的关键区别在于,每个分段都有界限寄存器,每个界限寄存器保存了段中最大有效页的值。

当你有两个看似相反的好主意时,你应该总是看到你是否可以将它们组合成一个能够实现两全其美的杂合体(hybrid)。

当然,并非所有的杂合都是好主意,请参阅Zeedonk(或Zonkey),它是斑马和驴的杂交。

但是,你可能会注意到,这种方法并非没有问题。首先,它仍然要求使用分段。正如我们讨论的那样,分段并不像我们需要的那样灵活,因为它假定地址空间有一定的使用模式。

我们将这种方法称为多级页表(multi-level page table),因为它将线性页表变成了类似树的东西。这种方法非常有效,许多现代系统都用它(例如x86 [BOH10])。

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

因此,你可以形象地看到多级页表的工作方式:它只是让线性页表的一部分消失(释放这些帧用于其他用途),并用页目录来记录页表的哪些页被分配。

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

在构建数据结构时,应始终考虑时间和空间的折中(time-space trade-off)。通常,如果你希望更快地访问特定的数据结构,就必须为该结构付出空间的代价。

应该指出,多级页表是有成本的。在TLB未命中时,需要从内存加载两次,才能从页表中获取正确的地址转换信息(一次用于页目录,另一次用于PTE本身),而用线性页表只需要一次加载。因此,多级表是一个时间—空间折中(time-spacetrade-off)的小例子。

设想一个大小为16KB的小地址空间,其中包含64个字节的页。因此,我们有一个14位的虚拟地址空间,VPN有8位,偏移量有6位。

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

从图中可以看到,在任何复杂的多级页表访问发生之前,硬件首先检查TLB。在命中时,物理地址直接形成,而不像之前一样访问页表。只有在TLB未命中时,硬件才需要执行完整的多级查找。在这条路径上,可以看到传统的两级页表的成本:两次额外的内存访问来查找有效的转换映射。

在反向页表(inverted page table)中,可以看到页表世界中更极端的空间节省。

更一般地说,反向页表说明了我们从一开始就说过的内容:页表只是数据结构。你可以对数据结构做很多疯狂的事情,让它们更小或更大,使它们变得更慢或更快。多层和反向页表只是人们可以做的很多事情的两个例子。

内核虚拟内存(kernelvirtual memory)

在一个内存受限的系统中(像很多旧系统一样),小结构是有意义的。在具有较多内存,并且工作负载主动使用大量内存页的系统中,用更大的页表来加速TLB未命中处理,可能是正确的选择。有了软件管理的TLB,数据结构的整个世界开放给了喜悦的操作系统创新者(提示:就是你)。

当你入睡时想想这些问题,做一个只有操作系统开发人员才能做的大梦。

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

地址空间

为了达到这个目的,操作系统需要记住给定页的硬盘地址(disk address)。

简单起见,现在假设它非常大

即使通过这个小例子,你应该也能看出,使用交换空间如何让系统假装内存比实际物理内存更大。

简单起见,假设有一个硬件管理TLB的系统。

硬件(或操作系统,在软件管理TLB时)判断是否在内存中的方法,是通过页表项中的一条新信息,即存在位(present bit)。如果存在位设置为1,则表示该页存在于物理内存中,并且所有内容都如上所述进行。如果存在位设置为零,则页不在内存中,而在硬盘上。访问不在物理内存中的页,这种行为通常被称为页错误(page fault)。

但是通常,当人们说一个程序“页错误”时,意味着它正在访问的虚拟地址空间的一部分,被操作系统交换到了硬盘上。

我们怀疑这种行为之所以被称为“错误”,是因为操作系统中的处理机制。当一些不寻常的事情发生的时候,即硬件不知道如何处理的时候,硬件只是简单地把控制权交给操作系统,希望操作系统能够解决。在这种情况下,进程想要访问的页不在内存中。硬件唯一能做的就是触发异常,操作系统从开始接管。由于这与进程执行非法操作处理流程一样,所以我们把这个活动称为“错误”,这也许并不奇怪。

页错误处理程序(page-fault handler)

当操作系统接收到页错误时,它会在PTE中查找地址,并将请求发送到硬盘,将页读取到内存中。

补充:为什么硬件不能处理页错误我们从TLB的经验中得知,硬件设计者不愿意信任操作系统做所有事情。那么为什么他们相信操作系统来处理页错误呢?有几个主要原因。首先,页错误导致的硬盘操作很慢。即使操作系统需要很长时间来处理故障,执行大量的指令,但相比于硬盘操作,这些额外开销是很小的。其次,为了能够处理页故障,硬件必须了解交换空间,如何向硬盘发起I/O操作,以及很多它当前所不知道的细节。因此,由于性能和简单的原因,操作系统来处理页错误,即使硬件人员也很开心。

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

选择哪些页被交换出或被替换(replace)的过程,被称为页交换策略(page-replacement policy)。

换言之,如果有人问你:“当程序从内存中读取数据会发生什么?”,你应该对所有不同的可能性有了很好的概念。

从图21.2的硬件控制流图中,可以注意到当TLB 未命中发生的时候有3种重要情景。第一种情况,该页存在(present)且有效(valid)(第18~21行)。在这种情况下,TLB未命中处理程序可以简单地从PTE中获取PFN,然后重试指令(这次TLB会命中),并因此继续前面描述的流程。第二种情况(第22~23行),页错误处理程序需要运行。虽然这是进程可以访问的合法页(毕竟是有效的),但它并不在物理内存中。第三种情况,访问的是一个无效页,可能由于程序中的错误(第13~14行)。在这种情况下,PTE中的其他位都不重要了。硬件捕获这个非法访问,操作系统陷阱处理程序运行,可能会杀死非法进程。

高水位线(HighWatermark,HW)

低水位线(Low Watermark,LW)

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

第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。

在现代系统中,磁盘访问的成本非常高,即使很小概率的未命中也会拉低正在运行的程序的总体AMAT。

为了更好地理解一个特定的替换策略是如何工作的,将它与最好的替换策略进行比较是很好的方法。

Belady展示了一个简单的方法(但遗憾的是,很难实现!),即替换内存中在最远将来才会被访问到的页,可以达到缓存未命中率最低。

因此,在你进行的任何研究中,知道最优策略可以方便进行对比,知道你的策略有多大的改进空间,也用于决定当策略已经非常接近最优策略时,停止做无谓的优化[AD03]。

希望最优策略背后的想法你能理解。这样想:如果你不得不踢出一些页,为什么不踢出在最远将来才会访问的页呢?这样做基本上是说,缓存中所有其他页都比这个页重要。道理很简单:在引用最远将来会访问的页之前,你肯定会引用其他页。

补充:缓存未命中的类型
在计算机体系结构世界中,架构师有时会将未命中分为3类:强制性、容量和冲突未命中,有时称为3C [H87]。发生强制性(compulsory miss)未命中(或冷启动未命中,cold-start miss [EF78])是因为缓存开始是空的,而这是对项目的第一次引用。与此不同,由于缓存的空间不足而不得不踢出一个项目以将新项目引入缓存,就发生了容量未命中(capacity miss)。第三种类型的未命中(冲突未命中,conflict miss)出现在硬件中,因为硬件缓存中对项的放置位置有限制,这是由于所谓的集合关联性(set-associativity)。它不会出现在操作系统页面缓存中,因为这样的缓存总是完全关联的(fully-associative),即对页面可以放置的内存位置没有限制。

遗憾的是,正如我们之前在开发调度策略时所看到的那样,未来的访问是无法知道的,你无法为通用操作系统实现最优策略。

对比FIFO和最优策略,FIFO明显不如最优策略,FIFO命中率只有36.4%(不包括强制性未命中为57.1%)。先进先出(FIFO)根本无法确定页的重要性:即使页0已被多次访问,FIFO仍然会将其踢出,因为它是第一个进入内存的。

LRU具有所谓的栈特性(stack property)[M+70]。对于具有这个性质的算法,大小为N + 1的缓存自然包括大小为N的缓存的内容。因此,当增加缓

页替换策略可以使用的一个历史信息是频率(frequency)。如果一个页被访问了很多次,也许它不应该被替换,因为它显然更有价值。页更常用的属性是访问的近期性(recency),越近被访问过的页,也许再次访问的可能性也就越大。
这一系列的策略是基于人们所说的局部性原则(principle of locality)[D70],基本上只是对程序及其行为的观察。这个原理简单地说就是程序倾向于频繁地访问某些代码(例如循环)和数据结构(例如循环访问的数组)。

因此,尽管在设计任何类型的缓存(硬件或软件)时,局部性都是一件好事,但它并不能保证成功。相反,它是一种经常证明在计算机系统设计中有用的启发式方法。

最后,你可以看到,最优策略的表现明显好于实际的策略。如果有可能的话,偷窥未来,就能做到更好的替换。

有一种方法有助于加快速度,就是增加一点硬件支持。

遗憾的是,随着系统中页数量的增长,扫描所有页的时间字段只是为了找到最精确最少使用的页,这个代价太昂贵。想象一下一台拥有4GB内存的机器,内存切成4KB的页。这台机器有一百万页,即使以现代CPU速度找到LRU页也将需要很长时间。这就引出了一个问题:我们是否真的需要找到绝对最旧的页来替换?找到差不多最旧的页可以吗?

这个想法需要硬件增加一个使用位(use bit,有时称为引用位,reference bit),这种做法在第一个支持分页的系统Atlas one-level store[KE + 62]中实现。

这样做的原因是:如果页已被修改(modified)并因此变脏(dirty),则踢出它就必须将它写回磁盘,这很昂贵。如果它没有被修改(因此是干净的,clean),踢出就没成本。物理帧可以简单地重用于其他目的而无须额外的I/O。因此,一些虚拟机系统更倾向于踢出干净页,而不是脏页。

为了支持这种行为,硬件应该包括一个修改位(modified bit,又名脏位,dirty bit)。每次写入页时都会设置此位,因此可以将其合并到页面替换算法中。

该策略有时称为页选择(page selection)策略(因为Denning这样命名[D70]),它向操作系统提供了一些不同的选项。

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

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

这种方法通常被称为准入控制(admission control),它表明,少做工作有时比尝试一下子做好所有事情更好,这是我们在现实生活中以及在现代计算机系统中经常遇到的情况(令人遗憾)。

目前的一些系统采用更严格的方法处理内存过载。例如,当内存超额请求时,某些版本的Linux会运行“内存不足的杀手程序(out-of-memory killer)”。这个守护进程选择一个内存密集型进程并杀死它,从而以不怎么委婉的方式减少内存。

所以,过度分页的最佳解决方案往往很简单:购买更多的内存。

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

在我们结束对虚拟内存的研究之前,让我们仔细研究一下VAX/VMS操作系统[LL82]的虚拟内存管理器,它特别干净漂亮。

该系统的操作系统被称为VAX/VMS(或者简单的VMS),其主要架构师之一是Dave Cutler,他后来领导开发了微软Windows NT [C93]。

关键问题:如何避免通用性“魔咒”操作系统常常有所谓的“通用性魔咒”问题,它们的任务是为广泛的应用程序和系统提供一般支持。其根本结果是操作系统不太可能很好地支持任何一个安装。VAX-11体系结构有许多不同的实现。那么,如何构建操作系统以便在各种系统上有效运行?

附带说一句,VMS是软件创新的很好例子,用于隐藏架构的一些固有缺陷。

VAX-11为每个进程提供了一个32位的虚拟地址空间,分为512字节的页。因此,虚拟地址由23位VPN和9位偏移组成。

基址寄存器

研究VMS有一个很好的方面,我们可以看到如何构建一个真正的地址空间(见图23.1)。

补充:为什么空指针访问会导致段错误你现在应该很好地理解一个空指针引用会发生什么。通过这样做,进程生成了一个虚拟地址0:[插图]硬件试图在TLB中查找VPN(这里也是0),遇到TLB未命中。查询页表,并且发现VPN 0的条目被标记为无效。因此,我们遇到无效的访问,将控制权交给操作系统,这可能会终止进程(在UNIX系统上,会向进程发出一个信号,让它们对这样的错误做出反应。但是如果信号未被捕获,则会终止进程)。

内核映射到每个地址空间,这有一些原因。这种结构使得内核的运转更轻松。例如,如果操作系统收到用户程序(例如,在write()系统调用中)递交的指针,很容易将数据从该指针处复制到它自己的结构。操作系统自然是写好和编译好的,无须担心它访问的数据来自哪里。相反,如果内核完全位于物理内存中,那么将页表的交换页切换到磁盘是非常困难的。如果内核被赋予了自己的地址空间,那么在用户应用程序和内核之间移动数据将再次变得复杂和痛苦。通过这种构造(现在广泛使用),内核几乎就像应用程序库一样,尽管是受保护的。

显然,操作系统不希望用户应用程序读取或写入操作系统数据或代码。因此,硬件必须支持页面的不同保护级别才能启用该功能。VAX通过在页表中的保护位中指定CPU访问特定页面所需的特权级别来实现此目的。因此,系统数据和代码被设置为比用户数据和代码更高的保护级别。

开发人员也担心会有“自私贪婪的内存”(memory hog)—— 一些程序占用大量内存,使其他程序难以运行。

聚集用于大多数现代系统,因为可以在交换空间的任意位置放置页,所以操作系统对页分组,执行更少和更大的写入,从而提高性能。

事实上,在20世纪80年代早期,Babaoglu和Joy表明,VAX上的保护位可以用来模拟引用位[BJ81]。其基本思路是:如果你想了解哪些页在系统中被活跃使用,请将页表中的所有页标记为不可访问(但请注意关于哪些页可以被进程真正访问的信息,也许在页表项的“保留的操作系统字段”部分)。

这种引用位“模拟”的关键是减少开销,同时仍能很好地了解页的使用。

VMS有另外两个现在成为标准的技巧:按需置零和写入时复制。

惰性(lazy)优化

提示:惰性惰性可以使得工作推迟,但出于多种原因,这在操作系统中是有益的。首先,推迟工作可能会减少当前操作的延迟,从而提高响应能力。例如,操作系统通常会报告立即写入文件成功,只是稍后在后台将其写入硬盘。其次,更重要的是,惰性有时会完全避免完成这项工作。例如,延迟写入直到文件被删除,根本不需要写入。

这个想法至少可以回溯到TENEX操作系统[BB+72],它很简单:如果操作系统需要将一个页面从一个地址空间复制到另一个地址空间,不是实际复制它,而是将其映射到目标地址空间,并在两个地址空间中将其标记为只读。如果两个地址空间都只读取页面,则不会采取进一步的操作,因此操作系统已经实现了快速复制而不实际移动任何数据。

通过改为执行写时复制的fork(),操作系统避免了大量不必要的复制,从而保留了正确的语义,同时提高了性能。

有一件事会让你感到惊讶:在诸如VAX/VMS这样的较早论文中看到的经典理念,仍然影响着现代操作系统的构建方式。

第24章 内存虚拟化总结对话

学生:我认为我现在对进程引用内存时会发生什么有了很好的概念,正如您多次说过的那样,每次获取指令以及显式加载和存储时都会发生。

学生:(继续)……是的,我知道你喜欢C,我也是!

我现在真的知道,我们在程序中可以观察到的所有地址都是虚拟地址。作为一名程序员,我只是看到了数据和代码在内存中的假象。我曾经认为能够打印指针的地址是很酷的,但现在我发现它令人沮丧——它只是一个虚拟地址!我看不到数据所在的真实物理地址。

教授:你看不到,操作系统肯定会向你隐藏的。

学生:还有一件更重要的事情:我了解到,地址转换结构需要足够灵活,以支持程序员想要处理的地址空间。在这个意义上,像多级表这样的结构是完美的。它们只在用户需要一部分地址空间时才创建表空间,因此几乎没有浪费。早期的尝试,比如简单的基址和界限寄存器,只是不够灵活。这些结构需要与用户期望和想要的虚拟内存系统相匹配。

学生:正如你所说的那样,最终解决策略问题的好办法很简单:购买更多的内存。但是你需要理解的机制才能知道事情是如何运作的。说到……

我再也不会交换到硬盘了——或者,如果发生交换,至少我会知道实际发生了什么!

第2部分 并发

永远不要忘记具体概念。好吧,事实证明,存在某些类型的程序,我们称之为多线程(multi-threaded)应用程序。每个线程(thread)都像在这个程序中运行的独立代理程序,代表程序做事。但是这些线程访问内存,对于它们来说,每个内存节点就像一个桃子。如果我们不协调线程之间的内存访问,程序将无法按预期工作。懂了吗?

首先,操作系统必须用锁(lock)和条件变量(condition variable)这样的原语,来支持多线程应用程序,我们很快会讨论。其次,操作系统本身是第一个并发程序——它必须非常小心地访问自己的内存,否则会发生许多奇怪而可怕的事情。真的,会变得非常可怕。

第26章 并发:介绍

目前为止,我们已经看到了操作系统提供的基本抽象的发展;也看到了如何将一个物理CPU变成多个虚拟CPU(virtual CPU),从而支持多个程序同时运行的假象;还看到了如何为每个进程创建巨大的、私有的虚拟内存(virtual memory)的假象,这种地址空间(address space)的抽象让每个程序好像拥有自己的内存,而实际上操作系统秘密地让多个地址空间复用物理内存(或者磁盘)。

线程(thread)

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

单个线程的状态与进程状态非常类似。

线程有一个程序计数器(PC),记录程序从哪里获取指令。每个线程有自己的一组用于计算的寄存器。所以,如果有两个线程运行在一个处理器上,从运行一个线程(T1)切换到另一个线程(T2)时,必定发生上下文切换(context switch)。

进程控制块(Process Control Block,PCB)

线程控制块(Thread Control Block,TCB)

与进程相比,线程之间的上下文切换有一点主要区别:地址空间保持不变(即不需要切换当前使用的页表)。

线程和进程之间的另一个主要区别在于栈。在简单的传统进程地址空间模型 [我们现在可以称之为单线程(single-threaded)进程] 中,只有一个栈,通常位于地址空间的底部(见图26.1左图)。

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

程创建有点像进行函数调用。然而,并不是首先执行函数然后返回给调用者,而是为被调用的例程创建一个新的执行线程,它可以独立于调用者运行,可能在从创建者返回之前运行,但也许会晚得多。

没有并发,计算机也很难理解。遗憾的是,有了并发,情况变得更糟,而且糟糕得多。

26.2 为什么更糟糕:共享数据

设想一个简单的例子,其中两个线程希望更新全局共享变量。

遗憾的是,即使是在单处理器上运行这段代码,也不一定能获得预期结果。有时会这样

提示:了解并使用工具 你应该学习使用新的工具,帮助你编程、调试和理解计算机系统。我们使用一个漂亮的工具,名为反汇编程序(disassembler)。如果对可执行文件运行反汇编程序,它会显示组成程序的汇编指令。

像gdb这样的调试器,像valgrind或purify这样的内存分析器,当然编译器本身也应该花时间去了解更多信息。工具用得越好,就可以建立更好的系统。

mov 0x8049a1c, %eax
add $0x1, %eax
mov %eax, 0x8049a1c

这个例子假定,变量counter位于地址0x8049a1c。在这3条指令中,先用x86的mov指令,从内存地址处取出值,放入eax。然后,给eax寄存器的值加1(0x1)。最后,eax的值被存回内存中相同的地址。

设想我们的两个线程之一(线程1)进入这个代码区域,并且因此将要增加一个计数器。它将counter的值(假设它这时是50)加载到它的寄存器eax中。因此,线程1的eax = 50。然后它向寄存器加1,因此eax = 51。现在,一件不幸的事情发生了:时钟中断发生。因此,操作系统将当前正在运行的线程(它的程序计数器、寄存器,包括eax等)的状态保存到线程的TCB。

简单来说,发生的情况是:增加counter的代码被执行两次,初始值为50,但是结果为51。这个程序的“正确”版本应该导致变量counter等于52。

临界区是访问共享变量(或更一般地说,共享资源)的代码片段,一定不能由多个线程同时执行。

事实上,所有这些术语都是由Edsger Dijkstra创造的,他是该领域的先驱,并且因为这项工作和其他工作而获得了图灵奖。请参阅他1968年关于“Cooperating Sequential Processes”的文章[D68],该文对这个问题给出了非常清晰的描述。在本书的这一部分,我们将多次看到Dijkstra的名字。

解决这个问题的一种途径是拥有更强大的指令,单步就能完成要做的事,从而消除不合时宜的中断的可能性。比如,如果有这样一条超级指令怎么样?
memory-add 0x8049a1c, $0x1

在这里,原子方式的意思是“作为一个单元”,有时我们说“全部或没有”。

因此,我们要做的是要求硬件提供一些有用的指令,可以在这些指令上构建一个通用的集合,即所谓的同步原语(synchronization primitive)。

临界区、竞态条件、不确定性、互斥执行

·临界区(critical section)是访问共享资源的一段代码,资源通常是一个变量或数据结构。

竞态条件(race condition)出现在多个执行线程大致同时进入临界区时,它们都试图更新共享的数据结构,导致了令人惊讶的(也许是不希望的)结果。

不确定性(indeterminate)程序由一个或多个竞态条件组成,程序的输出因运行而异,具体取决于哪些线程在何时运行。这导致结果不是确定的(deterministic),而我们通常期望计算机系统给出确定的结果。

关键问题:如何实现同步
为了构建有用的同步原语,需要从硬件中获得哪些支持?需要从操作系统中获得什么支持?如何正确有效地构建这些原语?程序如何使用它们来获得期望的结果?

本章提出了并发问题,就好像线程之间只有一种交互,即访问共享变量,因此需要为临界区支持原子性。事实证明,还有另一种常见的交互,即一个线程在继续之前必须等待另一个线程完成某些操作。

我们不仅要研究如何构建对同步原语的支持来支持原子性,还要研究支持在多线程程序中常见的睡眠/唤醒交互的机制。

操作系统是第一个并发程序,许多技术都是在操作系统内部使用的。

因为中断可能随时发生,所以更新这些共享结构的代码(例如,分配的位图或文件的inode)是临界区。因此,从引入中断的一开始,OS设计人员就不得不担心操作系统如何更新内部结构。

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

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

第27章 插叙:线程API

关键问题:如何创建和控制线程?
操作系统应该提供哪些创建和控制线程的接口?这些接口如何设计得易用和实用?

编写多线程程序的第一步就是创建新线程,因此必须存在某种线程创建接口。

第三个参数最复杂,但它实际上只是问:这个线程应该在哪个函数中运行?在C中,我们把它称为一个函数指针(function pointer),这个指针告诉我们需要以下内容:一个函数名称(start_routine),它被传入一个类型为void 的参数(start_routine后面的括号表明了这一点),并且它返回一个void 类型的值(即一个void指针)。

该线程一旦创建,可以简单地将其参数转换为它所期望的类型,从而根据需要将参数解包。

它就在那里!一旦你创建了一个线程,你确实拥有了另一个活着的执行实体,它有自己的调用栈,与程序中所有当前存在的线程在相同的地址空间内运行。

因为函数可以返回任何东西,所以它被定义为返回一个指向void的指针。

再次,我们应该注意,必须非常小心如何从线程返回值。特别是,永远不要返回一个指针,并让它指向线程调用栈上分配的东西。如果这样做,你认为会发生什么?(想一想!)下面是一段危险的代码示例,对图27.2中的示例做了修改。

在这个例子中,变量r被分配在mythread的栈上。但是,当它返回时,该值会自动释放(这就是栈使用起来很简单的原因!),因此,将指针传回现在已释放的变量将导致各种不好的结果。

我们应该注意,并非所有多线程代码都使用join函数。例如,多线程Web服务器可能会创建大量工作线程,然后使用主线程接受请求,并将其无限期地传递给工作线程。因此这样的长期程序可能不需要join。

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

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

这段代码的意思是:如果在调用pthread_mutex_lock()时没有其他线程持有锁,线程将获取该锁并进入临界区。如果另一个线程确实持有该锁,那么尝试获取该锁的线程将不会从该调用返回,直到获得该锁(意味着持有该锁的线程通过解锁调用释放该锁)。

当然,在给定的时间内,许多线程可能会卡住,在获取锁的函数内部等待。然而,只有获得锁的线程才应该调用解锁。

请注意,当你用完锁时,还应该相应地调用pthread_mutex_destroy(),所有细节请参阅手册。

更复杂的(非玩具)程序,在出现问题时不能简单地退出,应该检查失败并在获取锁或释放锁未成功时执行适当的操作。

当线程之间必须发生某种信号时,如果一个线程在等待另一个线程继续执行某些操作,条件变量就很有用

造成这种差异的原因在于,等待调用除了使调用线程进入睡眠状态外,还会让调用者睡眠时释放锁。

最后一点需要注意:等待线程在while循环中重新检查条件,而不是简单的if语句。

因此,将唤醒视为某种事物可能已经发生变化的暗示,而不是绝对的事实,这样更安全。

请注意,有时候线程之间不用条件变量和锁,用一个标记变量会看起来很简单,很吸引人。

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

要想写出健壮高效的多线程代码,只需要耐心和万分小心!

线程难的部分不是API,而是如何构建并发程序的棘手逻辑。

保持简洁。最重要的一点,线程之间的锁和信号的代码应该尽可能简洁。复杂的线程交互容易产生缺陷。

让线程交互减到最少。尽量减少线程之间的交互。

初始化锁和条件变量。未初始化的代码有时工作正常,有时失败,会产生奇怪的结果。

检查返回值。当然,任何C和UNIX的程序,都应该检查返回值,这里也是一样。否则会导致古怪而难以理解的行为,让你尖叫,或者痛苦地揪自己的头发。

注意传给线程的参数和返回值。

每个线程都有自己的栈。类似于上一条,记住每一个线程都有自己的栈。因此,线程局部变量应该是线程私有的,其他线程不应该访问。线程之间共享数据,值要在堆(heap)或者其他全局可访问的位置。

线程之间总是通过条件变量发送信号。切记不要用标记变量来同步。

多查手册。

第28章 锁

程序员在源代码中加锁,放在临界区周围,保证临界区能够像单条原子指令一样执行。

锁就是一个变量,因此我们需要声明一个某种类型的锁变量(lock variable,如上面的mutex),才能使用。这个锁变量(简称锁)保存了锁在某一时刻的状态。它要么是可用的(available,或unlocked,或free),表示没有线程持有锁,要么是被占用的(acquired,或locked,或held),表示有一个线程持有锁,正处于临界区。

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

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

锁为程序员提供了最小程度的调度控制。我们把线程视为程序员创建的实体,但是被操作系统调度,具体方式由操作系统选择。锁让程序员获得一些控制权。通过给临界区加锁,可以保证临界区内只有一个线程活跃。锁将原本由操作系统调度的混乱状态变得更为可控。

POSIX库将锁称为互斥量(mutex),因为它被用来提供线程之间的互斥。即当一个线程在临界区,它能够阻止其他线程进入直到本线程离开临界区。

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

我们已经从程序员的角度,对锁如何工作有了一定的理解。那如何实现一个锁呢?我们需要什么硬件支持?需要什么操作系统的支持?

近些年来,各种计算机体系结构的指令集都增加了一些不同的硬件原语,我们不研究这些指令是如何实现的(毕竟,这是计算机体系结构课程的主题),只研究如何使用它们来实现像锁这样的互斥原语。

在实现锁之前,我们应该首先明确目标,因此我们要问,如何评价一种锁实现的效果。为了评价锁是否能工作(并工作得好),我们应该先设立一些标准。

第一是锁是否能完成它的基本任务,即提供互斥(mutual exclusion)

第二是公平性(fairness)。当锁可用时,是否每一个竞争线程有公平的机会抢到锁?用另一个方式来看这个问题是检查更极端的情况:是否有竞争锁的线程会饿死(starve),一直无法获得锁?

最后是性能(performance),具体来说,是使用锁之后增加的时间开销。有几种场景需要考虑。

一种是没有竞争的情况,即只有一个线程抢锁、释放锁的开支如何?另外一种是一个CPU上多个线程竞争,性能如何?最后一种是多个CPU、多个线程竞争时的性能。

最早提供的互斥解决方案之一,就是在临界区关闭中断。

首先,这种方法要求我们允许所有调用线程执行特权操作(打开关闭中断),即信任这种机制不会被滥用。众所周知,如果我们必须信任任意一个程序,可能就有麻烦了

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

这种用法是可行的,因为在操作系统内部不存在信任问题,它总是信任自己可以执行特权操作。

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

因为关闭中断的方法无法工作在多处理器上,所以系统设计者开始让硬件支持锁。

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

为了理解test-and-set如何工作,我们首先实现一个不依赖它的锁,用一个变量标记锁是否被持有。

从这种交替执行可以看出,通过适时的(不合时宜的?)中断,我们很容易构造出两个线程都将标志设置为1,都能进入临界区的场景。这种行为就是专家所说的“不好”,我们显然没有满足最基本的要求:互斥。
性能问题(稍后会有更多讨论)主要是线程在等待已经被持有的锁时,采用了自旋等待(spin-waiting)的技术,就是不停地检查标志的值。自旋等待在等待其他线程释放锁的时候会浪费时间。尤其是在单处理器上,一个等待线程等待的目标线程甚至无法运行(至少在上下文切换之前)!

这个指令叫ldstub(load/store unsigned byte,加载/保存无符号字节)

在x86上,是xchg(atomic exchange,原子交换)指令。但它们基本上在不同的平台上做同样的事,通常称为测试并设置指令(test-and-set)。

测试并设置指令做了下述事情。它返回old_ptr指向的旧值,同时更新为new的新值。

我们来确保理解为什么这个锁能工作。首先假设一个线程在运行,调用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。

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

对于自旋锁,在单CPU的情况下,性能开销相当大。

但是,在多CPU上,自旋锁性能不错(如果线程数大致等于CPU数)。

比较并交换

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

如果你想看看如何创建建C可调用的x86版本的比较并交换,

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

链接的加载和条件式存储指令

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

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

提示:代码越少越好(劳尔定律)
程序员倾向于吹嘘自己使用大量的代码实现某功能。这样做本质上是不对的。我们应该吹嘘以很少的代码实现给定的任务。简洁的代码更易懂,缺陷更少。正如Hugh Lauer在讨论构建一个飞行员操作系统时说:“如果给同样这些人两倍的时间,他们可以只用一半的代码来实现”[L81]。我们称之为劳尔定律(Lauer’s Law),很值得记住。下次你吹嘘写了多少代码来完成作业时,三思而后行,或者更好的做法是,回去重写,让代码更清晰、精简。

28.11 获取并增加

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

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

以两个线程运行在单处理器上为例,当一个线程(线程0)持有锁时,被中断。第二个线程(线程1)去获取锁,发现锁已经被持有。因此,它就开始自旋。接着自旋。
然后它继续自旋。最后,时钟中断产生,线程0重新运行,它释放锁。最后(比如下次它运行时),线程1不需要继续自旋了,它获取了锁。因此,类似的场景下,一个线程会一直自旋检查一个不会改变的值,浪费掉整个时间片!如果有N个线程去竞争一个锁,情况会更糟糕。同样的场景下,会浪费N−1个时间片,只是自旋并等待一个线程释放该锁。因此,我们的下一个问题是:
关键问题:怎样避免自旋
如何让锁不会不必要地自旋,浪费CPU时间?

只有硬件支持是不够的。我们还需要操作系统支持!

如果临界区的线程发生上下文切换,其他线程只能一直自旋,等待被中断的(持有锁的)进程重新运行。有什么好办法

线程可以处于3种状态之一(运行、就绪和阻塞)。yield()系统调用能够让运行(running)态变为就绪(ready)态,从而允许其他线程运行。因此,让出线程本质上取消调度(deschedules)了它自己。

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

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

因此,我们必须显式地施加某种控制,决定锁释放时,谁能抢到锁。

简单起见,我们利用Solaris提供的支持,它提供了两个调用:park()能够让调用线程休眠,unpark(threadID)则会唤醒threadID标识的线程。

首先,我们将之前的测试并设置和等待队列结合,实现了一个更高性能的锁。其次,我们通过队列来控制谁会获得锁,避免饿死。

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

如果线程不能获取锁(它已被持有),线程会把自己加入队列(通过调用gettid()获得当前的线程ID),将guard设置为0,然后让出CPU。

通过setpark(),一个线程表明自己马上要park。如果刚好另一个线程被调度,并且调用了unpark,那么后续的park调用就会直接返回,而不是一直睡眠。lock()调用可以做一点小修改:

另外一种方案就是将guard传入内核。在这种情况下,内核能够采取预防措施,保证原子地释放锁,把运行线程移出队列。

目前我们看到,为了构建更有效率的锁,一个操作系统提供的一种支持。其他操作系统也提供了类似的支持,但细节不同。

Linux采用的是一种古老的锁方案,多年来不断被采用,可以追溯到20世纪60年代早期的Dahm锁[M82],现在也称为两阶段锁(two-phase lock)。

如果第一个自旋阶段没有获得锁,第二阶段调用者会睡眠,直到锁可用。上文的Linux锁就是这种锁,不过只自旋一次;更常见的方式是在循环中自旋固定的次数,然后使用futex睡眠。

以上的方法展示了如今真实的锁是如何实现的:一些硬件支持(更加强大的指令)和一些操作系统支持(例如Solaris的park()和unpark()原语,Linux的futex)。

程序x86.py允许你看到不同的线程交替如何导致或避免竞争条件。

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

计数器是最简单的一种数据结构,使用广泛而且接口简单。

它遵循了最简单、最基本的并发数据结构中常见的数据模式:它只是加了一把锁,在调用函数操作该数据结构时获取锁,从调用返回时释放锁。

这种方式类似基于观察者(monitor)[BH73]的数据结构,在调用、退出对象方法时,会自动获取锁、释放锁。

如果简单的方案就能工作,就不需要精巧的设计。

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

这个方法是最近的研究提出的,称为懒惰计数器(sloppy counter

懒惰计数器通过多个局部计数器和一个全局计数器来实现一个逻辑计数器,其中每个CPU核心有一个局部计数器。具体来说,在4个CPU的机器上,有4个局部计数器和1个全局计数器。除了这些计数器,还有锁:每个局部计数器有一个锁,全局计数器有一个。

懒惰计数器的基本思想是这样的。如果一个核心上的线程想增加计数器,那就增加它的局部计数器,访问这个局部计数器是通过对应的局部锁同步的。因为每个CPU有自己的局部计数器,不同CPU上的线程不会竞争,所以计数器的更新操作可扩展性好。
但是,为了保持全局计数器更新(以防某个线程要读取该值),局部值会定期转移给全局计数器,方法是获取全局锁,让全局计数器加上局部计数器的值,然后将局部计数器置零。

我们能够重写插入和查找函数,保持并发插入正确,但避免在失败情况下也需要调用释放锁吗?

让获取锁和释放锁只环绕插入代码的真正临界区。

研究人员发现的增加链表并发的技术中,有一种叫作过手锁(hand-over-hand locking,也叫作锁耦合,lock coupling)[MS04]。
原理也很简单。每个节点都有一个锁,替代之前整个链表一个锁。遍历链表的时候,首先抢占下一个节点的锁,然后释放当前节点的锁。

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

仔细研究这段代码,你会发现有两个锁,一个负责队列头,另一个负责队列尾。这两个锁使得入队列操作和出队列操作可以并发执行,因为入队列只访问tail锁,而出队列只访问head锁。

正如Knuth的著名说法“不成熟的优化是所有坏事的根源。”

我们让整个应用的某一小部分变快,却没有提高整体性能,其实没有价值。

第30章 条件变量

我们可以尝试用一个共享变量,如图30.2所示。这种解决方案一般能工作,但是效率低下,因为主线程会自旋检查,浪费CPU时间。我们希望有某种方式让父线程休眠,直到等待的条件满足(即子线程完成执行)。

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

父线程运行时,就会调用wait并卡在那里,没有其他线程会唤醒它。通过这个例子,你应该认识到变量done的重要性,它记录了线程有兴趣知道的值。睡眠、唤醒和锁都离不开它。

调用signal和wait时要持有锁(hold the lock when calling signal or wait),你会保持身心健康的。

本章要面对的下一个问题,是生产者/消费者(producer/consumer)问题,也叫作有界缓冲区(bounded buffer)问题。

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

信号的这种释义常称为Mesa语义(Mesa semantic),为了纪念以这种方式建立条件变量的首次研究[LR80]。另一种释义是Hoare语义(Hoare semantic),虽然实现难度大,但是会保证被唤醒线程立刻执行[H74]。实际上,几乎所有系统都采用了Mesa语义。

对条件变量使用while循环,这也解决了假唤醒(spurious wakeup)的情况。某些线程库中,由于实现的细节,有可能出现一个信号唤醒两个线程的情况[L11]。再次检查线程的等待条件,假唤醒是另一个原因。

假设目前没有空闲内存,线程Ta调用allocate(100),接着线程Tb请求较少的内存,调用allocate(10)。Ta和Tb都等待在条件上并睡眠,没有足够的空闲内存来满足它们的请求。
这时,假定第三个线程Tc调用了free(50)。遗憾的是,当它发信号唤醒等待线程时,可能不会唤醒申请10字节的Tb线程。而Ta线程由于内存不够,仍然等待。因为不知道唤醒哪个(或哪些)线程,所以图中代码无法正常工作。

第31章 信号量

我们应该讨论这些接口的几个突出方面。首先,sem_wait()要么立刻返回(调用sem_wait()时,信号量的值大于等于1),要么会让调用线程挂起,直到之后的一个post操作。当然,也可能多个调用线程都调用sem_wait(),因此都在队列中等待被唤醒。

二值信号量(锁)

我们可以用信号量来实现锁了。因为锁只有两个状态(持有和没持有),所以这种用法有时也叫作二值信号量(binary semaphore)。

可以看到,这里忘了互斥。向缓冲区加入元素和增加缓冲区的索引是临界区,需要小心保护起来。所以,我们使用二值信号量来增加锁。

虽然读者—写者锁听起来很酷,但是却很复杂,复杂可能意味着慢。因此,总是优先尝试简单的笨办法。

Hill简洁地总结了他的工作:“大而笨更好。”因此我们将这种类似的建议叫作Hill定律(Hill’s Law)。

它们通常加入了更多开锁(尤其是更复杂的实现),因此和其他一些简单快速的锁相比,读者写者锁在性能方面没有优势[CB08]。

假定有5位“哲学家”围着一个圆桌。每两位哲学家之间有一把餐叉(一共5把)。哲学家有时要思考一会,不需要餐叉;有时又要就餐。而一位哲学家只有同时拿到了左手边和右手边的两把餐叉,才能吃到东西。关于餐叉的竞争以及随之而来的同步问题,就是我们在并发编程中研究它的原因。

问题是死锁(deadlock)。假设每个哲学家都拿到了左手边的餐叉,他们每个都会阻塞住,并且一直等待另一个餐叉。具体来说,哲学家0拿到了餐叉0,哲学家1拿到了餐叉1,哲学家2拿到餐叉2,哲学家3拿到餐叉3,哲学家4拿到餐叉4。所有的餐叉都被占有了,所有的哲学家都阻塞着,并且等待另一个哲学家占有的餐叉。

因为最后一个哲学家会尝试先拿右手边的餐叉,然后拿左手边,所以不会出现每个哲学家都拿着一个餐叉

提示:小心泛化
在系统设计中,泛化的抽象技术是很有用处的。一个好的想法稍微扩展之后,就可以解决更大一类问题。然而,泛化时要小心,正如Lampson提醒我们的“不要泛化。泛化通常都是错的。”[L83]
我们可以把信号量当作锁和条件变量的泛化。但这种泛化有必要吗?考虑基于信号量去实现条件变量的难度,可能这种泛化并没有你想的那么通用。

很奇怪,利用信号量来实现锁和条件变量,是棘手得多的问题

信号量是编写并发程序的强大而灵活的原语。有程序员会因为简单实用,只用信号量,不用锁和条件变量。

第32章 常见并发问题

关键问题:如何处理常见的并发缺陷
并发缺陷会有很多常见的模式。了解这些模式是写出健壮、正确程序的第一步。

研究集中在4个重要的开源应用:MySQL(流行的数据库管理系统)、Apache(著名的Web服务器)、Mozilla(著名的Web浏览器)和OpenOffice(微软办公套件的开源版本)。研究人员通过检查这几个代码库已修复的并发缺陷,将开发者的工作变成量化的缺陷分析。理解这些结果,有助于我们了解在成熟的代码库中,实际出现过哪些类型的并发问题。

违反原子性(atomicity violation)缺陷和错误顺序(order violation)缺陷。

显然,当第一个线程检查之后,在fputs()调用之前被中断,第二个线程把指针置为空;当第一个线程恢复执行时,由于引用空指针,导致程序奔溃。

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

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

当线程之间的顺序很重要时,条件变量(或信号量)能够解决问题。

Lu等人的研究中,大部分(97%)的非死锁问题是违反原子性和违反顺序这两种。因此,程序员仔细研究这些错误模式,应该能够更好地避免它们。

如图32.1所示,其中的圈(cycle)表明了死锁。

只要线程1和线程2都用相同的抢锁顺序,死锁就不会发生。

其中一个原因是在大型的代码库里,组件之间会有复杂的依赖。以操作系统为例。虚拟内存系统在需要访问文件系统才能从磁盘读到内存页;文件系统随后又要和虚拟内存交互,去申请一页内存,以便存放读到的块。因此,在设计大型系统的锁机制时,你必须要仔细地去避免循环依赖导致的死锁。

另一个原因是封装(encapsulation)。软件开发者一直倾向于隐藏实现细节,以模块化的方式让软件开发更容易。然而,模块化和锁不是很契合。Jula等人指出[J+08],某些看起来没有关系的接口可能会导致死锁。

死锁的产生需要如下4个条件[C+71]。

·互斥:线程对于需要的资源进行互斥的访问(例如一个线程抢到锁)。
·持有并等待:线程持有了资源(例如已将持有的锁),同时又在等待其他资源(例如,需要获得的锁)。
·非抢占:线程获得的资源(例如锁),不能被抢占。
·循环等待:线程之间存在一个环路,环路上每个线程都额外持有一个资源,而这个资源又是下一个线程要申请的。

也许最实用的预防技术(当然也是经常采用的),就是让代码不会产生循环等待。最直接的方法就是获取锁时提供一个全序(total ordering)。假如系统共有两个锁(L1和L2),那么我们每次都先申请L1然后申请L2,就可以避免死锁。这样严格的顺序避免了循环等待,也就不会产生死锁。

因此,偏序(partial ordering)可能是一种有用的方法,安排锁的获取并避免死锁。Linux中的内存映射代码就是一个偏序锁的好例子[T+94]。

你可以想到,全序和偏序都需要细致的锁策略的设计和实现。另外,顺序只是一种约定,粗心的程序员很容易忽略,导致死锁。

当一个函数要抢多个锁时,我们需要注意死锁。比如有一个函数:do_something(mutex t *m1, mutex t *m2),如果函数总是先抢m1,然后m2,那么当一个线程调用do_something(L1, L2),而另一个线程调用do_something(L2, L1)时,就可能会产生死锁。

为了避免这种特殊问题,聪明的程序员根据锁的地址作为获取锁的顺序。按照地址从高到低,或者从低到高的顺序加锁,do_something()函数就可以保证不论传入参数是什么顺序,函数都会用固定的顺序加锁。

因为要提前抢到所有锁(同时),而不是在真正需要的时候,所以可能降低了并发

trylock()函数会尝试获得锁,或者返回−1,表示锁已经被占有。

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

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

最后一种常用的策略就是允许死锁偶尔发生,检查到死锁时再采取行动。举个例子,如果一个操作系统一年死机一次,你会重启系统,然后愉快地(或者生气地)继续工作。

第一种是非常常见的,非死锁缺陷,通常也很容易修复。这种问题包括:违法原子性,即应该一起执行的指令序列没有一起执行;违反顺序,即两个线程所需的顺序没有强制保证。

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

具体来说,一些基于图形用户界面(GUI)的应用[O96],或某些类型的网络服务器[PDZ99],常常采用另一种并发方式。这种方式称为基于事件的并发(event-based concurrency),在一些现代系统中较为流行,比如node.js[N13],

处理事件的代码叫作事件处理程序(event handler)。重要的是,处理程序在处理一个事件时,它是系统中发生的唯一活动。因此,调度就是决定接下来处理哪个事件。这种对调度的显式控制,是基于事件方法的一个重要优点。

但这也带来一个更大的问题:基于事件的服务器如何决定哪个事件发生,尤其是对于网络和磁盘I/O?具体来说,事件服务器如何确定是否有它的消息已经到达?

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

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

提示:请勿阻塞基于事件的服务器
基于事件的服务器可以对任务调度进行细粒度的控制。但是,为了保持这种控制,不可以有阻止调用者执行的调用。如果不遵守这个设计提示,将导致基于事件的服务器阻塞,客户心塞,并严重质疑你是否读过本书的这部分内容。

但是有一个问题:如果某个事件要求你发出可能会阻塞的系统调用,该怎么办?

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

一些系统提供了基于中断(interrupt)的方法。此方法使用UNIX信号(signal)在异步I/O完成时通知应用程序,从而消除了重复询问系统的需要。

要了解信号还有很多事情要做,以至于单个页面,甚至单独的章节,都远远不够。与往常一样,有一个重要来源:Stevens和Rago的书[SR05]。如果感兴趣,请阅读。

Pai等人 [PDZ99]描述了一种使用事件处理网络数据包的混合方法,并且使用线程池来管理未完成的I/O。详情请阅读他们的论文。

Adya等人称之为手工栈管理(manual stack management),这是基于事件编程的基础[A + 02]。

基于事件的方法的另一个问题是,它不能很好地与某些类型的系统活动集成,如分页(paging)。

基于事件的服务器为应用程序本身提供了调度控制,但是这样做的代价是复杂性以及与现代系统其他方面(例如分页)的集成难度。由于这些挑战,没有哪一种方法表现最好。因此,线程和事件在未来很多年内可能会持续作为解决同一并发问题的两种不同方法。

第34章 并发的总结对话

学生:我也是。想到自己是计算机专业的学生,却不能理解这几行代码,有点尴尬。
教授:不用这么难过。你可以看看最早的并发算法的论文,有很多都是有问题的。这些作者通常都是专家教授呢。

第3部分 持久性

你必须做更多的工作才能让桃子持久保持(persist)下去。信息也是如此。让信息持久,尽管计算机会崩溃,磁盘会出故障或停电,这是一项艰巨而有趣的挑战。

第36章 I/O设备

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

如果主CPU参与数据移动(就像这个示例协议一样),我们就称之为编程的I/O(programmed I/O,PIO)。

我们注意到这个协议存在的第一个问题就是轮询过程比较低效,在等待设备执行完成命令时浪费大量CPU时间,如果此时操作系统可以切换执行下一个就绪进程,就可以大大提高CPU的利用率。

多年前,工程师们发明了我们目前已经很常见的中断(interrupt)来减少CPU开销。有了中断后,CPU 不再需要不断轮询设备,而是向设备发出一个请求,然后就可以让对应进程睡眠,切换执行其他任务。

因此,中断允许计算与I/O重叠(overlap),这是提高CPU利用率的关键。

这种两阶段(two-phased)的办法可以实现两种方法的好处。

提示:中断并非总是比PIO好
尽管中断可以做到计算与I/O的重叠,但这仅在慢速设备上有意义。否则,额外的中断处理和上下文切换的代价反而会超过其收益。另外,如果短时间内出现大量的中断,可能会使得系统过载并且引发活锁[MR96]。这种情况下,轮询的方式可以在操作系统自身的调度上提供更多的控制,反而更有效。

另一个最好不要使用中断的场景是网络。网络端收到大量数据包,如果每一个包都发生一次中断,那么有可能导致操作系统发生活锁(livelock),即不断处理中断而无法处理用户层的请求。

解决方案就是使用DMA(Direct Memory Access)。DMA引擎是系统中的一个特殊设备,它可以协调完成内存和设备间的数据传递,不需要CPU介入。

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

从时间线中可以看到,数据的拷贝工作都是由DMA控制器来完成的。因为CPU在此时是空闲的,所以操作系统可以让它做一些其他事情,比如此处调度进程2到CPU来运行。因此进程2在进程1再次运行之前可以使用更多的CPU。

我们还没有真正讨论过操作系统究竟如何与设备进行通信!所以问题如下。

这些指令通常是特权指令(privileged)。操作系统是唯一可以直接与设备交互的实体。

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

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

这个问题可以通过古老的抽象(abstraction)技术来解决。在最底层,操作系统的一部分软件清楚地知道设备如何工作,我们将这部分软件称为设备驱动程序(device driver),所有设备交互的细节都封装在其中。

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

有趣的是,因为所有需要插入系统的设备都需要安装对应的驱动程序,所以久而久之,驱动程序的代码在整个内核代码中的占比越来越大。查看Linux内核代码会发现,超过70%的代码都是各种驱动程序。在Windows系统中,这样的比例同样很高。因此,如果有人跟你说操作系统包含上百万行代码,实际的意思是包含上百万行驱动程序代码。

错误处理。在每次操作之后读取状态寄存器。如果ERROR位被置位,可以读取错误寄存器来获取详细信息。

遗憾的是,即使现在还是计算机诞生的初期,我们就开始丢失了起缘的历史记录。

关于什么机器第一个使用DMA技术也有争论。Knuth和一些人认为是DYSEAC(一种“移动”计算机,当时意味着可以用拖车运输它),而另外一些人则认为是IBM SAGE[S08]。无论如何,在20世纪50年代中期,就有系统的I/O设备可以直接和内存交互,并在完成后中断CPU。

第37章 磁盘驱动器

所有现代驱动器的基本接口都很简单。驱动器由大量扇区(512字节块)组成,每个扇区都可以读取或写入。在具有n个扇区的磁盘上,扇区从0到n−1编号。因此,我们可以将磁盘视为一组扇区,0到n−1是驱动器的地址空间(address space)。

在更新磁盘时,驱动器制造商唯一保证的是单个512字节的写入是原子的(atomic,即它将完整地完成或者根本不会完成)。因此,如果发生不合时宜的掉电,则只能完成较大写入的一部分 [有时称为不完整写入(torn write)]。

读写过程由磁头(disk head)完成;驱动器的每个表面有一个这样的磁头。磁头连接到单个磁盘臂(disk arm)上,磁盘臂在表面上移动,将磁头定位在期望的磁道上。

在我们的简单磁盘中,磁盘不必做太多工作。具体来说,它必须等待期望的扇区旋转到磁头下。

在这个例子中,如果完整的旋转延迟是R,那么磁盘必然产生大约为R/2的旋转延迟,以等待0来到读/写磁头下面(如果我们从6开始)。对这个单一磁道,最坏情况的请求是第5扇区,这导致接近完整的旋转延迟,才能服务这种请求。

在该图中,磁头当前位于最内圈的磁道上(它包含扇区24~35)。下一个磁道包含下一组扇区(12~23),最外面的磁道包含最前面的扇区(0~11)。

例如,读取扇区11。为了服务这个读取请求,驱动器必须首先将磁盘臂移动到正确的磁道(在这种情况下,是最外面的磁道),通过一个所谓的寻道(seek)过程。寻道,以及旋转,是最昂贵的磁盘操作之一。

应该指出的是,寻道有许多阶段:首先是磁盘臂移动时的加速阶段。然后随着磁盘臂全速移动而惯性滑动。然后随着磁盘臂减速而减速。最后,在磁头小心地放置在正确的磁道上时停下来。停放时间(settling time)通常不小,例如0.5~2ms,因为驱动器必须确定找到正确的磁道(想象一下,如果它只是移到附近!)。

当扇区11经过磁盘磁头时,I/O的最后阶段将发生,称为传输(transfer),数据从表面读取或写入表面。因此,我们得到了完整的I/O时间图:首先寻道,然后等待转动延迟,最后传输。

扇区往往会偏斜,因为从一个磁道切换到另一个磁道时,磁盘需要时间来重新定位磁头(即便移到相邻磁道)。如果没有这种偏斜,磁头将移动到下一个磁道,但所需的下一个块已经旋转到磁头下,因此驱动器将不得不等待整个旋转延迟,才能访问下一个块。

另一个事实是,外圈磁道通常比内圈磁道具有更多扇区,这是几何结构的结果。那里空间更多。这些磁道通常被称为多区域(multi-zoned)磁盘驱动器,其中磁盘被组织成多个区域,区域是表面上连续的一组磁道。每个区域每个磁道具有相同的扇区数量,并且外圈区域具有比内圈区域更多的扇区。

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

在写入时,驱动器面临一个选择:它应该在将数据放入其内存之后,还是写入实际写入磁盘之后,回报写入完成?前者被称为后写(write back)缓存(有时称为立即报告,immediate reporting),后者则称为直写(write through)。后写缓存有时会使驱动器看起来“更快”,但可能有危险。如果文件系统或应用程序要求将数据按特定顺序写入磁盘以保证正确性,后写缓存可能会导致问题

第一个工作负载称为随机(random)工作负载,它向磁盘上的随机位置发出小的(例如4KB)读取请求。随机工作负载在许多重要的应用程序中很常见,包括数据库管理系统。第二种称为顺序(sequential)工作负载,只是从磁盘连续读取大量的扇区,不会跳过。顺序访问模式很常见,因此也很重要。

首先是“高性能”驱动器市场,驱动器的设计尽可能快,提供低寻道时间,并快速传输数据。其次是“容量”市场,每字节成本是最重要的方面。因此,驱动器速度较慢,但将尽可能多的数据放到可用空间中。

提示:顺序地使用磁盘
尽可能以顺序方式将数据传输到磁盘,并从磁盘传输数据。如果顺序不可行,至少应考虑以大块传输数据:越大越好。如果I/O是以小而随机方式完成的,则I/O性能将受到显著影响。而且,用户也会痛苦。而且,你也会痛苦,因为你知道正是你不小心的随机I/O让你痛苦。

补充:计算“平均”寻道时间
在许多书籍和论文中,引用的平均磁盘寻道时间大约为完整寻道时间的三分之一。这是怎么来的?
原来,它是基于平均寻道距离而不是时间的简单计算而产生的。

记住,我们仍然必须除以寻道总数(N2)来计算平均寻道距离:(N3/3)/(N2)= N/3。因此,在所有可能的寻道中,磁盘上的平均寻道距离是全部距离的1/3。现在,如果听到平均寻道时间是完整寻道时间的1/3,你就会知道是怎么来的。

SSTF:最短寻道时间优先
一种早期的磁盘调度方法被称为最短寻道时间优先(Shortest-Seek-Time-First,SSTF)(也称为最短寻道优先,Shortest-Seek-First,SSF)。SSTF按磁道对I/O请求队列排序,选择在最近磁道上的请求先完成。

第一个问题,主机操作系统无法利用驱动器的几何结构,而是只会看到一系列的块。幸运的是,这个问题很容易解决。操作系统可以简单地实现最近块优先(Nearest-Block-First,NBF),而不是SSTF,然后用最近的块地址来调度请求。
第二个问题更为根本:饥饿(starvation)。

由于现在应该很明显的原因,这种算法(及其变种)有时被称为电梯(elevator)算法,因为它的行为像电梯,电梯要么向上要么向下,而不只根据哪层楼更近来服务请求。试想一下,如果你从10楼下降到1楼,有人在3楼上来并按下4楼,那么电梯就会上升到4楼,因为它比1楼更近!如你所见,电梯算法在现实生活中使用时,可以防止电梯中发生战斗。在磁盘中,它就防止了饥饿。

答案当然是“视情况而定”。在工程中,事实证明“视情况而定”几乎总是答案,这反映了取舍是工程师生活的一部分。这样的格言也很好,例如,当你不知道老板问题的答案时,也许可以试试这句好话。然而,知道为什么视情况而定总是更好,我们在这里讨论要讨论这一点。

磁盘调度程序执行的另一个重要相关任务是I/O合并(I/O merging)

调度程序执行的所有请求都基于合并后的请求。合并在操作系统级别尤其重要,因为它减少了发送到磁盘的请求数量,从而降低了开销。

现代调度程序关注的最后一个问题是:在向磁盘发出I/O之前,系统应该等待多久?有人可能天真地认为,即使有一个磁盘I/O,也应立即向驱动器发出请求。这种方法被称为工作保全(work-conserving),因为如果有请求要服务,磁盘将永远不会闲下来。然而,对预期磁盘调度的研究表明,有时最好等待一段时间[ID01],即所谓的非工作保全(non-work-conserving)方法。通过等待,新的和“更好”的请求可能会到达磁盘,从而整体效率提高。当然,决定何时等待以及多久可能会非常棘手。

第38章 廉价冗余磁盘阵列(RAID)

我们使用磁盘时,有时希望它更快。I/O操作很慢,因此可能成为整个系统的瓶颈。我们使用磁盘时,有时希望它更大。越来越多的数据正在上线,因此磁盘变得越来越满。我们使用磁盘时,有时希望它更可靠。如果磁盘出现故障,而数据没有备份,那么所有有价值的数据都没了。

在考虑如何向系统添加新功能时,应该始终考虑是否可以透明地(transparently)添加这样的功能,而不需要对系统其余部分进行更改。要求彻底重写现有软件(或激进的硬件更改)会减少创意产生影响的机会。RAID就是一个很好的例子,它的透明肯定有助于它的成功。管理员可以安装基于SCSI的RAID存储阵列而不是SCSI磁盘,系统的其他部分(主机,操作系统等)不必更改一位就可以开始使用它。

故障—停止模型的一个关键方面是它关于故障检测的假定。具体来说,当磁盘发生故障时,我们认为这很容易检测到。例如,在RAID阵列中,我们假设RAID控制器硬件(或软件)可以立即观察磁盘何时发生故障。

第一个RAID级别实际上不是RAID级别,因为没有冗余。但是,RAID 0级(即条带化,striping)因其更为人所知,可作为性能和容量的优秀上限,所以值得了解。

我们做了一个简化的假设,在每个磁盘上只有1个块(每个大小为4KB)放在下一个磁盘上

在这种情况下,给定逻辑块地址A,RAID可以使用两个简单的公式轻松计算要访问的磁盘和偏移量:
磁盘= A % 磁盘数
偏移量 = A / 磁盘数
请注意,这些都是整数运算(例如,4/3 = 1而不是1.33333…)。我们来看看这些公式如何用于一个简单的例子。假设在上面的第一个RAID中,对块15的请求到达。鉴于有4个磁盘,这意味着我们感兴趣的磁盘是(14 % 4 = 2):磁盘2。确切的块计算为(14 / 4 = 3):块3。因此,应在第三个磁盘(磁盘2,从0开始)的第四个块(块3,从0开始)处找到块14,该块恰好位于该位置。
你可以考虑如何修改这些公式来支持不同的块大小。尝试一下!这不太难。

因此,确定“最佳”的大块大小是很难做到的,因为它需要大量关于提供给磁盘系统的工作负载的知识[CL95]。对于本讨论的其余部分,我们将假定该数组使用单个块(4KB)的大块大小。大多数阵列使用较大的大块大小(例如,64KB),但对于我们在下面讨论的问题,确切的块大小无关紧要。因此,简单起见,我们用一个单独的块。

从容量的角度来看,它是顶级的:给定N个磁盘,条件化提供N个磁盘的有用容量。从可靠性的角度来看,条带化也是顶级的,但是最糟糕:任何磁盘故障都会导致数据丢失。最后,性能非常好:通常并行使用所有磁盘来为用户I/O请求提供服务。

对于随机工作负载,我们假设每个请求都很小,并且每个请求都是到磁盘上不同的随机位置。

一些重要的工作负载(例如数据库管理系统(DBMS)上的事务工作负载)表现出这种类型的访问模式,因此它被认为是一种重要的工作负载。

当然,真正的工作负载不是那么简单,并且往往混合了顺序和类似随机的部分,行为介于两者之间。

你知道,顺序和随机工作负载会导致磁盘的性能特征差异很大。对于顺序访问,磁盘以最高效的模式运行,花费很少时间寻道并等待旋转,大部分时间都在传输数据。对于随机访问,情况恰恰相反:大部分时间花在寻道和等待旋转上,花在传输数据上的时间相对较少。

第一个超越条带化的RAID级别称为RAID 1级,即镜像。对于镜像系统,我们只需生成系统中每个块的多个副本。当然,每个副本应该放在一个单独的磁盘上。通过这样做,我们可以容许磁盘故障。

在一个典型的镜像系统中,我们将假设对于每个逻辑块,RAID保留两个物理副本。

从镜像阵列读取块时,RAID有一个选择:它可以读取任一副本。例如,如果对RAID发出对逻辑块5的读取,则可以自由地从磁盘2或磁盘3读取它。但是,在写入块时,不存在这样的选择:RAID必须更新两个副本的数据,以保持可靠性。但请注意,这些写入可以并行进行。例如,对逻辑块5的写入可以同时在磁盘2和3上进行。

在实践中,我们通常不喜欢把这样的事情交给运气。因此,大多数人认为镜像对于处理单个故障是很好的。

然而,因为逻辑写入必须等待两个物理写入完成,所以它遭遇到两个请求中最差的寻道和旋转延迟,因此(平均而言)比写入单个磁盘略高。

解决此问题的一般方法,是使用某种预写日志(write-ahead log),在做之前首先记录RAID将要执行的操作(即用某个数据更新两个磁盘)。通过采取这种方法,我们可以确保在发生崩溃时,会发生正确的事情。通过运行一个恢复(recovery)过程,将所有未完成的事务重新在RAID上执行,我们可以确保两个镜像副本(在RAID-1情况下)同步。

实际上,每个磁盘都会接收到每个其他块的请求。当它在跳过的块上旋转时,不会为客户提供有用的带宽。因此,每个磁盘只能提供一半的峰值带宽。因此,顺序读取只能获得MB/s的带宽。

具体来说,假设C2列中第一行的值丢失(它是1)。通过读取该行中的其他值(C0中的0,C1中的0,C3中的1以及奇偶校验列P中的0),我们得到值0、0、1和0。因为我们知道XOR保持每行有偶数个1,所以就知道丢失的数据肯定是什么——1

这种技术的问题在于它随磁盘数量而变化,因此在较大的RAID中,需要大量的读取来计算奇偶校验。因此,导致了减法奇偶校验(subtractive parity)方法。

你现在应该能够确定何时使用加法奇偶校验计算,何时使用减法方法。考虑系统中需要多少个磁盘,导致加法方法比减法方法执行更少的I/O。哪里是交叉点?

由于奇偶校验磁盘必须为每个逻辑I/O执行两次I/O(一次读取,一次写入),我们可以通过计算奇偶校验磁盘在这两个I/O上的性能来计算RAID-4中的小的随机写入的性能,从而得到(R / 2)MB/s。随机小写入下的RAID-4吞吐量很糟糕,向系统添加磁盘也不会改善。

RAID-5的工作原理与RAID-4几乎完全相同,只是它将奇偶校验块跨驱动器旋转(见表38.9)。

由于RAID-5基本上与RAID-4相同,只是在少数情况下它更好,所以它几乎完全取代了市场上的RAID-4。唯一没有取代的地方是系统知道自己绝不会执行大写入以外的任何事情,从而完全避免了小写入问题[HLM94]。在这些情况下,有时会使用RAID-4,因为它的构建稍微简单一些。

总之,如果你严格要求性能而不关心可靠性,那么条带显然是最好的。但是,如果你想要随机I/O的性能和可靠性,镜像是最好的,你付出的代价是容量下降。如果容量和可靠性是你的主要目标,那么RAID-5胜出,你付出的代价是小写入的性能。最后,如果你总是在按顺序执行I/O操作并希望最大化容量,那么RAID-5也是最有意义的。

最后,甚至可以将RAID构建为软件层:这种软件RAID系统更便宜,但有其他问题,包括一致更新问题[DAA05]。

有很多可能的RAID级别可供选择,使用的确切RAID级别在很大程度上取决于最终用户的优先级。例如,镜像RAID是简单的、可靠的,并且通常提供良好的性能,但是容量成本高。相比之下,RAID-5从容量角度来看是可靠和更好的,但在工作负载中有小写入时性能很差。为特定工作负载正确地挑选RAID并设置其参数(块大小、磁盘数量等),这非常具有挑战性,更多的是艺术而不是科学。

第39章 插叙:文件和目录

到目前为止,我们看到了两项关键操作系统技术的发展:进程,它是虚拟化的CPU;地址空间,它是虚拟化的内存。在这两种抽象共同作用下,程序运行时就好像它在自己的私有独立世界中一样,好像它有自己的处理器(或多处理器),好像它有自己的内存。

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

第二个抽象是目录(directory)。一个目录,像一个文件一样,也有一个低级名字(即inode号),但是它的内容非常具体:它包含一个(用户可读名字,低级名字)对的列表。例如,假设存在一个低级别名称为“10”的文件,它的用户可读的名称为“foo”。“foo”所在的目录因此会有条目(“foo”,“10”),将用户可读名称映射到低级名称。目录中的每个条目都指向文件或其他目录。通过将目录放入其他目录中,用户可以构建任意的目录树(directory tree,或目录层次结构,directory hierarchy),在该目录树下存储所有文件和目录。

提示:请仔细考虑命名
命名是计算机系统的一个重要方面[SK09]。在UNIX系统中,你几乎可以想到的所有内容都是通过文件系统命名的。除了文件、设备、管道,甚至进程[K84]都可以在一个看似普通的旧文件系统中看到。这种命名的一致性简化了系统的概念模型,使系统更简单、更模块化。因此,无论何时创建系统或接口,都要仔细考虑你使用的是什么名称。

然而,这通常只是一个惯例(convention):一般不会强制名为main.c的文件中包含的数据确实是C源代码。

我们将从创建、访问和删除文件的基础开始。你可能会认为这很简单,但在这个过程中,你会发现用于删除文件的神秘调用,称为unlink()。

特别是,有人曾问Ken Thompson,如果他重新设计UNIX,做法会有什么不同,他回答说:“我拼写creat时会加上e”。

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

在这段代码中,我们将程序echo的输出重定向到文件foo,然后文件中就包含单词“hello”。然后我们用cat来查看文件的内容。但是,cat程序如何访问文件foo?

为了弄清楚这个问题,我们将使用一个非常有用的工具,来跟踪程序所做的系统调用。在Linux上,该工具称为strace。其他系统也有类似的工具(参见macOS X上的dtruss,或某些较早的UNIX变体上的truss)。

strace的作用就是跟踪程序在运行时所做的每个系统调用,然后将跟踪结果显示在屏幕上供你查看。

调用lseek()从文件的随机位置读取或写入文件,然后读取/写入这些随机位置,确实会导致更多的磁盘寻道。因此,调用lseek()肯定会导致在即将进行的读取或写入中进行搜索,但绝对不会导致任何磁盘I/O自动发生

请注意,调用lseek()与移动磁盘臂的磁盘的寻道(seek)操作无关。对lseek()的调用只是改变内核中变量的值。执行I/O时,根据磁盘头的位置,磁盘可能会也可能不会执行实际的寻道来完成请求。

在某些情况下,还需要fsync()包含foo文件的目录。添加此步骤不仅可以确保文件本身位于磁盘上,而且可以确保文件(如果新创建)也是目录的一部分。毫不奇怪,这种细节往往被忽略,导致许多应用程序级别的错误[P+13]。

有了一个文件后,有时需要给一个文件一个不同的名字。在命令行键入时,这是通过mv命令完成的。在下面的例子中,文件foo被重命名为bar。
prompt> mv foo bar

利用strace,我们可以看到mv使用了系统调用rename(char * old, char * new),它只需要两个参数:文件的原来名称(old)和新名称(new)。

我们从跟踪的输出中删除了一堆不相关的内容,只留下一个神秘名称的系统调用unlink()。如你所见,unlink()只需要待删除文件的名称,并在成功时返回零。但这引出了一个很大的疑问:为什么这个系统调用名为“unlink”?为什么不就是“remove”或“delete”?

prompt> strace mkdir foo
...
mkdir("foo", 0777) = 0
...
prompt>

提示:小心强大的命令
程序rm为我们提供了强大命令的一个好例子,也说明有时太多的权利可能是一件坏事。例如,要一次删除一堆文件,可以键入如下内容:
prompt> rm *

如果你发出命令时碰巧是在文件系统的根目录,从而删除了所有文件和目录,这一小串字符会给你带来麻烦。哎呀!
因此,要记住强大的命令是双刃剑。虽然它们让你能够通过少量击键来完成大量工作,但也可能迅速而容易地造成巨大的伤害。

然而,与删除文件不同,删除目录更加危险,因为你可以使用单个命令删除大量数据。因此,rmdir()要求该目录在被删除之前是空的(只有“.”和“..”条目)。如果你试图删除一个非空目录,那么对rmdir()的调用就会失败。

通过带-i标志的ls,它会打印出每个文件的inode编号(以及文件名)。因此,你可以看到实际上已完成的链接:只是对同一个inode号(本例中为67158084)创建了新的引用。

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

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

可以使用stat()来查看文件的引用计数。让我们看看创建和删除文件的硬链接时,引用计数是什么。在这个例子中,我们将为同一个文件创建3个链接,然后删除它们。仔细看链接计数!

还有一种非常有用的链接类型,称为符号链接(symbolic link),有时称为软链接(soft link)。

硬链接有点局限:你不能创建目录的硬链接(因为担心会在目录树中创建一个环)。你不能硬链接到其他磁盘分区中的文件(因为inode号在特定文件系统中是唯一的,而不是跨文件系统),等等。

但是,除了表面相似之外,符号链接实际上与硬链接完全不同。第一个区别是符号链接本身实际上是一个不同类型的文件。我们已经讨论过常规文件和目录。符号链接是文件系统知道的第三种类型。对符号链接运行stat揭示了一切。

正如你在本例中看到的,符号链接与硬链接完全不同,删除名为file的原始文件会导致符号链接指向不再存在的路径名。

mount的作用很简单:以现有目录作为目标挂载点(mount point),本质上是将新的文件系统粘贴到目录树的这个点上。

因此mount的美妙之处在于:它将所有文件系统统一到一棵树中,而不是拥有多个独立的文件系统,这让命名统一而且方便。

第40章 文件系统实现

文件系统是纯软件。与CPU和内存虚拟化的开发不同,我们不会添加硬件功能来使文件系统的某些方面更好地工作(但我们需要注意设备特性,以确保文件系统运行良好)。

通过研究和改进心智模型,你可以对发生的事情有一个抽象的理解

文件系统的第二个方面是访问方法(access method)

当然,首先想到的是用户数据。实际上,任何文件系统中的大多数空间都是(并且应该是)用户数据。

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

每个inode都由一个数字(称为inumber)隐式引用,我们之前称之为文件的低级名称(low-level name)。

磁盘不是按字节可寻址的,而是由大量可寻址扇区组成,通常是512字节

我们将所有关于文件的信息称为元数据(metadata)。实际上,文件系统中除了纯粹的用户数据外,其他任何信息通常都称为元数据。

提示:考虑基于范围的方法
另一种方法是使用范围(extent)而不是指针。范围就是一个磁盘指针加一个长度(以块为单位)。因此,不需要指向文件的每个块的指针,只需要指针和长度来指定文件的磁盘位置。只有一个范围是有局限的,因为分配文件时可能无法找到连续的磁盘可用空间块。因此,基于范围的文件系统通常允许多个范围,从而在文件分配期间给予文件系统更多的自由。

双重间接指针(double indirect pointer)。该指针指的是一个包含间接块指针的块,每个间接块都包含指向数据块的指针。因此,双重间接块提供了可能性,允许使用额外的1024×1024个4KB块来增长文件,换言之,支持超过4GB大小的文件。

这样的表听起来很熟悉吗?我们描述的是所谓的文件分配表(File Allocation Table,FAT)——文件系统的基本结构。是的,在NTFS [C94]之前,这款经典的旧Windows文件系统基于简单的基于链接的分配方案。它与标准UNIX文件系统还有其他不同之处。例如,本身没有inode,而是存储关于文件的元数据的目录条目,并且直接指向所述文件的第一个块,这导致不可能创建硬链接。

管理空闲空间可以有很多方法,位图只是其中一种。一些早期的文件系统使用空闲列表(free list),其中超级块中的单个指针保持指向第一个空闲块。在该块内部保留下一个空闲指针,从而通过系统的空闲块形成列表。在需要块时,使用头块并相应地更新列表。

现代文件系统使用更复杂的数据结构。例如,SGI的XFS [S+96]使用某种形式的B树(B-tree)来紧凑地表示磁盘的哪些块是空闲的。与所有数据结构一样,不同的时间-空间折中也是可能的。

所有遍历都从文件系统的根开始,即根目录(root directory),它就记为/。因此,文件系统的第一次磁盘读取是根目录的inode。

在大多数UNIX文件系统中,根的inode号为2。因此,要开始该过程,文件系统会读入inode号2的块(第一个inode块)。

在每个进程的打开文件表中,为此进程分配一个文件描述符,并将它返回给用户。

程序可以发出read()系统调用,从文件中读取。第一次读取(除非lseek()已被调用,则在偏移量0处)将在文件的第一个块中读取,查阅inode以查找这个块的位置。它也会用新的最后访问时间更新inode。读取将进一步更新此文件描述符在内存中的打开文件表,更新文件偏移量,以便下一次读取会读取第二个文件块,等等。

补充:读取不会访问分配结构
我们曾见过许多学生对分配结构(如位图)感到困惑。特别是,许多人经常认为,只是简单地读取文件而不分配任何新块时,也会查询位图。不是这样的!分配结构(如位图)只有在需要分配时才会访问。inode、目录和间接块具有完成读请求所需的所有信息。inode已经指向一个块,不需要再次确认它已分配。

另外请注意,open导致的I/O量与路径名的长度成正比。

是的,读取文件时生活会变得非常糟糕。你会发现,写入一个文件(尤其是创建一个新文件)更糟糕。

因此,每次写入文件在逻辑上会导致5个I/O:一个读取数据位图(然后更新以标记新分配的块被使用),一个写入位图(将它的新状态存入磁盘),再是两次读取,然后写入inode(用新块的位置更新),最后一次写入真正的数据块本身。

关键问题:如何降低文件系统I/O成本
即使是最简单的操作,如打开、读取或写入文件,也会产生大量I/O操作,分散在磁盘上。文件系统可以做些什么,来降低执行如此多I/O的高成本?

相比之下,现代系统采用动态划分(dynamic partitioning)方法。具体来说,许多现代操作系统将虚拟内存页面和文件系统页面集成到统一页面缓存中(unified page cache)[S00]。通过这种方式,可以在虚拟内存和文件系统之间更灵活地分配内存,具体取决于在给定时间哪种内存需要更多的内存。

例如,如果应用程序创建文件并将其删除,则将文件创建延迟写入磁盘,可以完全避免(avoid)写入。在这种情况下,懒惰(在将块写入磁盘时)是一种美德。

某些应用程序(如数据库)不喜欢这种折中。因此,为了避免由于写入缓冲导致的意外数据丢失,它们就强制写入磁盘,通过调用fsync(),使用绕过缓存的直接I/O(direct I/O)接口,或者使用原始磁盘(raw disk)接口并完全避免使用文件系统。

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

这个问题正是磁盘碎片整理工具要解决的。它们将重新组织磁盘数据以连续放置文件,并为让空闲空间成为一个或几个连续的区域,移动数据,然后重写inode等以反映变化。

总的来说,创建一个新文件需要做很多工作!

在FFS中,文件放置的一般策略有一个重要的例外,它出现在大文件中。

FFS还引入了一个原子rename()操作,用于重命名文件。除了基本技术之外,可用性的改进也可能让FFS拥有更强大的用户群。

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

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

关键问题:考虑到崩溃,如何更新磁盘
系统可能在任何两次写入之间崩溃或断电,因此磁盘上状态可能仅部分地更新。崩溃后,系统启动并希望再次挂载文件系统(以便访问文件等)。鉴于崩溃可能发生在任意时间点,如何确保文件系统将磁盘上的映像保持在合理的状态?

我们将首先检查较老的文件系统采用的方法,即fsck,文件系统检查程序(file system checker)。然后,我们将注意力转向另一种方法,称为日志记录(journaling,也称为预写日志,write-ahead logging),这种技术为每次写入增加一点开销,但可以更快地从崩溃或断电中恢复。

这种写入将导致空间泄露(space leak),因为文件系统永远不会使用块5。

我们将这个一般问题称为崩溃一致性问题(crash-consistency problem,也可以称为一致性更新问题,consistent-update problem)。

fsck是一个UNIX工具,用于查找这些不一致并修复它们[M86]。在不同的系统上,存在检查和修复磁盘分区的类似工具。请注意,这种方法无法解决所有问题。例如,考虑上面的情况,文件系统看起来是一致的,但是inode指向垃圾数据。唯一真正的目标,是确保文件系统元数据内部一致。

fsck(和类似的方法)有一个更大的、也许更根本的问题:它们太慢了。对于非常大的磁盘卷,扫描整个磁盘,以查找所有已分配的块并读取整个目录树,可能需要几分钟或几小时。

对于一致更新问题,最流行的解决方案可能是从数据库管理系统的世界中借鉴的一个想法。

基本思路如下。更新磁盘时,在覆写结构之前,首先写下一点小注记(在磁盘上的其他地方,在一个众所周知的位置),描述你将要做的事情。写下这个注记就是“预写”部分,我们把它写入一个结构,并组织成“日志”。因此,就有了预写日志。

在写入日志期间发生崩溃时,事情变得有点棘手。在这里,我们试图将事务中的这些块(即TxB、I[v2]、B[v2]、Db、TxE)写入磁盘。一种简单的方法是一次发出一个,等待每个完成,然后发出下一个。但是,这很慢。

补充:强制写入磁盘
为了在两次磁盘写入之间强制执行顺序,现代文件系统必须采取一些额外的预防措施。在过去,强制在两个写入A和B之间进行顺序很简单:只需向磁盘发出A写入,等待磁盘在写入完成时中断OS,然后发出写入B。

正如Kahan所说,快速几乎总是打败慢速,即使快速是错的。

为避免该问题,文件系统分两步发出事务写入。首先,它将除TxE块之外的所有块写入日志,同时发出这些写入。当这些写入完成时,日志将看起来像这样(假设又是文件追加的工作负载):[插图]当这些写入完成时,文件系统会发出TxE块的写入,从而使日志处于最终的安全状态:

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

日志满时会出现两个问题。第一个问题比较简单,但不太重要:日志越大,恢复时间越长,因为恢复过程必须重放日志中的所有事务(按顺序)才能恢复。第二个问题更重要:当日志已满(或接近满)时,不能向磁盘提交进一步的事务,从而使文件系统“不太有用”(即无用)。

因此,我们在基本协议中添加了另一个步骤。1.日志写入:将事务的内容(包括TxB和更新内容)写入日志,等待这些写入完成。2.日志提交:将事务提交块(包括TxE)写入日志,等待写完成,事务被认为已提交(committed)。3.加检查点:将更新内容写入其最终的磁盘位置。4.释放:一段时间后,通过更新日志超级块,在日志中标记该事务为空闲。

一种更简单(也更常见)的日志形式有时称为有序日志(ordered journaling,或称为元数据日志,metadata journaling),它几乎相同,只是用户数据没有写入日志。因此,在执行与上述相同的更新时,以下信息将写入日志:

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

在大多数系统中,元数据日志(类似于ext3的有序日志)比完整数据日志更受欢迎。例如,Windows NTFS和SGI的XFS都使用无序的元数据日志。Linux ext3为你提供了选择数据、有序或无序模式的选项(在无序模式下,可以随时写入数据)。所有这些模式都保持元数据一致,它们的数据语义各不相同。

在上面的情况中,删除目录将导致撤销记录被写入日志。在重放日志时,系统首先扫描这样的重新记录。任何此类被撤销的数据都不会被重放,从而避免了上述问题。

这种方法仔细地对文件系统的所有写入排序,以确保磁盘上的结构永远不会处于不一致的状态。例如,通过先写入指向的数据块,再写入指向它的inode,可以确保inode永远不会指向垃圾。对文件系统的所有结构可以导出类似的规则。然而,实现软更新可能是一个挑战。

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

另一种方法是我们刚刚在威斯康星大学开发的方法。这种技术名为基于反向指针的一致性(Backpointer-Based Consistency,BBC),它在写入之间不强制执行排序。为了实现一致性,系统中的每个块都会添加一个额外的反向指针。例如,每个数据块都引用它所属的inode。访问文件时,文件系统可以检查正向指针(inode或直接块中的地址)是否指向引用它的块,从而确定文件是否一致。如果是这样,一切都肯定安全地到达磁盘,因此文件是一致的。如果不是,则文件不一致,并返回错误。通过向文件系统添加反向指针,可以获得一种新形式的惰性崩溃一致性[C+12]。

这种新方法名为乐观崩溃一致性(optimistic crash consistency)[C+13],尽可能多地向磁盘发出写入,并利用事务校验和(transaction checksum)[P+05]的一般形式,以及其他一些技术来检测不一致,如果出现不一致的话。对于某些工作负载,这些乐观技术可以将性能提高一个数量级。但是,要真正运行良好,需要稍微不同的磁盘接口[C+13]。

第43章 日志结构文件系统

引入的新型文件系统Rosenblum和Ousterhout称为LFS,是日志结构文件系统(Log-structured File System)的缩写。写入磁盘时,LFS首先将所有更新(包括元数据!)缓冲在内存段中。当段已满时,它会在一次长时间的顺序传输中写入磁盘,并传输到磁盘的未使用部分。LFS永远不会覆写现有数据,而是始终将段写入空闲位置。由于段很大,因此可以有效地使用磁盘,并且文件系统的性能接近其峰值。

关键问题:如何让所有写入变成顺序写入?
文件系统如何将所有写入转换为顺序写入?对于读取,此任务是不可能的,因为要读取的所需块可能是磁盘上的任何位置。但是,对于写入,文件系统总是有一个选择,而这正是我们希望利用的选择。

提示:细节很重要
所有有趣的系统都包含一些一般性的想法和一些细节。有时,在学习这些系统时,你会对自己说,“哦,我抓住了一般的想法,其余的只是细节说明。”你这样想时,对事情是如何运作的只是一知半解。不要这样做!很多时候,细节至关重要。正如我们在LFS中看到的那样,一般的想法很容易理解,但要真正构建一个能工作的系统,必须仔细考虑所有棘手的情况。

为了达到这个目的,LFS使用了一种称为写入缓冲(write buffering)的古老技术。在写入磁盘之前,LFS会跟踪内存中的更新。收到足够数量的更新时,会立即将它们写入磁盘,从而确保有效使用磁盘。

LFS一次写入的大块更新被称为段(segment)。虽然这个术语在计算机系统中被过度使用,但这里的意思是LFS用来对写入进行分组的大块。因此,在写入磁盘时,LFS会缓冲内存段中的更新,然后将该段一次性写入磁盘。只要段足够大,这些写入就会很有效。

例如,老UNIX文件系统将所有inode保存在磁盘的固定位置。因此,给定一个inode号和起始地址,要查找特定的inode,只需将inode号乘以inode的大小,然后将其加上磁盘数组的起始地址,即可计算其确切的磁盘地址。给定一个inode号,基于数组的索引是快速而直接的。

为了解决这个问题,LFS的设计者通过名为inode映射(inode map,imap)的数据结构,在inode号和inode之间引入了一个间接层(level of indirection)。imap是一个结构,它将inode号作为输入,并生成最新版本的inode的磁盘地址。因此,你可以想象它通常被实现为一个简单的数组,每个条目有4个字节(一个磁盘指针)。每次将inode写入磁盘时,imap都会使用其新位置进行更新。

计算机科学中所有问题的解决方案就是一个间接层(level of indirection)

检查点区域包含指向最新的inode映射片段的指针(即地址),因此可以通过首先读取CR来找到inode映射片段。

inode映射还解决了LFS中存在的另一个严重问题,称为递归更新问题(recursive update problem)[Z+12]。任何永远不会原地更新的文件系统(例如LFS)都会遇到该问题,它们将更新移动到磁盘上的新位置。

LFS巧妙地避免了inode映射的这个问题。即使inode的位置可能会发生变化,更改也不会反映在目录本身中。事实上,imap结构被更新,而目录保持相同的名称到inumber的映射

这样的文件系统称为版本控制文件系统(versioning file system),因为它跟踪文件的不同版本。

因此,清理应该使磁盘上的块再次空闲,以便在后续写入中使用。请注意,清理过程是垃圾收集(garbage collection)的一种形式,这种技术在编程语言中出现,可以自动为程序释放未使用的内存。

但是,我们现在有两个问题。第一个是机制:LFS如何判断段内的哪些块是活的,哪些块已经死了?第二个是策略:清理程序应该多久运行一次,以及应该选择清理哪些部分?

为了改进这一点,LFS尝试通过数据库社区中称为前滚(roll forward)的技术,重建其中许多段。基本思想是从最后一个检查点区域开始,找到日志的结尾(包含在CR中),然后使用它来读取下一个段,并查看其中是否有任何有效更新。

这种方法在数据库系统中称为影子分页(shadow paging)[L77],在文件系统中有时称为写时复制(copy-on-write),可以实现高效写入,因为LFS可以将所有更新收集到内存的段中,然后按顺序一起写入。

提示:将缺点变成美德
每当你的系统存在根本缺点时,请看看是否可以将它转换为特征或有用的功能。NetApp的WAFL对旧文件内容做到了这一点。

第44章 数据完整性和保护

该一般领域称为数据完整性(data integrity)或数据保护(data protection)。因此,我们现在将研究一些技术,确保放入存储系统的数据就是存储系统返回的数据。

现代磁盘似乎大部分时间正常工作,但是无法成功访问一个或几个块。具体来说,两种类型的单块故障是常见的,值得考虑:潜在扇区错误(Latent-Sector Errors,LSE)和块讹误(block corruption)。

提示:没有免费午餐
没有免费午餐这种事,或简称TNSTAAFL,是一句古老的美国谚语,暗示当你似乎在免费获得某些东西时,实际上你可能会付出一些代价。以前,餐馆会向顾客宣传免费午餐,希望能吸引他们。

稍微复杂的算法被称为Fletcher校验和(Fletcher checksum),命名基于(你可能会猜到)发明人John G. Fletcher [F82]。它非常简单,涉及两个校验字节s1和s2的计算。具体来说,假设块D由字节d1,…, dn组成。s1简单地定义如下:s1 = s1 + di mod 255(在所有di上计算)。s2依次为:s2 = s2 + s1 mod 255(同样在所有di上)[F04]。已知fletcher校验和几乎与CRC(下面描述)一样强,可以检测所有单比特错误,所有双比特错误和大部分突发错误[F04]。

最后常用的校验和称为循环冗余校验(CRC)。虽然听起来很奇特,但基本想法很简单。假设你希望计算数据块D的校验和。你所做的只是将D视为一个大的二进制数(毕竟它只是一串位)并将其除以约定的值(k)。该除法的其余部分是CRC的值。事实证明,人们可以相当有效地实现这种二进制模运算,因此也可以在网络中普及CRC。

无论使用何种方法,很明显没有完美的校验和:两个具有不相同内容的数据块可能具有相同的校验和,这被称为碰撞(collision)。

在选择良好的校验和函数时,我们试图找到一种函数,能够在保持易于计算的同时,最小化碰撞机会。

因为校验和通常很小(例如,8字节),并且磁盘只能以扇区大小的块(512字节)或其倍数写入,所以出现的一个问题是如何实现上述布局。驱动器制造商采用的一种解决方案是使用520字节扇区格式化驱动器,每个扇区额外的8个字节可用于存储校验和。

在没有此类功能的磁盘中,文件系统必须找到一种方法来将打包的校验和存储到512字节的块中。一种可能性如下:

在确定了校验和布局后,现在可以实际了解如何使用校验和。读取块D时,客户端(即文件系统或存储控制器)也从磁盘Cs(D)读取其校验和,这称为存储的校验和(stored checksum,因此有下标Cs)。然后,客户端计算读取的块D上的校验和,这称为计算的校验和(computed checksum)Cc(D)。此时,客户端比较存储和计算的校验和。如果它们相等 [即Cs(D)== Cc(D)],数据很可能没有被破坏,因此可以安全地返回给用户。如果它们不匹配 [即Cs(D)!= Cc(D)],则表示数据自存储之后已经改变(因为存储的校验和反映了当时数据的值)。在这种情况下,存在讹误,校验和帮助我们检测到了。

关键问题:如何处理错误的写入
存储系统或磁盘控制器应该如何检测错误位置的写入?校验和需要哪些附加功能?

具体来说,一些现代存储设备还有一个问题,称为丢失的写入(lost write)。当设备通知上层写入已完成,但事实上它从未持久,就会发生这种问题。因此,磁盘上留下的是该块的旧内容,而不是更新的新内容。

遗憾的是,答案是否定的:旧块很可能具有匹配的校验和,上面使用的物理ID(磁盘号和块偏移)也是正确的。因此我们最后的问题:

许多可能的解决方案有助于解决该问题[K+08]。一种经典方法[BS04]是执行写入验证(write verify),或写入后读取(read-after-write)。通过在写入后立即读回数据,系统可以确保数据确实到达磁盘表面。然而,这种方法非常慢,使完成写入所需的I/O数量翻了一番。

也许这种变化将迫使研究界和行业重新审视其中的一些基本方法,或发明全新的方法。时间会证明,或者不会。从这个角度来看,时间很有趣。

第45章 关于持久的总结对话

我认为我掌握了一个要点,即长期(持久)管理数据比管理非持久数据(如内存中的内容)要困难得多。毕竟,如果你的机器崩溃,那么内存内容就会消失!但文件系统中的东西需要永远存在。

我的朋友Kevin Hultquist曾经说过,“永远是一段很长的时间”。当时他在谈论塑料高尔夫球座,对于大多数文件系统中的垃圾来说,尤其如此。

因此,尽管技术可能正在发生变化,但我们研究的许多想法至少在一段时间内仍将继续有用。

第46章 关于分布式的对话

学生:有一个桃子?
教授:没错!但这一次,它离你很远,可能需要一些时间才能拿到桃子。而且有很多桃子!更糟糕的是,有时桃子会腐烂。但你要确保任何人咬到桃子时,都会享受到美味。

教授:无论怎样,忘了桃子吧。构建分布式系统很难,因为事情总是会失败。消息会丢失,机器会故障,磁盘会损坏数据,就像整个世界都在和你作对!

尽管发生了所有这些失败!这些公司在他们的系统中构建了大量的机器,确保即使某些机器出现故障,整个系统也能保持正常运行。他们使用了很多技术来实现这一点:复制,重试,以及各种其他技巧。人们随着时间的推移开发了这些技巧,用于检测故障,并从故障中恢复。

第47章 分布式系统

关键问题:如何构建在组件故障时仍能工作的系统
如何用无法一直正常工作的部件,来构建能工作系统?这个基本问题应该让你想起,我们在RAID存储阵列中讨论的一些主题。然而,这里的问题往往更复杂,解决方案也是如此。

有趣的是,虽然故障是构建分布式系统的核心挑战,但它也代表着一个机遇。是的,机器会故障。但是机器故障这一事实并不意味着整个系统必须失败。通过聚集一组机器,我们可以构建一个看起来很少失败的系统,尽管它的组件经常出现故障。这种现实是分布式系统的核心优点和价值,也是为什么它们几乎支持了你使用的所有现代Web服务,包括Google、Facebook等。

现代网络的核心原则是,通信基本是不可靠的。

性能是另一个重要标准:计算校验和的成本是多少?遗憾的是,有效性和性能通常是不一致的,这意味着高质量的校验和通常很难计算。

为了处理这种情况,我们需要一种额外的机制,称为超时(timeout)。当发送方发送消息时,发送方现在将计时器设置为在一段时间后关闭。如果在此时间内未收到确认,则发送方断定该消息已丢失。发送方然后就重试(retry)发送,再次发送相同的消息,希望这次它能送达。要让这种方法起作用,发送方必须保留一份消息副本,以防它需要再次发送。超时和重试的组合导致一些人称这种方法为超时/重试(timeout/retry)。非常聪明的一群人,那些搞网络的,不是吗?

遗憾的是,这种形式的超时/重试还不够。图47.5展示了可能导致故障的数据包丢失示例。在这个例子中,丢失的不是原始消息,而是确认消息。从发送方的角度来看,情况似乎是相同的:没有收到确认,因此超时和重试是合适的。但是从接收方的角度来看,完全不同:现在相同的消息收到了两次!虽然可能存在这种情况,但通常情况并非如此。设想下载文件时,在下载过程中重复多个数据包,会发生什么。因此,如果目标是可靠的消息层,我们通常还希望保证接收方每个消息只接收一次(exactly once)。

有许多方法可以检测重复的消息。例如,发送方可以为每条消息生成唯一的ID。接收方可以追踪它所见过的每个ID。这种方法可行,但它的成本非常高,需要无限的内存来跟踪所有ID。

一种更简单的方法,只需要很少的内存,解决了这个问题,该机制被称为顺序计数器(sequence counter)。利用顺序计数器,发送方和接收方就每一方将维护的计数器的起始值达成一致(例如1)。无论何时发送消息,计数器的当前值都与消息一起发送。此计数器值(N)作为消息的ID。发送消息后,发送方递增该值(到N + 1)。

常用的可靠通信层称为TCP/IP,或简称为TCP

提示:小心设置超时值
你也许可以从讨论中猜到,正确设置超时值,是使用超时重试消息发送的一个重要方面。如果超时太小,发送方将不必要地重新发送消息,从而浪费发送方的CPU时间和网络资源。如果超时太大,则发送方为重发等待太长时间,因此会感到发送方的性能降低

所以,从单个客户端和服务器的角度来看,“正确”值就是等待足够长的时间来检测数据包丢失,但不能再长。

多年来,系统社区开发了许多方法。其中一项工作涉及操作系统抽象,将其扩展到在分布式环境中运行。

分布式共享内存(Distributed Shared Memory,DSM)系统使不同机器上的进程能够共享一个大的虚拟地址空间[LH89]。这种抽象将分布式计算变成貌似多线程应用程序。唯一的区别是这些线程在不同的机器上运行,而不是在同一台机器上的不同处理器上。

DSM最大的问题是它如何处理故障。例如,想象一下,如果机器出现故障。那台机器上的页面会发生什么?如果分布式计算的数据结构分布在整个地址空间怎么办?在这种情况下,这些数据结构的一部分将突然变得不可用。如果部分地址空间丢失,处理故障会很难。想象一下链表,其中下一个指针指向已经消失的地址空间的一部分。

虽然在这个领域进行了大量研究,但实际影响不大。没有人用DSM构建可靠的分布式系统。

操作系统抽象对于构建分布式系统来说是一个糟糕的选择,但编程语言(PL)抽象要有意义得多。最主要的抽象是基于远程过程调用(Remote Procedure Call),或简称RPC [BN84]。

远程过程调用包都有一个简单的目标:使在远程机器上执行代码的过程像调用本地函数一样简单直接。因此,对于客户端来说,进行一个过程调用,并在一段时间后返回结果。服务器只是定义了一些它希望导出的例程。其余的由RPC系统处理,RPC系统通常有两部分:存根生成器(stub generator,有时称为协议编译器,protocol compiler)和运行时库(run-time library)。

存根生成器的工作很简单:通过自动化,消除将函数参数和结果打包成消息的一些痛苦。这有许多好处:通过设计避免了手工编写此类代码时出现的简单错误。此外,存根生成器也许可以优化此类代码,从而提高性能。

等待回复。由于函数调用通常是同步的(synchronous),因此调用将等待其完成。

如果向RPC包传入了一个指针,它需要能够弄清楚如何解释该指针,并执行正确的操作。

通常,这是通过众所周知的类型(例如,用于传递给定大小的数据块的缓冲区t,RPC编译器可以理解),或通过使用更多信息注释数据结构来实现的,从而让编译器知道哪些字节需要序列化。

在此期间,主线程不断接收其他请求,并可能将其发送给其他工作线程。这样的组织方式支持服务器内并发执行,从而提高其利用率。标准成本也会出现,主要是编程复杂性,因为RPC调用现在可能需要使用锁和其他同步原语来确保它们的正确运行。

我们必须克服的首要挑战之一,是如何找到远程服务。

最简单的方法建立在现有命名系统上,例如,当前互联网协议提供的主机名和端口号。

在这样的系统中,客户端必须知道运行所需RPC服务的机器的主机名或IP地址,以及它正在使用的端口号(端口号就是在机器上标识发生的特定通信活动的一种方式,允许同时使用多个通信通道)。

一旦客户端知道它应该与哪个服务器通信,以获得特定的远程服务,下一个问题是应该构建RPC的传输级协议。

遗憾的是,在可靠的通信层之上构建RPC可能会导致性能的低效率。回顾上面的讨论,可靠的通信层如何工作:确认和超时/重试。因此,当客户端向服务器发送RPC请求时,服务器以确认响应,以便调用者知道收到了请求。类似地,当服务器将回复发送到客户端时,客户端会对其进行确认,以便服务器知道它已被接收。在可靠的通信层之上构建请求/响应协议(例如RPC),必须发送两个“额外”消息

因此,许多RPC软件包都建立在不可靠的通信层之上,例如UDP。这样做可以实现更高效的RPC层,但确实增加了为RPC系统提供可靠性的责任。RPC层通过使用超时/重试和确认来实现所需的责任级别,就像我们上面描述的那样。通过使用某种形式的序列编号,通信层可以保证每个RPC只发生一次(在没有故障的情况下),或者最多只发生一次(在发生故障的情况下)。

还有一些其他问题,RPC的运行时也必须处理。例如,当远程调用需要很长时间才能完成时,会发生什么?鉴于我们的超时机制,长时间运行的远程调用可能被客户端认为是故障,从而触发重试,因此需要小心。

如果服务器一直说“是”,客户端应该感到高兴并继续等待(毕竟,有时过程调用可能需要很长时间才能完成执行)。

运行时还必须处理具有大参数的过程调用,大于可以放入单个数据包的过程。一些底层的网络协议提供这样的发送方分组(fragmentation,较大的包分成一组较小的包)和接收方重组(reassembly,较小的部分组成一个较大的逻辑整体)。如果没有,RPC运行时可能必须自己实现这样的功能。

许多系统要处理的一个问题是字节序(byte ordering)。你可能知道,有些机器存储值时采用所谓的大端序(big endian),而其他机器采用小端序(little endian)。大端序存储从最高有效位到最低有效位的字节(比如整数),非常像阿拉伯数字。小端序则相反。两者对存储数字信息同样有效。这里的问题是如何在不同字节序的机器之间进行通信。

在Saltzer等人的标志性论文中,他们通过一个很好的例子来证明这一点:两台机器之间可靠的文件传输。如果要将文件从机器A传输到机器B,并确保最终在B上的字节与从A开始的字节完全相同,则必须对此进行“端到端”检查。较低级别的可靠机制,例如在网络或磁盘中,不提供这种保证。

在Sun的RPC包中,XDR(eXternal Data Representation,外部数据表示)层提供此功能。

最后一个问题是:是否向客户端暴露通信的异步性质,从而实现一些性能优化。具体来说,典型的RPC是同步(synchronously)的,即当客户端发出过程调用时,它必须等待过程调用返回,然后再继续。因为这种等待可能很长,而且因为客户端可能正在执行其他工作,所以某些RPC包让你能够异步(asynchronously)地调用RPC。

正如人们在Google内部所说的那样,当你只有自己的台式机时,故障很少见。当你拥有数千台机器的数据中心时,故障一直在发生。所有分布式系统的关键是如何处理故障。

真正理解RPC包的最好方法,当然是亲自使用它。Sun的RPC系统使用存根编译器rpcgen,它是很常见的,在当今的许多系统上可用,包括Linux。尝试一下,看看所有这些麻烦到底是怎么回事。

第48章 Sun的网络文件系统(NFS)

分布式客户端/服务器计算的首次使用之一,是在分布式文件系统领域。

客户端文件系统的作用,是执行服务这些系统调用所需的操作如图48.2所示。

然后,文件服务器将从磁盘(或自己的内存缓存)中读取块,并发送消息,将请求的数据发送回客户端。然后,客户端文件系统将数据复制到用户的缓冲区中。请注意,客户端内存或客户端磁盘上的后续read()可以缓存(cached)在客户端内存中,在最好的情况下,不需要产生网络流量。

通过这个简单的概述,你应该了解客户端/服务器分布式文件系统中两个最重要的软件部分:客户端文件系统和文件服务器。它们的行为共同决定了分布式文件系统的行为。

最早且相当成功的分布式系统之一是由Sun Microsystems开发的,被称为Sun网络文件系统(或NFS)[S86]。在定义NFS时,Sun采取了一种不寻常的方法:Sun开发了一种开放协议(open protocol),它只是指定了客户端和服务器用于通信的确切消息格式,而不是构建专有的封闭系统。不同的团队可以开发自己的NFS服务器,从而在NFS市场中竞争,同时保持互操作性。

然而,NFSv2既精彩又令人沮丧,因此成为我们关注的焦点。
在NFSv2中,协议的主要目标是“简单快速的服务器崩溃恢复”。

通过设计无状态(stateless)协议,NFSv2实现了这个简单的目标。

出于这些原因,NFS的设计者决定采用无状态方法:每个客户端操作都包含完成请求所需的所有信息。不需要花哨的崩溃恢复,服务器只是再次开始运行,最糟糕的情况下,客户端可能必须重试请求。

理解NFS协议设计的一个关键是理解文件句柄(file handle)。文件句柄用于唯一地描述文件或目录。因此,许多协议请求包括一个文件句柄。

最后一个有趣的协议消息是GETATTR请求。给定文件句柄,它获取该文件的属性,包括文件的最后修改时间。我们将在NFSv2中看到,为什么这个协议请求很重要(你能猜到吗)。

希望你已对该协议如何转换为文件系统有所了解。客户端文件系统追踪打开的文件,通常将应用程序的请求转换为相关的协议消息集。

其次,你可能会注意到,服务器交互发生的位置。当文件第一次打开时,客户端文件系统发送LOOKUP请求消息。实际上,如果必须访问一个长路径名(例如/home/remzi/foo.txt),客户端将发送3个LOOKUP:一个在/目录中查找home,一个在home中查找remzi,最后一个在remzi中查找foo.txt。

提示:幂等性很强大
在构建可靠的系统时,幂等性(idempotency)是一种有用的属性。如果一次操作可以发出多次请求,那么处理该操作的失败就要容易得多。你只要重试一下。如果操作不具有幂等性,那么事情就会更困难。

在NFSv2中,客户端以唯一、统一和优雅的方式处理所有这些故障:就是重试请求。具体来说,在发送请求之后,客户端将计时器设置为在指定的时间之后关闭。如果在定时器关闭之前收到回复,则取消定时器,一切正常。但是,如果在收到任何回复之前计时器关闭,则客户端会假定请求尚未处理,并重新发送。如果服务器回复,一切都很好,客户端已经漂亮地处理了问题。

客户端之所以能够简单重试请求(不论什么情况导致了故障),是因为大多数NFS请求有一个重要的特性:它们是幂等的(idempotent)。如果操作执行多次的效果与执行一次的效果相同,该操作就是幂等的。例如,如果将值在内存位置存储3次,与存储一次一样。因此“将值存储到内存中”是一种幂等操作。但是,如果将计数器递增3次,它的数量就会与递增一次不同。因此,递增计数器不是幂等的。更一般地说,任何只读取数据的操作显然都是幂等的。对更新数据的操作必须更仔细地考虑,才能确定它是否具有幂等性。

一点补充:一些操作很难成为幂等的。例如,当你尝试创建已存在的目录时,系统会通知你mkdir请求已失败。因此,在NFS中,如果文件服务器收到MKDIR协议消息并成功执行,但回复丢失,则客户端可能会重复它并遇到该故障,实际上该操作第一次成功了,只是在重试时失败。所以,生活并不完美。

提示:完美是好的敌人(Voltaire定律)
即使你设计了一个漂亮的系统,有时候并非所有的特殊情况都像你期望的那样。以上面的mkdir为例,你可以重新设计mkdir,让它具有不同的语义,从而让它成为幂等的(想想你会怎么做)。但是,为什么要这么麻烦?NFS的设计理念涵盖了大多数重要情况,它使系统设计在故障方面简洁明了。因此,接受生活并不完美的事实,仍然构建系统,这是良好工程的标志。显然,这种智慧应该要感谢伏尔泰,他说:“一个聪明的意大利人说,最好是好的敌人。”因此我们称之为Voltaire定律。

答案你可能已经猜到(看到上面的节标题),就是客户端缓存(caching)。NFS客户端文件系统缓存文件数据(和元数据)。因此,虽然第一次访问是昂贵的(即它需要网络通信),但后续访问很快就从客户端内存中得到服务。

因此,NFS客户端缓存数据和性能通常很好,我们成功了,对吧?遗憾的是,并没完全成功。在任何系统中添加缓存,导致包含多个客户端缓存,都会引入一个巨大且有趣的挑战,我们称之为缓存一致性问题(cache consistency problem)。

NFSv2实现以两种方式解决了这些缓存一致性问题。首先,为了解决更新可见性,客户端实现了有时称为“关闭时刷新”(flush-on-close,即close-to-open)的一致性语义。具体来说,当应用程序写入文件并随后关闭文件时,客户端将所有更新(即缓存中的脏页面)刷新到服务器。通过关闭时刷新的一致性,NFS可确保后续从另一个节点打开文件,会看到最新的文件版本。

其次,为了解决陈旧的缓存问题,NFSv2客户端会先检查文件是否已更改,然后再使用其缓存内容。具体来说,在打开文件时,客户端文件系统会发出GETATTR请求,以获取文件的属性。重要的是,属性包含有关服务器上次修改文件的信息。如果文件修改的时间晚于文件提取到客户端缓存的时间,则客户端会让文件无效(invalidate),因此将它从客户端缓存中删除,并确保后续的读取将转向服务器,取得该文件的最新版本。

为了解决这种情况(在某种程度上),为每个客户端添加了一个属性缓存(attribute cache)。客户端在访问文件之前仍会验证文件,但大多数情况下只会查看属性缓存以获取属性。首次访问某文件时,该文件的属性被放在缓存中,然后在一定时间(例如3s)后超时。因此,在这3s内,所有文件访问都会断定使用缓存的文件没有问题,并且没有与服务器的网络通信。

为了避免这个问题,NFS服务器必须在通知客户端成功之前,将每次写入提交到稳定(持久)存储。这样做可以让客户端在写入期间检测服务器故障,从而重试,直到它最终成功。这样做确保了不会导致前面例子中混合的文件内容。

一个技巧是先写入有电池备份的内存,从而快速报告WRITE请求成功,而不用担心丢失数据,也没有必须立即写入磁盘的成本。第二个技巧是采用专门为快速写入磁盘而设计的文件系统,如果你最后需要这样做[HLM94,RO91]。

早期NFS实现中,安全性非常宽松。客户端的任何用户都可以轻松伪装成其他用户,并获得对几乎任何文件的访问权限。后来集成了更严肃的身份验证服务(例如,Kerberos [NT94]),解决了这些明显的缺陷。

第49章 Andrew文件系统(AFS)

该项目由卡内基梅隆大学著名教授M. Satyanarayanan(简称为Satya)领导,主要目标很简单:扩展(scale)。

AFS与NFS的不同之处也在于,从一开始,合理的、用户可见的行为就是首要考虑的问题。在NFS中,缓存一致性很难描述,因为它直接依赖于低级实现细节,包括客户端缓存超时间隔。在AFS中,缓存一致性很简单且易于理解:当文件打开时,客户端通常会从服务器接收最新的一致副本。

所有AFS版本的基本原则之一,是在访问文件的客户端计算机的本地磁盘(local disk)上,进行全文件缓存(whole-file caching)。

注意,与NFS的明显不同,NFS缓存块(不是整个文件,虽然NFS当然可以缓存整个文件的每个块),并且缓存在客户端内存(不是本地磁盘)中。

实际数据有助于取代直觉,让解构系统成为具体的科学。在他们的研究中,作者发现了AFSv1的两个主要问题。

通过使用实验证据而不是直觉,你可以将系统构建过程变成更科学的尝试。这样做也具有让你在开发改进版本之前,先考虑如何准确测量系统的优势。当你最终开始构建新系统时,结果两件事情会变得更好:首先,你有证据表明你正在解决一个真正的问题。第二,你现在有办法测量新系统,以显示它实际上改进了现有技术。因此我们称之为Patterson定律。

路径查找成本过高。执行Fetch或Store协议请求时,客户端将整个路径名(例如/home/remzi/notes.txt)传递给服务器。

客户端发出太多TestAuth协议消息。与NFS及其过多的GETATTR协议消息非常相似,AFSv1用TestAuth协议信息,生成大量流量,以检查本地文件(或其状态信息)是否有效。

AFSv1实际上还存在另外两个问题:服务器之间的负载不均衡,服务器对每个客户端使用一个不同的进程,从而导致上下文切换和其他开销。通过引入卷(volume),解决了负载不平衡问题。管理员可以跨服务器移动卷,以平衡负载。通过使用线程而不是进程构建服务器,在AFSv2中解决了上下文切换问题。但是,限于篇幅,这里集中讨论上述主要的两个协议问题,这些问题限制了系统的扩展。

如何重新设计协议,让服务器交互最少,即如何减少TestAuth消息的数量?进一步,如何设计协议,让这些服务器交互高效?

AFSv2引入了回调(callback)的概念,以减少客户端/服务器交互的数量。回调就是服务器对客户端的承诺,当客户端缓存的文件被修改时,服务器将通知客户端。

然而,与NFS的关键区别在于,每次获取目录或文件时,AFS客户端都会与服务器建立回调,从而确保服务器通知客户端,其缓存状态发生变化。好处是显而易见的:尽管第一次访问/home/remzi/notes.txt会生成许多客户端—服务器消息(如上所述),但它也会为所有目录以及文件notes.txt建立回调,因此后续访问完全是本地的,根本不需要服务器交互。

例如,如果要构建代码存储库,并且有多个客户端检入和检出代码,则不能简单地依赖底层文件系统来为你完成所有工作。实际上,你必须使用显式的文件级锁(file-levellocking),以确保在发生此类并发访问时,发生“正确”的事情。

由于回调和全文件缓存,AFS提供的缓存一致性易于描述和理解。有两个重要的情况需要考虑:不同机器上进程的一致性,以及同一台机器上进程的一致性。

有一个有趣的跨机器场景值得进一步讨论。具体来说,在极少数情况下,不同机器上的进程会同时修改文件,AFS自然会采用所谓的“最后写入者胜出”方法(lastwriter win,也许应该称为“最后关闭者胜出”,last closer win)。

在许多情况下,这样的混合文件输出没有多大意义,例如,想象一个JPEG图像被两个客户端分段修改,导致的混合写入不太可能构成有效的JPEG。

从上面的描述中,你可能会感觉,崩溃恢复比NFS更复杂。你是对的。

崩溃后服务器恢复也更复杂。问题是回调被保存在内存中。因此,当服务器重新启动时,它不知道哪个客户端机器具有哪些文件。因此,在服务器重新启动时,服务器的每个客户端必须意识到服务器已崩溃,并将其所有缓存内容视为可疑,并且(如上所述)在使用之前重新检查文件的有效性。

有很多方法可以实现这种恢复。例如,让服务器在每个客户端启动并再次运行时向每个客户端发送消息(说“不要信任你的缓存内容!”),或让客户端定期检查服务器是否处于活动状态(利用心跳(heartbeat)消息,正如其名)。如你所见,构建更具可扩展性和合理性的缓存模型需要付出代价。使用NFS,客户端很少注意到服务器崩溃。

有了新协议,人们对AFSv2进行了测量,发现它比原来的版本更具可扩展性。实际上,每台服务器可以支持大约50个客户端(而不是仅仅20个)。另一个好处是客户端性能通常非常接近本地性能,因为在通常情况下,所有文件访问都是本地的。

AFS也认真对待安全性,采用了一些机制来验证用户,确保如果用户需要,可以让一组文件保持私密。相比之下,NFS在多年里对安全性的支持非常原始。

通过让服务器交互最少(通过全文件缓存和回调),每个服务器可以支持许多客户端,从而减少管理特定站点所需的服务器数量。许多其他功能,包括单一命名空间、安全性和访问控制列表,让AFS非常好用。AFS提供的一致性模型易于理解和推断,不会导致偶尔在NFS中看到的奇怪行为。

也许很不幸,AFS可能在走下坡路。由于NFS是一个开放标准,许多不同的供应商都支持它,它与CIFS(基于Windows的分布式文件系统协议)一起,在市场上占据了主导地位。

但唯一持久的影响可能来自AFS的想法,而不是实际的系统本身。实际上,NFSv4现在添加了服务器状态(例如,“open”协议消息),因此与基本AFS协议越来越像。

附录A 关于虚拟机监视器的对话

虚拟机监视器(virtual machine monitor),也称为虚拟机管理程序(hypervisor)

附录B 虚拟机监视器

多年前,IBM将昂贵的大型机出售给大型组织,出现了一个问题:如果组织希望同时在机器上运行不同的操作系统,该怎么办?有些应用程序是在一个操作系统上开发的,有些是在其他操作系统上开发的,因此出现了该问题。

实际上,VMM作为操作系统的操作系统,但在低得多层次上。操作系统仍然认为它与物理硬件交互。因此,透明度(transparency)是VMM的主要目标。

因此,我们发现自己处于一个有趣的位置:到目前为止操作系统已经成为假象提供大师,欺骗毫无怀疑的应用程序,让它们认为拥有自己私有的CPU和大型虚拟内存,同时在应用程序之间进行切换,并共享内存。

另一个原因是测试和调试。当开发者在一个主平台上编写代码时,他们通常希望在许多不同平台上进行调试和测试。

因此,如果想在VMM之上“启动”新操作系统,只需跳转到第一条指令的地址,并让操作系统开始运行,就这么简单。

假设我们在单个处理器上运行,并且希望在两个虚拟机之间进行多路复用,即在两个操作系统和它们各自的应用程序之间进行多路复用。非常类似于操作系统在运行进程之间切换的方式(上下文切换,context switch),虚拟机监视器必须在运行的虚拟机之间执行机器切换(machine switch)。

在虚拟化环境中,不允许操作系统执行特权指令,因为它控制机器而不是其下的VMM。因此,VMM必须以某种方式拦截执行特权操作的尝试,从而保持对机器的控制。

例程

在用户模式下,操作受到限制,尝试执行特权操作将导致陷阱,并可能终止违规进程。

[插图]

那么VMM应该如何处理这个系统调用呢?VMM并不真正知道如何(how)处理调用。毕竟,它不知道正在运行的每个操作系统的细节,因此不知道每个调用应该做什么。然而,VMM知道的是OS的陷阱处理程序在哪里(where)。它知道这一点,因为当操作系统启动时,它试图安装自己的陷阱处理程序。当操作系统这样做时,它试图执行一些特权操作,因此陷入VMM中。那时,VMM记录了必要的信息(即这个OS的陷阱处理程序在内存中的位置)。

我们还有一个问题:操作系统应该运行在什么模式?它无法在内核模式下运行,因为这可以无限制地访问硬件。因此,它必须以比以前更少的特权模式运行,能够访问自己的数据结构,同时阻止从用户进程访问其数据结构。

在Disco的工作中,Rosenblum及其同事利用MIPS硬件提供的特殊模式(称为管理员模式),非常巧妙地处理了这个问题。在此模式下运行时,仍然无法访问特权指令,但可以访问比在用户模式下更多的内存。

这个额外的虚拟化层使“物理”内存成为一个虚拟化层,在VMM所谓的机器内存(machine memory)之上,机器内存是系统的真实物理内存。因此,我们现在有一个额外的间接层:每个操作系统通过其每个进程的页表映射虚拟到物理地址,VMM通过它的每个OS页面表,将生成的物理地址映射到底层机器地址。

假设有一个32位的虚拟地址空间和4-KB的页面大小。因此,32位地址被分成两部分:一个20位的虚拟页号(VPN)和一个12位的偏移量。

事情变得更有趣了

此时,VMM玩了花样:VMM不是安装操作系统的VPN-to-PFN映射,而是安装其所需的VPN-to-MFN映射。这样做之后,系统最终返回到用户级代码,该代码重试该指令,并导致TLB命中,从数据所在的机器帧中获取数据。

VMM通常不太了解操作系统正在做什么或想要什么,这种知识缺乏有时被称为VMM和OS之间的信息沟(information gap),可能导致各种低效率[B+97]

当OS没有其他任何东西可以运行时,它有时会进入空循环(idle loop),只是自旋并等待下一个中断发生:

补充:半虚拟化在许多情况下,最好是假定,无法为了更好地使用虚拟机监视器而修改操作系统(例如,因为你在不友好的竞争对手的操作系统下运行VMM)。但是,情况并非总是如此。如果可以修改操作系统(正如我们在页面按需置零的示例中所见),它可能在VMM上更高效地运行。运行修改后的操作系统,以便在VMM上运行,这通常称为半虚拟化(para-virtualization)[WSG02],因为VMM提供的虚拟化不是完整的虚拟化,而是需要操作系统更改才能有效运行的部分虚拟化。研究表明,一个设计合理的半虚拟化系统,只需要正确的操作系统更改,就可以接近没有VMM时的效率[BD+03]。

遗憾的是,出于同样的原因,VMM必须将它提供给每个操作系统的页面置零,因此很多时候页面将置零两次,一次由VMM分配给操作系统,一次由操作系统分配给操作系统的一个进程。Disco的作者没有很好地解决这个问题的方法:他们只是简单地将操作系统(IRIX)改为不对页面置零,因为知道已被底层VMM [B+97]置零。

有一个关键的区别:通过操作系统虚拟化,提供了许多新的抽象和漂亮的接口。使用VMM级虚拟化,抽象与硬件相同(因此不是很好)。虽然OS和VMM都虚拟化硬件,但它们通过提供完全不同的接口来实现。与操作系统不同,VMM没有特别打算让硬件更易于使用。

最后,硬件支持改变了平台支持虚拟化的方式。英特尔和AMD等公司现在直接支持额外的虚拟化层,从而避免了本章中的许多软件技术。

附录C 关于监视器的对话

。[插图]

啊,历史。那是为老人准备的,就像你一样,对吗?

附录D 关于实验室的对话

你知道,实际编程,做一些真正的工作,而不是这种不间断的谈话和阅读,才是真正的学习方式!

第二类基于一个真正的内核,一个在麻省理工学院开发的、又酷又小的教学内核,名为xv6。它是Intel x86的老版UNIX的“移植”,非常简洁!通过这些项目,你实际上可以重新编写内核的一部分,而不是编写与内核交互的代码(就像在系统编程中那样)。

还有一件事:如果你对系统编程部分感兴趣,还有一些关于UNIX和C编程环境的教程。

附录E 实验室:指南

关于编程的几点一般建议:如果想成为一名专业程序员,需要掌握的不仅仅是语言的语法。具体来说,应该了解你的工具,了解你的库,并了解你的文档。

就像生活中(几乎)所有值得做的事情,成为这些领域的专家需要时间——事先花时间了解有关工具和环境的更多信息,绝对值得付出努力。

gcc不是真正的编译器,而是所谓的“编译器驱动程序”,因此它协调了编译的许多步骤。

首先,gcc将执行cpp(C预处理器)来处理某些指令(例如#define和#include。程序cpp只是一个源到源的转换器,所以它的最终产品仍然只是源代码(即一个C文件)。

有两种类型的库:静态链接库(以.a结尾)和动态链接库(以.so结尾)。静态链接库直接组合到可执行文件中。也就是说,链接器将库的低级代码插入到可执行文件中,从而产生更大的二进制对象。动态链接通过在程序可执行文件中包含对库的引用来改进这一点。程序运行时,操作系统加载程序动态链接库。这种方法优于静态方法,因为它节省了磁盘空间(没有不必要的大型可执行文件),并允许应用程序在内存中共享库代码和静态数据。对于数学库,静态和动态版本都可用,

虽然我们不会详细介绍make语法,但如你所见,这个makefile可以让生活更轻松一些。

最后,在创建了良好的构建环境和正确编译的程序之后,你可能会发现程序有问题。解决问题的一种方法是认真思考——这种方法有时会成功,但往往不会。问题是缺乏信息。

要了解有关所有这些事情的更多信息,你必须做两件事:第一是使用这些工具;第二是自己阅读更多相关信息。

附录F 实验室:系统项目

该项目探索在实际应用中使用并发性。学生使用一个简单的Web服务器(或构建一个),并向其添加一个线程池,以便同时处理请求。线程池应该是固定大小的,并使用生产者/消费者有界缓冲区,将请求从主线程传递到固定的工作线程池。了解如何使用线程、锁和条件变量来构建真实服务器。变体包括线程的调度策略。

posted @ 2021-08-30 19:50  zh89233  阅读(342)  评论(0编辑  收藏  举报