Operating Systems: Three Easy Pieces
I 虚拟化
CPU虚拟化
ch7
我们介绍了调度的基本思想,并开发了两类方法。第一类是运行最短的工作,从而优化周转时间。第二类是交替运行所有工作,从而优化响应时间。但很难做到“鱼与熊掌兼得”,这是系统中常见的、固有的折中。我们也看到了如何将I/O结合到场景中,但仍未解决操作系统根本无法看到未来的问题。稍后,我们将看到如何通过构建一个调度程序,利用最近的历史预测未来,从而解决这个问题。这个调度程序称为多级反馈队列,是第8章的主题。
ch8
本章介绍了一种调度方式,名为多级反馈队列(MLFQ)。你应该已经知道它为什么叫这个名字—它有多级队列,并利用反馈信息决定某个工作的优先级。以史为鉴:关注进程的一贯表现,然后区别对待。 本章包含了一组优化的MLFQ规则。为了方便查阅,我们重新列在这里。
- 规则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系 列操作系统。
ch9
本章介绍了比例份额调度的概念,并简单讨论了两种实现:彩票调度和步长调度。彩票调度通过随机值,聪明地做到了按比例分配。步长调度算法能够确定的获得需要的比例。虽然两者都很有趣,但由于一些原因,并没有作为CPU调度程序被广泛使用。一个原因是这两种方式都不能很好地适合I/O[AC97];另一个原因是其中最难的票数分配问题并没有确定的解决方式,例如,如何知道浏览器进程应该拥有多少票数?通用调度程序(像前面讨论的MLFQ及其他类似的Linux调度程序)做得更好,因此得到了广泛的应用。
结果,比例份额调度程序只有在这些问题可以相对容易解决的领域更有用(例如容易确定份额比例)。例如在虚拟(virtualized)数据中心中,你可能会希望分配1/4的CPU周期给Windows虚拟机,剩余的给Linux系统,比例分配的方式可以更简单高效。详细信息请参考Waldspurger[W02],该文介绍了VMWare的ESX系统如何用比例分配的方式来共享内存。
ch10
本章介绍了多处理器调度的不同方法。其中单队列的方式(SQMS)比较容易构建,负载均衡较好,但在扩展性和缓存亲和度方面有着固有的缺陷。多队列的方式(MQMS)有很好的扩展性和缓存亲和度,但实现负载均衡却很困难,也更复杂。无论采用哪种方式,都没有简单的答案:构建一个通用的调度程序仍是一项令人生畏的任务,因为即使很小的代码变动,也有可能导致巨大的行为差异。除非很清楚自己在做什么,或者有人付你很多钱,否则别干这种事。
内存虚拟化
ch15
本章通过虚拟内存使用的一种特殊机制,即地址转换(address translation) ,扩展了受限直接访问的概念。利用地址转换,操作系统可以控制进程的所有内存访问,确保访问在地址空间的界限内。这个技术高效的关键是硬件支持,硬件快速地将所有内存访问操作中的虚拟地址(进程自己看到的内存位置)转换为物理地址(实际位置) 。所有的这一切对进程来说都是透明的,进程并不知道自己使用的内存引用已经被重定位,制造了美妙的假象。
我们还看到了一种特殊的虚拟化方式,称为基址加界限的动态重定位。基址加界限的虚拟化方式非常高效,因为只需要很少的硬件逻辑,就可以将虚拟地址和基址寄存器加起来,并检查进程产生的地址没有越界。基址加界限也提供了保护,操作系统和硬件的协作,确保没有进程能够访问其地址空间之外的内容。保护肯定是操作系统最重要的目标之一。没有保护,操作系统不可能控制机器(如果进程可以随意修改内存,它们就可以轻松地做出可怕的事情,比如重写陷阱表并完全接管系统) 。
遗憾的是,这个简单的动态重定位技术有效率低下的问题。例如,从图15.2中可以看到,重定位的进程使用了从32KB到48KB的物理内存,但由于该进程的栈区和堆区并不很大,导致这块内存区域中大量的空间被浪费。这种浪费通常称为内部碎片(internal fragmentation) ,指的是已经分配的内存单元内部有未使用的空间(即碎片) ,造成了浪费。在我们当前的方式中,即使有足够的物理内存容纳更多进程,但我们目前要求将地址空间放在固定大小的槽块中,因此会出现内部碎片[2]。所以,我们需要更复杂的机制,以便更好地利用物理内存,避免内部碎片。第一次尝试是将基址加界限的概念稍稍泛化,得到分段(segmentation)的概念,我们接下来将讨论。
ch16
分段解决了一些问题,帮助我们实现了更高效的虚拟内存。不只是动态重定位,通过避免地址空间的逻辑段之间的大量潜在的内存浪费,分段能更好地支持稀疏地址空间。它还很快,因为分段要求的算法很容易,很适合硬件完成,地址转换的开销极小。分段还有一个附加的好处:代码共享。如果代码放在独立的段中,这样的段就可能被多个运行的程序共享。
但我们已经知道,在内存中分配不同大小的段会导致一些问题,我们希望克服。首先,是我们上面讨论的外部碎片。由于段的大小不同,空闲内存被割裂成各种奇怪的大小,因此满足内存分配请求可能会很难。用户可以尝试采用聪明的算法[W+95],或定期紧凑内存,但问题很根本,难以避免。
第二个问题也许更重要,分段还是不足以支持更一般化的稀疏地址空间。例如,如果有一个很大但是稀疏的堆,都在一个逻辑段中,整个堆仍然必须完整地加载到内存中。换言之,如果使用地址空间的方式不能很好地匹配底层分段的设计目标,分段就不能很好地工作。因此我们需要找到新的解决方案。你准备好了吗?
ch17
在本章中,我们讨论了最基本的内存分配程序形式。这样的分配程序存在于所有地方,与你编写的每个C程序链接,也和管理其自身数据结构的内存的底层操作系统链接。与许多系统一样,在构建这样一个系统时需要做许多折中。对分配程序提供的确切工作负载了解得越多,就越能调整它以更好地处理这种工作负载。在现代计算机系统中,构建一个适用于各种工作负载、快速、空间高效、可扩展的分配程序仍然是一个持续的挑战。
ch18
我们已经引入了分页(paging)的概念,作为虚拟内存挑战的解决方案。与以前的方法(如分段)相比,分页有许多优点。首先,它不会导致外部碎片,因为分页(按设计)将内存划分为固定大小的单元。其次,它非常灵活,支持稀疏虚拟地址空间。
然而,实现分页支持而不小心考虑,会导致较慢的机器(有许多额外的内存访问来访问页表)和内存浪费(内存被页表塞满而不是有用的应用程序数据)。因此,我们不得不努力想出一个分页系统,它不仅可以工作,而且工作得很好。幸运的是,接下来的两章将告诉我们如何去做。
ch19
我们了解了硬件如何让地址转换更快的方法。通过增加一个小的、芯片内的TLB作为地址转换的缓存,大多数内存引用就不用访问内存中的页表了。因此,在大多数情况下,程序的性能就像内存没有虚拟化一样,这是操作系统的杰出成就,当然对现代操作系统中的分页非常必要。
但是,TLB也不能满足所有的程序需求。具体来说,如果一个程序短时间内访问的页数超过了TLB中的页数,就会产生大量的TLB未命中,运行速度就会变慢。这种现象被称为超出TLB覆盖范围(TLB coverage),这对某些程序可能是相当严重的问题。解决这个问题的一种方案是支持更大的页,把关键数据结构放在程序地址空间的某些区域,这些区域被映射到更大的页,使TLB的有效覆盖率增加。对更大页的支持通常被数据库管理系统(Database ManagementSystem,DBMS)这样的程序利用,它们的数据结构比较大,而且是随机访问。
另一个TLB问题值得一提:访问TLB很容易成为CPU流水线的瓶颈,尤其是有所谓的物理地址索引缓存(physically—indexed cache)。有了这种缓存,地址转换必须发生在访问该缓存之前,这会让操作变慢。为了解决这个潜在的问题,人们研究了各种巧妙的方法,用虚拟地址直接访问缓存,从而在缓存命中时避免昂贵的地址转换步骤。像这种虚拟地址索引缓存(virtually—indexed cache)解决了一些性能问题,但也为硬件设计带来了新问题。更多细节请参考 Wiggins的调查。
ch20
我们现在已经看到了如何构建真正的页表。不一定只是线性数组,而是更复杂的数据结构。这样的页表体现了时间和空间上的折中(表格越大,TLB未命中可以处理得更快,反之亦然),因此结构的正确选择强烈依赖于给定环境的约束。
在一个内存受限的系统中(像很多旧系统一样),小结构是有意义的。在具有较多内存,并且工作负载主动使用大量内存页的系统中,用更大的页表来加速TLB未命中处理,可能是正确的选择。有了软件管理的TLB,数据结构的整个世界开放给了喜悦的操作系统创新者(提示:就是你)。你能想出什么样的新结构?它们解决了什么问题?当你入睡时想想这些问题,做一个只有操作系统开发人员才能做的大梦。
ch21
在这个简短的一章中,我们介绍了访问超出物理内存大小时的一些概念。要做到这一点,在页表结构中需要添加额外信息,比如增加一个存在位(present bit,或者其他类似机制),告诉我们页是不是在内存中。如果不存在,则操作系统页错误处理程序(page—fault handler)会运行以处理页错误(page fault),从而将需要的页从硬盘读取到内存,可能还需要先换出内存中的一些页,为即将换入的页腾出空间。
回想一下,很重要的是(并且令人惊讶的是),这些行为对进程都是透明的。对进程而言,它只是访问自己私有的、连续的虚拟内存。在后台,物理页被放置在物理内存中的任意(非连续)位置,有时它们甚至不在内存中,需要从硬盘取回。虽然我们希望在一般情况下内存访问速度很快,但在某些情况下,它需要多个硬盘操作的时间。像执行单条指令这样简单的事情,在最坏的情况下,可能需要很多毫秒才能完成。
II 并发
ch26
在结束之前,你可能会有一个问题:为什么我们要在OS类中研究并发?一个词:“历史”。操作系统是第一个并发程序,许多技术都是在操作系统内部使用的。后来,在多线程的进程中,应用程序员也必须考虑这些事情。
例如,设想有两个进程在运行。假设它们都调用write()来写入文件,并且都希望将数据追加到文件中(即将数据添加到文件的末尾,从而增加文件的长度)。为此,这两个进程都必须分配一个新块,记录在该块所在文件的inode中,并更改文件的大小以反映新的、增加的大小(插一句,在本书的第3部分,我们将更多地了解文件)。因为中断可能随时发生,所以更新这些共享结构的代码(例如,分配的位图或文件的inode)是临界区。因此,从引入中断的一开始,OS设计人员就不得不担心操作系统如何更新内部结构。不合时宜的中断会导致上述所有问题。毫不奇怪,页表、进程列表、文件系统结构以及几乎每个内核数据结构都必须小心地访问,并使用正确的同步原语才能正常工作。
ch27 ch28
一些硬件支持(更加强大的指令)和一些操作系统支持(例如Solaris的park()和unpark()原语,Linux的futex())。当然,细节有所不同,执行这些锁操作的代码通常是高度优化的。读者可以查看Solaris或者Linux的代码以了解更多信息[L09,S09]。David等人关于现代多处理器的锁策略的对比也值得一看。
ch29
我们已经介绍了一些并发数据结构,从计数器到链表队列,最后到大量使用的散列表。同时,我们也学习到:控制流变化时注意获取锁和释放锁;增加并发不一定能提高性能;有性能问题的时候再做优化。关于最后一点,避免不成熟的优化(premature optimization),对于所有关心性能的开发者都有用。我们让整个应用的某一小部分变快,却没有提高整体性能,其实没有价值。
当然,我们只触及了高性能数据结构的皮毛。Moir和Shavit的调查提供了更多信息,包括指向其他来源的链接[MS04]。特别是,你可能会对其他结构感兴趣(比如B树),那么数据库课程会是一个不错的选择。你也可能对根本不用传统锁的技术感兴趣。这种非阻塞数据结构是有意义的,在常见并发问题的章节中,我们会稍稍涉及。但老实说这是一个广泛领域的知识,远非本书所能覆盖。感兴趣的读者可以自行研究。
ch30
我们看到了引入锁之外的另一个重要同步原语:条件变量。当某些程序状态不符合要求时,通过允许线程进入休眠状态,条件变量使我们能够漂亮地解决许多重要的同步问题,包括著名的(仍然重要的)生产者/消费者问题,以及覆盖条件。
ch31
信号量是编写并发程序的强大而灵活的原语。有程序员会因为简单实用,只用信号量,不用锁和条件变量。
本章展示了几个经典问题和解决方案。如果你有兴趣了解更多,有许多资料可以参考。Allen Downey关于并发和使用信号量编程的书[D08]就很好(免费的参考资料)。该书包括了许多谜题,你可以研究它们,从而深入理解具体的信号量和一般的并发。成为一个并发专家需要多年的努力,学习本课程之外的内容,无疑是掌握这个领域的关键。
ch32
在本章中,我们学习了并发编程中出现的缺陷的类型。第一种是非常常见的,非死锁缺陷,通常也很容易修复。这种问题包括:违法原子性,即应该一起执行的指令序列没有一起执行;违反顺序,即两个线程所需的顺序没有强制保证。
同时,我们简要地讨论了死锁:为何会发生,以及如何处理。这个问题几乎和并发一样古老,已经有成百上千的相关论文了。实践中是自行设计抢锁的顺序,从而避免死锁发生。无等待的方案也很有希望,在一些通用库和系统中,包括Linux,都已经有了一些无等待的实现。然而,这种方案不够通用,并且设计一个新的无等待的数据结构极其复杂,以至于不够实用。也许,最好的解决方案是开发一种新的并发编程模型:在类似MapReduce(来自Google)[GD02]这样的系统中,程序员可以完成一些类型的并行计算,无须任何锁。锁必然带来各种困难,也许我们应该尽可能地避免使用锁,除非确信必须使用。
ch33
我们已经介绍了不同风格的基于事件的并发。基于事件的服务器为应用程序本身提供了调度控制,但是这样做的代价是复杂性以及与现代系统其他方面(例如分页)的集成难度。由于这些挑战,没有哪一种方法表现最好。因此,线程和事件在未来很多年内可能会持续作为解决同一并发问题的两种不同方法。阅读一些研究论文(例如[A+02,PDZ99,vB+03,WCB01]),或者写一些基于事件的代码,以了解更多信息,这样更好。
III 持久性
ch36
至此你应该对操作系统如何与设备交互有了非常基本的理解。本章介绍了两种技术,中断和DMA,用于提高设备效率。我们还介绍了访问设备寄存器的两种方式,I/O指令和内存映射I/O。最后,我们介绍了设备驱动程序的概念,展示了操作系统本身如何封装底层细节,从而更容易以设备无关的方式构建操作系统的其余部分。
ch37
我们已经展示了磁盘如何工作的概述。概述实际上是一个详细的功能模型。它没有描述实际驱动器设计涉及的惊人的物理、电子和材料科学。对于那些对更多这类细节感兴趣的人,我们建议换一个主修专业(或辅修专业)。对于那些对这个模型感到满意的人,很好!我们现在可以继续使用该模型,在这些令人难以置信的设备之上构建更多有趣的系统。
ch38
我们讨论了RAID。RAID将大量独立磁盘扩充成更大、更可靠的单一实体。重要的是,它是透明的,因此上面的硬件和软件对这种变化相对不在意。
有很多可能的RAID级别可供选择,使用的确切RAID级别在很大程度上取决于最终用户的优先级。例如,镜像RAID是简单的、可靠的,并且通常提供良好的性能,但是容量成本高。相比之下,RAID—5从容量角度来看是可靠和更好的,但在工作负载中有小写入时性能很差。为特定工作负载正确地挑选RAID并设置其参数(块大小、磁盘数量等),这非常具有挑战性,更多的是艺术而不是科学。
ch39
UNIX系统(实际上任何系统)中的文件系统接口看似非常基本,但如果你想掌握它,还有很多需要了解的东西。当然,没有什么比直接(大量地)使用它更好。所以请用它!当然,要读更多的书。像往常一样,Stevens的书[SR05]是开始的地方。
我们浏览了基本的接口,希望你对它们的工作原理有所了解。更有趣的是如何实现一个满足API要求的文件系统,接下来将详细介绍这个主题。
ch40
我们已经看到了构建文件系统所需的基本机制。需要有关于每个文件(元数据)的一些信息,这通常存储在名为inode的结构中。目录只是“存储名称→inode号"映射的特定类型的文件。其他结构也是需要的。例如,文件系统通常使用诸如位图的结构,来记录哪些inode或数据块是空闲的或已分配的 文件系统设计的极好方面是它的自由。接下来的章节中探讨的文件系统,都利用了这种自由来优化文件系统的某些方面。显然,我们还有很多尚未探讨的策略决定。例如,创建一个新文件时,它应该放在磁盘上的什么位置?这一策略和其他策略会成为未来章节的主题吗?
ch41
我们介绍了崩溃一致性的问题,并讨论了处理这个问题的各种方法。构建文件系统检查程序的旧方法有效,但在现代系统上恢复可能太慢。因此,许多文件系统现在使用日志。日志可将恢复时间从O(磁盘大小的卷)减少到O(日志大小),从而在崩溃和重新启动后大大加快恢复速度。因此,许多现代文件系统都使用日志。我们还看到日志可以有多种形式。最常用的是有序元数据日志,它可以减少日志流量,同时仍然保证文件系统元数据和用户数据的合理一致性。
E1 分布式
E2 虚拟机
虚拟化正在复兴。出于多种原因,用户和管理员希望同时在同一台计算机上运行多个操作系统。关键是VMM通常透明地(transparently)提供服务,上面的操作系统完全不知道它实际上并没有控制机器的硬件。VMM使用的关键方法是扩展受限直接执行的概念。通过设置硬件,让VMM能够介入关键事件(例如陷阱),VMM可以完全控制机器资源的分配方式,同时保留操作系统所需的假象。
你可能已经注意到,操作系统为进程执行的操作与VMM为操作系统执行的操作之间存在一些相似之处。它们毕竟都是虚拟化硬件,因此做了一些相同的事情。但是,有一个关键的区别:通过操作系统虚拟化,提供了许多新的抽象和漂亮的接口。使用VMM级虚拟化,抽象与硬件相同(因此不是很好)。虽然OS和VMM都虚拟化硬件,但它们通过提供完全不同的接口来实现。与操作系统不同,VMM没有特别打算让硬件更易于使用。
如果你想了解有关虚拟化的更多信息,还有许多其他主题需要研究。例如,我们甚至没有讨论I/O会发生什么,这个主题在虚拟化平台方面有一些有趣的新问题。我们也没有讨论操作系统“作为兼职”运行在有时称为“托管”配置中,虚拟化如何工作。如果你感兴趣,请阅读有关这两个主题的更多信息[SVL01]。我们也没有讨论,如果VMM上运行的一些操作系统占用太多内存,会发生什么。
最后,硬件支持改变了平台支持虚拟化的方式。英特尔和AMD等公司现在直接支持额外的虚拟化层,从而避免了本章中的许多软件技术。也许,在尚未撰写的一章中,我们会更详细地讨论这些机制。