操作系统之哲学原理 第2版

邹恒明　著

ISBN：978-7-111-36692-8

本书纸版由机械工业出版社于2012年出版，电子版由华章分社（北京华章图文信息有限公司）全球范围内制作与发行。

版权所有，侵权必究

客服热线：+ 86-10-68995265

客服信箱：service@bbbvip.com

官方网址：www.bbbvip.com

新浪微博 @研发书局

腾讯微博 @yanfabook

目录

前言

计算机的心智

操作系统的奥秘

操作系统之哲学原理

本书内容安排

基础原理篇

进程原理篇

线程原理篇

内存原理篇

文件原理篇

I/O原理篇

多核原理篇

操作系统设计篇

本书特点

第一篇 基础原理篇

第1章 操作系统导论

引子：智者的挑战

1.1 人造与神造

1.2 程序是如何运行的

1.3 什么是操作系统

1.4 魔术与管理

1.5 用户程序与操作系统

1.6 操作系统的范畴

1.7 为什么学习操作系统

思考题

第2章 操作系统历史

引子：不能承受之真

2.1 第一阶段：状态机操作系统（1940年以前）

2.2 第二阶段：单一操作员单一控制端操作系统（20世纪40年代）

2.3 第三阶段：批处理操作系统（20世纪50年代）

2.4 第四代：多道批处理操作系统（20世纪60年代）

2.5 第五代之一：分时操作系统（20世纪70年代）

2.6 第五代之二：实时操作系统

2.7 第六代：现代操作系统（1980年以后）

2.8 操作系统的演变过程

2.9 操作系统的未来发展趋势

2.10 讨论：操作系统虚拟化和虚拟化的操作系统

思考题

第3章 操作系统基本概念

引子：软件师的尴尬

3.1 计算机硬件基本知识

3.2 抽象

3.3 内核态和用户态

3.4 操作系统结构

3.5 进程、内存和文件

3.6 系统调用

3.7 壳

思考题

第二篇 进程原理篇

第4章 进程

引子：牛顿的困惑

4.1 进程概论

4.2 进程模型

4.3 多道编程的好处

4.4 进程的产生与消亡

4.5 进程的层次结构

4.6 进程的状态

4.7 进程与地址空间

4.8 进程管理

4.9 进程的缺陷

思考题

第5章 进程调度

引子：恐怖分子的调度

5.1 进程调度的定义

5.2 进程调度的目标

5.3 先来先服务调度算法

5.4 时间片轮转算法

5.5 短任务优先算法

5.6 优先级调度算法

5.7 混合调度算法

5.8 其他调度算法

5.9 实时调度算法

5.10 进程调度的过程

5.11 高级议题：调度异常之优先级倒挂

思考题

第6章 进程通信

引子：孤独爆破手的自白

6.1 为什么要通信

6.2 进程对白：管道、记名管道、套接字

6.3 进程电报：信号

6.4 进程旗语：信号量

6.5 进程拥抱：共享内存

6.6 信件发送：消息队列

6.7 其他通信机制

思考题

第三篇 线程原理篇

第7章 线程

引子：亚历山大的分身术

7.1 进程的分身术——线程

7.2 线程管理

7.3 线程模型的实现

7.4 现代操作系统的线程实现模型

7.5 多线程的关系

7.6 讨论：从用户态进入内核态

7.7 讨论：线程的困惑——确定性与非确定性

思考题

第8章 线程同步

引子：滑铁卢的同步

8.1 为什么要同步

8.2 线程同步的目的

8.3 锁的进化：金鱼生存

8.4 睡觉与叫醒：生产者与消费者问题

8.5 信号量

8.6 锁、睡觉与叫醒、信号量

8.7 管程

8.8 消息传递

8.9 栅栏

思考题

第9章 死锁应对之哲学原理

引子：迷雾笼罩的加拿大

9.1 为什么会发生死锁

9.2 死锁的描述

9.3 死锁的4个必要条件

9.4 哲学家就餐问题

9.5 死锁的应对

9.6 消除死锁的必要条件

9.7 银行家算法：冒险的代价

9.8 哲学家就餐问题之解

9.9 讨论：死锁的思考——综合治理

9.10 讨论：死锁、活锁与饥饿

思考题

第10章 锁的实现

引子：高登绳结

10.1 以中断启用与禁止来实现锁

10.2 以测试与设置指令来实现锁

10.3 以非繁忙等待、中断启用与禁止来实现锁

10.4 以最少繁忙等待、测试与设置来实现锁

10.5 中断禁止、测试与设置

思考题

第四篇 内存原理篇

第11章 基本内存管理

引子：让别人无路可走

11.1 内存管理的环境

11.2 内存管理的目标

11.3 虚拟内存的概念

11.4 操作系统在内存中的位置

11.5 单道编程的内存管理

11.6 多道编程的内存管理

11.7 闲置空间管理

思考题

第12章 页式内存管理

引子：虚拟概念的变现

12.1 基址极限管理模式的问题

12.2 分页内存管理

12.3 分页系统的优缺点

12.4 翻译速度

12.5 缺页中断处理

12.6 锁住页面

12.7 页面尺寸

12.8 内存抖动

思考题

第13章 页面更换算法

引子：黑洞理论的替换

13.1 页面需要更换

13.2 页面更换的目标

13.3 随机更换算法

13.4 先进先出算法

13.5 第二次机会算法

13.6 时钟算法

13.7 最优更换算法

13.8 NRU算法

13.9 LRU算法

13.10 工作集算法

13.11 工作集时钟算法

13.12 页面替换策略

思考题

第14章 段式内存管理

引子：否定之否定

14.1 分页系统的缺点

14.2 分段管理系统

14.3 分段的优缺点

14.4 段页式内存管理

14.5 段号是否占用寻址字位

14.6 讨论：否定之否定的嵌套——纯粹分段与逻辑分段、分页与段页

思考题

第五篇 文件原理篇

第15章 磁盘操作

引子：EMC——从不可能到可能

15.1 磁盘组织与管理

15.2 磁盘的结构

15.3 盘面的结构

15.4 磁盘驱动器的访问速度

15.5 操作系统界面

15.6 磁盘调度算法

15.7 讨论：固态盘

15.8 讨论：智能磁盘系统

思考题

第16章 文件系统

引子：掩饰的极致

16.1 为什么需要文件系统

16.2 文件系统

16.3 文件系统的目标

16.4 文件的基本知识

16.5 从用户角度看文件系统

16.6 地址独立的实现机制：文件夹

16.7 文件系统调用

16.8 内存映射的文件访问

思考题

第17章 文件系统实现

引子：成功中的失败

17.1 文件系统的布局

17.2 文件的实现

17.3 目录实现：地址独立的实现

17.4 闲置空间管理

思考题

第18章 文件系统性能

引子：从不可能到可能

18.1 文件授权管理

18.2 主动控制：访问控制表

18.3 能力表

18.4 访问控制的实施

18.5 其他文件安全措施

18.6 文件系统性能

18.7 文件系统效率性能

18.8 文件系统设计分析：日志结构的文件系统

18.9 海量数据文件系统

思考题

第六篇 1/O原理篇

第19章 输入输出

引子：约翰逊的输出——从没有到爆发

19.1 输入输出

19.2 输入输出的目的

19.3 输入输出硬件

19.4 I/O软件

19.5 I/O软件分层

思考题

第七篇 多核原理篇

第20章 多核结构与内存

引子：不能承受之热

20.1 以量取胜

20.2 多核基本概念

20.3 多核的内存结构

20.4 对称多处理器计算机的启动过程

20.5 多处理器之间的通信

20.6 SMP缓存一致性

20.7 多处理器、超线程和多核的比较

思考题

第21章 多核环境下的进程同步与调度

引子：“多核”帝国的陨落

21.1 多核环境下操作系统的修正

21.2 多核环境下的进程同步与调度

21.3 多核进程同步

21.4 硬件原子操作

21.5 总线锁

21.6 多核环境下的软件同步原语

21.7 旋锁

21.8 其他同步原语

21.9 多核环境下的进程调度

21.10 多核环境下的能耗管理

21.11 讨论：多核系统的性能

思考题

第八篇 操作系统设计篇

第22章 操作系统设计之哲学原理

引子：残缺心智的胜利

22.1 操作系统设计的追求

22.2 操作系统设计的第1条哲学原理：层次架构

22.3 操作系统设计的第2条哲学原理：没有对错

22.4 操作系统设计的第3条哲学原理：懒人哲学

22.5 操作系统设计的第4条哲学原理：让困于人

22.6 操作系统设计的第5条哲学原理：留有余地

22.7 操作系统设计的第6条哲学原理：子虚乌有——海市蜃楼之美

22.8 操作系统设计的第7条哲学原理：时空转换——沧海桑田之变

22.9 操作系统设计的第8条哲学原理：策机分离与权利分离

22.10 操作系统设计的第9条哲学原理：简单为美——求于至简，归于永恒

22.11 操作系统设计的第10条哲学原理：适可而止

思考题

结语：失望与希望

参考文献

前言

In Pursuit of Absolute Simplicity求于至简，归于永恒

当你在电脑上玩游戏、与朋友聊天或编写一个程序并加载运行的时候，你有没有一种像在观看魔术的感觉？编写好的程序能够编译运行，计算出结果，并显示或打印出来。你有没有觉得它很神秘？

如果想揭开这层神秘的面纱，你就得学习操作系统。

因为操作系统是掌控计算机运行的系统，在学习它的过程中，读者能够了解程序在计算机上运行的全景，或者说我们所认为的全景（见图1）。之所以这么说，是因为精确了解程序在计算机上运行的全景是极其困难的（有人认为这根本就是不可能的）。当然，这里的程序指的是有一定规模的程序，而不是那种只有几行代码的小程序（trivial program）。从某种程度上说，没有人敢肯定自己清楚计算机在任意时刻所处的状态。例如，在多流水线计算机上，如果发生中断或异常，我们根本就得不到一个精准的状态。唯一能做的就是推倒重来。

图　1　风靡世界的游戏《第二生命》

计算机的心智

人有心智吗？我想所有人都会回答：有！

人的心智就是人的灵气。这是每一个人的生命之气。就是这个灵气赋予了人丰富的思维、感受和行动能力（当然，也有人认为这是肉体进化的结果，不过这不是本书要讨论的问题）。

那么计算机有心智吗？这不是一个诡秘或者搞笑的问题。

人们通常认为能够运动的生命都是有灵气的，既然计算机能够完成一些人脑才能够完成的理性任务，它当然也有心智！而这个心智就是操作系统（见图2）。因为操作系统赋予了计算机活力。虽然读者有可能尚不明白操作系统是怎么一回事儿，但也许知道若没有操作系统，现代计算机是运转不起来的（这里需排除远古时代的古老计算机）。操作系统作为计算机赖以运转的控制中心，称其为计算机的心智可谓恰如其分。

图　2　计算机的心智就是操作系统

众所周知，理解或看透一个人的心智是很困难的，所谓画虎画皮难画骨，知人知面难知心。依此类推，既然操作系统是计算机的心智，恐怕理解起来也是困难重重的了。而这正是许多人在学习和研究操作系统时的共同感受。

操作系统的奥秘

记得小时候常常念过的一首诗是这样的：

从小时候就开始数了。

数到懂事、数到成熟，

还没有数清。

天上的星星为什么数不清呢？

像记忆和幻想，

永远背负着固执的谜……

对于许多大学计算机及相关专业的同学来说，操作系统就像天上的星星（见图3），隐藏着一个固执的谜，永远学不清楚。不过，操作系统真的学不清楚吗？

不是的。学不清楚是因为没有看到其背后的奥秘。这个奥秘不是所有的人都知道的。即使是研究操作系统的人也不一定意识到它，更别提计算机初学者了。

图　3　理解操作系统有点类似于数清楚天上的星星

那么这个奥秘是什么呢？

天上的星星数不清是因为我们试图做的事情是数星星。如果我们换个角度，不去数星星，而是寻找星星的设计师，让他告诉我们星星的数量，不就清楚了吗？

这也正是学习操作系统的奥秘。要理解操作系统，就要找到操作系统的设计师，让他们告诉我们操作系统所蕴涵的所有秘密。当然，这里的寻找设计师并不是真的找来他们，因为找到所有的设计师是不可能的。这里的设计师指的是一种抽象，一种所有设计师所共有的人生哲学，因为设计师在设计操作系统时会不自觉地将自己的思维或人生追求构造在操作系统里，从而赋予操作系统以心智，而操作系统也就在这种心智的指挥下运行着。

操作系统之哲学原理

正如前面所述，让设计师告诉我们操作系统的秘密是理解操作系统的最好办法。他们所用的载体就是其所遵循的生活哲学，这些生活哲学就是操作系统所遵循的哲学原理。

本书就是试图从这些哲学原理（也就是人类生活哲学）的视角来阐述操作系统，从而揭开操作系统的神秘面纱。

例如，CPU管理（进程与线程）、内存管理（虚拟存储）、外存管理（文件系统）、I/O管理（输入与输出）等操作系统的核心机制不外乎是对资源的管理，它们都遵循着一切人类资源管理的基本原则，即如何有效地发掘资源、监控资源、分配资源和回收资源。

除了提供管理的功能外，操作系统还需要保证自己的正常运转，即它必须尽力使自身不发生失效或崩溃，因为这是提供其他一切功能的基础。这与人类确保自身健康生活作为开发利用资源的前提是一个道理。

如果我们掌握了资源的根本属性，即资源管理必然涉及共享和竞争的管理，理解了操作系统必须首先保障自己的正常运转，就会理解操作系统的一切行为。前者指引着操作系统功能的设计与进化，后者则推动着操作系统可靠性的演变。

资源管理也好，保证自身的正确性也好，它们都存在着根本的线索。这条根本线索就是人类在长期的生活实践中摸索出来的管理社会和保障自身安全的各种办法。这些办法是随着人类哲学思维的变化而改进的。因此，只要明白了人类的哲学思维，就能明白操作系统所遵循的哲学原理，进而明白整个操作系统的设计与构造。

除了使操作系统易于理解外，从哲学的层面阐述操作系统的原理还有如下好处：

●操作系统可以变化，但支持其存在的哲学原理是不变的。这样，本书的内容可以在操作系统不断演变的环境下保持有效，而不会像其他书中的内容，随着时间的推移而过时。

●对于很多人来说，操作系统所采取的机制、策略和手段看上去十分枯燥，如果从哲学原理上给它们赋予人性的特点，这些机制、策略和手段便不再枯燥。

通过将人生哲学与操作系统结合起来，从操作系统哲学原理的层次阐述操作系统的核心技术，就能够理解和掌握操作系统的精髓。

本书内容安排

为清楚地阐述操作系统的哲学原理，也为了使内容显得紧凑，逻辑上一气呵成，本书只选择了操作系统的核心内容进行分析，放弃了对操作系统核心以外内容，如安全、多媒体系统、虚拟机技术、光盘技术等的论述。本书集中精力对操作系统发展的历史背景、进程与线程、内存管理、文件系统、输入与输出、多核环境下的进程调度和操作系统设计进行了哲学原理层面上的分析与论述，而将安全、多媒体、虚拟机等技术留给别的专业书籍进行论述。对内容的这种安排有如下好处：

●可使本书重点突出、逻辑清晰、内容连贯，便于学生顺利掌握操作系统的核心与关键。

●操作系统的核心内容经过长久的研究与实践，已经变得较为稳定并且形成了众所公认的标准，讲解起来没有歧义。

●操作系统的非核心部分由于研究的时间短，工业界参与的程度较低，并无公认的标准，论述起来要么不全面，要么显得凌乱，使刚刚接触操作系统的读者感到迷惑。

●只要掌握了核心内容的原理，读者便能通过自学掌握操作系统核心以外的知识。

本书覆盖全国硕士研究生入学统一考试计算机学科《研究生考试大纲》中操作系统部分全部内容。

本书分为8篇，分别是基础原理篇、进程原理篇、线程原理篇、内存原理篇、文件原理篇、I/O原理篇、多核原理篇和操作系统设计篇，内容结构如图4所示。

图　4　本书内容结构

基础原理篇

该篇包含第1～3章的内容。第1章的内容包括智者的挑战、人造与神造、程序是如何运行的、什么是操作系统、魔术与管理、用户程序与操作系统、操作系统范畴和为什么学习操作系统。第2章探讨操作系统演变的主要过程：从单一控制终端、单一操作员，到批处理、多道批处理、分时操作系统、实时操作系统、现代操作系统；对商业操作系统演变的过程进行了分析，然后探讨操作系统的分类和未来发展趋势。第3章简要回顾计算机硬件基本知识，探讨什么是“抽象”，讲解用户态与内核态，阐述操作系统结构、系统调用、操作系统的壳等知识。

进程原理篇

该篇对操作系统最核心的概念——进程进行讲解，包括第4～6章的内容。第4章阐述进程出现的逻辑必然性、多道编程的效率、进程的创建和消亡、进程的状态及其转换、进程与地址空间、进程模型的实现和进程模型的缺陷。第5章讲解进程模型实现的手段：调度，主要内容包括调度的目标，先来先服务、时间片轮转、短任务优先、优先级调度、混合调度、实时调度等算法，并对优先级倒挂和线程的不确定性进行讨论。第6章的内容包括为什么要通信、管道、记名管道、套接字、信号、信号量、共享内存、消息队列等。

线程原理篇

进程虽然让并发从理想变为现实，但其自身却是串行的：进程只有一个执行序列。若要使进程本身也提供并发，则需要对线程进行讨论。该篇讨论了进程级的并发机制-线程模型，包括第7～10章的内容。第7章的内容包括进程分身术——线程、线程管理、线程的用户态和内核态以及混合态实现、现代操作系统的线程实现模型、多线程之间的关系、线程模型主要考虑的问题等。第8章的内容包括为什么同步、同步的目的、锁原语的进化、睡觉与叫醒原语、信号量、管程、消息传递和栅栏等。第9章对死锁的产生、发展、防止与避免进行讲解，并讨论死锁、活锁和饥饿的关系。第10章讲述如何使用中断启用和禁止、测试与设置来实现锁原语。

内存原理篇

该篇对操作系统的另外一个重要构成部分——内存管理进行阐述，包括第11～14章的内容。第11章讲述内存管理的环境、内存管理的目标、虚拟内存、操作系统在内存中的位置、单道编程的内存管理、固定加载地址、多道编程的内存管理、固定分区、非固定分区、交换、地址翻译、闲置空间管理等内容。第12章讲解的内容包括基址极限的问题、分页管理、页表、页面翻译过程、分页管理系统的优缺点、多级页表、地址翻译速度、锁住页面、内存抖动和页面尺寸设计。第13章对页面更换算法的来龙去脉、欲达到的目的、各种具体的页面更换算法进行细致讲解。第14章的内容包括分段管理系统、分段的优缺点、段号与寻址位数，并对否定之否定在内存管理模式发展过程中的作用进行了讨论。

文件原理篇

该篇对操作系统的第三个核心构件——文件系统进行讲解。包括第15～18章的内容。第15章讲述的内容包括磁盘结构、磁盘访问速度、磁盘的操作系统界面、磁盘访问过程和磁盘调度。第16章讲述为什么需要文件系统、什么是文件系统、文件系统的目的、文件的基本知识、文件的存储结构、文件类型、文件访问、文件属性、文件操作、文件夹、相对与绝对路径、共享与链接、内存映射的文件等内容。第17章的内容包括文件系统分布、文件的实现、文件夹的实现、共享文件的实现、磁盘空间的管理等。第18章的内容包括文件安全性能（文件访问控制、访问控制表、能力表）、文件可靠性能（持久性、一致性、日志、交易、随影、一致性检查）和文件系统的效率性能（提前读取、减少磁臂移动距离、日志结构的文件系统LFS）。

I/O原理篇

该篇对计算机与外界进行沟通的机制——输入与输出进行讲解。该篇仅有一章（第19章），讨论的内容包括输入输出的重要性和目的、输入输出硬件的哲学原理、物理I/O模式（专有通道I/O、内存映射的I/O、复合I/O、DMA）、输入输出软件的哲学原理、软件I/O模式（可编程I/O、中断驱动的I/O、DMA）、I/O软件分层和设备驱动程序等。

多核原理篇

该篇对新出现的多核技术进行讲解，重点讨论多核环境给操作系统带来的影响。该篇分为多核结构和多核环境下的进程同步与调度两章。第20章讲解的内容包括多核处理器结构（超线程结构、多核结构、多核超线程结构）、多核内存结构（UMA、NUMA、COMA、NORMA）、对称多核处理器计算机的启动过程、多处理器之间的通信、SMP缓存一致性等。第21章的内容包括多核进程同步、多核环境下的软件同步原语、旋锁及其实现、队列旋锁、多核环境下的进程调度、多核环境下的能耗管理和多核系统性能。

操作系统设计篇

该篇从高屋建瓴的角度对操作系统设计的十条哲学原理进行阐述。显然，操作系统的设计原则有很多，本篇选取的只是诸多原则里面非常重要的十条。第22章将从操作系统和人类社会两个层面对这十条原理进行论述与比较，以使读者更加清楚地明白操作系统就是人类社会在计算机里面的反映。读者可自行发现操作系统的其他设计原则。

本书特点

本书从哲学的视角对操作系统阐述了独到的见解。从人类自然的行为规范推演到操作系统的设计，以一条逻辑主线演绎了整个操作系统的各种原理。本书的特点是抽象提升（即从哲学原理上阐述操作系统的各种原理与设计）、联系生活（即通过人所熟知的生活实例来分析操作系统）、模块整合（即将操作系统的各个模块通过举例连结起来）、逻辑贯通（即将操作系统的各种机制以一根逻辑主线的发展依次讲解）和系统关联（即将涉及的其他学科知识点如体系结构和编译器等嵌入进来）。本书内容上更加新颖、系统上更加完整、逻辑上更加连贯、解说上更加易懂和层次上更加丰富的特点。

读者在阅读学习完本书后，将达到如下目标：

★了解操作系统在计算机软硬件整个体系中的中心主导作用。

★掌握操作系统的基本概念、原理、技术和实现机制。

★理解操作系统原理背后的人文背景与历史动机。

★运用操作系统知识来分析和解决问题。

★掌握操作系统设计的原理，为以后设计操作系统打下基础。

这里需要提醒的是，本书阐述的是操作系统的原理，它不依赖于任何具体的实现，而是凌驾于所有具体商业操作系统的进程实现之上，即本书所阐述的思想和原理对所有操作系统都适用。但具体商业操作系统在应用这些原理时可以有很灵活的方式。事实上，具体的商业操作系统在应用这些原理时确实采取了不同的方式，有的更为精密，也有的更加精简。另外，由于我们注重的是原理，对个体机制实现时采取的数据结构通常不做详细的论述，而是点到为止。这是因为数据结构必须以真正的操作系统为蓝本进行讲解，而真正的商业操作系统使用的数据结构通常非常复杂，对此进行繁琐的讲解将把学生弄得晕头转向，而不利于对操作系统核心原理的把握。

当然了，如果要达到能够设计开发真正商业操作系统的境界，读者还需要进行“操作系统工程”或“操作系统实现”等知识的学习。而这种工程或实现的课程通常以具体的操作系统为对象进行讲述。这些具体操作系统可以是Windows、UNIX、Linux，当然也可以是其他一些非主流商业操作系统（如Android、Symbian等）。如果能够将本书阐述的原理与操作系统工程或实现相结合，将取得极好的效果。

此外，本书使用的伪代码采用类似C/C++的结构，每种原理的表示均以展示操作系统的思路为最高目标，并不对表示的细节进行优化，以使得逻辑清晰，方便绝大多数读者理解。

现在就让我们一起来揭示秘密，数清操作系统里的星星吧。

第一篇　基础原理篇

大伪似真，大道无形

对于任何一门课来说，首要探讨的问题就是这门课的主题到底是什么。对于刚接触操作系统的入门者来说，自然想到的问题当然也会是操作系统到底是什么东西。回答这个问题是本篇的职责。此外，操作系统作为计算机的核心控制系统，它在计算机运行过程中扮演什么角色？它的来历是什么？它有一些什么基本概念？我们应该如何看待操作系统？它是如何参与到程序的执行过程中的？这些也是学习操作系统需要回答的基本问题。

本篇就是针对上述问题而写成。它对这些问题进行解答和讨论，并为我们接下来介绍操作系统的核心功能部件打下基础和铺垫。本篇包含第1～3章的内容。第1章的内容包括智者的挑战、人造与神造、程序是如何运行的、什么是操作系统、魔术与管理、用户程序与操作系统、操作系统范畴和为什么学习操作系统。第2章探讨操作系统演变的主要过程：从单一操作员单一控制终端，到批处理、多道批处理、分时操作系统、实时操作系统、现代操作系统；对商业操作系统演变的过程进行了分析，然后探讨操作系统的分类和未来发展趋势。第3章简要回顾计算机硬件基本知识，探讨什么是“抽象”，讲解用户态与内核态，阐述操作系统结构、系统调用、操作系统的壳等知识。

本篇最为重要的核心思想是操作系统在计算机运行过程中扮演的角色：魔术师和管理者。魔术师将丑陋变得美好，将没有变为有，将少变为多；而管理者则对所有计算机资源进行管理以达到公平和效率的“双料”境界。对操作系统这两个角色的理解将非常有助于对进程、线程、虚拟内存、文件系统和输入输出系统的掌握。

化丑为美：魔术师是操作系统扮演的一个根本角色

第1章　操作系统导论

引子：智者的挑战

西方有一个著名的故事，名曰：智者的挑战。相传很久以前，有个村子里住着一位智者。同村有个年轻人学到一些知识后就想来挑战这位智者。于是，年轻人想到了一个方法。他来到智者面前，将双手放在背后，问这个智者：

“我刚刚从树上抓了只鸟，现在我手中。但你能告诉我，这只鸟是活的还是死的呢？”

这是一个诡诈的问题。因为，如果回答“是活的”，则这个年轻人只需要在背后将鸟掐死，然后给智者看这只死鸟；如果回答“是死的”，则这个年轻人只需要将鸟放飞即可。这样，无论智者如何回答，年轻人都可以让智者答错，然后可以大大地嘲笑一番智者的水平。

作为智者，当然一眼就看出了年轻人的诡计。但是又不能不回答这个问题。因为不回答问题等于承认答不上来，当然也就是不是什么智者了。但如何回答呢？

智者的回答简洁、精妙，甚至妙不可言："As you will"。

will这个词在英语中的意思是意愿或意志。因此这个答案的意思是：这只鸟的死活与年轻人的意志保持一致：年轻人的意志是让这只鸟活，则这只鸟就是活的；年轻人的意志是让这只鸟死，则这只鸟就是死的。

如果到此止步，则这个答案仍存在着巨大的漏洞：年轻人可以将鸟掐死，但坚持说自己的意志是让鸟活着，即自己不小心将鸟掐死了，而自己的意志却是想让鸟活着。若是这样的话，智者的回答就错了。

妙不可言的是，will在英语中还有一层意思：将要，即将要发生的事或将要采取的行动。就是说，这只鸟的死活与年轻人将要采取的行动保持一致：年轻人将要放飞这只鸟，则这只鸟就是活的；年轻人将要掐死这只鸟，则这只鸟就是死的。

因此，智者的回答将人的意志与行为全部包括了进来。这样，即使年轻人声称他的意愿和行为并不一致，智者的回答也正确无误。

好，我们知道了智者的回答。但这与操作系统课程有什么关系吗？

有！很多人都觉得操作系统枯燥、乏味，甚至令人厌烦。更有人说懂不懂操作系统没有关系。不是很多人在学习操作系统之前就已经写过程序了吗？有的人甚至已经写过很大很复杂的程序了。可见，不懂操作系统并不妨碍我们学习使用计算机。

如果读者这样想，我劝你再想一想。你虽然写过程序，可你知道程序到底是如何在计算机上运行的吗？如果不知道，你怎么敢肯定你的程序总会运行正确呢？你怎么敢说你写的程序最大限度地利用了系统的能力呢？一个人觉得操作系统没用，那是因为他不知道怎么用，或者他没有用操作系统的意愿。说明白一点，如果你认为操作系统没有用，那是因为你的编程和程序开发处在一个低级的水平上。如果你掌握了操作系统，你的编程水平将显著提高。

换句话说，操作系统有没有用，我的回答是"As you will"。你如果有意愿或者有行动使用操作系统，操作系统就是有用的；如果你没有意愿或行动，则操作系统就是没有用的。当然了，我希望读者在看完这本书后能够领悟到操作系统的巨大用途。万一在读完本书后，读者还感觉困惑或者认为操作系统没用，我唯一能说的也是"As you will"了。当然，我希望这种情况发生的概率不大。

1.1　人造与神造

要想学好操作系统，具有恰当的思维模式是十分必要的。这个思维模式就是本书所强调的“哲学”：一种思维方式或一种生活方式。我们以一个问题来说明这一点。这个问题是：

“什么是计算机的根本特征？”

对于这个问题，相信很多人会说计算机就是台计算机器，或者用来进行大规模计算的机器，或者用于数据处理的机器，或者具备某些其他具体功能的机器。这些回答当然没有错，问题是这些答案并不能帮助我们更好地学习和理解计算机。就像我们问“张三这个人的根本特征是什么”，而回答却是“张三有175cm高”一样。这种答案虽然是正确的，但意义不大，因为我们无法从答案中推导出一系列有用的结论。

那这个问题该如何回答呢？这就要看我们对事物的观察程度了。如果我们仔细看看身边的事物，就会发现所有的东西可以划分为两类：一类是本来就存在于自然中，人类所做的只不过是发现；另一类是本来并不存在，人类所做的是发明。前者称为神造事物或者自然存在的事物，后者当然就是人造事物。从这个思维模式上看，计算机毫无疑问就是人造事物。这正是我们所需要的答案，即计算机的根本特征是“人造”。

引申一下可知，计算机学科就是一个人造学科。那么知道计算机学科是人造学科对我们学习计算机有什么帮助呢？有，太多了。下面我们来看看人造和神造有什么区别。

人造学科的四个特点：

●不精确、具有相对性。

●从对人类活动的观察导出。

●依赖于人的主观判断力。

●通常符合人的直觉。

第一个特点就是所谓的“没有对错”。在人造学科里，没有什么绝对的对或错，而只有所谓的“好”或“坏”，“有意义”或“没意义”。例如，如果本书在讨论计算机时对某个方面的论述与你见到的计算机不一样，这并不能说明本书错了。就算世界上不存在本书所论述的计算机，也不能说明本书错了。我们只需要按照本书的论述再造一台计算机即可。但是，本书论述的计算机与你知道的计算机之间可以进行好和坏的比较。

第二个特点说的是人造学科是从哪里得到灵感的，那就是“对人类活动的观察”。这样，读者只要对人类生活仔细观察，就可以很容易地理解计算机里面的许多原理。例如，在操作系统里面广泛使用的栈和队列就是对从生活中观察到的现象进行抽象所获得，如图1-1所示。

图　1-1　从观察人类活动而获得的栈和队列结构

第三个特点说的是在人造学科里，人的主观能动性起着关键作用。不同的人观察同样的现象，得出的结论或抽象出的东西可能不一样，甚至完全相反。这样，多数人所认同的抽象就将成为人造学科里的标准，即存在少数服从多数的原则。

第四个特点说的是人造学科里面的许多原理与人的直觉直接呼应，即如果我们按照人的直觉去理解这些原理，就会十分直截了当。例如，操作系统里面的同步机制与人类男女谈恋爱时所用的约会机制十分相似。对于一个谈过恋爱或与别人约会过的人来说，如果将自己谈恋爱的直觉用在操作系统进程的学习上，就会发现进程同步是个十分容易理解的概念。

相对人造学科，自然存在或神造学科刚好具有相反的四个属性：

●精确、绝对。

●从对自然存在的观察导出。

●不依赖于人的主观判断力。

●通常违反人的直觉。

第一个特点说的是神造的事物具有精确、绝对的属性。对于这种学科，存在正确与错误之分，我们提出的观点要么正确，要么错误，不存在中间状态。例如，纯数学领域的各种运算，如22的结果应该是4。如果运算的结果不是4，则属于运算错误。

第二个特点说的是人类对这些事物的理解是从对自然存在的观察中获得。例如，牛顿通过观察苹果落地的自然现象和严密的推理，得出了万有引力定律。

第三个特点说的是这些观察的结果是不依赖于人的主观能动性的。如果一个人的观察结果是正确的话，那么他的观察将和所有观察正确的人的结果一样，而绝不会是两样。从另外一个角度说，一个人的观察抽象结果是可以被他人验证的。例如，30 000 000 000+20 000 000 000对于任何人来说，如果计算正确，则结果必然是50 000 000 000。

第四个特点说的是如果我们按照人的直觉来学习，就会面临重重困难。因为人的思维与神不一样。自然，按照人的思维模式将很难理解神所创造的这一切。这就是为什么在这些学科耕耘的人都必须依赖灵感的出现，以及严密、一丝不苟的数学与逻辑推理（见图1-2）。

明白了计算机是人造事物，操作系统是一个人造系统，我们就可以按照人造事物的特点来学习，从而易如反掌地掌握操作系统的原理。

图　1-2　从观察自然存在和严密的数学推理而获得的质能方程

1.2　程序是如何运行的

计算机程序是如何运行的呢？对于多数人来说，或多或少地知道任何程序必须首先得有人写出来，即编程，然后放到计算机运行。这种解释当然是过于简单了。计算机程序的运行实际上是一件十分复杂的事情，牵扯到方方面面。

首先，当然得进行编程，而编程需要计算机程序设计语言作为基础。对于绝大多数编写程序的人来说，使用的编程语言称为高级程序设计语言，如C、C++、Java等。但由于计算机并不认识高级语言编写的程序，编好的程序需要进行编译变成计算机能够识别的机器语言程序，而这需要编译器和汇编器的帮助。其次，机器语言程序需要加载到内存，形成一个运动中的程序，即进程，而这需要操作系统的帮助。进程需要在计算机芯片CPU上执行才算是真正在执行，而将进程调度到CPU上运行也由操作系统完成。最后，在CPU上执行的机器语言指令需要变成能够在一个个时钟脉冲里执行的基本操作，这需要指令集结构和计算机硬件的支持，而整个程序的执行过程还需要操作系统提供的服务和程序语言提供的执行环境（runtime environment）。这样，一个从程序到微指令执行的过程就完成了。图1-3所示的就是这个过程。

当然了，图1-3描述的从程序到结果的演变过程还是过于简单。我们只是从一个线性角度来看程序的演变过程，而没有考虑各种因素之间的穿插和交互过程。不过，对于刚入门的计算机专业学生来说，这种描述能够帮助理解整个程序是如何在计算机上执行的问题。

图　1-3　由程序到结果的演变

从这个描述中可以看出：程序的运行至少需要如下四个因素：

●程序设计语言。

●编译系统。

●操作系统。

●指令集结构（计算机硬件系统）。

这四个因素都将是大学学习的专业课程。需要注意的是，操作系统在程序的执行过程中具有关键的作用，本书要做的就是阐述这个关键作用是如何发挥的。

需要注意的是，图1-3给出的程序执行过程是从高级语言编写的程序开始的。而实际上并非总是这样。事实上，程序可以直接在机器语言或汇编语言上编写。用这种称为“低级”的语言编写出来的机器语言程序无需经过编译器的翻译就可以在计算机指令集上执行。如果是在汇编语言上编写的汇编程序，则只需要经过汇编器的翻译即可加载执行。

1.3　什么是操作系统

操作系统这个术语听上去稀松平常，并不给人任何兴奋的感觉，甚至有点俗气。原因在于中文的“操作”这个词：提到操作员，通常让人想起操作车床、磨床和起重机的穿着油腻工作服的工人，这自然让人兴奋不起来。将英文的operating翻译为中文的“操作”，是因为翻译的人没有理解英文Operating Systems（OS，操作系统）这个名字所蕴涵的精髓。

那么英文的Operating Systems意味着什么呢？

各位见过手术过程吗？在手术室里，主刀大夫称为Operating Surgeon。在整个手术过程中，主刀大夫具有至高无上的权威：他说要打麻药，麻醉师便要赶紧打麻药；他说需要手术钳，助理大夫就赶忙递给他手术钳；他说需要止血，护士就要忙不迭地拿止血药棉来止血。整个手术最关键的部分，切开皮肤、拿掉器官、安装移植器官等均由主刀大夫完成。当然，主刀大夫有时候也会将某些任务，如缝合创口，交给助理大夫或护士来做，但整个手术的过程皆由其主控。一句话，Operating Surgeon就是掌控整个手术过程、具有精湛技术和敏锐判断力的医师。

引申至非医学领域，Operating Person意思是操刀手，就是掌控事情的人。再将Person这个词换成System，则Operating Systems指的就是掌控局势的一种系统。也就是说计算机里面的一切事情均由Operating Systems来掌控。那么，我们现在面临两个问题：第一个问题是，操作系统到底是什么东西？第二个问题是，操作系统到底操控什么事情？

我们先回答第一个问题。既然操作系统是掌控计算机局势的一个系统，自然很重要。但这个说法并不能帮助读者理解操作系统，也无法形成有形的概念。如果我们换个说法：操作系统是介于计算机和应用软件之间的一个软件系统，则概念就具体多了。从这个定义出发，我们知道操作系统的上面和下面都有别的对象存在：下面是硬件平台，上面是应用软件，如图1-4所示。

图　1-4　操作系统上下界面

现在回答第二个问题。我们知道操作系统代表的是掌控事情的系统，掌控什么事情呢？当然是计算机上或计算机里发生的一切事情。最原始的计算机并没有操作系统，而是直接由人来掌控事情，即所谓的单一控制终端、单一操作员模式。但是随着计算机复杂性的增长，人已经不能胜任直接掌控计算机了。于是我们编写出操作系统这个“软件”来掌控计算机，将人类从日益复杂的掌控任务中解脱出来。这个掌控有着多层深远的意义。

首先，由于计算机的功能和复杂性不断发生变化（趋向更加复杂），操作系统所掌控的事情也就越来越多，越来越复杂。同时，操作系统本身能够使用的资源也不断增多（如内存容量）。这是早期驱动操作系统不断改善的根本原因。

其次，既然操作系统是专门掌控计算机的，那么计算机上发生的所有事情自然需要操作系统的知晓和许可，未经操作系统同意的任何事情均视为非法的，也就是病毒和入侵攻击所试图运作的事情。作为操作系统的设计人员，我们当然要确保计算机不发生任何我们不知情或不同意的事情。但是人的能力是有限的，人的思维也是有缺陷的，我们设计出的系统自然不会十全十美，也会有缺陷，这就给了攻击者可乘之机。操作系统设计人员和攻击者之间的博弈是当前驱动操作系统改善的一个重要动力。

再次，掌控事情的水平有高低之分，有效率不同之分。就像手术大夫之间也有水平高低之分。为了更好地掌控事情，同时也为了更好地满足人类永不知足的各种越来越苛刻的要求，操作系统自然需要不断改善。这种改善在过去、现在和将来都会继续下去的。

最后，我们可以给操作系统下定义了：

●操作系统是一个软件系统；

●操作系统使计算机变得好用（将人类从繁琐、复杂的对机器掌控的任务中解脱）；

●操作系统使计算机运作变得有序（操作系统掌控计算机上所有的事情）。总结起来就是：操作系统是掌控计算机上所有事情的软件系统。

从这个定义可以引申出操作系统的功能：

●替用户及其应用管理计算机上的软硬件资源。

●保证计算机资源的公平竞争和使用。

●防止对计算机资源的非法侵占和使用。

●保证操作系统自身正常运转。

1.4　魔术与管理

提升上面所列的操作系统功能，可以得出操作系统所扮演的两个根本角色：管理者和魔术家。只要记住了这两个角色，就差不多明白了什么是操作系统。

1.4.1　魔术家角色

魔术家的目标是把差的东西变好，把少的东西变多，把复杂变简单。同样，操作系统将计算机以一个更加容易、更加方便、更加强大的方式呈现给用户。例如，如果在裸机上直接编程是很困难的，因为各种数据转移均需要用户自己来控制，对不同设备要用不同命令来驱动，而这对一般人很难胜任。操作系统将这些工作从用户手中接过来，从而让用户感觉编程是一件容易的事（相对来说，编程对于有些人来说永远很难），如图1-5所示。

操作系统通过进程抽象让每一个用户感觉有一台自己独享的CPU；通过虚拟内存抽象，让用户感觉物理内存空间具有无限扩张性，这就是把少变多。当然，操作系统的把少变多不是无中生有，变多也不是无限多，只是针对磁盘容量的大小。

1.4.2　管理者角色

操作系统管理计算机上软硬件资源。例如，操作系统对CPU、内存、磁盘等的管理，使得不同用户之间或者同一用户的不同程序之间可以安全有序地共享这些硬件资源。那怎么让用户很好地利用这些硬件资源呢？就是分块（parcel out），把硬件分块给应用程序使用。这里关键的原则是有效和公平，这是管理者的必备素质。有效指的是不能浪费资源，公平指的是每个人都有可能享有资源，即不能有不公平的现象。当然真正的公平是没有的事，这很像人类生活的现实。不过追求公平却是我们的本能，在虚拟世界里尽可能公平一点还是非常应该的，至少应该是设计操作系统时的不懈追求。

图　1-5　操作系统就是一个魔术师（illusionist）

根据管理的资源不同，操作系统具体功能如下：

●CPU管理，即如何分配CPU给不同应用和用户。

●内存管理：即如何分配内存给不同应用和用户。

●外存管理：即如何分配外存（磁盘）给不同应用和用户。

●I/O管理：即如何分配输入输出设备给应用和用户。

除了对上述资源进行管理和抽象外，操作系统作为掌控一切的软件系统，其自身必须是稳定和安全的，即操作系统自己不能出现故障。因此，操作系统的设计还需包括如下两项：

●健壮性管理：即如何确保操作系统自身的正常运作。

●安全性管理：即如何防止非法操作和入侵。

为完成上面所列的功能，操作系统设计人员构思了许多机制。而所有这些机制均有其渊源，其背后隐含的是人的哲学思维。本书就是要讲解操作系统背后的哲学原理，并依据这些哲学原理阐述操作系统是通过何种机制、以何种方式完成上述列举的各种管理功能的。

1.5　用户程序与操作系统

前面说过，操作系统上下分别是虚拟机器界面和物理机器界面。处于物理机器下面的是硬件，而硬件和操作系统的关系将是本书的关注点。处于虚拟机器界面上面的是应用软件，应用软件和操作系统的关系不是本书的重点，而是系统编程或底层编程等课程的关注点。在这里，我们只想来简要讨论一下应用程序和操作系统的关系，因为这个关系对理解操作系统非常重要。

那么，操作系统和应用程序之间是什么关系呢？很显然，操作系统为用户程序提供了一个虚拟机器界面，而应用程序运行在这个界面之上。但这个答案似乎太抽象，并不能帮助深入理解它们之间的关系。前面讲过，操作系统是一个程序，而用户程序也是程序，程序和程序之间能有什么关系呢？无非是调用和被调用的关系。

那操作系统和用户程序之间到底谁是调用者，谁是被调用者呢？答案似乎很清楚：操作系统通过虚拟机器界面为用户程序提供各种服务，用户程序在运行过程中不断使用操作系统提供的服务来完成自己的任务。例如，用户程序在运行过程中需要读写磁盘，这个时候就需要调用操作系统的服务来完成磁盘读写操作；如果需要收发数据包，也需要调用操作系统的服务来完成。当调用这些服务时，控制从用户程序转移到操作系统，而操作系统在完成这些服务后将控制返回给用户程序。在这种思维模式下，用户程序是主程序，而操作系统是子程序，如图1-6所示。

图　1-6　用户程序为主程序，操作系统为子程序

但是有正就有反，这就是哲学中的矛盾论。如果我们从另一个角度来看，会得出相反的结论。系统启动之后最先启动的是什么程序？操作系统。用户程序不能在操作系统启用之前启动（除非是很厉害的病毒）。在此之后，每次启动一个用户程序，都相当于操作系统将控制转移给用户程序；而在用户程序执行完毕后，控制又回到操作系统。这样看上去，操作系统是主程序，它在一生当中不断调用各种应用程序，而每个应用程序执行完之后再回到操作系统。就这样循环往复，直到无穷或机器关闭。在此种思维模式下，操作系统是主程序，用户程序是子程序，如图1-7所示。

上述两种看法完全相反，但又似乎都有道理，有谁对谁错之分吗？没有。我们说过，人造学科没有对错之分，只有好坏之分。你喜欢哪种观点就持那种观点，哪种观点帮助你理解操作系统，你就持那种观点。如果两种观点都有帮助，你可同时持有两种观点。

图　1-7　操作系统为主程序，用户程序为子程序

当然，上述关系的描述是非常简单化的。实际上，操作系统和各种用户程序可以看做互相调用，从而形成一个非常复杂的动态关系。了解并阐述这种复杂的动态关系就是本书的目的。

1.6　操作系统的范畴

前面介绍了操作系统的两个角色：魔术师和管理者。这两个角色之间既有区别，又有联系。为了完成不同的任务，操作系统有时需要扮演魔术师的角色，有时需要扮演管理者的角色，有时则需要同时扮演这两个角色。那么操作系统要完成的任务具体有哪些呢？前面提到过：

●CPU管理，即如何分配CPU给不同应用和用户。

●内存管理：即如何分配内存给不同应用和用户。

●外存管理：即如何分配外存（磁盘）给不同应用和用户。

●I/O管理：即如何分配输入输出设备给应用和用户。

CPU管理就是将要介绍的进程管理。进程管理的主要目的有3个：第一个是公平，即每个程序都有机会使用CPU。第二个是非阻塞（non-blocking），即任何程序不能无休止地阻挠其他程序的正常推进。如果一个程序在运行过程中需要输入输出或者因别的什么事情而发生阻塞，这个阻塞不能妨碍别的进程继续前进。就像人类世界，缺了谁地球照样旋转。第三个是优先级。在人类生活中人的地位不完全一样，地位高的就比地位低的优先级高。人类把自己生活中的这种关系搬到操作系统里面，就有了优先级的概念，即某些程序比另外一些程序优先级高。如果优先级高的程序开始运行，则优先级低的程序就要让出资源。就像我们经常说的，我们坚决反对大锅饭，应该让一部分人（程序）先富起来。

内存管理主要是管理缓存、主存、磁盘、磁带等存储介质所形成的内存架构。为此目的，操作系统设计人员发明了虚拟内存的概念，即将物理内存（缓存和主存）扩充到外部存储介质（磁盘、光盘和磁带）上。这样内存的空间就大大地增加了，能够运行的程序的大小也大大地增加了。内存管理的另一个目的是让很多程序共享同一个物理内存。这就需要对物理内存进行分割和保护，不让一个程序访问另一个程序所占的内存空间，专业术语称为运行时不能越界。在生活中，就是我家的东西不希望你跑来拿。

外存管理通常也称为存储管理，它就是众所周知的文件系统了。文件系统的主要目的是将磁盘变成一个很容易使用的存储媒介以提供给用户使用。这样我们在访问磁盘时无须了解磁盘的物理属性或数据在磁盘上的精确位置，诸如磁道、磁柱、扇面等。当然，文件系统还可以建立在光盘和磁带上。只是使用最为频繁的文件系统都以磁盘为介质。

I/O管理也称为设备管理，就是管理输入输出设备。I/O管理的目的有两个：一是屏蔽不同设备的差异性，即用户用同样的方式访问不同的设备，从而降低编程的难度；二是提供并发访问，即将那些看上去并不具备共享特性的设备（如打印机）变得可以共享。

另外还有一个任务称为批处理，它提供一种无需人机交互的程序运行模式。有时我们不需要人来交互，就交给计算机批处理。主要目的是达到吞吐量最大化，单位时间完成的任务最多。图1-8描述的是操作系统的5个核心功能。

当然，在真实的操作系统里，上述5个核心部件不一定界限分明，甚至它们不在同一个态势下运行（本书后面将说明这一点）。图1-9描述的是Windows操作系统简化了的结构。

图　1-8　操作系统的5个核心功能

图　1-9　Windows操作系统简化结构图

1.7　为什么学习操作系统

到目前为止，本书论述了操作系统的定义、主要任务以及操作系统与用户程序的关系，读者可以体会到操作系统的重要性。但仅仅是因为操作系统重要就要学习它吗？世界上重要的东西太多了，难道我们都要学吗？即使是计算机专业的学生，不学操作系统也照样可以编程写软件。那我们为什么要学呢？当然我们有一千个理由要学，但这里仅列出了几个。

首先，操作系统的功能应用于很多领域。如果开发并发程序：Web Service、分布式系统和网络，你会发现，这些领域大量使用了操作系统的概念和技术。如果你学好了操作系统，你就可以对你做的事情更加有信心。

其次，操作系统的技巧也应用于很多领域，如抽象、缓存、并发等。操作系统简单来说就是实现抽象：进程抽象、文件抽象、虚拟存储抽象等。而很多领域也使用抽象。如数据结构和程序设计就大量使用了抽象，记得抽象数据类型吗？记得抽象类吗？很多地方都用缓存。你开发Web应用要不要缓存呢？这些你都需要。如果学习操作系统，你就掌握了这些知识。触类旁通，你学习别的知识时就容易多了。

不过最重要的理由并不是上面的两条，而是操作系统真的很有趣。对于一个计算机专业的学生来说，难道不想知道自己写的程序如何在计算机上运行吗？读者一定见过汽车吧。汽车前面那个盖子叫前盖（hood）。很多人买车后第一件事是什么？打开前盖。那么打开前盖看到的是什么东西？马达、变速箱。为什么第一件事要打开前盖呢？因为好奇这辆汽车是怎么开动的（见图1-10）。

图　1-10　学习操作系统就是揭开覆盖在计算机上的“前盖”

那么对于一个程序员来说，有没有在看到一台计算机的时候，想过为什么计算机能进行计算？有没有买来一台新计算机后就打开盖子呢？多数人恐怕没有打开过计算机外壳。不过，没有打开过也不用遗憾。因为即使你把计算机后盖打开，还是不能明白计算机是怎么运转的，此时只看到一堆硬件：芯片、主板、布线等，而这些硬件并不会告诉你太多有关计算机运转的信息。如果真的想知道计算机是怎么运转的，你就得学习操作系统。当然，如果你想知道计算机在硬件层面上是如何运转的，则还应该学习计算机组成和体系结构等课程。

虽然学习操作系统很有趣，但并不是所有人都有这样的感觉。历史证明，对很多人来说，学习操作系统是一件很痛苦的事情。不过我希望阅读本书对读者来说是一件乐事，难道窥探奥秘不是一件激动人心的事吗？

思考题

1.什么是操作系统？请用一句话描述你对操作系统的理解。

2.你对操作系统和用户程序之间的关系有何看法？阐述你的想法。

3.简要列出操作系统覆盖的范畴及每个范畴的核心内容。

4.你为什么要学习操作系统？与本书列出的理由相同吗？简要阐述你的动机。

5.操作系统要对不同的部件进行管理，请论述这些管理之间的异同点。

6.设备管理要达到的目的是什么？

7.有人说设备管理软件（设备驱动程序）因为经常由第三方提供，因此不应该作为操作系统的一部分。你对此有何看法？你认为应该如何判断一个软件是否属于操作系统？

8.请列出程序执行过程中操作系统的介入情况。

9.说操作系统是人造学科，根据是什么？

10.人造学科的特点是什么？它对我们学习操作系统有何帮助？

11.OS需要编译器来编译，而编译器的运行需要OS来支持，那到底是谁先出现谁后出现呢？

第2章　操作系统历史

引子：不能承受之真

20世纪末21世纪初，美国兴起了励志演讲（motivational speech）大潮，各种人等乐此不疲。在这拨励志大潮中，出现了多名影响力广泛的励志演讲家。这些演说家所到之处，真是万人空巷，人潮涌动，场面之壮观令人叹为观止。

其中一位演讲家由于其名望很高接受了电视台采访。

记者：“您在励志演讲领域声名远扬，影响巨大，您的每场演讲都爆满，能否请您阐述一下什么是励志演说？”

励志演讲家：“你想知道吗？告诉你，我跟你一样，我压根就不知道什么是励志演讲。我只知道励志演讲非常流行，我只不过是利用这个潮流来赚钱而已。既然大家都喜欢，我就讲，至于我讲的东西是什么意思，我根本不知道。但是听众很喜欢！”

我想起了一句英语歌谣："sometimes when we touch, the honest is too much！"（有时，当我们拥抱时，所感受的诚实太过分）。也许人们都不喜欢真实，于是成就了很多人在论述操作系统历史（甚至人类历史）时的粉饰太平。

操作系统的演变就是我们对计算机硬件进行粉饰的过程。

操作系统进化的推动因素

大多数的教材都专门有一章谈论相关领域的历史，操作系统自然也不例外。不过不同的教材谈论历史的目的却又不同。多数教材是为了铺垫一下本学科的发展背景，让学生了解相关领域里的发展大事，并没有将历史与现实的发展联系起来。本书谈论的历史则是以史明鉴，不是为了谈论历史而谈论历史，而是为了让学生明白操作系统为什么是现在这个样子，以及将来会是什么样子。从根本上把握操作系统这一计算机领域核心学科的脉搏，深刻理解社会变迁给计算机这门人造学科带来的不可抵挡的变化。同时，我们还将揭示计算机发展史上一些鲜为人知的重要细节，给学生一个窥探全貌的快感。

如前面所述，操作系统的不断发展与改善由两个因素驱动：

●硬件成本不断下降。

●计算机的功能和复杂性不断变化。

就是这些因素决定了操作系统的历史，我们一定要牢牢掌握这两个趋势。一是硬件成本不断降低，就以硬盘为例，IBM制造的第一张硬磁盘直径达到2米，造价100多万美元，而容量仅仅1MB，而现在一个容量100GB的硬盘成本只有几十美元。当然，过去的硬盘和今天的硬盘的制造技术完全不同，第1张硬盘的质量坚挺，可以当做咖啡桌使用，而现在的硬盘（指盘片）非常软，根本不能承受重物。二是计算机复杂性不断增加。这一点对于很多人来说并不感到惊奇。人类做的任何事情，都是越来越复杂。你不整理住的地方很快就乱了，有没有这种经验？当然，你的生活随着年龄的增长，也会越来越复杂。最初，计算机的组件虽然巨大，但数量少，且功能简单，现在，一台计算机里面包括的元件数量实在是太多了。

硬件成本的下降和计算机复杂性的提高就推动了操作系统的演变。成本降低意味着同样的价格可以买到更先进的计算机。而复杂性提高自然需要操作系统的能力也得提高。就是这些变化使得操作系统从最初的仅仅几百或几千行代码的独立库函数，发展到今天多达4000万行代码的操作系统（如Windows XP），而某些Linux的版本的代码行数更加庞大。

操作系统之所以越来越复杂是因为硬件质量和数量的提升使得操作管理的东西增多，而且人类永不满足的各种越来越苛刻的要求也使得计算机操作系统的复杂性增加。

另外，还有另外一个附加因素影响着操作系统的发展，这就是操作系统和攻击者之间的博弈。这个世界上总有些人想利用计算机的缺陷来进行各种损人利己或损人又不利己的活动。操作系统在最初设计时根本就没有想到会有人从事破坏活动。大概因为最早的计算机工作者认为能够达到使用计算机水平的人都是好人，无需设计任何安全机制。这样，在后来发现有人试图利用计算机进行不良操作时，就迫不得已修改操作系统，使其具有安全上的防范功能。每当操作系统改进了安全性，攻击者也会改良他们的攻击手段，这样循环往复，造就操作系统安全水平和攻击者攻击水平不断交替上升。

下面我们就来说一说操作系统是如何因上述驱动因素的变化而变化的。

2.1　第一阶段：状态机操作系统（1940年以前）

这是计算机处在萌芽时期出现的操作系统。这种操作系统运行在英国人巴贝斯（Babbes）想象中的自动机中。所谓状态机操作系统实际上算不上是我们现在通常所定义的操作系统，而是一种简单的状态转换程序：根据特定输入和现在的特定状态进行状态转换而已。这个时候的计算机也不是现代意义上的计算机，而是所谓的自动机，其功能非常简单，可以用原始来形容。能做的计算也只限于加减法。这个时代的操作系统没有什么功能，不支持交互命令输入，也不支持自动程序设计，甚至这个时候还没有存储程序的概念。

驱动这一阶段操作系统的动力是个人英雄主义。因为此时尚无任何计算机工业、计算机研究及计算机用户。计算机及其操作系统的发展完全是某些人的个人努力。

这个阶段因为计算机刚刚出现，没有多少人能够接触到计算机，自然不存在什么安全问题。

这个阶段没有操作系统。如果非要说有的话，人就是这个时代的操作系统：因为自动机的一切动作均是人在操控的。

2.2　第二阶段：单一操作员单一控制端操作系统（20世纪40年代）

这种单一操作员单一控制终端（Single Operator, Single Console, SOSC）的操作系统是在刚出现计算机时人们能想到的最直观的控制计算机的方式。这个时候的代表机型为美国宾夕法尼亚大学与其他机构合作制作的ENIAC计算机。这是第一台电子计算机，但不是第一台计算机。在这之前有个英国人造了一部机械计算机，通过手柄摇动进行计算。在ENIAC刚造出来的时候，谁都不知道计算机是怎么回事，所以没有操作系统的整体概念，唯一能想到的就是提供一些标准命令供用户使用，这些标准命令集合就构成了我们的原始操作系统SOSC。

SOSC操作系统的设计目的是满足基本功能，并提供人机交互。在这种操作系统下，任何时候只能做一件事，即不支持并发和多道程序运行。操作系统本身只是一组标准库函数而已。操作系统本身并不自我运行，而是等待操作员输入命令再运行。用户想使用什么服务，就直接在命令行键入代表该服务的对应操作系统的库函数名（文件名）即可。这种操作系统的资源利用率很低：你输入一个命令就执行一个库函数，拨一下动一下。当操作员在思考时或进行输入输出时，计算机则安静地等待。当然了，从人的角度来看，效率并不低，你键入什么，计算机就立即执行什么。但从机器的角度考虑，因为时刻都等着人相对较慢的动作，效率就太低了。

由于这个时代的计算机很稀少，整个世界也只有几台，而人却不是，提高计算机的利用率就变得十分重要。

2.3　第三阶段：批处理操作系统（20世纪50年代）

为了提高单一操作员单一控制终端的操作系统SOSC的效率，人们提出了批处理操作系统。在仔细考察了SOSC后，人们发现，SOSC效率之所以低下，是因为计算机总是在等待人的下一步动作，而人的动作总是很慢。因此，人们觉得，如果去掉等待人的时间，即让所有的人先想好自己要运行的命令，列成一个清单，打印在纸带上，然后交给一个工作人员来一批一批地处理，效率不就提高了吗？这样就形成了批处理操作系统。

批处理操作系统的代表、第二代通用计算机IBM的1401和7094等，就是这样通过减少人机交互的时间而达到CPU和输入输出利用率的改善。批处理的过程是：用户将自己的程序编在卡片或纸带上，交给计算机管理员处理。管理员在收到一定数量的用户程序后，将卡片和纸带上的程序和数据通过IBM 1401机器读入，并写到磁带上。这样每盘磁带通常会含有多个用户的程序。然后，计算机操作员将这盘磁带加载到IBM 7094上，一个一个地运行用户的程序，运行的结果写在另一个磁盘上。所有用户程序运行结束后，将存有结果的磁盘取下来，连到IBM 1401机器上打印结果，然后就可以将打印结果交给各个用户了。

很显然，在批处理下，操作系统的功能和复杂性均得到提升。在SOSC环境下，每个用户自己控制程序的开始和结束。而在批处理下，很多用户的程序一个接一个地存放在磁带上，用户本人并不在场，无法自己控制程序的开始和结束。而这个任务就交给了批处理操作系统。负责这个任务的操作系统功能就称为批处理监视器（batch monitor）。而整个批处理操作系统就是由批处理监视器和原来的操作系统库函数组成的，如图2-1所示。

图　2-1　SOSC和批处理操作系统比较

批处理监视器的功能就是按部就班地执行用户的程序。这个时代的操作系统仍然只能在同一时间执行一个程序，但此时文件的概念已经出现。之前在SOSC诞生时期没有文件的概念。为什么到批处理时期出现了文件的概念呢？因为磁带上的多个用户程序必须以某种方式进行隔离，这需要一个抽象的东西来区分。这个抽象的东西不是别的，就是文件。除了文件管理外，此时的操作系统还能够管理读卡机、磁带、打印机等。此种操作系统的任务就是加载一个程序、运行、打印结果，然后执行下一个程序。批处理操作系统的两个部分的关系也很清楚：一部分是控制程序执行，一部分是支持程序执行。

批处理操作系统的重要代表有IBM开发的FORTRAN监视系统FMS，用于IBM 709；IBM开发的基于磁带的工作监控系统IBSYS，用于IBM 7090和7094；以及密歇根大学开发的UMES（密歇根大学执行系统），用于IBM 7094。

密歇根大学执行系统（UMES）：操作系统的黎明

在当时，世界上最先进的计算机是IBM 7094。作为礼物，IBM分别给密歇根大学（UM）和麻省理工学院（MIT）各捐赠了一台。密歇根大学坐落在密歇根湖和伊犁湖旁，麻省理工学院紧邻大西洋。IBM的高管喜欢搞帆船比赛。每次搞帆船比赛都需要使用计算机来安排赛程、计算成绩、打印名次等。因此，IBM在捐赠机器给UM和MIT时有一个要求：平时归学校使用（MIT的机器还需要与新英格兰周围的学校如达特茅斯学院等共享），一旦进行帆船比赛就得停下一切计算任务为IBM服务。这当然使得学校很恼火。因为那个时候很难在程序执行中间停下来，将中间结果保留等以后再执行。只要停下来，就要从头再来。

于是，密歇根大学的R.M.Graham、Bruce Arden和Bernard Galler在1959年开发出了当时著名的MAD/UMES系统，即密歇根算法译码器和密歇根大学执行系统。密歇根算法译码器是一种可扩展的程序设计语言，而密歇根大学执行系统是一个能够保存中间结果的操作系统。有了这个系统，密歇根大学的计算机运行基本上不受IBM帆船比赛所造成的中断的影响。MIT在知道这个系统后，将其安装到自己的7094机器上。MAD编程语言随后又进一步移植到Philco、Univac和CDC等机器上，其很多功能后来被加入到FORTRAN语言里。

驱动这个阶段操作系统发展的动力是改善效率。因为机器的昂贵，我们不能容忍机器在操作员思考或I/O设备工作期间闲置起来。

2.4　第四代：多道批处理操作系统（20世纪60年代）

虽然批处理操作系统无需人机交互过程就能在一定程度上提高计算机的效率，但还是不那么令人满意。因为CPU和I/O设备的运行是串行的，即在程序进行输入输出时，CPU只能等待。CPU需要不断地探询I/O是否完成，因而不能执行别的程序。

由于I/O设备的运行速度相对于CPU来说实在太慢，这种让高速设备等待低速设备的状况令人颇感痛心。人们又想，能否将CPU和I/O进行并发呢？即在一个程序输入输出时，让另一个程序继续执行。换句话说，能否将CPU运行和I/O设备的运行重叠起来而改善整个系统的效率呢？答案是肯定的，不过需要付出代价。因为CPU和I/O重叠需要我们将多个程序同时加载到计算机内存里，从而出现了多道批处理操作系统。

在多道批处理操作系统时代，同一时间可以运行多个程序（宏观上），但控制计算机的人还是一个，即用户将自己的程序交给计算机管理员，由管理员负责将用户的程序加载到计算机里并执行。由于多个程序同时执行，因此操作系统需要能够在多个程序（工作）之间进行切换，并且能够管理多个I/O设备，同时还需要能够保护一个进程不被另一个进程干扰。

显而易见，操作系统的功能和复杂性都比简单批处理时复杂得多：既要管理工作，又要管理内存，还要管理CPU调度。

OS/360（M）：划时代的多道批处理操作系统

典型的多道批处理操作系统是IBM的OS/360（M），它由密歇根大学为IBM公司开发（这是该操作系统名称后面的M所代表的意义）。它运行在IBM的第三代计算机System/360、System370、System 4300等上。OS/360在技术上和理念上都是划时代的操作系统，引进了内存的分段管理，但在商业上没有获得广泛使用。因为它有很多错误。这些错误很难避免，划时代、崭新的东西很难一次做到完美。尽管如此，OS/360还是被公认为一个划时代的操作系统，它同时支持商业和科学应用，而之前的操作系统只能支持科学计算。IBM随后对OS/360（M）进行了改进，使其逐渐演变为一个功能强大、性能可靠的操作系统。这个改进的版本被命名为OS/390。该操作系统提供了资源管理和共享，允许多个I/O同时运行，以及CPU和磁盘操作可以并发。390获得了广泛的商业应用，一直到今天。

驱动这个阶段操作系统发展的动力仍然是改善效率。因为机器的昂贵，我们不能容忍机器（CPU）在I/O设备工作期间闲置起来。同时，我们对计算机的要求也开始多起来。因此，这个阶段还伴随着对用户不断增长的要求进行满足。

2.5　第五代之一：分时操作系统（20世纪70年代）

多道批处理操作系统的出现使计算机的效率（主要是吞吐率）大大提高。不过这时人们又提出了另外一个问题：将程序制作在卡片上交给计算机管理员来统一运行，将使得用户无法即时获知程序运行的结果。而这是一个大问题。想想如果你编写了一个程序，却需要让别人去运行，并等上若干天才能知道结果，这个滋味显然不好受。万一计算机管理员疏忽了，忘记运行你的程序，或者操作错误，导致程序丢失，情况就更加糟糕。

基于上述原因，人们考虑能否让人回到计算机前面来，每个人自己管自己的程序，但是，大家的程序可以同时运转。人的因素又引了回来。这看上去与原始的SOSC似乎一样，但有个关键的不同：多个人同时连在计算机上，每个人看做另外的一个I/O终端而已。每个用户拥有一个终端显示器，这些终端显示器经过RS232穿行线缆与计算机连接。终端显示器只能接收和发送文本命令和信息。计算机在所有连接的终端用户之间进行分时，即分给每个人有限的时间，只要时间到了，就换一个进程。这种时分切换下的操作系统就是分时操作系统。

在分时操作系统下，任意时间可以运行多个程序，且用户直接与计算机交互，当场调试程序。这就和单一操作员单一终端不一样了，从人本转成了物本。在单一操作员单一终端的情况下，一切等着人。以前执行一条命令就等人，分时系统就是人等机器。这个模型带来一个直接的结果，就是机器不再等你，等你想问题时机器就切换到别的程序，等你想好了机器我再切换回来，接受你的输入。就这样，计算机就在很多人之间来回转，你敲个命令就响应，然后切换走。如果时间掌握得好，用户输入完一个命令计算机正好回来，用户就无需等待。当然，如果一个用户打字速度足够快，可能会觉得计算机慢；如果打字速度非常慢，就有可能觉得计算机很快。不同的人感觉有可能完全不同。

显然，和前面几代的操作系统比，分时操作系统要复杂得多。相比于多道批处理系统，最主要的变化是资源的公平管理。在多道批处理下，公平不公平没有人知道。大家交了工作后只管回家等结果。至于自己的程序排在谁前谁后，抑或占用了多少CPU时间是无关紧要的。现在，大家都坐在计算机显示终端前面，任何的不公平将立即感觉到。因此，公平的管理用户的CPU时间就变得非常重要。除此之外，池化（pooling）、互斥、进程通信等机制相继出现，使得分时操作系统的复杂性大为增加。

傲慢的代价：MULTICS操作系统

分时操作系统里面最著名的应该是MULTICS和UNIX。前面讲过，IBM在其捐赠7094给UM和MIT时附加了一些条件，而这些附加条件使得学校非常恼火，但又不能拒绝（想想别的学校吧，他们连这种恼火的机会都没有）。为此，UM开发了UMES系统以应对这个问题。MIT则将UMES移植到了自己的7094中。后来大家觉得只保存中间结果还不是最好的办法，毕竟频繁地保存中间结果等帆船比赛结束后再进行重载仍存在麻烦，于是就想开发一个可支持多用户的分时操作系统，以便一劳永逸地解决这个问题。这个时候MIT想到了密歇根的R.M.Graham，将其请来。在R.M.Graham的主持下，来自贝尔实验室、DEC（Digital Equipment Corporation，美国数字仪器公司）和MIT的设计人员同仇敌忾（针对IBM的傲慢），一起努力，开始了MULTICS分时操作系统的研制。

不过，在MULTICS还没有开发出来时，开发团队内部出现了分歧，贝尔实验室的几个人越来越看不惯DEC和MIT的人，觉得这伙人的思维方式跟自己不一样，做的东西不专业，觉得和他们一起做研究把自己的水平降低了，就自立门户，搞出了UNIX，并因此获得图灵奖。而UNIX的出现，使得MULTICS从一面世，就不能挺立，真是中国历史上“既生瑜，何生亮”在计算机操作系统历史上的完美演绎。不过，MIT最后还是做出了一个应用于部分商用领域的分时操作系统CTSS（Compatible Time Sharing System）。而DEC公司则做出了非常成功的VMS操作系统。

分时操作系统通常运行在第三代计算机PDP、VAX和CRAY上，其中PDP、VAX是DEC公司生产的，不过DEC已经不复存在。CRAY是CRAY公司生产的。

驱动这个阶段操作系统发展的动力是响应时间和对越来越多资源的管理。因为机器的昂贵，我们不能容忍机器（CPU）在I/O设备工作期间闲置。同时，因为人的时间宝贵，我们不能容忍人们坐在机器前漫长等待。因此发明了分时操作系统来解决这两个问题。因为分时引入的多道程序设计，又造成操作系统的空前复杂，我们需要应对竞争、通信、死锁、保护等一系列的新功能。因此，操作系统在本阶段变得相当复杂。

2.6　第五代之二：实时操作系统

随着人类技术的进步，计算机得到了广泛应用。其中的一种应用称为进程控制系统，即使用计算机监控某些工业进程，并在需要的时候采取行动。所有这些系统都具备一个特点：计算机对这些应用必须在规定时间内做出响应，否则有可能发生事故或灾难。例如，在工业装配线上，当一个部件从流水线上一个工作站流到下一个工作站时，这个工作站上的操作必须在规定时间内完成，否则就有可能造成流水线瘫痪，而影响企业的生产和利润。又例如，在导弹防卫系统中，对来袭导弹的轨迹计算必须在规定时间内完成，否则就可能被来袭导弹击中而无法做出反应。其他对计算机响应时间有要求的系统包括核反应堆状态监视系统、化学反应堆监视系统、航空飞行控制系统等。

这种对计算机响应时间有要求的系统通常称为临界系统或应用。而为了满足这些应用对响应时间的要求，人们开发出了实时操作系统。实时操作系统是指所有任务都在规定时间内完成的操作系统，即必须满足时序可预测性（timing pre-dictability）。需要注意的是，实时系统并不是指反应很迅速的系统，而是指反应具有时序可预测性的系统。当然了，在实际中，实时系统通常是反应很迅速的系统。但这是实时系统的一个结果，而不是其定义。

显然，实时操作系统的最重要部分就是进程或工作调度。只有精确、合理和及时的进程调度才能保证响应时间。当然，对资源的管理也非常重要。没有精密复杂的资源管理，确保进程按时完成就成了一句空话。另外，基于其使用环境，实时操作系统对可靠性和可用性要求也非常高。如果在这些方面出了问题，时序可预测性将无法达到。

实时系统通常又分为软实时系统和硬实时系统。软实时系统在规定时间得不到响应所产生的后果是可以承受的，如流水装配线。即使装配线瘫痪，也只是损失了资金；而硬实时系统在得不到实时响应后则可能产生不能承受的灾难，如导弹防卫系统。如果反应迟钝，结果就可能是严重损失。

商用实时操作系统的代表有VxWorks和EMC的DART系统。

2.7　第六代：现代操作系统（1980年以后）

在20世纪80年代后期，计算机工业获得了井喷式的发展。各种新计算机和新操作系统不断出现和发展，计算机和操作系统领域均进入了一个百花齐放、百家争鸣的时代。尤其重要的是工作站和个人机的出现，使计算机大为普及。独享计算机也可以负担得起。这个时候的操作系统代表有：DOS、Windows、UNIX、Linux和各种主机操作系统，如VM、MVS、VMS等。DOS、Windows、UNIX、Linux通常称为开放式系统操作系统，分别运行在PC机、VAX和Workstation上。操作系统也重新回到子函数库的状态。

随着硬件越来越便宜，个人机出现在人们的视野中。人们可以拥有自己的计算机，而无需与他人分享。在刚刚出现个人机的时候，拥有个人机的人感觉很好，而那些需要与别人共享小型机的人则感觉不好。由于个人机由用户一个人独享，分时操作系统的许多功能就无须存在。因此，个人机操作系统又变回到了标准函数库系统。这时最著名的当属DOS、Windows 3X、苹果机操作系统（Mac OS）等。

但在独享了一阵个人机后，人们发现，没有分时功能的操作系统使一些事情无法完成。这是因为，虽然只有一个人在使用机器，但这个人可能想同时做好几件事，例如，同时运行好几个程序，没有分时功能这是不可能的。于是，人们觉得需要对个人机操作系统进行改善，将各种分时功能又加入了操作系统。

这时候就需要对程序进行保护，因为现在运行多个程序。虽然都是你的东西，但是也不能混淆。于是，Windows NT出现了，Xenix出现了，Ultrix出现了。

这个时候的另外一个特征是网络的出现。网络促进了网络操作系统和分布式操作系统的出现。对于网络操作系统来说，其任务是将多个计算机虚拟成一个计算机。传统的网络操作系统是在现有操作系统的基础上增加网络功能，而分布式操作系统则是从一开始就把对多计算机的支持考虑进来，是重新设计的操作系统，所以比网络操作系统效率高。分布式操作系统除了提供传统操作系统的功能外，还提供多计算机协作的功能。

2.8　操作系统的演变过程

计算机操作系统的演变可以分为三条发展线索，分别是主机操作系统、服务器操作系统和个人机操作系统。图2-2简略地表示了这三条线索上操作系统的演变历史。

图2-2最左面的3列代表主机操作系统的演变，最右面的两列代表个人机操作系统的演变，中间列出了服务器操作系统的演变。当然，到最后，这三大块的界限也不是很清晰。

图　2-2　操作系统的历史演变

主机操作系统的演变从输入输出控制系统IOCS和IBSYS开始，经历OS/360的里程碑式的突破，逐步演变为VS、MVS和VM三个系列（其中VM系列还吸取了UMES和CTSS的某些特征）。目前IBM是这三个系列的开发商和运营商，其VM操作系统经历多代后已经变得十分可靠。美国大型金融证券公司都在使用它们。

服务器操作系统的演变从UMES开始，经CTSS演变为IBM的MVS和VM操作系统，经MULTICS演变为UNIX系统。在20世纪80年代初，UNIX一分为二：由AT＆T（美国电话电报公司）提供的System系列和由UCB提供的BSD系列。XENIX为微软公司为PC机而移植的AT＆T版的UNIX操作系统。AT＆T是UNIX的鼻祖，UCB则在美国国防部的支持下开发了BSD（伯克利软件分配）系列。

IBM和斯坦福大学看到AT＆T和UCB的UNIX软件后，也不甘示弱，分别研发了AIX和SUN OS（SUN是Stanford University Networks（斯坦福大学网络）的缩写）。这样UNIX就形成了4个系列：AT＆T的System系列、IBM的AIX系列、SUN的SUN OS系列以及UCB的BSD系列。

20世纪90年代中期，在美国国防部停止对BSD的支持后，UCB亦停止了BSD系列的研发，而AT＆T也在与BSD焦头烂额的较量中放弃了System系列。Linux则趁AT＆T和UCB忙于与对方较量的时候发展起来。卡内基梅隆大学（Car-negie Mellon University）在看到UCB和斯坦福大学都研发了UNIX后，慌忙开发了个MACH操作系统。MACH为微内核操作系统，在学术界得到了一定的使用，但由于其运行效率低下而没有获得商业上的广泛应用。这样UNIX的商业使用版本就剩下AIX、SOLARIS系列（SUN OS的后续版本）和Linux系列。后来由于惠普公司（HP）加入到服务器行列使得UNIX家族又增加了一个版本：HP-UX。在UNIX家族中，SUN公司濒临灭亡的命运令SOLARIS的前途堪忧。

个人机操作系统的演变可以说是从DOS开始的。微软公司在1980年以100美元的成本买断了DOS的版权。而DOS的功能很简单：文件没有文件夹，所有文件都保存在同一个地方，谁都可以删除操作系统。当然那时也没有那么多人从事破坏活动。在看到苹果公司的Mac OS的图形界面后，微软公司给DOS增加了一个图形界面，称为Windows。Windows在发展了几个版本后，到Windows 98时，微软公司改变了战略。因为到当时为止，所有的Windows并不是真正的操作系统，而是覆盖在DOS上的一个用户图形界面，并不支持多道编程。微软高管比尔·盖茨亲自打电话给DEC的David Cutler，请其过来主持新一代Windows操作系统的研发工作。

David Cutler是DEC公司VMS操作系统的主要设计人员。他从DEC带过来一批人到微软公司工作，设计出了Windows NT操作系统。这是一个真正的支持多道编程的操作系统。Windows NT继承了VMS的优良结构和Windows 3X的图形界面，在一推出就获得了市场的认可。Windows NT经过几代演变，成为现在的Windows Vista。Windows操作系统系列也从单一的支持个人机演变为支持个人机和服务器的“双料”操作系统。

操作系统的分类

操作系统基本上可以分为：主机操作系统，如OS/260、OS/390、CTSS；服务器操作系统，如UNIX、Windows 2000、Linux；多CPU计算机操作系统，如Novell Netware；个人计算机操作系统Windows 2000、Windows XP、Mac OS；实时操作系统，如VxWorks、DART；嵌入式操作系统，如Palm OS、Windows CE、Android、Symbian等。

同一台计算机可以运行不同的操作系统，而同一个操作系统也可以运行在不同的计算机上。例如，个人机上可以运行的操作系统包括DOS、Linux、NeXTSTEP、Windows NT、SCO UNIX等DEC VAX计算机上可以运行的操作系统有VMS、Ultrix-32、BSD UNIX等。UNIX操作系统可以运行在XENIX 286、APPLE A/UX、CRAY-Y/MP、IBM 360/370等计算机上；Windows NT/XP可以运行在Intel 386和Itaninum、DEC的Alpha、摩托罗拉的PowerPC和MIPS计算机的MIPS上。

当然，运行在不同机器上的UNIX版本并不一样，例如运行在IBM 360/370上的UNIX是Amdahl UNIX UTS/580和AIX/ESA，而运行在CRAY-Y/MP计算机上的UNIX是AT＆T System V。

2.9　操作系统的未来发展趋势

随着计算机不断普及，操作系统的功能会变得越来越复杂。在这种趋势下，操作系统的发展将面临两个方向的选择：一是向微内核方向发展，二是向大而全的全方位方向发展。微内核操作系统虽然有不少人在研究，但在工业界获得的承认并不多。这方面的代表是MACH系统。在工业界来说，操作系统是向着多功能、全方位方向发展。Windows XP操作系统现在有4000万行代码，Windows 7的代码规模更大，某些Linux版本有2亿行代码，SOLARIS的代码行数也在不断增多。鉴于大而全的操作系统管理起来比较复杂，现代操作系统采取的都是模块化的方式，即一个小的内核加上模块化的外围管理功能。

例如最新的SOLARIS将操作系统划分为核心内核和可装入模块两个部分。其中核心内核分为：系统调用、调度、内存管理、进程管理、VFS框架、内核锁定、时钟和计时器、中断管理、引导和启动、陷阱管理、CPU管理；可装入模块分为：调度类、文件系统、可加载系统调用、可执行文件格式、流模块、设备和总线驱动程序等。

最新的Windows将操作系统划分成内核（kernel）、执行体（executive）、视窗和图形驱动和可装入模块。Windows执行体又划分为：I/O管理、文件系统缓存、对象管理、热插拔管理器、能源管理器、安全监视器、虚拟内存、进程与线程、配置管理器、本地过程调用等。而且，Windows还在用户层设置了数十个功能模块，可谓功能繁多，结构复杂（见图2-3）。

图　2-3　Windows 2000/XP/2003系统结构（来源：参考文献[8]）

进入21世纪以来，操作系统发展的一个新动态是虚拟化技术和云操作系统的出现。虚拟化技术和云操作系统虽然听上去有点不易理解，它们不过是传统操作系统和分布式操作系统的延伸和深化。虚拟机扩展的是传统操作系统，将传统操作系统提供的一个虚拟机变成多个虚拟机，从而同时运行多个传统操作系统；云操作系统扩展的是分布式操作系统，而这种扩展有两层意思：分布式范围的扩展和分布式从同源到异源的扩展。虚拟机技术带来的最大好处是闲置计算资源的利用，云操作系统带来的最大好处是分散的计算资源整合和同化。

2.10　讨论：操作系统虚拟化和虚拟化的操作系统

随着虚拟化及云计算等技术的出现和不断演变，人们心目中的各种传统计算技术、模型和理论都在发生改变。操作系统作为计算机的核心系统软件，其面临的调整或挑战可能更为深刻，也更为细腻。有人断言，虚拟化将迫使操作系统退出历史舞台，因为操作系统的功能将由虚拟机来替换。但情况的发展似乎并没有为这种观点提供佐证。

不论从管理者的角度看，还是从魔术师的角度看，虚拟机本身就是一个操作系统，只要虚拟机在运行，操作系统就在运行，只不过更换了一个名字而已。因此，所谓的操作系统将退出历史舞台一说实属偷换概念。当然，虚拟机与传统的操作系统有所不同，虚拟机设计的侧重点、功能范畴、某些功能的实现哲学并不完全等同于传统的操作系统。也许，那些声称操作系统将退出历史舞台的人指的是传统的非虚拟机操作系统。

事实上，传统的操作系统也不会退出历史舞台。不过，其在虚拟化时代发挥作用的方式有可能发生变化。有迹象表明，传统操作系统扮演的角色在全虚拟化环境下有所收敛并出现专攻趋势。例如，一种已经露出端倪的倾向是将应用软件和操作系统打包发布，即应用程序与其赖以运行的操作系统的部分功能作为一个软件包发布出来。用户运行软件时不再需要先安装操作系统，只需要安装一个虚拟机即可。这样不同的软件包可以打包不同的操作系统，用户无需因为购买了不同的软件而需要安装不同的操作系统。根据应用软件的具体情况，被打包的操作系统功能部分可能很小，也可能较大，但由于一个应用软件常常不会用到一个传统操作系统各方面的功能，因此这种打包里面的操作系统功能将不包括整个传统操作系统的功能。

在这种打包发布模式下，用户的运行环境只有虚拟机，而与传统操作系统剥离。应用软件开发商无需针对不同的操作系统为自己的软件开发多个版本，而只需要支持一种操作系统即可。由于一个应用只为一个操作系统而写，因此应用软件开发商可以选择一个最能增加应用软件稳定性的操作系统作为平台来打包发布。这样一个重要的问题就是应用软件开发商如何选择最合适的操作系统。考虑的因素当然包括安全性、稳定性、易用性、功能性和效率。至于每一个因素所应该占的比重到底是多少，当然是仁者见仁、智者见智，也因软件开发商的不同而不同。

无论虚拟化或者去虚拟化，无论操作系统与应用软件打包还是不打包，一个不变的因素是扮演魔术师与管理者角色的这种软件是不可或缺的，不管这个角色是叫做操作系统，还是叫做虚拟机，或者是其他更加令人匪夷所思的名称。其实，名称叫什么不要紧，关键的问题是学习掌握如何扮演魔术师和管理者。而达到这个目的途径就是——学习操作系统。

操作系统奇异点

随着人们不断提高对信息安全重视程度，如何构建可靠、可用和安全的操作系统就成为一个十分重要的课题。而对可靠、可用和安全的追求无疑将令操作系统更复杂，操作系统的规模也将不断增大。从UNIX的1400行代码到Windows XP的4000万行代码，这完全是一种爆炸式增长。而爆炸式增长的后果就是，操作系统设计已经变得极为复杂和困难。以致商用操作系统在关键机制的实现上都或多或少地进行了偷工减料，从而严重影响了操作系统的稳定性。另外，规模的庞大使得没有什么人能够完全理解一个完整的操作系统，而这种状况同样将限制操作系统的可靠性、可用性和安全性。这迫使我们不得不思考：操作系统能在复杂性和规模不断增加的情况下保持正确性和可靠性吗？它能在今天的云计算时代取得突破，而达到我们所祈望的“操作系统奇异点”（OS singularity）吗？

经上写着，不要预测将来。那就让我们拭目以待吧……

思考题

1.请列出你曾经用过的所有操作系统。哪个操作系统你觉得最好？为什么？

2.在你用过的操作系统中，你感觉哪个操作系统最好？解释你的答案。

3.计算机从过去单一操作员单一控制终端到现在的个人机，似乎我们转了一个圈。是不是我们人类总是喜欢反复无常呢？请阐述你对这种否定之否定的观点。

4.虽然我们不赞成对未来进行预测，但你是否对操作系统的未来演变有自己的看法呢？

5.MULTICS的出现在很大程度上是由于IBM的傲慢，你认为人的傲慢在操作系统发展过程中占据什么样的角色呢？

6.驱动操作系统发展的主要动力有哪些？它们是如何驱动的？

7.很多人都说，没有操作系统的计算机是一堆废铁，无法运转。但在计算机刚诞生的时候，谁也不知道操作系统这回事儿。那个时候的计算机为什么在没有操作系统的情况下能够运转呢？它们又是如何运转呢？

8.操作系统根据其运行的计算机硬件结构不同而分为主机操作系统、服务器操作系统和个人机操作系统。简要论述这三种操作系统的关键不同点。

9.MACH所提倡的微内核操作系统因为运行效率低下而没有获得广泛的商业应用，你认为其效率低下的原因何在？

10.简要论述实时操作系统和分时操作系统的区别。

第3章　操作系统基本概念

引子：软件师的尴尬

有一个小幽默也许读者听过，说是三个数学家和三个软件师（即软件工程师或软件专家）在一列火车上相遇。攀谈中，六个人发现大家都是去参加同一个会议的。然而让软件师们吃惊的是，三个数学家只买了一张车票，而他们却各买了一张票。

三个软件师于是问数学家：“你们三个人只买一张票，等会儿列车员查票你们怎么办呢？”（这里需要指出的是，西方的火车站不查票，而是在车上查票。）

三个数学家回答说：“这你们就不懂了吧。你们看好了，学习学习。”

过了不久，果然开始查票。只见三个数学家急急忙忙跑到厕所里面，将门从里面反锁上。查票员过来后敲厕所的门，问：“查票了，里面有人吗？”里面传来一个声音：“有人，但正在上厕所，无法开门，能否将车票从门底下递出来检查？”这个时候厕所门下面递出来一张车票。查票员看了没有任何问题，于是就离开了。

三个软件师看完这一幕，十分惊叹。心想，数学家就是比开发软件的人聪明，我们怎么从来没有想到这一招呢？于是，他们相约回程再同乘一趟车。

过了几天，开完会后，六个人在回程中又聚到了一起。见面后，三个软件师就迫不及待地告诉数学家们：“我们这次只买了一张票。你们是否也只买了一张票？”

只见数学家们微笑着，不动声色地回答道：“我们这次没有买票。”

软件师们一听，都不敢相信自己的耳朵，接着问道：“那你们这次怎么应付查票呢？”

数学家们丢下一句话：“你们自己想吧。”

软件师们想了半天，实在想不出来有什么办法可以应付查票员。没有办法，只好硬着头皮来问数学家：“我们想不出来，你们告诉我们吧。”

数学家们仍然微笑着说：“你们的脑袋不够用了吧。看我们再教你们一招。”

过了不久，开始查票了。三个软件师急急忙忙跑到厕所里面，将门从里面反锁上。这时有人敲厕所的门，说：“查票了，里面有人没有？”里面传来一个声音：“有人，但正在上厕所，无法开门，能否将车票从门底下递出来检查？”这个时候厕所门下面递出来一张车票。

门外的人接了车票，却再也没有递回来……

“差不多”精神

这个幽默对于学软件的人来说，也许不会感到很好笑，可对于学数学的人来说，听后感觉很好笑。开发软件的人认为数学家的薪水没有他们高，于是编造这么个故事来趁机阿Q一把。

不过静下心仔细想一想，发现数学家的做法还真有点道理。这个道理不是别的，而是因为数学是一门严谨的学科，一切都以精确为追求（这里是指纯数学，不包括那些不被认为是数学的应用数学）。而软件却没有任何精确可言，是十足的“差不多”学科。因为软件是一门人造学科，它没有对与错（这里指的是同一功能的不同实现，而不是说程序不可能出现错误），只有好或坏。我们设计软件的时候，也是觉得差不多就可以了，而没有什么精确的追求。

如果不信，就看看我们是如何分析算法的吧。我们使用所谓的渐进分析，将系数和非决定项都忽略不计，所谓的“只要数量级对就差不多”。至于软件可靠性、健壮性和成本估算，那就更不用说了，连差不多都不如，而是差很多。

操作系统作为一种软件，自然也是“差不多”就可以了，其中的许多设计都是各种折中的结果，到处体现着“差不多”精神。读者在学习操作系统时只要留心观察，就会发现很多这些差不多。而要理解这种“差不多”系统，自然需要抱着一种“差不多”的态度。如果一切吹毛求疵，学习操作系统会非常痛苦，也很难精确（不好意思，用了一次“精确”）把握操作系统的精髓。

令人欣慰的是，中国人自古就有“差不多”的思维逻辑，因此采纳“差不多”的态度对于中国人是一件很容易的事情。从这种角度看，学好操作系统是水到渠成的事情。（这里需要强调的是，笔者并不是说计算机学科中没有任何需要精确的时候，而是说在计算机学科里面体现“差不多”的地方非常多，以致整个学科都带有某种“差不多”的精神）。

前面说过，操作系统是一种软件，它运行在硬件上，又为更高层的应用软件提供服务。因此，对底层硬件的了解将帮助我们更好地掌握操作系统。下面我们就从计算机硬件开始介绍，探讨一下操作系统的主要概念。

3.1　计算机硬件基本知识

从概念上讲，计算机的结构非常简单：首先布置一根总线，然后将各种硬件设备挂在总线上。所有的这些设备都有一个控制设备，外部设备都由这些控制器与CPU通信。而所有设备之间的通信均需通过总线，如图3-1所示。图3-1中的粗线条表示总线。

图　3-1　计算机结构概览图

为了提高计算机的效率，人们又设计出了流水线结构，即仿照工业流水装配线，将计算机的功能部件分为多个梯级，并将计算机的每条指令分拆为同样多个步骤，使每条指令在流水线上流动，到流水线最后一个梯级时指令执行完毕。流水线上的每个梯级都可以容纳一条指令并同时执行，如图3-2所示。

图　3-2　5个梯级的流水线结构

为了进一步提高计算机的效率，在流水线的基础上，人们又发明了多流水线、超标量计算和超长指令字等多指令发射机制。这些机制的发明在提升计算机效率（主要是吞吐量）的同时，也极大地增加计算机结构的复杂度，并对操作系统和编译器提出了更高的要求。

图3-3描述的是一个超标量发射的体系结构。这个结构有两队指令读取和译码单元，以及三个执行单元。通过一个指令保持缓冲区，就可以实现多路复用（multiplex）和反多路复用（de-multiplex），从而提高系统每个功能单元的利用率和整个系统的吞吐量。

图　3-3　4个梯级的超标量发射结构

除了指令执行单元外，计算机中的另一个重要部件是指令的存放单元，称为存储架构。存储架构包括了缓存、主存、磁盘、磁带等。有的情况下还存在多级缓存和外部光盘。图3-4描述的是一个包括寄存器的5级存储介质构成的存储架构。

从寄存器到磁带，每一级存储媒介的访问延迟和容量均依次增大，而价格却依次降低。寄存器的访问速度最快，容量最小，但成本最高；磁带的访问速度最慢，容量最大，成本却最低。通过合理搭配，可以形成一个性价比颇高的存储架构。

图　3-4　典型的5级存储结构

磁盘是计算机的主要存储媒介。可以说，没有磁盘，计算机就不能称为计算机，或者说计算机的用处就要大打折扣。虽然确实存在无磁盘的计算机（即disk-less计算机），但这些计算机都是专用的，并不是给一般用户用的。磁盘从概念上看非常简单，每张磁盘有多块盘片，盘片两面都可以存储数据。图3-5描述的是典型的磁盘结构。

图　3-5　典型的磁盘内部结构

中断是计算机里面的一个最为重要的机制，它也是操作系统获得计算机控制权的根本保证。若没有中断，很难想象操作系统能够完成人们所赋予的任务。中断的基本原理是：设备在完成自己的任务后向CPU发出中断，CPU判断优先级，然后确定是否响应。如果响应，则执行中断服务程序，并在中断服务程序执行完后继续执行原来的程序。图3-6简单地描述了中断机制。

中断是一个很复杂的过程，中断处理过程中又可以发生中断，且还可以有所谓的软中断，即软件发出的中断。透彻理解中断对了解计算机操作系统的运行具有重要意义。因此，对中断机制不甚了解的读者请复习在计算机组成与体系结构中所学习的中断内容。

图　3-6　磁盘中断的响应流程

3.2　抽象

我们已经多次提到过，操作系统提供的是一个抽象。所谓的抽象，就是在根本上存在但现实中不存在的东西（exist in essence but not in reality）。那么到底怎样理解抽象呢？

抽象来源于具体，但又超越具体。例如，人是具体的动物。但如果将人的具体属性，如肉体和骨架全部剥离，剩下的就是抽象，即人的灵魂。绘画史上有抽象派，而抽象画所表现的就是现实中不存在的东西，但这些东西确实又来源于现实，如瓦西里·康定斯基的《海战》（见图3-7）。

图　3-7　康定斯基的《海战》：来源于现实，又高于现实

操作系统提供的抽象自然也来源于现实，就是具体的计算机硬件，如CPU、内存、I/O设备等。但又超出这些现实，给人提供了强于现实的东西，使人和应用软件感觉到更多、更好的硬件存在，而且只存在于操作系统层面，一般的人才会觉得计算机是可以使用的。

另外，抽象不只是操作系统提供给用户的一个存在，它也存在于操作系统内部。操作系统内部分为不同的功能块，而不同的功能块之间互相提供的也是抽象。

3.3　内核态和用户态

就像世界上的人并不平等一样，并不是所有的程序都是平等的。世界上有的人占有资源多，有的人占有资源少，有的人来了，别人得让出资源，有的人则专门为别人让出资源。程序也是这样的，有的程序可以访问计算机的任何资源，有的程序则只能访问少量受限资源。操作系统作为计算机的管理者，自然不能和被管理者享受一样的待遇。它应该享有更多的方便或权限。为了区分不同的程序的不同权限，人们发明了内核态和用户态的概念。

那么什么是内核态，什么又是用户态呢？只要想一想现实生活中处于社会核心的人与处于社会边缘的人有什么区别，就能明白处于核心的人拥有的资源多！因此，内核态就是拥有资源多的状态，或者说访问资源多的状态，称为特权态。相对来说，用户态就是非特权态，在此种状态下访问的资源将受到限制。如果一个程序运行在特权态，则该程序就可以访问计算机的任何资源，即它的资源访问不受限制。如果一个程序运行在用户态，则其资源需求将受到各种限制。

例如，如果要访问操作系统的内核数据结构，如进程表，则需要在特权态下才能办到。如果要访问用户程序里的数据，则在用户态就可以了。

由于内核态的程序可以访问计算机的所有资源，因此这种程序的可靠性和安全性就显得十分重要。试想如果一个不可靠的程序在内核态下修改了操作系统的各种内核数据结构，结果会怎样呢？整个系统有可能崩溃。运行于用户态的程序就比较简单，如果其可靠性和安全性出了问题，其造成的损失只不过是让用户程序崩溃，而操作系统将继续运行。

很显然，内核态和用户态各有优势：运行在内核态的程序可以访问的资源多，但可靠性、安全性要求高，维护管理都较复杂；用户态程序程序访问的资源有限，但可靠性、安全性要求低，自然编写维护起来比较简单。一个程序到底应该运行在内核态还是用户态则取决于其对资源和效率的需求。

一般来说，如果一个程序能够运行于用户态，就应该让它运行在用户态。只在迫不得已的情况下，才让程序运行于内核态。凡是牵扯到计算机本体根本运行的事情都应该在内核态下执行，只与用户数据和应用相关的东西则放在用户态执行。另外，对时序要求特别高的操作，也应该在内核态完成。

那么什么样的功能应该在内核态下实现呢？首先，CPU的管理和内存管理都应该在内核态实现。这些功能可不可以在用户态下实现呢？当然能，但是不太安全。从保障计算机安全的角度来说，CPU和内存的管理必须在内核态实现。

诊断与测试程序也需要在内核态下实现。因为诊断和测试需要访问计算机的所有资源，否则怎么判断计算机是否正常呢？就像中医治病，必须把脉触摸病人。你不让中医触摸，他怎么能看病呢（当然，很多人认为中医是伪科学，根本治不了病，本书对此问题不做讨论）？输入输出管理也一样，因为要访问各种设备和底层数据结构，所以也必须在内核态实现。

对于文件系统来说，则可以一部分放在用户态，一部分放在内核态。文件系统本身的管理，即文件系统的宏数据部分的管理必须放在内核态，否则任何人都可能破坏文件系统的结构；用户数据的管理则可以放在用户态。编译器、网络管理的部分功能、编辑器、用户程序等，自然都可以放在用户态下执行。图3-8描述的是Windows操作系统的内核态与用户态的界线。

图　3-8　Windows操作系统的内核态与用户态的界线

3.3.1　态势的识别

那么计算机是如何知道现在正在运转的程序是内核态程序呢？而正确做出内核态或用户态的判断对系统的正确运行至关重要。显然做出这种判断需要某种标志。这个标志就是处理器的一个状态位。这个状态位是CPU状态字里面的一个字位。也就是说，所谓的用户态、内核态实际上是处理器的一种状态，而不是程序的状态。我们通过设置该状态字，可以将CPU设置为内核态、用户态或者其他的子态（有的CPU有更多种子态）。一个程序运行时，CPU是什么态，这个程序就运行在什么态。

3.3.2　内核态与用户态的实现

前面说过，内核态是特权态，而用户态是普通态。特权态下运行的程序可以访问任何资源，而用户态下的访问则受到限制。那么这种限制是如何实现的呢？

显然，要限制一个程序对资源的访问，需要对程序执行的每一条指令进行检查才能完成。而这种检查就是地址翻译。程序发出的每一条指令都要经过这个地址翻译过程。而通过对翻译的控制，就可以限制程序对资源的访问。关于地址翻译的内容本书将在第12章详细阐述。

为了赋予内核态程序访问所有资源的权限，当系统处于内核态时，内核程序可以绕过内存地址翻译而直接执行特权指令，如停机指令。这种绕过翻译的做法突破了系统对资源的控制。

本书在介绍完进程和内存后，将再次讨论内核态与用户态的议题。

3.4　操作系统结构

操作系统的结构也和操作系统历史类似，经历了好几个阶段。在操作系统刚刚出现时，人们还没有意识到操作系统的存在，也没有将那些库函数称为操作系统。那个时候，人们想到什么功能，就把那个功能加进来，并没有对所有的功能进行统筹兼顾的计划。自然，那个时候的操作系统就是杂乱的、无结构的。

而随着操作系统的演化，人们对操作系统的认识逐步加深，操作系统慢慢多了一些结构。各种功能归为不同的功能块，每个功能块相对独立，又经过固定的界面互相联系。任意一个功能块都可以调用另一个功能块的服务。整个操作系统本身是一个巨大的单一体（monolithical system），运行在内核态下，为用户提供服务，如图3-9所示。

图　3-9　单一体的操作系统结构

后来人们发现单一体的操作系统结构有很多缺点：功能块之间的关系复杂，修改任意功能块导致其他所有功能块都需要修改，从而导致操作系统设计开发困难；这种没有层次关系的网状联系容易造成循环调用，形成死锁，从而导致操作系统可靠性降低。这时候，人们想到了人类社会中的层次关系，何不将人类熟悉的层次关系搬到操作系统设计里，定义操作系统的层次关系呢？将操作系统的功能分成不同层次，低层次的功能为紧邻其上一个层次的功能提供服务，而高层次的功能又为更高一个层次的功能提供服务。就像人类团体中的结构：村长→镇长→县长→市长……如图3-10所示。

图　3-10　层次化的操作系统结构

从图3-9和图3-10可以看出，操作系统的所有功能都在内核态下运行。而这带来了几个问题。首先，操作系统的所有服务都需要进入内核态才能使用，而从用户态转换为内核态是有时间成本的，这样就造成操作系统效率低下。在操作系统还比较简单时这个问题并不突出，但随着操作系统功能和复杂性的增加，这种问题就十分明显了。

其次，前面说过，在内核态运行的程序可以访问所有资源，因此其安全性和可靠性要求十分高。在操作系统规模很小时，将其设计得可靠和安全并不困难。而且，在操作系统历史的早期也没有那么多安全问题，自然安全上的考虑就不用太多。但随着操作系统越来越大，破坏者的水平越来越高，操作系统的可靠性和安全性就变得很难达到。只要想一想，1400行代码的操作系统和4000万行代码的操作系统有什么区别就知道了。

因此，人们又想出了一个办法，就是微内核结构，即只将操作系统核心中的核心放在内核态运行，其他功能都移到用户态运行。这样就同时提高了效率和安全性（见图3-11）。

图　3-11　微内核的操作系统结构

各种操作系统结构各有优缺点，但当前的趋势是第三种模式，即微内核的操作系统结构。至于这个微内核到底有多“微小”，则是仁者见仁、智者见智。例如，美国卡内基梅隆大学开发的MACH操作系统的内核非常小，而微软公司的Windows XP的内核就大多了。

3.5　进程、内存和文件

进程是操作系统里面的核心概念。它指的是一个运动中的程序。从名字上看，进程表示的就是进展中的程序。一个程序一旦在计算机里运行起来，它就成为一个进程。操作系统对进程的管理通过进程表来实现。进程表里存放的是关于进程的一切信息。在任何时候，进程所占有的全部资源，包括分配给该进程的内存、内核数据结构和软资源形成一个进程核（core）。核快照（core image）代表的是进程在某一特定时刻的状态。

如果在Linux或UNIX操作系统下编写程序，在出现分段错误（segmentation fault）时，操作系统会自动进行核倒出（core dump）。“核倒出”把所有计算机的状态保存在一个文件中，通过阅读这个文件的内容可以得知溢出时的进程状况，从而帮助调试程序。

进程与进程之间可以通信、同步、竞争，并在一定情况下可能形成死锁。这些概念都将在第4章进行详细阐述。

内存是操作系统里面的另一个核心概念。它是进程的存放场所。如何对内存进行管理，使得数据的读写具有高效率、高安全、高空间利用率和位置透明的特性是内存管理所要达到的目的。

文件是操作系统提供的外部存储设备的抽象，它是程序和数据的最终存放地点。如何让用户的数据存放变得容易、方便、可靠和安全是文件系统要解决的问题。

3.6　系统调用

前面说过，操作系统是一个系统程序，即为别的程序提供服务的程序。那么操作系统的服务是通过什么方式提供的呢？答案是系统调用（system call）。系统调用就是操作系统提供的应用程序界面（Application Programming Interface, API）。用户程序通过调用这些API获得操作系统的服务。例如，如果用户程序需要进行读磁盘操作，在C程序代码里将使用下面的语句：

result=read（fd, buffer, nbytes）；

这个read函数是C语言提供的库函数，而这个库函数本身则是调用的操作系统的read系统调用。注意这里有两个read，一个是read库函数，由程序语言提供；另一个是read系统调用，由操作系统提供。编译器在看到上述语句后将read库函数扩展为read系统调用。在真正执行时，操作系统将完成上述文件的读操作。

系统调用按照功能可以划分为六大类：

●进程控制类。

●文件管理类。

●设备管理类。

●内存管理类。

●信息维护类。

●通信类。

系统调用一般不在操作系统原理的课程中论述，而是在操作系统编程或系统编程的课程中论述。这里我们简单说一下系统调用的过程。系统调用分为三个阶段，分别是：

●参数准备阶段。

●系统调用识别阶段。

●系统调用执行阶段。

在参数准备阶段，需要使用系统服务的程序将系统调用所需要的参数，如上述例子中的fd、buffer、nbytes，压入栈中。然后调用read库函数。read库函数将read系统调用的代码放在一个约定好的寄存器里，通过陷入（trap，一种中断方式）将控制交给操作系统。由此进入第二个阶段。操作系统获得控制权后，将系统调用代码从寄存器里取出，与操作系统维护的一张系统调用表进行比较，获得read系统调用的程序体所在的内存地址。之后跳到该地址，进入到第三阶段，执行系统调用函数。系统调用执行完毕后返回到用户程序（见图3-12）。

图　3-12　read系统调用的过程

系统调用中的参数传递

从图3-12可以看出，read系统调用的参数被压入到栈中，即参数传递是通过栈来进行。但这并不是唯一的参数传递办法。事实上，这还不是效率最高的传递方法。效率最高的方法是将参数存放在指定的寄存器里面。由于寄存器的访问速度高于栈，因此这种参数传递将可以提升系统调用执行的效率。例如，在x64体系结构下，最前面的8个参数由寄存器传递。只有超过8个参数时，后面的参数才通过栈来传递。

3.7　壳

3.4节说明了操作系统是如何给用户程序提供服务的。用户程序通过调用操作系统提供的系统调用API来获得操作系统的各种服务。但使用API需要编程。对于不编程序的用户来说，或对于需要与操作系统进行交互的用户来说，又怎么使用操作系统的服务呢？

为了向这些不编程的用户提供服务，操作系统提供了一个壳（shell）来与用户交互。每个操作系统都提供某种壳，以便与用户进行交互。这个壳是覆盖在操作系统服务上面的一个用户界面，既可以是图形界面，也可以是文本界面。用户在这个界面上输入命令，操作系统则执行这些命令。当然，用户输入的命令不是直接的操作系统服务，而是所谓的utilities。utilities的功用相当于C语言中的库函数。因为用户不能直接调用系统调用（至于为什么不能留给读者思考），C语言提供了库函数来解决这个问题。

同理，在壳上用户也不能直接使用操作系统的服务，而是通过utilities来获得操作系统服务。UNIX和Linux的壳都是文本形式，而Windows的壳是图形界面的。在UNIX和Linux里，要启动一个壳只需要运行shell即可。在Windows里面，要启动壳需要执行explore.exe。在Linux和UNIX中可以同时启动多个壳，而在Windows下只能启动一个壳。2006年，微软公司推出了Powershell，从此改变了在Windows下只能启用一个壳的限制。Powershell是一个文本命令壳，可以运行在Windows XP SP2、Windows Server 2003、Windows Vista和Windows Server 2008上。Powershell提供了一种类似于UNIX和Linux上utilities的东西，称作cmdlets。用户通过键入cmdlets里面不同的命令而指挥计算机进行各种操作。

一个壳的具体功能包括如下几项：

●显示提示符，如UNIX下的提示符通常为-和%。

●接受用户命令并执行。

●实现输入输出间接（或间接输入输出）。

●启动后台进程。

●进行工作控制。

●提供伪终端服务。

例如：可以在UNIX shell上执行下述命令：

键入：＄date

显示：＄September 16，2008

还可以进行输出间接（或间接输出）：

键入：＄date＞file

显示：＄

上述命令行里面的“＞”符号是间接符号，将输出从显示屏转移到了文件file。如果打开文件file，里面的内容将是September 16，2008。

还可以同时进行输入和输出间接（或间接输入输出）：

＄sort＜file1＞file2

上述命令将file1里面的内容进行排序，然后将排序后的结果存放在文件file2里。

还可以将输出间接发送到打印机：

＄cat file1 file2 file3＞/dev/lp1

上述命令将file1、file2、file3里面的内容进行连接，然后将结果发送到打印机lp1并打印。

可以启动后台程序：

＄make all＞log＆

上述命令启动一个编译后台程序，将编译的结果输出到文件log里。

那么壳是怎么实现的呢？下面是一个最为简单的壳（UNIX shell的伪代码，并不能真正运行）：

while（true）｛

type＿prompt（）；//显示命令提示符

read＿command（command, parameters）//从用户获得命令

if（fork（）==0）｛//fork一个子进程

execve（command, parameters，0）；//子进程代码，执行用户命令

｝

else｛//父进程代码段

waitpid（-1，＆status，0）；//等待子进程结束

｝

｝

这个壳一旦启动，就循环往复直到无穷。它所做的事情很简单：

1）显示命令提示符。

2）等待用户输入命令。

3）使用fork创建一个子进程。

4）使用execve在创建的子进程里执行用户输入的命令。

5）重复步骤1）～4）。

上述程序段里的fork和execve均为UNIX操作系统提供的系统调用。fork的功能是创建一个子进程，并将自己的一切数据复制到子进程里，也就是说，fork完成的实际上是自我复制。execve的功能是用另外一个程序的内容覆盖自己，即执行新的程序。

这里值得一提的是，在上述程序代码里，fork命令有两次返回：一次返回值为0，表明是子进程（创建的新进程），对应if-else复合语句中的if部分；另一次返回值不是0，表明是父进程，对应if-else复合语句中的else部分，而这个返回值就是子进程的进程ID。也就是说，复合语句if-else的if和else部分在fork完成后都将执行。这听上去奇怪吗？

如果奇怪，那是因为你看到的是一个程序。我们知道，在一个程序里，if-else语句的if和else两个部分只能有一个部分执行，而不能两个部分同时执行。我们还知道，一个函数是无法返回两次的。因为只要写了return，后面的语句就不能再执行了。也就是说，return后面无法再出现return了，即一个函数不能返回两次。那么fork命令为什么能返回两次呢？

这是因为fork的特殊功能使然。fork的功效是创建一个和自己完全一样的进程。在fork系统调用完成后，我们面对的是两个进程，而不再是一个进程。这两个进程的程序代码完全一样，就是我们上面给出的代码。在其中一个进程中，执行的是if部分；在另一个进程中，执行的是else部分。因此，从每一个进程内部来看，复合语句if-else的语义并没有遭到破坏。

而且，既然在fork后有了两个副本，那么每个副本里都有同样的fork调用语句。从另一个角度讲，有两个程序调用了fork。这两次调用都需要返回。从内存结构看，这两个程序副本或者进程都有自己的调用栈，并都有调用返回的位置。对于每一个进程来说，它调用了一次fork，获得了一次返回。因此，从每个进程内部看，也遵循了函数返回一次的语义。

那么系统中的两个一模一样的进程如何区分呢？这个比较简单，给每个进程一个不同的返回值即可。返回给父进程的是子进程的进程ID，对应else部分；返回给子进程的是0，对应if部分。

本章论述了操作系统的基本概念。这些概念为下一步的进程、内存、文件和输入输出等章节的学习打下了基础。在这些概念中，最为关键的是内核态和用户态的定义及其关系。当然，抽象是操作系统的根本，而系统调用和操作系统结构也非常重要。

思考题

1.操作系统提供的用户界面有几种？分别是什么？

2.举出人类社会中采用的几个抽象示例，并与操作系统提供的抽象进行对比。

3.有人认为，内核态程序可以访问任何资源的权限对系统安全造成严重威胁，你怎么看？

4.处理器的状态设置需要在何种态势下完成？为什么？

5.处理器从用户态转为内核态时面临的关键问题是什么？如何解决？

6.下述操作是否可以在用户态下执行？

a）保护中断现场。

b）因系统调用陷入内核。

c）启动外部设备。

d）外设与内存经DMA进行数据交换。

e）缺页中断。

7.论述系统调用和壳之间的关系。

8.操作系统采用层次结构的优点是什么？层次结构有什么缺点？

9.fork是如何实现一次调用两次返回的？它有必要吗？为什么？

10.请调研当前业界通用系统里面缓存、主存、磁盘、磁带的平均容量。11.挑战题：内核态的特权是如何实现的？

第二篇　进程原理篇

恍兮惚兮，其中有像；惚兮恍兮，其中有物

有了基础原理篇的铺垫，我们就可以进入操作系统核心功能部件了。

顾名思义，计算机是用来进行计算的，或者说计算机的基本功能是计算功能。而进行计算的关键部件是计算机的芯片，即CPU。CPU能够按照一定的顺序进行正确计算是在一个指挥者的控制之下完成的。这个指挥者就是操作系统。操作系统对CPU进行管理的重要手段就是进程模型，或者说进程是操作系统这个魔术师施法的道具。

进程是操作系统演化过程中的一个里程碑，由于进程的出现，人类希望的并发从理想变为了现实。从根本上说，进程出现的动机是人类渴望的并发。进程的出现也让操作系统的复杂性大为增加：由于需要对进程进行分离存储而导致出现内存管理；由于需要让不同进程有条不紊地往前推进而导致进程调度的出现。显然，理解进程对理解操作系统十分重要，对其进行管理也就理所当然地成为操作系统的一个关键职责。

本篇包括第4～6章的内容。第4章阐述的内容包括进程出现的逻辑必然性、多道编程的效率、进程的创建和消亡、进程的状态及其转换、进程与地址空间、进程管理和进程模型的缺陷。第5章讲解的内容包括调度的目标、先来先服务、时间片轮转、短任务优先、优先级调度、混合调度、实时调度等算法，并对优先级倒挂和线程的不确定性进行讨论。第6章包括为什么要通信、管道、记名管道、套接字、信号、信号量、共享内存、消息队列等。

进程从根本上说是操作系统对CPU进行的抽象和装扮

本篇最重要的内容是并发。因为要并发，所以我们发明了进程。

第4章　进程

引子：牛顿的困惑

在艾德蒙德·哈雷（Edmond Halley）的耐心劝说和敦促下，牛顿（Issac Newton）（见图4-1）撰写了其传世名作《自然哲学的数学原理》（Philosophiae Naturalis Principia Mathematica，一部包含牛顿经典力学和万

有引力定律的物理学著作）。在完成此书后，一种困惑始终挥之不去。牛顿写完此书后的总览后对他的朋友哈雷感叹道：“一切物体的运动规律，天上的、地上的，我已经全部、完美地阐述清楚了。但一个一直困惑我的问题是：这些规律是从何而来的？或者说这些太空中的天体、地上的物体为什么会遵守这些规律呢？它们如果不遵守这些规律，难道我们还能把它们怎么样不成？”

如果这些规律是随着时间与整个宇宙一起进化或演变的，则在进化到一半的时候，即在这些规律既是规律，又不是规律，或者说还是“半规律”时，宇宙是如何得以存在的呢？我们知道天体的运行轨道只要偏离一点，其存在就有可能大成问题，如果很多天体都随机运动，则后果将不堪设想。因此，按照“半规律”存在的宇宙是不可想象的。

剩下的唯一推论就是，这些规律在宇宙出现的一刹那就完美地存在了。或者更为直白地说，这些规律在宇宙存在之前就已经存在了。而这与《约翰福音》里的"In the beginning was the word"（太初有道）不谋而合。这里的word是希腊语原文logos的英文翻译。而logos就是逻辑，就是规律。这句话的意思是在还没有宇宙的时候，规律就已经存在了。

而由于规律本身不是物质，其存在自然是无中生有了。至于是谁或者什么导致这些规律的存在，那就留给读者自己去遐思了……

本章讨论的进程也是无中生有。这点与宇宙的运行规律有一些相似。不过与宇宙规律不同的是，我们知道是谁导致了计算机中进程的出现：人！

当人们面临困境时通常的做法就是：发明新的概念、新的术语或新的机制来解脱困境。

图　4-1　位于剑桥大学三一学院门厅里的牛顿雕像

4.1　进程概论

进程管理、内存管理和文件管理是操作系统的三大核心功能。那么什么是进程呢？顾名思义，进程就是进展中的程序，或者说进程是执行中的程序。就是说，一个程序加载到内存后就变为进程。即：

进程=程序+执行

进程在Multics操作系统出现前叫做工作（job）。工作是IBM用于多道批处理程序设计中的概念。由于历史的原因，Multics操作系统的研发人员不愿意承用IBM发明的术语，而将工作改为了进程（process）。那么进程出现的动机是什么呢？

本书在第2章提到，单一操作员单一控制终端、批处理均存在效率低下的问题，即CPU使用率不高。为了提高CPU利用率，人们想起将多个程序同时加载到计算机里，并发执行。这些同时存在于计算机内存的程序就称为进程。进程让每个用户感觉到自己独占CPU。因此，进程就是为了在CPU上实现多道编程而出现的概念，如图4-2所示。

图　4-2　进程让每个用户感觉到自己独占CPU

4.2　进程模型

那么进程到底是什么呢？什么是进展中的程序呢？从物理内存的分配来看，每个进程占用一片内存空间，从这点上说，进程就是内存的某片空间。由于在任意时刻，CPU只能执行一条指令，因此任意时刻在CPU上执行的进程只有一个，而到底执行哪条指令由物理程序计数器指定。也就是说，在物理层面上，所有进程共用一个程序计数器。

而从逻辑层面上来看，每个进程可以执行，也可以暂时挂起让别的进程执行，之后又可以接着执行。这样，进程就需要某种办法记住每次挂起时自己所处的执行位置，这样才能在下次接着执行时从正确的地点开始。因此，从这个角度看，每个进程有着自己的计数器，记录其下一条指令所在的位置。从逻辑上说，程序计数器可以有很多个。

而从时间上看，每个进程都必须往前推进。在运行一定的时间后，进程都应该完成了一定的工作量，即每次进程返回，它都处在上次返回点之后。这就像古希腊哲学家赫拉克里特说过的：“一个人不能两次踏入同一条河流。”进程的这3种概念可以由图4-3表示。

图　4-3　进程模型的3个视角

这里需要注意的是，进程不一定必须终结。事实上，许多系统进程（用来为别的进程提供系统服务的进程）是不会终结的，除非强制终止或计算机关机。

进程模型的实现

对于操作系统来说，进程是其提供的一种抽象，目的是通过并发来提高系统利用率，同时还能缩短系统响应时间。这种抽象听上去很不错。但这种抽象是如何实现的呢？或者说，操作系统如何实现进程呢？

首先，任何抽象都需要有一个物理基础。对于进程来说，其物理基础就是程序。程序运行在计算机上，而在计算机上运行首先需要解决的问题是进程的存储：给进程分配合适的内存，让其有一个安身之处。由于多个进程可能同时并存，因此进程的存储需要考虑如何让多个进程共享同一个物理内存而不发生冲突。操作系统解决这个问题的手段是内存管理。

此外，进程运行实际上是指进程在CPU上执行。那么如何将CPU在多个进程之间进行交接或切换，这就是进程实现需要解决的另一个问题。操作系统解决这个问题的手段就是进程调度：决定在什么时候让什么进程使用CPU。这个议题将在本书第5章予以讨论。

4.3　多道编程的好处

人们发明进程是为了支持多道编程，而进行多道编程的目的则是提高计算机CPU的效率，或者说系统的吞吐量。例如，如果一个进程有20%的时间使用CPU进行计算，另外80%的时间用来进行I/O，则如果使用单道编程，CPU的利用率只有20%。但如果同时运行两个这样的进程，即进行所谓的2道编程，则CPU利用率将提高到36%（CPU只在两个进程同时进行I/O时才处于闲置状态，因此CPU的利用率为1-0.8×0.8=36%）。这里忽略了进程切换所需要的系统消耗。

同理，如果同时运行3个这样的进程，则CPU利用率将进一步提高到48.8%。4个进程的CPU利用率将为59%，5个进程的CPU利用率为67.2%，6个进程的CPU利用率为73.8%，7个进程的CPU利用率为80%。这样，随着进程数量的增加，也就是随着多道编程的度的增加，CPU利用率将逐步提升，但提升的幅度则逐步降低，直到某个临界点时为止。这个临界点就是多道编程的极限。超过这个极限，多道编程的好处将逐步消失，甚至呈下降趋势。对于这个系统来说，多道编程的度达到12以后，CPU利用率已经达到94%，之后CPU利用率的提升空间就很小了，而因为进程切换所带来的系统消耗则变得非常明显。多道编程的度数与CPU效率的关系如图4-4所示。

图　4-4　多道编程度数和CPU利用率的关系（I/O时间为80%）

这里需要注意的是，图4-4描述的是一种非常简单的模型：所有的进程都进行同样的I/O工作量（80%），且进程切换时间忽略不计。如果情况不是这样，例如不同的进程进行I/O的工作量各不相同，且进程切换时间也需要考虑，则CPU利用率的图线就不是这么简单了。但图4-4所描述的趋势变化仍然成立：随着度的增加，CPU利用率增加，直到一个临界点。

响应时间改善

除了提高CPU利用率外，多道编程更大的好处是改善系统响应时间，即用户等待时间。毕竟，对于大多数用户来说，关心的是计算机的响应速度，至于CPU利用率到底有多高，除了系统管理员或者某些有偏执狂的人外，一般人是不会在意的。

下面我们通过一个多道编程的具体例子来展示多道编程对系统响应时间的改善。假定我们有4个程序，每个程序花费80%的时间进行I/O，20%的时间使用CPU（这种假设非常符合算法中的80-20规律）。每个程序的启动时间和其需要使用CPU进行计算的分钟数如表4-1所示。

下面我们来看看该计算机里面各个程序的执行情况（所有计算均为大约情况，不是精确情况）。

从0点0分开始到0点10分，程序1开始运行。由于系统里只有1个程序，因此属于单道编程状态。单道编程时CPU的利用率为20%，因此第1个程序在该10分钟里总共使用了CPU达2分钟（其他8分钟都用来进行I/O了）。

从0点10分到0点20分，程序2开始运行，此时程序1尚未结束，系统里将有两个程序，因此属于2道编程。我们前面计算过，2道编程时CPU利用率为36%，因此在这10分钟时间内，CPU使用了3.6分钟。假定这两个程序完全平等，那么每个程序使用CPU的时间为1.8分钟。至此，程序1总共运行了2+1.8=3.8分钟CPU时间，程序2运行了1.8分钟CPU时间。

从0点20分开始到0点30分，程序3开始运行，而程序1和2皆未结束，系统里有3个程序，因此属于3道编程状态。3道编程时CPU的利用率为48.8%，因此在这10分钟时间内，CPU使用了大约4.8分钟（其他5.2分钟都用来进行I/O了）。同样，假定所有程序完全平等，那么每个程序使用CPU的时间为1.6分钟。至此，程序1总共运行了3.8+1.6=5.4分钟CPU时间，程序2运行了1.8+1.6=3.4分钟CPU时间，程序3运行了1.6分钟。此时，程序1离结束还需要2.6分钟的CPU时间，程序2离结束还需0.6分钟的CPU时间，程序3离结束所需要的CPU时间最短，仅为0.4分钟。

从0点30分开始，程序4开始运行，此时程序1、2、3皆未结束，系统里同时有4个程序，因此属于4道编程。我们前面计算过，4道编程时CPU利用率为59%。而如果程序3想再运行0.4分钟CPU时间，则整个系统需运行时间为约2.7分钟（2.7分钟时间内CPU共使用1.6分钟，平均每个程序使用的CPU时间为0.4分钟）。因此，在0点32.7分时，程序3执行完毕，系统中还剩下程序1、2、4，系统变成3道编程。

此时，程序1总共运行了5.4+0.4=5.8分钟CPU时间，程序2总共运行了3.4+0.4=3.8分钟CPU时间，而程序4则总共运行了0.4分钟。此时，程序2离结束所需的CPU时间最短，为0.2分钟。那么系统需要运行多长时间才能使程序2获得0.2分钟的CPU时间呢？答案是1.25分钟。因为3道编程的CPU利用率大约为48%，而1.25分钟内CPU的使用时间是0.6分钟。3个程序平分，每个程序运行了0.2分钟时间。因此，1.25分钟后，也就是在0点33.95分，程序2结束运行，系统里只剩下程序1和程序4两个程序。

此时，程序1已经使用了5.8+0.2=6分钟CPU时间，程序4已经使用了0.4+0.2=0.6分钟CPU时间。程序4离结束所需的CPU时间最短，为0.4分钟。而在约2.2分钟后，即0点41.15分，程序4结束运行，剩下程序1。此时程序1已经运行了6+0.4=6.4分钟，还需用运行8-6.4=1.6分钟。在单道编程的情况下，该程序则在8分钟后，即0点49.15分结束运行。读者可自行绘出一张各程序的开始和终止时刻图。

在多道编程环境下，4个程序和整个系统的响应时间为：

而在单道编程环境下，4个程序和整个系统的响应时间为：

由上述两个表格的数据可以看出，多道编程比起单道编程，系统平均响应时间缩短了约21分钟，响应时间改善程度约47%，这是一个巨大的改善。

当然了，多道编程带来的好处到底有多少与每个程序的性质、多道编程的度数、进程切换消耗等均有关系。但一般来说，只要度数适当，多道编程总是利大于弊。

4.4　进程的产生与消亡

什么事件可以造成进程的产生和消亡呢？当然有很多这样的事件。对于进程产生来说，主要的事件有：

●系统初始化（神创造人）。

●执行进程创立程序（人生子）。

●用户请求创立新进程（试管婴儿）。

在一个系统初始化时，将有许多进程产生。产生的这些进程是系统正常运行必不可少的。这些进程的存在使得新的进程和用户程序的执行成为可能。例如：在系统初始化后，Windows操作系统将自动产生诸如对话管理（SMSS）、登录管理（WINLOGON）、安全管理（LSASS）、Windows子系统（CSRSS）、Windows壳（explore）等系统进程。

在系统初始化后，系统就等待用户输入命令。如果这个用户启动一个程序，如双击一个可执行文件，那么系统将为这个可执行文件创建一个进程。除此之外，用户也可以在程序里面通过系统调用如fork或者CreateProcess直接生成新的进程。

造成进程消亡的事件则可以分为四种情况：

●寿终：进程运行完成而退出。

●自杀：进程因错误而自行退出。

●他杀：进程被其他进程所终止。

●处决：进程因异常而被强行终结。

前两种情况均为自愿退出，后两种情况均为非自愿退出。在程序设计时我们追求的是前面两种退出，也算是我们在虚拟世界里面追求人权（进程权）的努力吧。第3种情况通常是一个父进程发出命令终止一个子进程。当然，一个用户也可以“杀死”自己的进程，但不能“杀死”别人的进程。但一个超级用户（具有系统管理员权限）则可以“杀死”任何进程。第4种情况在一个进程进行某种非法操作，如访问出界或者除以0后发生，而这种非法操作将被操作系统捕捉。操作系统捕捉到这种异常后将终结造成异常的进程。

4.5　进程的层次结构

我们说过，一个进程在执行过程中可以通过系统调用创建新的进程。这个新创建的进程就称为子进程，而创建子进程的进程则称为父进程。子进程又可以再创建子进程，这样子子孙孙创建下去就形成了所谓的进程树。UNIX称这个进程树里面的所有进程为一个进程组，进程组里面的进程分布在不同的层次上，从而形成一个层次架构。

Windows没有进程组的概念，而是所有进程均地位平等。

4.6　进程的状态

我们前面说过，进程可以在CPU上执行，也可以处于挂起状态。显然，一个进程至少有这么两种状态。那么进程还有别的状态吗？

如果进程在CPU上执行，自然就是执行状态。而如果是挂起状态呢？那就得看是什么原因挂起的。因为操作系统在进行进程调度时要从挂起的进程中选择一个来执行，所以清楚一个进程挂起的原因对调度的有效推进十分重要。

那么进程挂起有哪些原因呢？首先是一个进程在运行过程中执行了某种阻塞操作，如读写磁盘。由于阻塞操作需要等待结果后才能继续执行，因此操作系统将把这个进程挂起，让其他进程运转。另外一种情况是一个进程执行的时间太长了，为了公平，操作系统将其挂起，让其他进程也有机会执行。

这两种挂起的原因十分不同：第一种挂起是进程自身的原因。这个时候，即使把CPU控制权交给它，它也无法运行。第二种挂起是操作系统的原因。进程自己并无问题。只要把CPU交给进程，它就可以立即运行。这样，如果将挂起进程分为这样两类，操作系统在进程调度时就只需要查看第二类进程，而无须浪费时间查看第一类进程。

因此，将进程分为3种状态：执行、阻塞和就绪，如图4-5所示。

在3种状态之间可以进行各种转换。如果每个状态都可以转换为另外一种状态，则一共有6种转换：

图　4-5　进程的3个典型状态

●执行→就绪

●执行→阻塞

●阻塞→就绪

●就绪→执行

●阻塞→执行

●就绪→阻塞

问题是，上面的转换并不是都可以发生。一个进程在执行时，因为运行时间太长，操作系统可以将其挂起，转换为就绪状态，因此第1种转换是可以的。在进程执行过程中如果执行了某种阻塞操作，则进入阻塞状态，因此第2种转换也是可以的。一个阻塞的进程在其等待的资源到达后，就可以随时执行，进入就绪状态，因此第3种转换也是可以的。最后，就绪进程由操作系统调度到CPU上就进入执行状态，因此第4种转换也是可以的。

但是第5、第6两种转换是不可以的。我们前面讲过，阻塞进程即使被给予CPU，也无法执行，因此操作系统在调度时并不会在阻塞队列里挑选。因此，阻塞状态无法转换为执行状态。对于处于就绪状态的进程来说，因为它并没有执行，自然无法进入阻塞状态。这就像一个人停滞不前，自然就不会有任何人成为其障碍。因此，就绪状态无法转换为阻塞状态。

这里需要注意的是，第5、第6两种转换虽然都不存在，但其原因不同。第5种转换不可以是因为我们不让它发生。如果我们乐意，完全可以让操作系统在阻塞队列里挑选一个进程予以执行，只不过这个进程在执行第1条指令时就会又发生阻塞（因为其等待的数据尚不可用或者发生异常）。因此，从理论上说，阻塞到执行是可以的，只不过这种状态转换没有任何实际价值而被操作系统禁止。而第6种状态转换则在理论上是不可以的。一个进程只能在执行时才可能阻塞，没有执行的进程无法直接转换到阻塞状态。

这里阐述的进程的3种典型状态并不是唯一的分类方式。事实上，许多商业操作系统的进程状态不止3个，例如，Windows的进程有7个状态，Solaris的进程则有6个状态。但不管3个、6个、7个还是几个，其目的都是便于操作系统管理进程。只要细分对管理有利，我们就细分。否则就维持3个状态。

4.7　进程与地址空间

进程空间也称为地址空间。简单来说，地址空间就是进程要用的所有资源。于是所有资源就构成了状态的划分。由于不可能有两个进程状态完全一样，因此每个进程对应计算机的一种状态，而计算机状态就是所有存储单元的内容。

地址空间的特点就是被动，自己不能做什么，只提供支持。打个比方。看过演出吗？如话剧、芭蕾、歌剧等。京剧总看过吧？有个舞台，那些道具和舞台就是地址空间。这些空间本身不能发生任何动作。做动作的只能是演员。而那些演员就是我们将要讲述的线程。跳上来一个演员就是一个线程，如图4-6所示。

图　4-6　音乐剧《剧院魅影》：舞台布景及设施是地址空间，而舞台上的男女演员是线程

进程与地址空间研究的主要内容是如何让多个进程空间共享一个物理内存。具体来说，就是高效、安全地让所有进程共享这片物理内存。

4.8　进程管理

那么谁管理进程的资源？是操作系统。本书前面说过，Operating的意思就是掌控一切。那么如何掌控呢？操作系统要掌控一切状态，就必须拥有某些手段或资源。那需要什么手段或资源呢？如果让你监视一群人，要你掌握他们的一切情况，你第一件要做的事是什么？装监视器？不是！而是要知道这群人到底是哪些人！即你需要知道并维持关于这群人的各种信息！

4.8.1　进程管理所需要的手段

4.4　节讲到了进程的产生和消亡，但这到底是什么意思呢？产生一个进程对于操作系统来说意味着什么呢？进程消亡又对操作系统有何影响呢？要回答这个问题只需要看一下一个人的出生对一个社会来说意味着什么就可以了。在一个人出生后，医院需要在几天内为其建立记录，该记录包括诸如姓名、性别、体重、身高、父母为何人、在何时何地出生、健康状态等信息，然后该记录用来登记户口、办理身份证等。在完成这些手续之后，这个人就正式存在了。有了这些记录，政府就可以对这个人进行各种管理了。

与一个社会管理人的过程类似，操作系统要管理进程就要维护关于进程的一些信息。当一个进程产生时，操作系统也需要为其创建记录。操作系统用于维护进程记录的结构就是进程表或进程控制块（Process Control Block, PCB）。这个进程表或PCB中存放的就是有关该进程的资料。那么进程表里有什么资料呢？显然，不同的操作系统维护的进程资料不尽相同。但一般来说，维护的资料信息应当包括寄存器、程序计数器、状态字、栈指针、优先级、进程ID、信号、创建时间、所耗CPU时间、当前持有的各种句柄等。而采纳的数据结构主要是线性表、链表和结构（struct），当然也可能使用树和图（网络）结构。例如，Solaris的进程表就使用了上述4种数据结构。表4-2描述的是一个高度简化的进程表。

这个进程表保持在操作系统所在的内核空间里，如图4-7所示。

图　4-7　进程表存放在操作系统所在的内核空间里

如果想更深入地了解进程表的结构，读者可以参阅相关商业操作系统的内核教程。

4.8.2　进程的创建过程

前面说过，对于一个计算机系统来说，不断创建和终结进程，创建进程和终结进程各有4种方法。但操作系统是如何创建一个进程的呢？一般来说，创建进程的步骤如下所示：

1）分配进程控制块。

2）初始化机器寄存器。

3）初始化页表。

4）将程序代码从磁盘读进内存。

5）将处理器状态设置为“用户态”。

6）跳转到程序的起始地址（设置程序计数器）。

这里一个最大的问题是，跳转指令是内核态指令，而在第5步时处理器状态已经被设置为用户态，但在用户态下是不能执行内核态指令的。这个问题如何解决？当然了，这就需要硬件帮忙了。硬件必须将第5和第6两步作为一个步骤一起完成。

进程创建在不同的操作系统里方法也不一样。例如，UNIX将进程创建分为两个步骤：第1步是fork，创建一个与自己完全一样的新进程；第2步是exec，将新的进程的地址空间用另一个程序的内容覆盖，然后跳转到新程序的起始地址，从而完成新程序的启动。而Windows使用一个系统调用就可以完成进程创建。这个系统调用就是CreateProcess。在调用该函数时我们把欲执行的程序名称作为参数传过来，创建新的页表，而不需要复制别的进程。

UNIX和Windows的进程创建过程各有优缺点。UNIX的创建过程要灵活一些，因为我们既可以自我复制，也可以启动新的程序。而自我复制在很多情况下是很有用的。例如，Web服务器在每收到一个用户请求后，就创建一个新的一模一样的进程来服务用户请求。而在Windows下，复制自我就要复杂一些了。而且，共享数据只能通过参数传递来实现。

4.8.3　进程管理要处理的问题

进程管理的最大问题是资源分配。那么如何分配资源呢？人类社会最大的问题也是资源分配。谁能解决资源争端，让地球上每个人高高兴兴地共享资源，谁就是人类的救星。当然，计算机的资源分配问题不像人类那样困难，因为程序没有自我意识，就算我们对某些程序不公平，它们也无法抱怨。但这不能成为我们偷懒和堕落的理由。毕竟，我们制造计算机就是想好好地利用它，自然希望能够让所有进程高兴地相处在一起。毕竟，我们的本性还是追求公平的。

除了公平之外，还有一个问题要考虑：效率，也就是最优。每个进程分配同样的资源肯定不行。以前32个终端连到一台计算机上，慢得不行，结果没有任何人高兴。不如让部分人先富起来，给他们使用资源的优先权。

这样，公平与效率就成了进程管理中永恒的主题。到底是公平重要，还是效率重要？天平的不同倾斜将引出十分不同的进程管理模式。

4.9　进程的缺陷

看上去，进程的抽象或模型似乎很好：既提高了系统利用率，又缩短了系统响应时间。它通过支持多道编程，让我们感觉每个人都拥有自己的CPU和其他资源，似乎皆大欢喜。

难道进程模型就没有任何问题了吗？当然不是。

如果仔细观察，就会发现，进程有个很严重的问题。假定现在有两部很好的电影，都只放映一次，以后再也不放映了。而且，这两部电影同时放映，当然了，是在不同的两个房间放映。而你很想将这两部电影都看了，有什么办法吗？假定没有光碟刻录机也没有录像机等。

当然，我们没有办法同时看两部电影。这也是进程的缺点。它只能在一个时间做一件事情。如果想同时做两件或多件事情，进程就不够用了。

另外，更为重要的是，如果进程在执行的过程中阻塞，例如等待输入，整个进程就将挂起（暂停），而无法继续执行。这样，即使进程里面有部分工作不依赖于输入数据，也无法推进。

而为了解决上述两个问题，人们就发明了线程。本书将在第三篇对线程进行讨论。

思考题

1.发明进程的根本动机是什么？它与程序是什么关系？请予以论述。

2.进程带给我们的最大好处是什么？它有什么缺点吗？

3.进程空间是什么意思？它包括哪些东西？它与进程是什么关系？

4.进程有哪3种状态，分别代表什么意思？

5.在进程的6种状态转换里有两种是不存在的，但它们不存在的理由却不一样。请予以解释。

6.在商用操作系统里，进程的状态通常多于3个，请问，设置多于3个进程状态的可能原因是什么？

7.操作系统管理进程的根本手段是什么？

8.进程管理时的两个重要考虑是公平和效率。除此之外，还有什么因素需要考虑吗？

9.进程的产生与消亡与人的出生与消亡有着某种类比性，你能否予以阐述？10.多道编程是否总能提高CPU的利用率？为什么？

11.有同学认为在进程状态的6种转换中，从就绪到阻塞在理论上可行（操作系统将就绪进程的状态改变为阻塞状态），而从阻塞到运行则在理论上不可行，你同意吗？为什么？

12.分析：在内核态下的进程通常共享一个地址空间，这是为什么？

第5章　进程调度

引子：恐怖分子的调度

1970年的冬天，加拿大魁北克省渥太华的皇家骑警总部。来自美国联邦调查局、美国中央情报局、加拿大皇家骑警、加拿大安全情报局的特工和警察领导正在这里举行紧急会议。

一个星期前，一群来自欧洲和中东的政要及大亨在美国乘坐由美国政府安排的列车游览美国壮丽河山的时候被一群恐怖分子劫持。这列掌握在恐怖分子手中的高级列车已经在美国广袤大地上游荡了一个星期。恐怖分子身上携带有生物病毒，他们以这些病毒和被他们控制的西方人质作为筹码，要求美国政府在政治和经济上作出让步，但美国政府一直在口头上虚与委蛇，暗中却布置警察和特工对列车进行了多次反劫持行动，不过皆没有成功。美国政府的行动激怒了恐怖分子，他们杀死了几名政要，并扬言释放生物病毒。情况万分危急。

在恐怖分子的要挟下，列车进入了加拿大，在加拿大更加广袤的土地上游荡。恐怖分子继续与美国、加拿大政府交涉。在加拿大皇家骑警总部召开的会议就是来讨论这列列车的问题。

参会人员提出了各种武装袭击方案，包括从空中和陆地两个方向进行攻击，但都被否决了。因为这些方案美国已经用过，没有成功。而且，恐怖分子已经扬言，再有此类行动，就杀死所有人质并释放生物病毒。在大家一筹莫展的时候，一名加拿大特工慢慢地站起来，不无幽默地说：“解决这个问题的办法很简单，关键词就是‘调度’。”“调度？”大家迷惑不解。加拿大特工回答说：“对，调度。各位都知道，加拿大是一个多山的国家，加拿大的铁路穿过许多的山洞和隧道。我们只需要对列车进行调度，让其进入加拿大北方领土的一条超长隧道里，将隧道里面的灯光熄灭，在隧道两边埋伏特别行动小组，待列车进入隧道后，突然行动。由于隧道灯光熄灭，恐怖分子从明亮的地方突然进入黑暗的隧道，一时反应不过来，看不见黑暗中埋伏的特别行动人员，但车里有灯光，我们的人员却可以看见车里的恐怖分子，行动成功的把握非常大，至少我很有信心。”说完之后，加拿大特工洋洋得意。

美国联邦调查局的一名特工听完后反问道：“那万一不成功，恐怖分子就要释放生物病毒，你就会给这个世界带来灾难。这个问题你考虑了没有？”

加拿大特工回答：“这个问题我当然考虑了。我说的这条隧道很长，列车要运行20分钟。我们预先在隧道两端埋好炸药，留给特别行动小组15分钟的时间行动。如果15分钟没有取得成功或者行动在15分钟内已经失败，我们就炸毁隧道，将列车永远埋葬在大山里，生物病毒就无法释放出来。由于这里是加拿大的北方领土，一片崇山峻岭，这条铁路也基本上没有人使用，炸毁隧道基本上不会给加拿大带来任何负面的影响。当然了，特别行动小组和西方人质也将伴随恐怖分子一起葬身在这座巨大的墓地里。这是这次行动需要准备付出的代价。不过，只要我们调度得当，这种情况不会出现，我们等来的结果将是成功。”

加拿大特工说的没错，调度是成功的关键因素。美国和加拿大政府通过调度，使列车进入已设计好的范围，从而达到目的。对于掌管着众多进程的操作系统来说，也需要通过调度进程来达到维持计算机功能的目的。事实上，调度是操作系统实现进程模型的根本手段。

5.1　进程调度的定义

本书前面讲过，在多进程并发的环境里，虽然从概念上看，有多个进程在同时执行，但在单个CPU下，实际上在任何时刻只能有一个进程处于执行状态。而其他进程则处于非执行状态。那么这就有一个需要解决的问题：我们是如何确定在任意时刻到底由哪个进程执行，哪些不执行呢？或者说，我们是如何进行进程调度的呢？

进程的调度就是操作系统进程管理的一个重要组成部分。其任务是选择下一个要运行的进程。那么如何进行选择呢？要探明这一点，首先需要确定操作系统进程调度的目标是什么。有了目标，我们就知道选择什么进程最合适了。

那么操作系统进程调度的目标是什么呢？这需要对进程使用CPU的模式进行分析。那么进程在执行时有什么样的模式呢？

一般来说，程序使用CPU的模式有3种：一种是程序大部分时间在CPU上执行；另一种是程序大部分时间在进行输入输出；还有一种是程序介于前两种模式之间。

第1种程序运行的模式是在CPU上执行较长时间，接着进行短暂的输入，然后又在CPU上进行较长的运算，之后又进行短暂的输入输出操作，就这样循环往复。这种程序由于使用CPU的时间远远长于其用于输入输出上的时间，因此称为CPU导向（CPU-bound）或计算密集型程序。计算密集型程序通常是科学计算方面的程序。计算宇宙大爆炸各种参数的程序、矩阵乘法程序等就都是CPU导向的程序。

第2种程序则与第1种相反，这种程序的大部分时间用来I/O，每次I/O后进行短暂的CPU执行，因此称为I/O导向（I/O-bound）或输入输出密集型程序。一般来说，人机交互式程序均属于这类程序。如游戏程序以及讲课时使用的PPT程序，都属于I/O导向的程序。

第3种程序自然介乎二者之间，既有长时间的CPU执行部分，又有长时间的I/O部分。或者说，这种程序使用CPU和I/O的时间相差不大。这种程序称为平衡型程序。例如，网络浏览或下载、网络视频等就属于此类程序。

自然，对于不同性质的程序，调度所要达到的目的也有所不同。例如，对于I/O导向的程序来说，响应时间非常重要；而对于CPU导向的程序来说，周转时间（turnaround）就比较重要；对于平衡型程序来说，进行某种响应和周转之间的平衡就显得重要。

5.2　进程调度的目标

CPU调度就是要达到极小化平均响应时间、极大化系统吞吐率、保持系统各个功能部件均处于繁忙状态和提供某种貌似公平的机制。

极小化平均响应时间就是要极小化用户发出命令和看到某种结果之间所花费的时间，即减少做一件工作平均等待的时间；极大化系统吞吐率就是要在单位时间内完成尽可能多的程序，就是单位时间内能完成的工作数量，即整个系统运行效率高；保持系统各个功能部件繁忙就是要让CPU和输入输出设备均处于忙碌状态。由于CPU非常昂贵，让其闲置显然是一种浪费，因此保持CPU繁忙十分重要。就像生命非常珍贵，因此要一直保持学习繁忙状态，才能不浪费生命。

提供公平就是要让各个程序感到某种“平等”，即在CPU面前“人人平等”。公平是任何系统都应该努力达到的目标。因为没有公平，该系统对用户的吸引力就会急剧下降。这就像一个国家或者社会，如果缺乏公平，公民对该国家的认同度就会急剧下降一样。

对于不同的系统来说，在调度目标方面也有一些细微的不同。例如，对于批处理系统来说，由于用户并不坐在计算机前面等待结果，响应时间就显得不太重要，但系统吞吐率、CPU利用率和周转时间则很重要。

对于交互式系统来说，由于用户在等待计算机，因此响应时间要很快。但在这里要注意的是适度性（proportionality）。适度性就是响应时间要和期望值相匹配。这里是说你不要超越用户的期望。比如，用户期待1秒钟的响应时间，你就给他1秒钟的响应时间，而不必提供0.1秒钟的响应时间。这是因为，提供超出用户期望的响应会增加系统设计的难度，而又不会提高用户的满意度（对于一个人来说，1秒钟和0.1秒钟的差别并不是很大）。

对于实时系统来说，调度就是要达到在截止时间前完成所应该完成的任务和提供性能可预测性。

5.3　先来先服务调度算法

先来先服务调度算法缩写为FCFS（First Come First Serve）。谁先来，就先服务谁。这个算法所有地球人都能想到。因为先来先到是人的本性中的一种公平观念，而且生活实际中这种规则随处可见。例如，我们排队买东西或者办理政务体现的就是先来先到原则。

先来先到的一个隐含条件就是不能抢占，一个程序一旦启动就一直运行到结束或者受阻塞为止。这是因为一旦允许抢占，就破坏了先来先到的原则。先来先到的优点就是简单，人人都能理解，实现起来容易。而缺点则是短的工作有可能变得很慢，因为其前面有很长的工作。这样就造成用户的交互式体验也比较差。

例如，现在有两个程序：A需要运行100秒，B需要运行1秒。A程序与B程序几乎同时启动，但B就是慢了一点儿，排在A之后执行，则需要等待100秒。这样A的响应时间为100秒，而B的响应时间则为101秒，从而，平均响应时间100.5秒。那么响应时间非常慢。

就像我们排队办理事情，你要办理的事情只要几分钟就可办好，而你前面的一个人办理的事情因为复杂需要1个小时。这个时候你要等在他后面就十分不高兴。这个时候你就想，要是每个人轮流办理10分钟事务的话，那多好呀。

自然，研究进程调度的人也想到了这一点，而这种轮流办理的调度方式就是时间片轮转。

5.4　时间片轮转算法

时间片轮转算法是对FCFS算法的一种改进，其主要目的是改善短程序的响应时间。其方法就是周期性地进行进程切换。例如，每1秒钟进行一次进程轮换。这样，短程序排在长程序后面也可以很快得到执行。因此长程序执行1秒后就得把CPU让出来。这样整个系统的响应时间就得到了改善。以前面的A、B程序为例，A需要运行100秒，B需要运行1秒。使用FCFS时系统平均响应时间为100.5秒。而使用时间片轮转，则A在执行1秒后，CPU切换到进程B，在执行1秒后，B结束，A接着执行99秒。这样A的响应时间是101秒，而B的响应时间为2秒。系统的平均响应时间是51.5秒。这显然比FCFS的效率强多了。

仔细的读者可能已经看出，系统响应时间依赖于时间片的选择。我们因为选择了1秒钟的时间片，上述系统的响应时间是51.5秒。如果时间片是10秒钟，则上述系统的平均响应时间将是65秒。如果选择别的时间片，则响应时间还将不同。那我们自然想知道，到底选择多大的时间片才合适呢？

显然，如果选择的时间片过大，时间片轮转将越来越像FCFS，当选择的时间片超过任何一个程序所需要的执行时间长度时，则完全退化为FCFS。而如果选择的时间片过小，则进程切换所用的系统消耗将太多，使得系统的大部分时间花在进程的上下文切换上，而用来真正执行程序的有用时间很少，从而降低系统效率，并造成浪费。

那如何选择一个合适的时间片呢？做研究。我们需要知道进行一次进程切换所用系统消耗和我们能够承受的整个系统消耗，就可以得出合适的时间片。例如，如果每次进程切换需要消耗0.1毫秒的CPU时间，则选择10毫秒的时间片将浪费约1%的CPU时间在上下文切换上；如果选择5毫秒的时间片，则浪费约为2%；20毫秒的时间片，则浪费约为0.5%。如果我们能够承受的CPU浪费为1%，则选择10毫秒的时间片就很合理。

时间片选择还需考虑的一个因素是，有多少进程在系统里运行？如果运行的进程多，时间片就需要短一些，不然，用户的交互体验会很差。进程数量少，时间片就可以适当长一些。因此，时间片的选择是一个综合的考虑，需要权衡各方利益，进行适当折中。

时间片轮转看上去非常公平，响应时间非常好，每个进程周期性地获得CPU时间。但时间片轮转真的很公平吗？时间片轮转的系统响应时间总是比FCFS的响应时间短吗？答案却不一定。

还用上面的例子，假设是B比A略微先到，如果用FCFS，则B的响应时间为1秒，A的响应时间为101秒，这样系统的平均响应时间为51秒。这是最优的响应时间。如果使用时间片轮转，除非时间片选择的是1秒，否则时间片轮转的系统响应时间将比FCFS慢（记住，进程切换是需要消耗系统时间的）。

5.5　短任务优先算法

诚然，时间片轮转和先来先服务到底哪一种方式更公平是一个仁者见仁、智者见智的话题。但是一个事实是，现在的社会都不太认同终身制，而更认同轮流坐庄。18世纪以前，欧洲的皇帝是终身制，逃到美国的清教徒则显然不喜欢终身制，从而选择了4年一轮的总统制度。而这种轮流坐庄的制度已被世界绝大多数国家和不同时期的社会制度所认可，皇帝终身制度已被世人唾弃。从这一点来说，时间片轮转比起FCFS来说似乎更加公平。

那么时间片轮转真的那么公平吗？当然不是。如果每个人的能力一样，大家轮流坐庄当然比较公平，问题是每个人的能力并不完全一样。让一个能力很差的人与一个能力很好的人轮流执政恐怕没有多少人会同意。我们不是因为不满大锅饭而提出让一部分人先富起来吗？而时间片轮转就是大锅饭！

从前面可以看出，时间片轮转改善了所谓的系统响应时间的论断也不一定经得起推敲。更为重要的是，时间片轮转所达到的系统响应时间并不是我们所能达到的响应时间下限。如果有30个用户，其中一个用户只需要1秒钟时间执行，而其他29个用户需要30秒钟执行，如果因为某种原因，这个只要1秒钟的程序排在另外29个程序的后面轮转，则它需要等待29秒钟才能执行（假定时间片为1秒）。这个程序的响应时间和交互体验就变得非常差。

那要改善短任务排在长任务后面轮转而造成响应时间和交互体验下降的办法就是短任务优先（Shorted Time to Completion First, STCF）算法。这种算法的核心是所有的程序并不都一样，而是有优先级的区分。具体来说，就是短任务的优先级比长任务的高，而我们总是安排优先级高的程序先运行。就像晚辈在公交汽车上见到长辈需要让座一样。

短任务优先算法有两个变种：一种是非抢占，一种是抢占。非抢占短任务优先算法的原理是让已经在CPU上运行的程序执行到结束或阻塞，然后在所有候选的程序中选择需要执行时间最短的进程来执行。抢占式短任务优先算法则是每增加一个新的进程就需要对所有进程（包括正在CPU上运行的进程）进行检查，谁的时间短，就运行谁。

显然，由于短任务优先总是运行需要执行时间最短的程序，因此其系统平均响应时间在目前已经讨论过的几种调度算法里面是最优的。这就是STCF算法的优点。事实上，在所有非抢占调度算法中，STCF算法的响应时间最优。而在所有抢占调度算法中，抢占式STCF算法的响应时间最优。

下面来看一个例子：假定有A、B、C 3个进程，A、B均是纯计算进程，分别需要使用CPU计算50毫秒和100毫秒，而C每计算1毫秒后进行9毫秒的输入输出操作，并这样重复10次。

显然，如果A、B单独运行，则CPU利用率是100%；如果C单独运行，则磁盘利用率为90%。如果将它们一起运行，结果会怎么样呢？这里假定使用STCF调度算法。

要使用STCF算法，首先需要清楚哪个工作是短工作，哪个是长工作。A、B、C 3个进程相对来说，C是短工作，因为其使用CPU的时间远远短于其花在I/O上面的时间，而A、B皆是长工作。但相对来说，A又是短工作，因为其使用CPU的时间短于B进程。这样，STCF调度的优先级就是C、A、B。由此我们获得如图5-1所示的调度模式。

图　5-1　使用STCF调度算法对A、B、C 3个进程的调度情况（每个字符占用1毫秒）

在STCF调度算法下，磁盘在90%的情况下保持繁忙，这与只运行C一个进程的结果相同。A进程在系统启动后55毫秒结束，B进程在系统启动后159毫秒结束，C进程在系统启动后99毫秒结束。故整个系统的平均响应时间为104.3毫秒。

若使用FCFS算法，如果A或者B在C之前达到，则磁盘将被闲置150毫秒后才能第一次启动，磁盘的利用率将大大低于STCF调度算法。而系统的响应时间（假定A在B前面）为A是50毫秒，B是150毫秒，C是250毫秒，系统平均响应时间是150毫秒。

如果C在A、B前到达，由于C在执行1毫秒后就阻塞进行I/O，则整个状况与A、B先来的时候差不多，这个留待读者完成。

如果使用时间片轮转，假定时间片大小为10毫秒，按照C、A、B的顺序轮转，我们获得图5-2所示的调度情况。

图　5-2　使用时间片轮转调度算法对A、B、C 3个进程的调度情况

在时间片轮转情况下，系统的响应时间是A为104毫秒，B为159毫秒，C为99毫秒，系统平均响应时间为120.67毫秒。

由此可见，STCF算法的响应时间确实最短。当然，在时间片轮转的情况下，时间片选择的大小不同，结果将有所不同，但其响应时间不会短于STCF算法的响应时间。

但STCF调度算法也有缺点。第一是可能造成长程序无法得到CPU时间而导致“饥饿”。除此之外，还有一个重大缺点，就是我们怎么知道每个进程还需要运转多久？难道我们能够预测将来不成？就好像你第一次做某种事情时怎么知道需要多长时间呢？

这个时候就需要做研究！我们可以用一些启发式（heuristic）方法来进行估算，例如，根据程序大小来推测一个程序所需CPU执行时间。但这个方法并不可靠。另外一个办法就是先将每个程序运行一遍，记录其所用CPU时间，这样在以后的运行中，即可根据这个实测数据来进行STCF调度了。

5.6　优先级调度算法

前面介绍的STCF算法有一个缺点是可能造成长进程“饥饿”。但这个问题比较容易解决，使用优先级即可。优先级调度算法的原理是给每个进程赋予一个优先级，每次需要进程切换时，找一个优先级最高的进程进行调度。这样，如果赋予长进程一个高优先级，则该进程就不会再“饥饿”。事实上，STCF算法本身就是一种优先级调度，只不过它给予短进程高优先级而已。

优先级调度的优点是可以赋予重要的进程以高优先级以确保重要任务能够得到CPU时间。其缺点则与STCF算法一样，低优先级的进程可能会“饥饿”。不过，这个问题在优先级调度算法里比在STCF里好解决：只要动态地调节优先级即可。例如，在一个进程执行特定CPU时间后将其优先级降低一个级别，或者将处于等待进程的优先级提高一个级别。这样，一个进程如果等待时间很长，其优先级将因持续提升而超越其他进程的优先级，从而得到CPU时间。这样，“饥饿”现象就可以防止。

不过，优先级调度还有一个缺点，就是响应时间不能保证，除非将一个进程的优先级设置为最高。即使将优先级设置为最高，但如果每个人都将自己进程的优先级设为最高，则响应时间还是无法保证。

5.7　混合调度算法

之前介绍的所有算法都存在缺点，我们自然想设计一个算法合并它们的优点，摒弃它们的缺点。这就是所谓的混合调度算法。该算法的原理是将所有进程分成不同的大类，每个大类为一个优先级。如果两个进程处于不同的大类，则处于高优先级大类的进程优先执行；如果两个进程处于同一个大类，则采用时间片轮转来执行。混合调度算法的示意图如图5-3所示。

图　5-3　混合调度算法

5.8　其他调度算法

除了上面介绍的各种算法外，有的操作系统还实现了一些其他算法，它们包括：保证调度（Guaranteed Scheduling）、彩票调度（Lottery Scheduling）、用户公平调度（Fair Share Scheduling Per User）。其中保证调度算法的目标是保证每个进程占用CPU的时间完全一样，即如果系统里一共有n个进程，则每个进程占用CPU的时间为1/n。保障调度算法就是保障每个进程使用1/n的CPU时间。保障就是肯定运转1/n的时间，而不是运转大概1/n时间。那么保障调度和轮转调度是一样的吗？时间片轮转能不能达到1/n的效果？关键就是达到1/n不一定要靠轮转。轮转是能够达到1/n的，但是保障调度不一定要轮转。每次给的时间片不一定要一样。

彩票调度算法是一种概率调度算法。若买过彩票就知道，买的越多中奖的概率越大。在该算法里，给每个进程分发一定数量的彩票，而调度器则从所有彩票里随机抽取一张彩票，而持有该彩票的进程就获得CPU。这样，如果想让某个进程获得更多的CPU时间，我们可以给该进程多发几张彩票。彩票调度算法的优越性是显而易见的，通过给每个进程至少一张彩票就可以防止“饥饿”，因为该进程获得CPU的概率将大于0。除此之外，彩票算法还可以用于模拟其他进程调度算法。例如，如果给每个进程一样多的彩票，则该算法就近似保证调度算法；如果给短任务赋予更多的彩票，则该算法将类似于短任务优先算法。

那么彩票调度算法有什么用呢？比如，如果要保障A进程50%的CPU时间，那么就把一半的彩票分给A，这样的话就能保障50%的CPU时间。别的调度方法也可能达到这个效果，但是不灵活。

用户公平调度算法按照每个用户而不是每个进程来进行公平分配。前面讲过的算法均以进程为单位。这样一个贪婪的用户可以通过启动许多进程来抢占CPU时间。如果每个用户都很贪婪，都试图启动很多进程，则将造成整个系统效率低下，甚至停顿。用户公平调度算法就是将CPU时间按照用户进行平均分配。如果一个用户的进程多，则其所拥有的进程所获得的CPU时间将短；反之，则多。

自然，CPU调度算法并不只有上面介绍的几种。由于不同系统的目标不同，不同进程的性质和重要性不同，因此进程调度也就不同。事实上，调度算法非常多，有兴趣的读者可以参阅有关调度方面的大量论文。

5.9　实时调度算法

实时系统是一种必须提供时序可预测性的系统。由于其应用范围广和特性不同于一般计算机系统，因此其调度算法也别出一格，和前面讲过的所有调度算法均有所不同。前面的算法主要考虑的是平均响应时间和系统吞吐率的问题，而实时系统则必须考虑每个具体任务的响应时间必须符合要求，即每个任务必须在什么时间之前完成，而无须考虑如何降低整个系统的响应时间或吞吐率。

比如，计算来袭导弹轨迹的进程，其计算时间是非常有限的。如果该进程不能在规定时间内计算出来袭导弹的轨迹，则结果毫无意义。但如果能够在截止时间前完成，那么提前多少则无关紧要。这就是说，只要达到一定响应时间后，再提升响应时间并不能获得任何好处。比如，视频输出，在NTSC制式下（美国、日本、中国台湾使用的电视视频制式），只要每33毫秒发出一个图像帧，所看到的视频就是连贯的。而即使发送得比这更快，也不会获得任何额外的好处。其他实时系统还有物理控制系统（如核反应堆控制温度的系统、汽车测速机制等）。

实时系统调度算法种类繁多，本书不可能覆盖。这样我们只论述一下其最主要或者说最经典的两种算法：动态优先调度和静态优先级调度。动态优先级调度又称为最早截止任务优先（Earliest Deadline First, EDF）算法，而静态优先级调度又称为最短周期优先（Rate Monotonic Scheduling, RMS）算法。

5.9.1　EDF调度算法

EDF调度算法就是最早截止的任务先做。如果新的工作来了，比正在运行的程序的截止时间更靠前，那么就抢占当前进程。EDF调度算法是实时调度里面的最优算法。如果一组任务可以被调度的话（指所有任务的截止时间在理论上能够满足），则EDF可以满足。一批任务如果不能全部满足，那么EDF能满足的任务数最多。这就是它最优的体现。

例如，任务A需要15毫秒执行时间，截止时间在进入到系统后第20毫秒，B需要执行10毫秒，截止时间为进入系统后第30毫秒，C需要5毫秒执行时间，截止时间为进入到系统后第10毫秒。使用EDF调度算法的结果就是先运行C，再运行A，最后运行B。如图5-4所示。

图　5-4　使用EDF调度算法对A、B、C 3个任务的调度结果

细心的读者可能已经看出，EDF调算算法就是STCF算法变化来的。如果将STCF算法的任务所需执行时间变为截止时间，则抢占式STCF算法就是EDF调度算法。

5.9.2　RMS调度算法

EDF算法是一种动态调度算法。意思是该算法动态地计算每个任务的截止时间并动态调节优先级。如果需要，还会对当前进程进行抢占。虽然EDF算法在理论上是最优的，但动态计算截止时间和动态抢占CPU均要消耗系统资源，因此EDF算法实际效果比其理论效果要差一截。与EDF算法相对的是所谓的RMS调度算法。该算法在进行调度前先计算出所有任务的优先级，然后按照计算出来的优先级进行调度，任务执行中间既不接收新的进程，也不进行优先级的调整或进行CPU抢占。因此这种算法的优点是系统消耗小，缺点是不灵活。一旦该系统的任务决定了，就不能再接收新的任务。

对于RMS算法来说，一个重要的任务是判断一个任务组能否调度。而这个判断并不是容易做的。Liu和Kayland在1973年证明了如果一个系统里所有任务的CPU的利用率低于ln 2，则这些任务的截止时间均可以得到满足。具体来说，一个系统里所有任务的截止时间如果想都得到满足，则这些任务必须满足下面的条件：

这里，n为任务的数量，ci为第i个任务的执行时间，pi为第i个任务的释放周期。而当n趋向无穷时，U的值变为ln2。

根据上述公式，如果CPU利用率在ln 2以下时，所有任务的截止时间均可满足。因为ln2约等于0.693147，此时系统还剩下约30%的CPU时间。这个时间可以用来处理一些非实时任务。

RMS算法为静态最优算法。即如果任何静态优先级算法可以满足一组任务的截止时间，则RMS算法也必能满足。具体证明请参阅Liu和Kayland于1973年发表的论文。

5.10　进程调度的过程

前面的内容讨论了进程调度的含义及各种调度算法，但调度一个进程对于操作来说意味着什么却并没有解释。那么在更换进程的时候到底有哪些操作需要完成呢？首先，当然需要将当前进程的状态予以保护，以便将来能够重新执行。然后是将选中的进程的环境布置好，这包括设置寄存器、栈指针、状态字等操作。最后是跳转到选中的进程，也就是设置或恢复其程序计数器。下面给出的是调度进程时操作系统所执行的操作概览：

●因时序或外部中断或进程挂起而导致操作系统获得CPU控制权。

●操作系统在所有就绪的进程中按照某种算法遴选进程。

●如果选中的是非当前进程，则操作系统将当前进程（中断或挂起的进程）状态予以保护。

●将选中的进程的环境布置好（设置寄存器、栈指针、状态字等）。

●跳转到选中的进程。

5.11　高级议题：调度异常之优先级倒挂

优先级倒挂（priority inversion）将在第8章介绍，在这里仅简单介绍。其所指的是一个低优先级任务持有一个被高优先级认为所需要的共享资源。这样高优先级任务因资源缺乏而处于受阻状态，一直到低优先级任务释放资源为止。这样实际上造成了这两个任务的优先级倒挂。如果此时有其他优先级介于二者之间的任务，并且其不需要这个共享资源，则该中级优先级的进程将获得CPU控制，从而超越这两个任务，导致高优先级进程被临界区外的低优先级进程阻塞。

在某些时候，优先级倒挂并不会造成损害。高优先级任务的延迟并不会注意到。因为低优先级进程最终会释放资源。但在其他一些时候，优先级倒挂则可能引起严重后果。如果一个高优先级进程一直不能获得资源，有可能造成系统故障，或激发事先定义的纠正措施，如系统复位。例如，美国的火星探测器Mars Path-finder就是因为优先级倒挂而出现故障。

如果高优先级进程在等待资源时不是阻塞等待，而是循环（繁忙）等待，则它将永远无法获得所需资源。因为此时的低优先级进程无法与高优先级进程争夺CPU时间，从而无法执行，进而无法释放资源。而这将造成高优先级进程无法获得资源而继续推进。

优先级倒挂还可能造成系统性能降低。低优先级进程之所以优先级低是因为其所执行的任务并不重要。例如，它们可能是批处理任务或其他非交互式任务。而高优先级任务执行的则是较为重要的任务，如为交互用户提供数据或实时任务。由于优先级倒挂造成低优先级任务在高优先级任务之前执行，因此系统的响应将降低，甚至实时系统的响应时间保证都有可能违反。

倒挂的解决方案

优先级倒挂的问题在20世纪70年代就已经发现，但却没有找到一个可以预测其发生的方法。这是因为在实际运行中很难判断哪些进程之间会进行资源竞争，哪些进程又会从别人的资源竞争中渔翁得利。不过，虽然我们不能预测其发生，但却可以采取手段防止其出现。由于优先级倒挂必须满足某些条件，因此我们只需将这些条件排除即可防止优先级倒挂。

那么如何防止优先级倒挂呢？首先我们应该看到，优先级倒挂有两种形式：

●不持有资源的低优先级进程阻碍需要资源的高优先级进程的执行。

●持有资源的低优先级进程阻碍需要资源的高优先级进程的执行。

第1种情况为优先级倒挂的正常定义。这种倒挂的出现需要3种优先级别的存在：一个低优先级进程持有资源，一个不需要该资源的中优先级进程和一个需要使用该资源的高优先级进程。由于中优先级进程不需要使用低优先级进程占用的资源，因此它将能够抢占低优先级进程而获得CPU，从而在高优先级进程前面运行。

要防止这种优先级倒挂，只需要将系统的优先级别限制为两种即可解决。这就可以得出我们的第1种防止办法：使用中断禁止。

●使用中断禁止

这种办法的核心是通过禁止中断来保护临界区。在采用此种策略的系统中只有两个优先级：可抢占优先级和中断禁止优先级。前者为一般进程运行时的优先级，后者为运行于临界区进程的优先级。由于不存在第3种优先级，因此第1种优先级倒挂无法发生。由于系统里只存在一把锁（禁止中断操作在任何时候只能由一个进程执行），不能发生乱序，因此也不会发生死锁。又由于进入临界区的进程总是能够不被打断而一直运行到结束，因此也不会发生悬挂（hang）。此时，即使高优先级进程采用繁忙等待也不会发生死锁。

这里需要注意的是，所有中断都必须禁止。如果禁止的仅仅是一个特定的硬件设备的中断，则硬件的中断优先处理机制将再次引入优先级倒挂。

该方法普遍应用于简单的嵌入式系统。这种系统的特点是可靠性、简易性和资源需求低。不过这种方法对程序员的要求较高，因此在程序设计时需要将临界区设计得很短（中断禁止时间过长会造成系统响应时间增加而导致错误或灾难），通常应该在100微秒以下。而这个时间对于通用计算机来说很不现实。

在多CPU环境下，由于不能使用中断禁止，因此使用一个简单的变种：单一共享标志锁。该方法在共享内存里面提供一个单一标志。所有CPU在进入跨CPU临界区时都必须先获得该标志。这个标志在有的操作系统里面称为旋锁。本书将在第七篇对旋锁进行讨论。

●优先级上限（priority ceiling）

使用中断禁止可以防止第1种优先级倒挂，但无法避免第2种情况的倒挂。而要防止第2种优先级倒挂，就不能让低优先级进程持有高优先级进程所需要的资源。而我们的思路就是让共享的临界区有自己的优先级，并让访问临界区的进程获得临界区的优先级。这样，只要临界区的优先级设置得足够高，就可以避免优先级倒挂。（因为获得资源的进程具有的将是高优先级！）在实际实现的时候，共享的mutex进程（操作系统代码）有其自身的高优先级。一个程序如果进入mutex保护的临界区，将获得该临界区所具有的高优先级别。此时如果其他试图访问mutex的进程的优先级都低于mutex的优先级，则将不会发生优先级倒挂。但如果试图访问mutex的程序具有高于mutex的优先级，则仍可能发生倒挂。

●优先级继承（priority inheritance）

如果要完全杜绝优先级倒挂，则需要在任何时候都确保等待资源的进程所具有的优先级必须低于持有资源的进程的优先级。而实现这种目标的机制就是优先级继承。在此种方式下，当一个高优先级进程等待一个低优先进程所持有的资源时，这个低优先级进程将暂时获得高优先级进程的优先级别。这样就能防止一个中间优先级的进程抢占低优先级进程而延长资源占用时间，同时也防止了因高优先级进程自身进行繁忙等待可能导致的死锁。即此种方法对两种优先级倒挂都有效果。在释放共享资源后，低优先级进程回到其原来的优先级别。

思考题

1.进程调度追求的目标是什么？

2.程序使用CPU的模式有哪几种？分别有何特点？

3.短任务优先和优先级调度是否是同一种调度，为什么？

4.如果想让某个进程获得50%的运行机会，请问应该使用哪一种调度策略？

5.假定有A、B、C 3个进程，A、B均是纯计算进程，分别需要使用CPU计算50毫秒和100毫秒，而C每计算1毫秒后进行9毫秒的输入输出操作，并这样重复10次。

a）按顺序C、A、B几乎同时到达系统，给出FCFS的调度情况并计算系统响应时间。

b）按顺序B、A、C几乎同时到达系统，给出FCFS的调度情况并计算系统响应时间。如果使用时间片轮转，情况又如何？这里假设时间片大小为10毫秒。

6.仔细讨论保障调度和时间片轮转调度的异同点。它们的根本区别是什么？

7.优先级倒挂是怎么回事？它有什么危害？

8.请分析讨论解决优先级倒挂的3种办法，哪一种的优势最明显。

9.请从人类的中庸之道分析讨论现代商用操作系统所采取的混合调度策略。

10.试阐述适度性在进程调度中的作用，并举例予以说明。

11.调研某一商业操作系统的进程调度策略，它与本章阐述的调度策略有何异同？

12.如果一个进程在最后一个CPU任务结束后有一个很长的I/O操作，则在计算CPU响应时间的时候是否需要计入该I/O操作的耗时？请说明你的理由。

13.假如时间片轮转的时间片的大小是10毫秒，进程A在1毫秒的时候阻塞或者结束，进程B接着用该时间片，则进程B拥有的是9毫秒的时间片，还是10毫秒？为什么？

14.如果一个任务因为I/O操作阻塞了，其恢复后需要排到队尾等待吗？

第6章　进程通信

引子：孤独爆破手的自白

1995年4月24日，《纽约时报》和《华盛顿邮报》非常不情愿地同时刊登了一篇长达4个版面的文章，标题是《工业社会及其未来》。文章作者是西奥多约翰·卡扎斯基，一个被美国联邦调查局搜捕了17年之久的“孤独的爆破手”（见图6-1）。该作者在从1978年至1995年的17年间，通过发送炸弹邮件，炸死炸伤几十名学者、科技公司高管和技术倡导人士。

图　6-1　孤独的爆破手卡扎斯基（Theodore Kaczynski）

作为哈佛大学学士、密歇根大学博士，卡扎斯基于25岁成为加州大学伯克利分校（UC-Berkeley）的数学教授。“无论说他怎么聪明都不过分”，这是熟悉他的教授、同事给出的评价。就是这样一个人，却在1969年突然从加州大学伯克利分校消失了，并随后隐居美国蒙大拿州乡间。他在使用其他方式反对技术对人类的威胁都遭到失败后，于1978年开始通过自制炸弹来袭击技术界人士，以表达他反对技术所带给人类各种灾难的立场。而联邦调查局也随即开始了该局历史上耗时最长、花费最为昂贵的搜捕行动。但却一无所获，“孤独的爆破手”的炸弹仍然继续发挥着威力。

卡扎斯基的文章在《纽约时报》发表后，他的弟弟认出了其笔迹和信仰，报告了联邦调查局，卡扎斯基从而被捕。联邦调查局历史上最长、最昂贵的搜捕行动宣告结束。

卡扎斯基因为其沟通的欲望而落入法网，可见沟通对人的重要性。而进程也不例外，它们对沟通的需求也非常强烈。

6.1　为什么要通信

通信是人的基本需求。古罗马皇帝的实验证明，没有与人交互的婴儿无法存活。美国“孤独的爆破手”西奥多约翰·卡扎斯基就因为其强烈的与人沟通的欲望，迫使《纽约时报》发表了其立场宣言，从而导致其被联邦调查局捕获。

进程作为人类的发明，自然免不了脱离人类的习性，也有通信需求。如果进程之间不进行任何通信，那么进程所能完成的任务就要大打折扣。例如，父进程在创建子进程后，通常需要监督子进程的状态，以便在子进程没有完成给定的任务时，可以再创建一个子进程来继续。这就需要父子进程间通信。

进程之间的交互称为进程间通信（Inter-Process Communication, IPC）。那么进程之间的通信是如何进行的呢？由于进程是人类的创造，我们只要看看人类是如何通信的就知道了。

人类通信的方式无外乎对白（通过声音沟通）、打手势、写信、发电报、拥抱等方法。类似，进程也可以同样的方式进行通信。下面我们就来看一下进程的这些交互方式。

6.2　进程对白：管道、记名管道、套接字

人们最常使用的通信手段就是对白。对白的特点就是一方发出声音，另一方接收声音。而声音的传递则通过空气（当面或无线交谈）、线缆（有线电话）进行传递。类似，进程对白就是一个进程发出某种数据信息，另外一方接收数据信息，而这些数据信息通过一片共享的存储空间进行传递。

在这种方式下，一个进程向这片存储空间的一端写入信息，另一个进程从存储空间的另外一端读取信息。这看上去像什么？管道（见图6-2）。管道所占的空间既可以是内存，也可以是磁盘。就像两人对白的媒介可以是空气，也可以是线缆一样。要创建一个管道，一个进程只需调用管道创建的系统调用即可。该系统调用所做的事情就是在某种存储介质上划出一片空间，赋给其中一个进程写的权利，另一个进程读的权利即可。

6.2.1　管道

从根本上说，管道是一个线性字节数组，类似文件，可以使用文件读写的方式进行访问。但却不是文件。因为通过文件系统看不到管道的存在。另外，我们前面说了，管道可以设在内存里，而文件很少设在内存里（当然，有研究人员在研发基于内存的文件系统，但这个还不是主流）。

创建管道在壳命令行下和在程序里是不同的。壳（shell）命令行下，只需要使用符号“｜”即可。例如，在UNIX壳下，我们可以键入如下命令：

＄sort＜file1｜grep zou

在两个utility“排序”（sort）和“查找”（grep）之间创建了一个管道，数据从sort流向grep。即sort的结果将作为grep的输入。上述命令的意思是对file1的内容进行排序，排完序的结果作为utility程序grep的输入，在结果里面找出所有包括字符串zou的文本行。

在程序里面，创建管道需要使用系统调用popen（）或者pipe（）。popen（）需要提供一个目标进程作为参数，然后在调用该函数的进程和给出的目标进程之间创建一个管道。这很像人们打电话时必须提供对方的号码，才能创建连接一样。

图　6-2　操作系统里的管道类似生活中的管道

创建时还需要提供一个参数表明管道类型：读管道或者写管道。而pipe（）调用将返回两个文件描述符（文件描述符是用来识别一个文件流的一个整数，与句柄不同），其中一个用于从管道进行读操作，一个用于写入管道。也就是说，pipe（）将两个文件描述符连接起来，使得一端可以读，另一端可以写。通常情况下，在使用pipe（）调用创建管道后，再使用fork产生两个进程，这两个进程使用pipe（）返回的两个文件描述符进行通信。

例如，下面的代码段创建一个管道并利用它在父子进程间通信。

int pp[2]；

pipe（pp）；//创建管道

if（fork（）==0）｛//子进程

read（pp[0]）；//从父进程读

……

｝

else｛

write（pp[1]）；//写给子进程

……

｝

管道的一个重要特点是使用管道的两个进程之间必须存在某种关系，例如，使用popen需要提供另一端进程的文件名，使用pipe（）的两个进程则分别隶属于父子进程。

6.2.2　记名管道

如果要在两个不相关的进程（如两个不同进程里面的进程）之间进行管道通信，则需要使用记名管道。顾名思义，命名管道是一个有名字的通信管道。记名管道与文件系统共享一个名字空间，即我们可以从文件系统中看到记名管道。也就是说，记名管道的名字不能与文件系统里的任何文件名重名。例如，在UNIX下使用ls命令可以查看已经创建的记名管道。

%ls-l fifo1

prw-r——r——1 john users 0 Sep 22 23：11 fifo1｜

一个进程创建一个记名管道后，另外一个进程可使用open来打开这个管道（无名管道则不能使用open操作），从而与另外一端进行交流。

记名管道的名称由两部分组成：计算机名和管道名，例如＼＼[主机名]＼管道＼[管道名]＼。对于同一主机来讲，允许有多个同一命名管道的实例并且可以由不同的进程打开，但是不同的管道都有属于自己的管道缓冲区而且有自己的通信环境，互不影响。命名管道可以支持多个客户端连接一个服务器端。命名管道客户端不但可以与本机上的服务器通信也可以同其他主机上的服务器通信。

管道和记名管道虽然具有简单、无需特殊设计（指应用程序方面）就可以和另外一个进程进行通信的优点，但其缺点也很明显。首先是管道和记名管道并不是所有操作系统都支持。主要支持管道通信方式的是UNIX和类UNIX（如Linux）的操作系统。这样，如果需要在其他操作系统上进行通信，管道机制就多半会力不从心了。其次，管道通信需要在相关的进程间进行（无名管道），或者需要知道按名字来打开（记名管道），而这在某些时候会十分不便。

6.2.3　虫洞：套接字

套接字（socket）是另外一种可以用于进程间通信的机制。套接字首先在BSD操作系统中出现，随后几乎渗透到所有主流操作系统中。套接字的功能非常强大，可以支持不同层面、不同应用、跨网络的通信。使用套接字进行通信需要双方均创建一个套接字，其中一方作为服务器方，另外一方作为客户方。服务器方必须先创建一个服务区套接字，然后在该套接字上进行监听，等待远方的连接请求。欲与服务器通信的客户则创建一个客户套接字，然后向服务区套接字发送连接请求。服务器套接字在收到连接请求后，将在服务器方机器上创建一个客户套接字，与远方的客户机上的客户套接字形成点到点的通信通道。之后，客户方和服务器方就可以通过send和recv命令在这个创建的套接字通道上进行交流了。

服务区套接字有点类似于传说中的虫洞（worm hole），如图6-3所示。虫洞的一端是开放的，它在宇宙内或宇宙间飘移着，另外一端处于一个不同的宇宙，监听是否有任何东西从虫洞来。而欲使用虫洞者需要找到虫洞的开口端（发送连接请求），然后穿越虫洞即可。

图　6-3　服务器套接字与宇宙虫洞类似

使用套接字进行通信稍微有点复杂，我们下面以一个网页浏览的例子对套接字这种通信方式予以说明。对于一个网站来说，要想提供正常的网页浏览服务，其网站服务器需要首先创建一个服务区套接字，作为外界与本服务器的通信信道。为了使该信道为外人所知，我们通常将该服务区套接字与某公共主机的一个众所周知的端口进行绑定，如图6-4所示。

图　6-4　网站服务器创建服务区套接字并与端口80进行绑定

图6-4进行套接字和端口绑定的语句里的socket.gethostname（）用来将套接字向外界公开。如果将该语句的使用socket.gethostname（）改为＇＇或者＇localhost或者某个具体的IP地址（如120.121.4.1），则该服务区套接字将被限制在本地机器上使用。

在创建了服务区套接字并将其向外界公开后，网站服务器就可以在该套接字上进行监听，如图6-5所示。

图　6-5　在打开的套接字上进行监听

图6-5语句里的数字5将端口上的等待队列长度限制为5，即超过5个的请求将被拒绝。

到这里，服务器方的设置就宣告结束。

对于客户方来说，如要访问上述网站，则需要点击该网站的网址。在点击网址后（我们这里假定该网站网址为www.sjtu.edu.cn），客户机上的网络浏览器进行若干步操作，如图6-6所示。

图　6-6　客户端网络浏览器创建客户机套接字并与网站连接

图6-6中的s.connect命令将向服务器www.sjtu.edu.cn在端口80打开的服务器套接字发送连接请求。而服务器端在接收到该连接请求后，将生成一个新的客户端套接字与该客户端套接字对接，从而建立一个套接字通信信道。图6-7为网站服务器上运行的主循环。

图　6-7　网站服务器上运行的主循环

至此，套接字通信信道成功创建。客户端程序可以使用套接字s来发送请求、索取网页，而服务器端则使用套接字clientsocket进行发送和接收消息。

这里需要指出的是服务区套接字既不发送数据，也不接收数据（指不接收正常的用户数据，而不是连接请求数据），而仅仅生产出“客户”套接字。当其他（远方）的客户套接字发出一个连接请求时，我们就创建一个客户套接字。一旦创建客户套接字clientsocket，与客户的通信任务就交给了这个刚刚创建的客户套接字。而原本的服务器套接字serversocket则回到其原来的监听操作上。

套接字由于其功能强大而获得了很大发展，并出现了许多种类。不同的操作系统均支持或实现了某种套接字功能。例如按照传输媒介是否为本地，套接字可以分为本地（UNIX域）套接字和网域套接字。而网域套接字又按照其提供的数据传输特性分为几个大类，分别是：

●数据流套接字（stream socket）：提供双向、有序、可靠、非重复数据通信。

●电报流套接字（datagram socket）：提供双向消息流。数据不一定按序到达。

●序列包套接字（sequential packet）：提供双向、有序、可靠连接，包有最大限制。

●裸套接字（raw socket）：提供对下层通信协议的访问。

套接字从某种程度上来说非常繁杂，各种操作系统对其处理并不完全一样。因此，如要了解某个特定套接字实现，读者需要查阅关于该套接字实现的具体手册或相关文档。

6.3　进程电报：信号

管道和套接字虽然提供了丰富的通信语义，并且也得到了广泛应用，但它们也存在某些缺点，并且在某些时候，这两种通信机制会显得很不好用。

首先，如果使用管道和套接字方式来通信，必须事先在通信的进程间建立连接（创建管道或套接字），这需要消耗系统资源。其次，通信是自愿的。即一方虽然可以随意向管道或套接字发送信息，但对方却可以选择接收的时机。即使对方对此充耳不闻，你也奈何不得。再次，由于建立连接消耗时间，一旦建立，我们就想进行尽可能多的通信。而如果通信的信息量微小，如我们只是想通知一个进程某件事情的发生，则用管道和套接字就有点“杀鸡用牛刀”的味道，效率十分低下。

因此，我们需要一种不同的机制来处理如下通信需求：

●想迫使一方对我们的通信立即做出回应。

●我们不想事先建立任何连接，而是临时突然觉得需要与某个进程通信。

●传输的信息量微小，使用管道或套接字不划算。

应付上述需求，我们使用的是信号（signal）。

那么信号是什么呢？在计算机里，信号就是一个内核对象，或者说是一个内核数据结构。发送方将该数据结构的内容填好，并指明该信号的目标进程后，发出特定的软件中断。操作系统接收到特定的中断请求后，知道是有进程要发送信号，于是到特定的内核数据结构里查找信号接收方，并进行通知。接到通知的进程则对信号进行相应处理。

信号非常类似我们生活当中的电报，如图6-8所示。如果你想给某人发一封电报，就拟好电文，将报文和收报人的信息都交给电报公司。电报公司则将电报发送到收报人所在地的邮局（中断），并通知收报人来取电报。发报时无需收报人事先知道，更无需进行任何协调。如果对方选择不对信号做出响应，则将被操作系统终止运行。

图　6-8　信号非常类似人类生活中的电报发送与接收

6.4　进程旗语：信号量

信号量（semaphore）是由荷兰人E.W.Dijkstra在20世纪60年代所构思出的一种程序设计构造。其原型来源于铁路的运行：在一条单轨铁路上，任何时候只能有一列列车行驶在上面（如图6-9）。而管理这条铁路的系统就是信号量。任何一列火车必须等到表明铁路可以行驶的信号后才能进入轨道。当一列列车进入单轨运行后，需要将信号改为禁止进入，从而防止别的火车同时进入轨道。而当列车驶出单轨后，则需要将信号变回允许进入状态。这很像以前的旗语，如图6-10所示。

在计算机里，信号量实际上就是一个简单整数。一个进程在信号变为0或者1的情况下推进，并且将信号变为1或0来防止别的进程推进。当进程完成任务后，则将信号再改变为0或1，从而允许其他进程执行。

需要注意的是，信号量不只是一种通信机制，更是一种同步机制。本书将在第8章再论述信号量。

图　6-9　信号量来源于铁路信号系统

图　6-10　信号量相当于人类旗语

6.5　进程拥抱：共享内存

管道、套接字、信号、信号量，虽然满足了多种通信需要，但还是有一种需要未能满足。这就是两个进程需要共享大量数据。这就像两个人，他们互相喜欢，并想要一起生活时（共享大量数据量），打电话、握手、对白等就显得不够了，这个时候需要的是拥抱，只有将其紧紧拥抱于怀，感觉才最到位，也才能尽可能地共享。

进程的拥抱就是共享内存（见图6-11）。共享内存就是两个进程共同拥有同一片内存。对于这片内存中的任何内容，二者均可以访问。要使用共享内存进行通信，一个进程首先需要创建一片内存空间专门作为通信用，而其他进程则将该片内存映射到自己的（虚拟）地址空间。这样，读写自己地址空间中对应共享内存的区域时，就是在和其他进程进行通信。

图　6-11　共享内存

乍一看，共享内存有点像管道，有些管道不也是一片共享内存吗？这是形似而神不似。首先，使用共享内存机制通信的两个进程必须在同一台物理机器上；其次，共享内存的访问方式是随机的，而不是只能从一端写，另一端读，因此其灵活性比管道和套接字大很多，能够传递的信息也复杂得多。

共享内存的缺点是管理复杂，且两个进程必须在同一台物理机器上才能使用这种通信方式。共享内存的另外一个缺点是安全性脆弱。因为两个进程存在一片共享的内存，如果一个进程染有病毒，很容易就会传给另外一个进程。就像两个紧密接触的人，一个人的病毒是很容易传染另外一个人的。

这里需要注意的是，使用全局变量在同一个进程的进程间实现通信不称为共享内存。

6.6　信件发送：消息队列

消息队列是一列具有头和尾的消息排列。新来的消息放在队列尾部，而读取消息则从队列头部开始，如图6-12所示。

图　6-12　消息队列

乍一看，这不是管道吗？一头读、一头写？没错。这的确看上去像管道，但它不是管道。首先，它无需固定的读写进程，任何进程都可以读写（当然是有权限的进程）。其次，它可以同时支持多个进程，多个进程可以读写消息队列。即所谓的多对多，而不是管道的点对点。另外，消息队列只在内存中实现。最后，它并不是只在UNIX和类UNIX操作系统中实现。几乎所有主流操作系统都支持消息队列。

6.7　其他通信机制

除了上面介绍的主流通信方式外，有些操作系统还提供了一些其所特有的通信机制，例如Windows支持的进程通信方式就有所谓的剪贴板（clipboard）、COM/DCOM、动态数据交换（DDE）、邮箱（mailslots）；而Solaris则有所谓的Solaris门机制，让客户通过轻量级（16KB）系统调用使用服务器的服务。

虽然进程之间的通信机制繁多，且每种机制都有着自己的特性，但归根结底都来源于AT＆T的UNIX V系统。该系统在1983年加入了对共享内存、信号量和消息队列的支持。而这三者就是众所周知的System V IPC（POSIX IPC也是源于该系统并成为当前IPC的标准）。因此，虽然不同操作系统的IPC机制可能不尽相同，但其基本原理则并无大的区别。如果需要了解具体操作系统的IPC机制的实现，读者可以阅读相关的操作系统内核教程。

思考题

1.进程为什么需要通信？

2.进程通信要达到的目的是什么？

3.消息队列和管道有何异同？

4.管道的物理实体是什么？

5.套接字和管道的异同点是什么？可以用管道实现套接字的功能吗？

6.请调查某个商用操作系统的套接字实现，并予以讨论。

7.在本章介绍的所有通信机制里面，哪一种机制支持的信息交换量最大？

8.试将本章介绍的通信机制按照出现的逻辑顺序排列出来，并简要说明其逻辑关系。

9.有同学认为本章介绍的信号和信号量实际上是一回事情，你同意吗？为什么？

10.请举出一个需要进程通信的编程实例。

第三篇　线程原理篇

善闭者无关楗不可启，善结者无绳约不可解

进程虽然让并发从理想变为现实，但其自身却是串行：进程只有一个执行序列。只有一个执行序列的进程模型并不能从根本上满足人类的所有并发的奢望。并发其实并非人类的唯一诉求，甚至也不一定是最重要的诉求。有了并发，我们希望的是并发能够帮助我们实现在非并发时难以实现或实现成本高昂的功能：并发的个体通过合作来完成复杂的任务。

虽然可以使用诸多进程来相互协作实现需要并发才能完成的功能，但进程间的协作有着重要的限制：每个进程有自己的独立空间。这种限制导致进程之间的协作存在明显缺陷：如果相互协作的进程需要动态共享大量的数据，则操作起来十分麻烦。就像一群人之间的协作一样，由于每个人都是独立的个体，各有不同的喜好和习惯，协作难免产生误解和沟通困难。如果一件事情让一个人来处理，协作上就不会发生问题。因此，如果能够让一个进程内部实现并发来完成一个复杂的任务，协作上的难度就会少很多。

在进程内部实现并发就是进程出现的动机，如下图所示。

本篇即对进程级的并发机制——进程模型进行讨论。本篇包括第7～10章的内容。第7章的内容包括进程分身术——进程、进程管理、进程的用户态、内核态和混合态实现、现代操作系统的进程实现模型、多进程之间的关系、进程模型主要考虑的问题。第8章的内容包括为什么同步、同步的目的、锁原语的进化、睡觉与叫醒原语、信号量、管程、消息传递和栅栏。第9章对死锁的产生、发展、防止与避免进行讲解，并讨论死锁、活锁和饥饿的关系。第10章讲述如何使用中断启用和禁止、测试与设置来实现锁原语。

从根本上说，进程是操作系统给进程模型提供并发能力的手段

虽然进程和进程出现的动机都是并发，但它们的并发层次不同：进程属于处理器级并发，即在处理器这一层次上提供并发的抽象；进程则属于进程级并发，即在进程这个层次上再提供一层并发的抽象。如果我们下到底层，进入计算机体系结构里就会发现，流水线提供的也是一种并发，不过是指令级并发。这样，流水线、进程、进程就从低到高在3个层次上提供我们所迫切需要的并发！

第7章　线程

引子：亚历山大的分身术

公元前337年，年仅20岁的亚历山大继位成为马其顿（Macedonia）国王。此时，相邻的希腊各城邦共和国纷纷起来反叛马其顿的统治。北方的野蛮民族也蠢蠢欲动。亚历山大的侍臣顾问们纷纷建议亚历山大放弃对希腊各城邦共和国的平叛，而集中精力对北方野蛮民族进行毁灭性打击。这些侍臣顾问们认为，亚历山大一个人无法分身分神同时对付希腊各城邦和北方各野蛮民族，因此，只能选择其一进行打击，而通过杀鸡给猴看让另外的城邦臣服。

但是亚历山大拒绝了侍臣顾问们的建议。他认为，只要他速度足够快，就能够完成先破希腊再定北方边界的分身术。亚历山大的神速决战，迅速击败了希腊各城邦。而此时，北方野蛮民族还没有回过神来，当亚历山大出现在他们面前时，他们已经失去了行动的时机。

而就是这种神速的时间分身术，让亚历山大在一即位就慑服了所有闻其名的人，也奠定了其后来东攻西掠、南征北战、征服整个文明世界的壮举，如图7-1所示。

图　7-1　雕塑：亚历山大在战斗中

每个人在人生的某个时候，都希望自己能够分身，从而完成某件不可能完成的事。而进程也是一样，它也希望在某些时候能够分身，从而完成更加复杂的使命。于是便有了线程。

7.1　进程的分身术——线程

那么线程是什么？我们知道，进程是运转的程序，是为了在CPU上实现多道编程而发明的一个概念。但是进程在一个时间只能干一件事情。如果想同时干两件事，例如同时看两场电影，我们自然想到传说中的分身术，就像孙悟空那样同时变出多个真身。

当然，人在现实中进行分身是办不到的。但进程却可以办到，办法就是线程。线程就是我们为了让一个进程能够同时干多件事情而发明的“分身术”。

既然线程是进程的分身，那么每个线程自然在本质上是一样的，即拥有同样的程序文本。但由于是分身，自然也应该有不一样的地方，这就是线程执行时的上下文不一致。事实上，我们说线程是进程里面的一个执行上下文或者执行序列。显然，一个进程可以同时拥有多个执行序列。这就像舞台，舞台上可以有多个演员同时出场，而这些演员和舞台就构成了一出戏。类比进程和线程，每个演员是一个线程，舞台是地址空间，这个同一个地址空间里面的所有线程就构成了进程。

在线程模式下，一个进程至少有一个线程，但也可以有多个线程，如图7-2所示。

图　7-2　线程模型下的单线程进程和多线程进程对比

将进程分解为线程还可以有效地利用多处理器和多核计算机。在没有线程的情况下，增加一个处理器并不能提高一个进程的执行速度。但如果分解为多个线程，则可以让不同的线程同时运转在不同的处理器上，从而提高了进程的执行速度。例如，当我们使用文字处理软件如Microsoft Word时，实际上是打开了多个线程。这些线程一个负责显示，一个接收输入，一个定时进行存盘。这些线程一起运转，让我们感觉到输入和显示同时发生，而不用键入一些字符，等待一会儿才显示到屏幕上。在我们不经意间，文字处理软件还能自动存盘。当然，此项操作取决于系统当时的状况，有时我们会感觉到存盘时，计算机接收输入的速度慢了下来。但在绝大多数情况下，一切都还是令人满意的，如图7-3所示。

图　7-3　文本处理进程的3个线程

7.2　线程管理

有进程后，要管理进程。那么有线程后，也要进行管理。而管理的基础也与进程管理的基础类似：就是要维持线程的各种信息。这些信息包含了线程的各种关键资料。存放这些信息的数据结构称为线程控制表或线程控制块。那么线程控制块里面到底包含哪些信息呢？

我们说过线程共享一个进程空间，因此，许多资源是共享的。这些共享的资源显然不需要存放在线程控制块里面，而是存放在进程控制块即可。但由于线程是不同的执行序列，总会有些不能共享的资源。就像一家的兄弟姐妹，家里很多东西都是共享，如所有人同住在父母的房子里，共用冰箱、彩电、餐桌等。但有的东西则是每个人独享的：如衣服、日记本等。而这些不被共享的资源和信息就需要存放在线程控制块里。

到底哪些资源是（同一进程的）不同线程所共享，哪些是不共享的呢？这当然是仁者见仁、智者见智。但也是有规律的。这个规律就是应当让共享的资源越多越好，因为这是我们发明线程的主要动机之一。由于我们发明线程的目的就是要经常协作，共享自然是我们的不懈追求。因此，一般的评判标准是：如果某资源不独享会导致线程运行错误，则该资源就由每个线程独享；而其他资源都由进程里面的所有线程共享。

按照这个标准来划分，线程共享的资源有地址空间、全局变量、文件、子进程等。定时器、信号和占用CPU时间也可以共享。但程序计数器不能共享，因为每个线程的执行序列不一样。同理，寄存器也不能共享，栈也不能共享，这是线程的上下文（运行环境）。表7-1给出的是一般情况下（同一进程的）线程间共享和独享资源的划分。

7.3　线程模型的实现

线程模型在进程基础上提供第二次并发。由于线程之间的共享远比进程之间的共享丰富，因此其在需要高度共享的环境下发挥着重要的作用。但线程这个模型如何实现呢？

与进程一样，线程本身也对应某种物理现实，也需要存储和调度。在存储上，由于线程依附于进程而存在，其存储解决方案无需额外设计，而是直接附于进程存储方案上。

线程的调度却与进程调度有稍许不同。由于线程是在进程的基础上产生的概念（进程里面的一个执行序列），其调度可以由进程负责。当然，我们也可以将线程的调度交给操作系统。而这两种不同的调度推手就形成了线程的两种实现：用户态实现和内核态实现。由进程自己管理就是用户态线程的实现，由操作系统管理就是内核态线程实现。用户态和内核态的判断以线程表所处的位置为依据：位于内核叫内核态实现，位于用户层叫用户态实现。

细心的读者也许已经注意到，我们在讲述进程时没有提到过实现方式的问题，即应该是在用户态还是在内核态实现的问题。这是因为进程是在CPU上实现并发（多道编程），而CPU是由操作系统管理的，因此，进程的实现只能由操作系统内核来进行，而不存在用户态实现的情况，根本没有这种探讨的需要。但对于线程就不同了，因为线程是进程内部的东西，当然存在由进程直接管理线程的可能性。因此，线程存在着内核态与用户态两种实现可能。

7.3.1　内核态线程实现

前面说过，线程是进程的分身，是进程的不同执行序列。既然每个线程是不同的执行序列，则说明线程应该是CPU调度的基本单位。我们知道，CPU调度是由操作系统实现的。因此，让操作系统来管理线程似乎是天经地义的事情。

那么操作系统怎么管理线程呢？与管理进程一样，操作系统要管理线程，就要保持维护线程的各种资料，即将线程控制块存放在操作系统内核空间。这样，操作系统内核就同时保有进程控制块和线程控制块。而根据进程控制块和线程控制块提供的信息，操作系统就可以对线程进行各种类似进程的管理，如线程调度、线程的资源分配、各种安全措施的实现等。图7-4描述的就是内核态线程的实现示意图。

图　7-4　内核态线程的实现

由操作系统来管理线程有很多好处，最重要的好处是用户编程简单。因为线程的复杂性由操作系统承担，用户程序员在编程时无需管理线程的调度，即无需担心线程什么时候会执行、什么时候会挂起。另外一个重要好处是，如果一个线程执行阻塞操作，操作系统可以从容地调度另外一个线程执行。因为操作系统能够监控所有的线程。

那么内核态线程实现有什么缺点呢？首先是效率较低。因为线程在内核态实现，每次线程切换都需要陷入到内核，由操作系统来进行调度。而从用户态陷入到内核态是要花时间的。另外，内核态实现占用内核稀缺的内存资源，因为操作系统需要维护线程表。操作系统所占内存空间一旦装载结束后就已经固定，无法动态改变。由于线程的数量通常大大多于进程的数量，因此随着线程数量的增加，操作系统内核空间将迅速耗尽。

如果要建立进程线程，但内核空间不够了，怎么办？我们可以做的选择有：“杀死”别的进程；创建失败；让它等一下。前面说过，“杀死”别的进程是一件很不好的事情，因为将造成服务不确定性。宣称创建失败也很差。因为创建失败有可能意味着某个进程无法往前推进，这违反了我们前面说过的进程模型的时序推进要求。让创建者等一下，这要看创建的是什么进程和线程了。如果是系统进程线程，等一下可能意味着关键服务无法按时启动；如果是用户进程线程，等一下可能引起用户的强烈不满。而且，等多久谁也不知道。

那在内核空间满了后，应该怎么办呢？打一个战场上的比方就清楚了。如果战场上对手太厉害了，想再调个师的军队，结果没有，怎么办？投降。也就是说，如果内核空间溢出，操作系统将停止运转。因为要创立的进程可能很重要，所以不能不创建。所以最好的结局是“死掉”。别人发现系统“死了”就会采取行动来补救。如果操作系统还要运转，却不能正确地运转，那是很危险的事情。操作系统采取的这种行动在灾难应对领域称为“无害遽止”。

但上面两个缺点还不是最致命的。最致命的是内核态实现需要修改操作系统，这在线程概念提出之初是一件很难办到的事情。试想，如果你作为研究人员提出了线程概念，然后你去找一家操作系统研发商，要求其修改操作系统，加入线程的管理，结果会怎样？操作系统开发商会请你走开。有谁敢把一个还未经证明的新概念加入到对计算机影响甚大的操作系统里？除非我们先证明线程的有效性，否则很难说服他人修改操作系统。

这样，就有了线程的用户态实现。

7.3.2　用户态线程实现

线程在刚刚出现时，由于无法说服操作系统人员修改操作系统，其实现的方式只能是在用户态。（谁提出谁举证。）那么用户态实现意味着什么呢？或者说用户态实现是什么意思呢？就是用户自己做线程的切换，自己管理线程的信息，而操作系统无须知道线程的存在。

那么在用户态如何进行线程调度呢？那就是用户自己写一个执行系统（runtime system）作调度器，即除了正常执行任务的线程外，还有一个专门负责线程调度的线程。由于大家都在用户态下运行，谁也不比谁占优势，要想取得CPU控制权只能靠大家的自愿合作。一个线程在执行完一段时间后主动把资源释放给别人使用，而在内核态下则无须如此。因为操作系统可通过周期性的时钟中断把控制权夺过来。在用户态实现情况下，执行系统的调度器（runtime scheduler）也是线程，没有能力强行夺走控制权，所以必须合作。图7-5描述的是用户态线程的实现示意图。

那么用户态实现有什么优点呢？有。首先是灵活性。因为操作系统无须知道线程的存在，所以在任何操作系统上都能应用；其次是线程切换快。因为切换在用户态进行，无须陷入到内核态。最后是不用修改操作系统，实现容易。

图　7-5　用户态线程实现

那么这种实现方式有什么缺点吗？有。首先，编程序变得很诡异。我们前面说过，用户态线程需要相互合作才能运转。这样，我们在写程序时，必须仔细斟酌在什么时候应该让出CPU给别的线程使用。而让出时机的选择对线程的效率和可靠性有很大的影响。这并不是一件容易的事。另外一个更为严重的问题是，用户态线程实现无法完全达到线程提出所要达到的目的：进程级多道编程。

如果在执行过程中一个线程受阻，它将无法将控制权交出来（因为受阻后无法执行交出CPU的指令了），这样整个进程都无法推进。操作系统随即把CPU控制权交给另外一个进程。这样，一个线程受阻造成整个进程都受阻，我们期望通过线程对进程实施分身的计划就失败了。这是用户态线程的致命弱点。

但是，作为线程的提出者，自然不愿意这么快就承认线程的概念破产。因此，总要想点办法来挽救。那用什么办法来挽救呢？既然线程阻塞造成整个进程阻塞，解决的办法只有两种：一是不让线程阻塞；二是阻塞后想办法激活同一进程的另外线程。

第一种办法如何实现呢？有几种办法。首先来看线程阻塞的原因。

线程之所以阻塞是因为它执行了阻塞操作，如读写磁盘、收发数据包等。那我们就想，如果将这些操作改为非阻塞操作，就可以解决问题了。但是这种办法根本就行不通。首先，将所有系统调用改为非阻塞就得修改操作系统，而我们刚才说了，用户态线程实现就是不想修改操作系统；其次，就算操作系统的人员很仁慈，帮你修改，那可以吗？不可以，因为很多系统调用的语义里面就包括阻塞，即阻塞是其正确运行的前提。使用这些系统调用的程序期望着阻塞。而修改系统调用的语义就会造成这些程序运行错误。所以这个建议行不通。

既然不能将阻塞操作修改为非阻塞操作，那我们可以不让线程调用阻塞操作。我们只需要在线程进行任何系统调用前，先确认一下该调用是否会发生阻塞，即我们写一个包裹（wrap），将系统调用包裹起来，用户程序使用系统调用时需通过这个包裹。而包裹里有一段代码，专门检查发出的系统调用会不会阻塞。如果会，就禁止调用；否则，就放行。

例如，在读写磁盘时，先执行一个小的检查程序，看看需要的数据是否可以迅速获得（如检查一下排在前面的磁盘读操作有多少），如果是，就放行该操作。否则，先切换给别人，到不会阻塞了再调用。这样就可以在一定程度上缓解线程阻塞造成进程阻塞的问题。（但并没有完全解决，知道为什么吗？）但这样做有个很大的缺点：一是，需要修改操作系统，将系统调用包裹起来；二是，这样做大大降低了线程的效率；三是，这种做法有个关键前提条件。因为我们不让程序发出阻塞调用并不是要永远不让该线程运行，而是让它等待一段时间。因此，本做法隐含的前提条件是你等待一段时间后该调用就会由阻塞调用变成非阻塞调用，否则的话，该程序就永远不能运转了。而这个前提假定却不一定成立。

当然了，有的调用在等待一段时间后再调用确实会变成非阻塞操作。例如，你想从网络上接收一个数据包，但是发送方尚未发送，这个时候你如果使用re-ceive系统调用（假如我们使用阻塞版的receive）将发生阻塞。但如果过一段时间后你再调用，这个包可能已经发出来了，你的调用就是非阻塞了。但问题是，并不是所有的阻塞调用在等待一段时间后都会转变成非阻塞操作。比如读磁盘，你一调用就阻塞，但如果你不调用，就不会读磁盘，那么你在将来任何时候读磁盘仍将阻塞，这样该线程就永远无法推进。

既然不让线程阻塞的两种办法都不怎么样。那就来第二种解决办法，即在进程阻塞后想办法激活受阻进程的其他线程。这种办法的实现必须依赖操作系统。因为线程阻塞后，CPU控制权已经回到操作系统手里。而要激活受阻进程的其他线程，唯一的办法是让操作系统在进行进程切换时先不切换，而是通知受阻的进程执行系统（即调用执行系统），并问其是否还有别的线程可以执行。如果有，将CPU控制权交给该受阻进程的执行系统线程，从而调度另一个可以执行的线程到CPU上。这种做法被称为调度器激活（scheduler activation）。因为我们所干的事情就是激活进程里面的调度器（执行系统）。

我们将这种做法称为“第二机会”。因为在一个进程挂起后，操作系统并不立即切换到别的进程，而是给该进程第二次机会，让其继续执行。如果该进程只有一个线程，或者其所有线程都已经阻塞，则控制权将再次返回给操作系统。而这次，操作系统就会切换到别的进程了。

这种办法似乎解决了阻塞线程阻塞进程的问题，但也有两个缺点，首先是需要修改操作系统，使得其在进行进程切换时，不是立即切换到别的进程，而是调用受阻进程的执行系统。但由于此种修改范围小，只需要对调度器程序做一个外科手术式的小改动即可，因而尚可以忍受。

但该做法还存在一个更为严重的缺陷：这种操作系统调用用户态执行系统的做法违反了我们所遵循的层次架构原则。因为这种调用属于所谓的up-call，即下层功能调用了上层功能（操作系统在下，执行系统在上）。而平时用户程序使用操作系统服务的调用属于down-call，即上层程序调用下层服务。这种违反上下有别的做法使得操作系统的设计和管理都变得复杂，而且，由于调度器在第一次切换时总是选择阻塞的进程，这样也为黑客和各种攻击者提供了一个系统缺口。另外，这种层次结构的违反让习惯了上下有别的人类感到十分不快，因此，此种做法没有得到商用操作系统的认可。

7.4　现代操作系统的线程实现模型

鉴于用户态和内核态的线程模型都存在缺陷，因此现代操作系统将二者结合起来使用。用户态的执行系统负责进程内部线程在非阻塞时的切换；内核态的操作系统负责阻塞线程的切换。即我们同时实现内核态和用户态线程管理。其中内核态线程数量较少，而用户态线程数量较多。每个内核态线程可以服务一个或多个用户态线程。换句话说，用户态线程被多路复用到内核态线程上。例如，某个进程有5个线程，我们可以将5个线程分成两组，一组3个线程，另一组2个线程。每一组线程使用一个内核线程。这样，该进程将使用两个内核线程。如果一个线程阻塞，则与其同属于一组的线程皆阻塞，但另外一组线程却可以继续执行。如图7-6所示。

图　7-6　线程的内核态与用户态混合实现

这样，在分配线程时，我们可将需要执行阻塞操作的线程设为内核态线程，而不会执行阻塞操作的线程设为用户态线程。这样我们就可以获得两种态势实现下的优点，而避免其缺点。

7.5　多线程的关系

推出线程模型的目的就是实现进程级并发。因为在一个进程中通常会出现多个线程，否则，我们也没有必要搞什么线程了。就像看舞台剧，如果只跳上来一个人，从头演到尾，没有其他人上场，大部分观众都会觉得无聊。我们要看的是演员对不同人物的刻画，色彩斑斓。因此，研究线程就要研究多线程，多个线程共享一个舞台，时而交互、时而独舞。

但共享一个舞台会带来不必要的麻烦。就像人们共享资源时难免产生争端一样。线程在共享地址空间的过程中也会产生矛盾。这些矛盾归结到下面两个根本问题：

●线程之间如何通信？

●线程之间如何同步？

而上述两个问题在进程层面也同样存在。从一个更高的层次上看，不同的进程也共享着一个巨大的空间，这个空间就是整个计算机。因此，进程之间也会存在矛盾，而这些矛盾也体现在如何通信（沟通）和如何同步（协调）上。

本书第8章就对线程同步进行详细论述。

7.6　讨论：从用户态进入内核态

什么情况会造成一个线程从用户态进入到内核态呢？

首先，如果在程序运行过程中发生中断或异常，系统将自动切换到内核态来运行中断或异常处理机制。图7-7描述的就是中断导致态势切换的流程。异常处理的流程也与此相同或相似。

图　7-7　中断导致程序运行从用户态切换到内核态

此外，程序进行系统调用也将造成从用户态进入到内核态的转换。例如，一个C++程序调用函数cin。cin是标准命名空间STD中的一个流对象，它调用C函数库里面的scanf（）库函数，scanf（）库函数则进一步调用操作系统的READ函数来真正获取用户输入。这里的READ函数实际上是由操作系统提供的一个系统调用（需要注意的是，很多高级语言的函数库里也包含名为READ的库函数，不过此READ非彼READ）。其执行过程如下：

1）执行汇编语言里面的系统调用指令（如syscall）。

2）将调用的参数SYS＿READ、file number, size存放在指定的寄存器或栈上（事先约好）。

3）当处理器执行到"syscall"指令时，察觉这是一个系统调用指令，将进行如下操作：

●设置处理器至内核态。

●保存当前寄存器（栈指针、程序计数器、通用寄存器）。

●将栈指针设置指向内核栈地址。

●将程序计数器设置为一个事先约定的地址上。该地址上存放的是系统调用处理程序的起始地址。

4）系统调用处理程序执行系统调用，并调用内核里面的READ函数。这样就实现了从用户态到内核态的转换，并完成系统调用所要求的功能。

7.7　讨论：线程的困惑——确定性与非确定性

讲完本章后，读者应该可以得出一个结论：线程是很有用的东西，因为它实现了进程内部的并发。但这个结论是真的吗？线程让进程有了分身术，它在进程级别上实现了多道编程，使得一个进程可以同时做多件事情，提高了程序运行的效率，提高了硬件资源的使用率。似乎线程带给我们的只有优点，或者说线程带给我们的优点远远大于其缺点。

但真的是这样吗？有人不这样认为。2006年夏天，有人在IEEE Computer上发表文章，说线程带给我们的优势有限，而其造成的缺憾却巨大，应该剔除掉，说这是万恶之源。

那么线程到底是好还是坏呢？

从某种程度上说，线程提供了程序层面的并发性能。毫无疑问，并发的好处是显而易见的，既提高了系统的效率或者说吞吐率，又改善了用户感觉到的响应时间。这有点像一个人烧菜的情况。在单线程情况下，我们只用一个锅，在任何时刻，只有一个菜在锅里烧，我们可以专注于这一个菜，发生烧糊、烧焦的概率不大。但这种策略的时间效率也不高。如果我们同时用几个锅，一口锅烧红烧肉，一口锅烧汤，另一口锅炒肚片，则因为并发操作而提高了时间效率，即可以同时烧好3个菜。

虽然线程的优势很明显，但带来的问题也是显而易见的，那就是系统运行的不确定性。虽然在多进程时，系统运行也存在一定的不确定性，但这种不确定性基本体现在程序执行的先后顺序上，而每个程序运行的结果基本是确定的。而线程的引入却带来了程序本身运行结果的不确定性：由于多线程的存在，就每个单一线程来看，其执行效率、执行正确性均存在不确定性。当然，通过使用同步机制，可以改善这种不确定性。但如果在多线程执行过程中出现异常，则情况就相当麻烦。另外，在多线程下，如果某个进程的某个线程创建了一个子进程，那对于该进程的其他线程来讲意味着什么？这是一个众说纷纭、莫衷一是的话题。用烧菜的例子来说，就是一般情况下我们也许能够掌控局势，使得每个菜都烧得不错。但问题是，如果因为环境的问题，如火候、水量不对，或者烧菜者的协调和判断能力不足，造成红烧肉干底，汤溢出，则将造成手乱脚忙，顾此失彼，从而造成烧菜失败。

如果读者对计算机硬件与体系结构熟悉，可能能够看出来，线程的机制非常类似于硬件的流水线机制。流水线也是提供并发，不过是指令级的（而不是程序级的）。并发当然提高了计算机的吞吐率，改善了用户响应时间。但问题是，在多流水线多梯级情况下，由于有许多指令同时在不同的流水线和梯级上执行，其之间存在的数据和指令依赖关系十分复杂。如果万一再发生异常，如何保存一个一致性的状态都成了问题。图7-8给出的是流水线的指令级并发和线程的程序级并发的对照。

图　7-8　线程与流水线的相似性

更为重要的是，线程和流水线的管理十分复杂。读者已经看到，线程的同步机制繁杂，使得整个操作系统都变得更为复杂，从而增加整个系统的不可靠性。在硬件层，流水线也一样，需要许多复杂技术的支持，如多指令发射里的超长指令字与超标量计算等。这使得编译器或指令级结构或者两者同时变得复杂。复杂的东西其可靠性是很难保证的。

从某种程度上说，线程与流水线分别是软件层和硬件层不确定性的根源。流水线使得我们可以在硬件指令执行上并发，线程则使我们在软件指令执行上并发。但其带来的操作系统、编译系统和指令集结构的高度复杂是否值得，也许并不容易回答。

思考题

1.线程与进程的相似和不同之处何在？请予以详细论述。

2.用户态线程实现由于不能应对阻塞操作，有人认为其是没有任何用处的。你持何种看法？

3.调度器激活（scheduler activation）是一种良好的解决用户态线程问题的方案吗？它为什么没有得到商业应用？

4.一个进程里面的不同线程是否受到相互保护？为什么？

5.两个不同进程里面的线程是否受到相互保护？为什么？

6.一个进程中的不同线程如何共享信息？这种共享可以在不同进程的不同线程之间直接实现吗？

7.如果将整个计算机看做一个巨大的进程，则运行在上面的正常进程就相当于这个巨大进程里面的子进程。请比较此种进程观的优缺点。

8.从某种程度上，人类社会里的一个家庭可以看做一个进程，而家庭成员则是线程。请根据对家庭的体验论述线程之间资源共享有可能存在的矛盾。9.请解释包裹在线程实现上的作用，其实用价值大吗？

10.如果在创建线程时，如果内核空间已满，我们可以等待一段时间后再试图创建。请讨论这种策略的优缺点及可行性。

11.请调查几种商用操作系统的线程实现模型，并予以论述。

12.分析：当一个线程从用户态进入到内核态时，需要进行哪些切换。

13.线程之间需要通信吗？如果需要，请说明理由，并详细阐述其通信与进程间通信的异同。如果不需要，也请说明理由。

第8章　线程同步

引子：滑铁卢的同步

1815年6月18日，在比利时小城滑铁卢（见图8-1），法国皇帝拿破仑发出向由英国元帅惠灵顿指挥的第七联盟军进攻的命令。一时间，法军如潮水般涌向英军阵线，英军左右抵挡，勉强支撑，形势十分危急。就在惠灵顿元帅准备放弃抵抗时，由布鲁切率领的普鲁士（今德国的一部分）军队来到，并加入战斗。在普鲁士有生力量的帮助下，英国军队气势大振，与普军联手击败了法军，从而彻底结束了拿破仑的政治生命。

图　8-1　1815年6月18日的滑铁卢大战

普鲁士军队的姗姗来迟差点造成英军的全面崩溃，同时，也由于普鲁士军队的最后加入拯救了由英国、俄国、瑞典、荷兰等14个国家和地区组成的第七联盟……

在战场上，友军之间的同步对战争的胜利至关重要。而在计算机里，线程之间的同步则对程序的正确运行至关重要。

8.1　为什么要同步

人类活动中不乏各种同步的需要。正是普鲁士军队与英国军队的同步作战才使得不可一世的法国皇帝拿破仑遭到了其生命中的“滑铁卢”。

我们前面曾说过，线程之间的关系是合作关系。既然是合作，那就得有某种约定的规则，否则合作就会出问题。例如，有两个线程同时运行，第一个线程在执行了一些操作后想检查当前的错误状态errno，但在其做出检查之前，线程2却修改了errno。这样，当第一个线程再次获得控制权后，检查结果将是线程2改写过的errno，而这是不正确的，如图8-2所示。

图　8-2　一个进程的两个线程因为操作不同步而造成线程1运行错误

之所以出现上述问题，是基于两个原因：

●errno是线程之间共享的全局变量。

●线程之间的相对执行顺序是不确定的。

因此，要解决上述问题有两个办法，即分别消除上述两个原因。消除第1个原因的办法就是限制全局变量，给每个线程一个私有的errno变量。事实上，如果可以将所有的资源都私有化，让线程之间不共享，那么这种问题就不复存在。

但问题是，如果所有资源都不共享，那么还有必要发明线程吗？甚至也没有必要发明进程了。因为这样就违背了进程和线程设计的初衷：共享资源、提高资源利用率。因此，这种解决办法是不切实际的。

那剩下的办法就是消除第2个原因，即让线程之间的相对执行顺序在需要的时候可以确定。在讲述这个办法之前，我们再看一个更加戏剧化的例子。

假定有两个线程A和B，分别执行指令x=1和x=2，即

线程A：x=1；

线程B：x=2；

请问在上述两个线程结束后，x的值是什么？

由于x是共享变量，且线程之间的相对执行顺序是不确定的，因此线程A既可以在线程B之前执行，也可能在线程B之后出现。前者将使x=2，后者则使x=1。

还有别的结果吗？x有可能等于3吗？

一般情况下，在任何计算机体系结构中，x=1对应的不是一条微指令，即一条高级指令对应的是多条微指令，因此一条高级指令需要多个时钟周期（通常为6个时钟周期以上）才能完成。例如，x=1的赋值语句就是由多个步骤构成。这些步骤可能包括：先把总线清零，然后把1加到上面去。这样的话，线程A和线程B的执行可能形成如下穿插：线程A把总线清零，线程B把总线清零，线程A将数值1加到总线上，线程B将2加到总线上。这样就有可能出现结果3。当然了，出现x=3的结果依赖于特定的指令集结构。如果指令集结构在执行赋值语句时不是先将总线清零，然后将要赋值的常数加到总线上，就不会出现结果3，而只能有结果1或者2。

就算赋值语句可以完整（不分割）地执行，编译器的优化也有可能造成不可预料的结果。例如，设定x、y两个变量的初始值为0，进程中的两个线程执行的指令如下：

线程A：r2=x；y=1；

线程B：r1=y；x=2；

该程序运行的结果是什么呢？r1=0，r2=0显然是第1种结果；r1=0，r2=2是第2种可能的结果；r1=1，r2=0是第3种可能的结果。但r1=1，r2=2是否可能呢？看上去似乎不可能，因为线程A和线程B的指令无论如何穿插，此种结果也不可能出现。遗憾的是，这种推论没有考虑到编译器优化的影响。由于编译器在进行优化时可以（其实是通常）改变语句的顺序，有可能导致两个线程执行的实际指令顺序如下：

线程A：y=1；r2=x；

线程B：x=2；r1=y；

而此时，如果两个线程的指令执行进行穿插的话，结果就有可能是r1=1，r2=2了！

我们再看一个例子：

线程A　线程B

i=0

i=0

while（i＜10）｛while（i＞-10）｛

i++ i——

｝｝

printf"A finished"printf"B finished"

请问，哪个线程会赢呢？即哪个线程会先运行结束？或者说，有没有哪个线程肯定会赢？答案是不确定的。如果两个线程恰好是你一步、我一步地执行的话，则两个线程都将无法结束。事实上，如果不采取特殊措施，就没有办法确保谁会赢，也没办法确保是否会结束。

由上述例子可见，引入线程后，也引入了一个巨大的问题，即多线程程序的执行结果有可能是不确定的。而不确定则是我们人类非常反感的东西。那么如何在保持线程这个概念的同时，消除其执行结果的不确定性呢？答案是线程的同步。

8.2　线程同步的目的

线程同步的目的就是不管线程之间的执行如何穿插，其运行结果都是正确的。或者说，要保证多线程执行下结果的确定性。而在达到这个目标的同时，要保持对线程执行的限制越少越好。

除此之外，线程同步的另外一个目的涉及执行效率。除了前面说过的多线程执行的结果是不确定的之外，其执行效率也是不确定的。比如，在某段时间内，线程A执行了5条指令，而线程B只执行了3条指令。线程A比线程B多执行了两条指令。但这并不是问题的关键。问题的关键是到底线程A是否比线程B执行的多，或者是多多少等，皆是不确定的。如果我们想使其变得确定，就需要进行线程同步。

那么到底什么是“同步”呢？同步就是让所有线程按照一定的规则执行，使得其正确性和效率都有迹可寻。线程同步的手段就是对线程之间的穿插进行控制。下面以“金鱼问题”来演示线程同步的各种控制手段。

8.3　锁的进化：金鱼生存

读者有没有养过金鱼？养过金鱼的人都知道，金鱼有一个很大的特点，就是没有饱的感觉。因此，金鱼吃东西不会因为吃饱了就停止。它们会不停地吃，一直到鱼缸里面的食物都被吃完为止。所以，如果你一直喂，它就一直吃，直到胀死。因此，金鱼吃多少得由养金鱼的人来确定，其死活也由人所控制。

现在假定左怡和尤尔两个人合住一套公寓，共同养了一条金鱼。该金鱼每天进食一次。两个人想把金鱼养活，一天只喂一次，也只能喂一次。如果一天内两人都喂了鱼，鱼就胀死。如果一天内两人都没有喂鱼，鱼就饿死。

他们二人为把鱼养好，既不让鱼胀死，也不让鱼饿死，做出如下约定：

●每天喂鱼一次，且仅一次。

●如果今天左怡喂了鱼，尤尔今天就不能再喂；反之亦然。

●如果今天左怡没有喂鱼，尤尔今天就必须喂；反之亦然。

显然，要想保持鱼活着，左怡和尤尔得进行某种合作。当然，最简单的情况是不进行任何沟通，每个人觉得需要喂鱼时，查看一下鱼的状态：如果感觉到鱼像是没进过食，则喂鱼；否则不喂。图8-3给出的是在没有同步情况下左怡和尤尔所执行的程序。

图　8-3　没有同步情况下左怡和尤尔执行的喂鱼程序

那上述程序里是如何判断noFeed的值呢？程序里没有给出，因此只能依靠左怡和尤尔的高超养鱼技术，即通过查看鱼的外形来判断金鱼当天是否进食了。当然，只有高手才能达到这个水平，一般的人是看不出来的。万一左怡或者尤尔没有看出对方已经喂过鱼了，再喂一次，鱼就胀死了。或者，没有看出对方没有喂过鱼，而没有喂，鱼就饿死了。

即使假设左怡和尤尔都是养鱼高手，通过查看鱼的外形就可以判断鱼是否喂过，上述程序也能正确执行吗？答案是否定的。由于线程的执行可以任意穿插，左怡可以先检查鱼，发现没有喂，就准备喂鱼。但就在左怡准备喂但尚未喂的时候，程序切换，轮到尤尔执行。尤尔一看，鱼还没有喂（确实如此），就喂鱼。在喂完鱼后，线程再次切换到左怡。此时左怡从检查完鱼状态后的指令开始执行，就是喂鱼。这样鱼被喂了两次，鱼就胀死了，如图8-4所示。

图　8-4　没有同步情况下左怡和尤尔喂鱼程序的可能结果

为什么这个程序会出现鱼胀死的情况呢？因为左怡和尤尔两个人同时执行了同一段代码（if（noFeed）feed fish）。两个或多个线程争相执行同一段代码或访问同一资源的现象称为竞争（race）。这个可能造成竞争的共享代码段或资源称为临界区（critical section）。

当然，我们知道两个线程不可能真的在同一时刻执行（单核情况）。但有可能在同一个时刻两个线程都在同一段代码上。这个例子里竞争的是代码，是代码竞争。如果是两个线程同时访问一个数据就叫数据竞争。图8-3的程序造成鱼胀死就是因为两个线程同时进入了临界区。

以人类进化来比喻，图8-3中的程序只相当于氨基酸阶段，胡乱竞争，并不具备任何协调能力。

8.3.1　变形虫阶段

要防止鱼胀死，就需要防止竞争。要想避免竞争，就需要防止两个或多个线程同时进入临界区。要达到这一点，就需要某种协调手段。

协调的目的就是在任何时刻都只能有一个人在临界区里，这称为互斥（mutual exclusion）。互斥就是说一次只有一个人使用共享资源，其他人皆排除在外，并且互斥不能违反前面给出的进程模型。因此，正确互斥需要满足4个条件：

●不能有两个进程同时在临界区里面。

●进程能够在任何数量和速度的CPU上正确执行。

●在互斥区域外不能阻止另一个进程的运行。

●进程不能无限制地等待进入临界区。

如果任何一个条件不满足，那么设计的互斥就是不正确的。

那么有没有办法确保一次只有一个人在临界区呢？有，让两个线程协调。当然，最简单的协调方法是交谈。问题是左怡和尤尔不一定有时间碰面。那么剩下的办法是留字条。由此，获得第一种同步方式：左怡和尤尔商定，每个人在喂鱼之前先留下字条，告诉对方自己将检查鱼缸并在需要时喂鱼，如图8-5所示。

图　8-5　第一种同步机制：留字条

上述机制能否避免鱼胀死呢？不能。如果左怡和尤尔交叉执行上述程序，还是会造成鱼胀死的结局，如图8-6所示。

那上面的程序为什么没有效果呢？这是因为使用的是互斥手段，即留字条，并没有达到互斥的目的。因为字条并没有防止左怡和尤尔两个人同时进入临界区。当然，与第一个解决方案比起来，本方案还是有所改善，即鱼胀死的概率降低了。

只有在左怡和尤尔严格交叉执行的情况下，才可能发生鱼胀死的现象。因此，我们并没有完全白费力气。

图　8-6　第1种同步机制的一种可能结果

此程序虽然加入了一点同步机制，但这个机制太原始，达不到真正的同步目的。以人类进化来比喻，此程序相当于变形虫阶段。

8.3.2　鱼阶段

仔细分析可以发现，上述程序不解决问题的原因是我们先检查有没有字条，后留字条。这样造成一个空当，使得检查字条和留字条之间的空隙。那我们就修改一下顺序，先留字条，再检查有没有对方的字条。如果没有对方的字条，那么就喂鱼，喂完把字条拿掉。不过这种方法需要区分字条是谁的。我们得到如下程序（见图8-7）。

图　8-7　第2种同步机制：改进的留字条方法

上述程序怎么样？鱼还会不会胀死呢？答案是不会了。因为无论按照什么顺序穿插，总有一个人的留字条指令在另一个人的检查字条指令前执行，从而将防止两个人同时进入临界区。因而鱼不会因为两个人都喂而胀死。

那这个程序是成功了。不过，不要过早下结论。我们来看一下，如果两个人穿插执行会出现什么结果。这个时候问题出现了。由于穿插执行，因此在两者检查字条时，如果发现对方已经留有字条，从而都不会喂鱼，这个时候鱼饿死，如图8-8所示。

图　8-8　第2种同步机制的一种可能结果

由于存在饿死这种可能性，这个程序似乎并没有任何改善。难道我们这个力气白费了吗？没有。因为比起胀死来说，饿死是一种改善。对于一个计算机系统来说，饿死也是好于胀死。如果胀死，则程序的运行很可能出错：几个线程同时获得同一个资源，出现不一致性及结果不确定性几乎是难以避免的。但如果是饿死，即大家都拿不到某个资源，线程处于饥饿状态，至多是停止推进，而这不一定产生错误结果，或许只是推迟结果的出现。

虽然饿死比胀死好受一点，但毕竟还是存在死的可能，还是在很原始的阶段。以人类进化来比，相当于鱼阶段。因此，我们需要继续进化，或者说努力。

8.3.3　猴阶段

那么为什么鱼会饿死呢？是因为没有人进入临界区。虽然互斥确保了没有两个人同时进入临界区，但这种没有人进入临界区的情况则有点互斥过了头。要想鱼不饿死，除了互斥之外，还要保证有一个人进入临界区来喂鱼。那用什么办法来保证呢？

办法就是让某个人等着，直到确认有人喂了鱼才离去，不要一见到对方的字条就开溜走人。也就说，在两个人同时留下字条的情况下，必须选择某个人来喂鱼。这样就得出第3种同步方式（见图8-9）。

图　8-9　第3种同步机制：循环等待

鱼显然不会胀死，因为使用的办法包括了第2种同步方式。那么鱼会不会饿死呢？也不会。因为前面说过，鱼饿死的唯一情况是两个人同时留字条，并且又都走人。而上述程序在两个人都留字条的情况下，左怡不会走人，而是一直循环等待直到对方删除字条后，再检查鱼有没有喂，并在没有喂的情况下喂鱼。因此，该同步方式既防止了胀死，又防止了饿死。

我们终于有了个能正确养鱼的程序了，一切似乎大功告成。但真的吗？

我们终于进化到了猴子阶段，能够有一定的组织，不会饿死，也不会胀死了。但这就足够了吗？

8.3.4　锁

图8-9的第3种同步机制虽然正确，但存在很多问题。

首先是程序不对称。左怡执行的程序和尤尔执行的程序并不一样。那不对称有什么问题吗？当然有。做科学研究的人说完全对称的人最好看。自然，完全对称的程序也最好看。上述程序因为不对称而不美观。这样就没有达到Donald Knuth所谓的“程序就像蓝色的诗歌”的境界。其次，不对称造成程序编写困难。为了追求程序的正确性，即使是做同样操作的线程也得编写得不同，这自然就增加编程的难度。最后，不对称还造成程序证明的困难。要想从理论上证明图8-9中程序的正确性是一件十分复杂的事情。这一点研究程序证明的人是很清楚的。

上述程序的另一个大问题是浪费。上述程序中左怡执行的循环等待是一种很大的浪费。但浪费还不是循环等待的唯一问题，它还可能造成CPU调度的优先级倒挂。优先级倒挂就是高优先级的线程等待低优先级的线程。例如，如果尤尔先于左怡启动，留下字条后正准备检查是否有左怡的字条时，左怡启动。由于左怡的优先级高于尤尔，因此左怡获得CPU，留下字条，进入循环等待。由于左怡的优先级高，因此尤尔将无法获得CPU而完成剩下的工作，进而造成左怡始终处于循环等待阶段无法推进。这样高优先级的左怡就被低优先级的尤尔所阻塞。由于优先级倒挂完全违反了设立优先级的初衷，就像总统得听平民指挥似的，因此令人无法容忍（至少让总统无法容忍）。

那我们有没有更好的办法来解决喂养金鱼的问题呢？有，就是继续对同步方案进行改进。那么在哪一个方案的基础上改进呢？我们自然会想到最后一个方案，因为它已经满足了鱼既不饿死也不胀死的条件，无非就是不好看和循环等待。关键是这两点可以改进吗？答案是否定的。循环等待不能去掉，一去掉变成第2个方案；若想使其对称、美观，就需要将尤尔改为和左怡同样，而这样同样会造成鱼饿死的可能。因此对最后一个方案进行修改似乎不是明智之举。看来，我们这种零敲碎打似的推进模式已经走到了尽头，需要新的思路了。

新的思路就是直接对最开始的两个方案进行修改。由于最开始的两个方案均达不到既不饿死又不胀死的条件，因此我们自然选择一个较为美观、简单的方案来修改。在两个方案之间，第1个方案完全对称，而第2个方案不完全对称，因为每个人的字条不同。因此，我们选择第1个方案作为修改的基础。但如何修改呢？

要想知道如何修改，就得知道第1个方案为什么不满足条件。

那么第1个方案为什么不满足条件呢？我们说过，是因为检查字条和留字条是两个步骤，中间留有被别的线程穿插的空当，从而造成字条作用的丧失。我们就想，能否将这两个步骤并为一个步骤，或者变成一个原子操作，使其中间不留空当，不就解决问题了吗？

换句话说，我们之所以到现在还没把金鱼问题处理掉，是因为我们一直在非常低的层次上打转。因为我们试图工作的层面是鱼和鱼缸这个层面，即留字条是为了防止两个人同时查看鱼和鱼缸。我们仅仅在指令层上进行努力。由于控制的单元是一条条的指令，因此对指令之间的空当无能为力。而解决这种问题的办法就是提高抽象的层次，将控制的层面上升到对一组指令的控制。

例如，在金鱼问题里，如果我们将抽象层次从保护鱼和鱼缸的层次提高到保护放置鱼缸的房间的层次，这个问题就可以解决。即设计一种同步措施，使得在任何时候只能有一个人进入放置鱼缸的房间。这样，检查字条和留字条的两步操作就变成将房间锁上的一步操作。

那么如何保证这个房间一次只进入一个人呢？我们先看看生活当中我们是如何确保一个房间只能进入一个人的。例如，两个教师都想使用同一个教室来为学生补课，怎么协调呢？进到教室后将门锁上，另外一个教师就无法进来使用教室了。即教室是用锁来保证互斥的。那么在操作系统里，这种可以保证互斥的同步机制称为锁。

有了锁，金鱼问题就可以解决了。当一个人进来想喂鱼时，就把放有鱼缸的房间锁住。这样另外一个人进不来，自然无法喂鱼，如图8-10和图8-11所示。

图　8-10　锁

图　8-11　同步中的锁与现实生活中锁的概念非常相似

从上面程序我们可以看到，基于锁的互斥性，左怡和尤尔只能有一个人进入房间来喂鱼，因此鱼不会胀死。并且，如果两人都同时执行上述程序时，由于先拿到锁的人会进入房间来喂鱼，因此鱼也不会饿死。更为重要的是，两个人执行完全同样的代码。既对称，也容易写，证明起来也不困难。这样，金鱼问题从而得到解决。

以人类进化来比喻，上述程序相当于人阶段了。

锁的基本操作

从图8-10可以看出，锁有两个基本操作：闭锁和开锁。闭锁就是将锁锁上，其他人进不来；开锁就是你做的事情做完了，将锁打开，别的人可以进去了。

闭锁操作有两个步骤，分别如下：

1）等待锁达到打开状态。

2）获得锁并锁上。

开锁操作很简单，就是一步：打开锁。

显然，闭锁的两个操作应该是原子操作，即不能分开。不然，就会留下穿插的空当，从而造成锁的功效的丧失。那么我们是如何让闭锁的两个操作成为一个原语操作的呢？本书将在第10章来回答这个问题。这里，我们先来仔细看看锁的机制。

细心的读者可能已经看出，我们给出的第一种解决方案里的字条，从某种意义上来说，就是一把锁，只是这把锁没有将资源锁住。那为什么没有锁住呢？这是因为这把锁不具备一把正常锁所应该具备的特性：

●锁的初始状态是打开状态。

●进临界区前必须获得锁。

●出临界区时必须打开锁。

●如果别人持有锁则必须等待。

而我们的字条解决方案违反了第4个条件，即在别人持有锁（留下字条）的情况下，也照样进入临界区（因为检查是否有别人持有锁在别人留锁之前进行）。因此，这个字条无法起到锁的作用，即是一把破锁。

在使用了正常的锁之后（见图8-10），整个程序就可以正确运行了。读者请看，图8-10的程序漂亮吧？想形式化证明这个程序也是很容易的事情。

那么这个程序有什么问题没有？如果左怡正在喂鱼的话，尤尔能干什么事情吗？只能等待（等待锁变为打开状态）。如果左怡喂鱼的动作很慢，尤尔等待的时间就会很长。而这种繁忙等待不仅将造成浪费，而且将降低系统效率。那有没有办法消除锁的繁忙等待呢？答案是否定的，因为锁的特性就是在别人持有锁的情况下需要等待。不过还是可以减少繁忙等待的时间长度。怎么缩短等待的时间呢？

仔细分析发现，左怡喂鱼并不需要在持有锁的状态下进行。我们就希望喂鱼的这段时间不要放在锁里面，而是获得锁后留下字条说他喂鱼去了，然后释放锁，再喂鱼。而尤尔在拿到锁后先检查有没有字条，有字条就释放锁，干别的去。没有就留字条，然后释放锁，再喂鱼。这样，由于持锁的时间只限于设置字条的时间，因此，对方循环等待的时间会很短，而真正的操作（在这里是喂鱼）则随便多慢也没有问题了，如图8-12所示。

图　8-12　减少锁的繁忙等待时间

图8-12中的程序比起图8-10中的程序，已经好多了。因为在锁上的繁忙等待时间已经很少了。但不管怎样，终究还是需要等待的。那有没有办法不用进行任何繁忙等待呢？有，答案就是睡觉与叫醒，即sleep和wake up。

8.4　睡觉与叫醒：生产者与消费者问题

什么是睡觉与叫醒呢？就是如果对方持有锁，你就不需要等待锁变为打开状态，而是去睡觉，锁打开后对方再来把你叫醒。我们下面用生产者与消费者的问题来演示这个机制。

有人说这个世界上只存在两种人：生产者和消费者。要么你生产某种东西，要么你消费某种别人生产的东西。当然，你也可能既是生产者，又是消费者。即在一个产品上你是生产者，但在另一产品上你却是消费者。但对于某个特定的产品，一个人只可能是消费者或者生产者，而不能同时身兼两职。当然，对于某个具体的产品，你可能既不是消费者，也不是生产者。但对于任何特定的产品，它一定存在一个消费者和一个生产者（我们这里不探讨废品或没有人要的商品）。

那么生产者生产的产品由消费者来消费，但消费者一般不直接从生产者手里获取产品，而是通过一个中介机构，比如商店。生产者把东西放在这里，消费者到这里来拿。为什么需要这个中介机构呢？这是因为商店的存在使得生产者和消费者能够相对独立地运行，而不必亦步亦趋地跟在另一方后面。因为只要商店货架没满，生产者就可以一直生产。他就不用等消费者订一件他才做一件。如果没有商店，则生产者无法独立操作，必须拿到消费者订单后才能生产。消费者在每次订货后需要等待生产者制造出产品后，才能进行新的订货。这种你走一步我才走一步的生产-消费模式效率十分低下（见图8-13）。

图　8-13　在现实生活中，超市相当于消费者和生产者之间的缓冲区

用计算机来模拟生产者和消费者是件很简单的事：一个进程代表生产者，一个进程代表消费者，一片内存缓冲区代表商店。生产者生产的物品从一端放入缓冲区，消费者从另外一端获取物品，如图8-14所示。

图　8-14　生产者-消费者问题

一个非常好的例子是校园中的售货机。售货机是缓冲区，负责装载售货机的送货员是生产者，而购买可乐、糖果的学生是消费者。只要售货机不满也不空，送货员和学生就可以继续他们的送货和消费。问题是，如果学生来买可乐，却发现售货机空了，怎么办？学生当然有两个选择：一是坐在售货机前面等待，直到送货员来装货为止；二是回宿舍睡觉，等售货员装货后再来买。第1种方式显然效率很低，估计没有什么人愿意这么做。比较起来，第2种方式要好些。只不过睡觉中的学生不可能知道售货员来了，因此我们需要送货员来了后将学生叫醒。

同样，如果送货员来送货发现售货机满时也有两种应对办法：一是等有人来买走一些东西，然后将售货机填满；二是回家睡觉，等有人买了后再来补货。当然，这个时候购买者需要将送货员叫醒。以程序来表示生产者和消费者问题的解决方案，如图8-15所示。

图　8-15　消费者-生产者的同步程序

图8-15中的sleep和wakeup就是操作系统里的睡觉和叫醒操作原语。一个程序调用sleep后将进入休眠状态，将释放其所占用的CPU。一个执行wakeup的程序将发送一个信号给指定的接收进程，如wakeup（producer）就发送一个信号给生产者。

我们仔细来看上面的程序。最上面两行定义了缓冲区的大小（可容纳100件商品）和当前缓冲区里的商品个数，初始化为0。生产者程序的运行如下：每生产一件商品，检查当前缓冲区的商品数，如果缓冲区已满，则该程序进入睡眠状态；否则将商品放入缓冲区，将计数加1。然后判断计数是否等于1，如果是，说明在放这件商品前缓冲区中的商品个数为0，有可能存在消费者见到空缓冲区而去睡觉，因此需要发送叫醒信号给消费者。

消费者程序运行如下：先检查当前商品计数，如果是0，没有商品，当然去睡觉。否则，从缓冲区拿走一件商品，将计数减1。然后判断计数是否等于N-1，如果是，则说明在拿这件商品前缓冲区中的商品计数为N，有可能存在生产者见到满缓冲区而去睡觉，因此需要发送叫醒信号给生产者。然后尽情地享用商品。

这个程序看上去似乎正确无误。但真是这样吗？

可能有的读者会注意到一个问题：如果生产者在判断count==1时发送叫醒信号给消费者，但也有可能没有消费者在睡觉（售货机为空的时候，恰恰没有人来买东西，自然就不会有学生睡觉了），这个信号不是无的放矢吗？同理，消费者也存在无的放矢发送叫醒生产者信号的问题。那这是不是问题呢？

发过电报的人都知道，如果对方因故（如查无此人、此人暂时不在）无法接收电报，并不会造成什么危害，充其量就是大家浪费一点时间来收发这封电报而已。这里也一样，如果发送的信号没有进程接收，则这个信号权当浪费了，并不会造成任何损害。因此，这个可能无的放矢的信号发送不是什么问题。

那还有没有其他问题呢？

我们看出来了，这个count有问题。因为变量count没有被保护，所以可能发生数据竞争。即生产者和消费者可能同时对该数据进行修改。例如，假定count现在等于1，那么生产者先运行，对count加1操作后count变为2，但在判断count是否等于1之前，CPU被消费者获得，随后对count进行了减1的操作后切换回生产者，这个时候count等于1，因此生产者将发出叫醒消费者的信号。显然，这个信号是不应该发出的。

对count没有保护并不是上述程序的唯一问题。关键问题是上述程序可能造成生产者和消费者均无法往前推进的情况，即死锁。例如，假定消费者先来，这个时候count=0，于是去睡觉，但是在判断count等于0后并且在执行sleep语句前CPU发生切换，生产者开始运行，它生产一件商品后，给count加1，发现count结果为1，因此发出叫醒消费者信号。但这个时候消费者还没有睡觉（正准备要睡），所以该信号没有任何效果，浪费了。而生产者一直运行直到缓冲区满了后也去睡觉。这个时候CPU切换到消费者，而消费者执行的第1个操作就是sleep，即睡觉。至此，生产者和消费者都进入睡觉状态，从而无法相互叫醒而继续往前推进。系统死锁发生。

那我们如何解决上述两个问题呢？对第1个问题，解决方案很简单：用我们刚刚讲完的锁！在进行对count的操作前后分别加上开锁和闭锁即可防止生产者和消费者同时访问count情况的出现。不过有细心的读者会说，我们不就是因为锁存在繁忙等待才发明sleep和wakeup原语吗？怎么又把锁给请回来了呢？

确实，我们不喜欢锁所采用的繁忙等待，因而发明了sleep和wakeup原语。这样在需要等待时，我们去睡觉。但是，我们不喜欢等待，并不是一刻都不能等待。只要等待的时间很短，就是可以接受的。就是让一个人等1分钟，通常都不会觉得漫长。而在count的访问前后加上锁所造成的繁忙等待是很短的。不就是将商品放入或拿出缓冲区吗？这要多长时间呢？

好，上述解释勉强算把第1个问题解决了。但第2个问题怎么解决呢？

显然，生产者和消费者都不会自己从睡觉中醒过来。所以如果二者同时去睡觉了，自然也无法叫醒对方。那解决的方案就是不让二者同时睡觉。而造成二者同时睡觉的原因是生产者发出的叫醒信号丢失（因为消费者此时还没睡觉）。那我们就想，如果用某种方法将发出的信号累积起来，而不是丢掉，问题不就解决了吗？在消费者获得CPU并执行sleep语句后，生产者在这之前发送的叫醒信号还保留，因此消费者将马上获得这个信号而醒过来。而能够将信号累积起来的操作系统原语就是信号量。

8.5　信号量

信号量（semphore）可以说是所有原语里面功能最强大的。它不仅是一个同步原语，还是一个通信原语。而且，它还能作为锁来使用！本章前面已经讨论过作为通信原语的信号量，现在我们来看其作为同步原语和锁的能力。

简单来说，信号量就是一个计数器，其取值为当前累积的信号数量。它支持两个操作：加法操作up和减法操作down，分别描述如下。

down减法操作：

1）判断信号量的取值是否大于等于1。

2）如果是，将信号量的值减去1，继续往下执行。

3）否则在该信号量上等待（线程被挂起）。up加法操作：

1）将信号量的值增加1（此操作将叫醒一个在该信号量上面等待的线程）。

2）线程继续往下执行。

这里需要注意的是，down和up两个操作虽然包含多个步骤，但这些步骤是一组原子操作，它们之间是不能分开的。如何实现原子操作将在第10章里论述。

down和up操作在历史上被称为P和V操作。P和V指的是荷兰语里prob-eren和verhogen两个单词，分别表示减少和增加的意思。由于美国人在操作系统领域取得了绝对控制权，自然不能用什么荷兰语来表示操作系统里面最重要的同步原语的两个基本操作，因此，将名字改为up和down。

如果将信号量的取值限制为0和1两种情况，则获得的就是一把锁，也称为二元信号量（binary semaphore），其操作如下。

二元信号量down减法操作：

1）等待信号量的值变为1。

2）将信号量的值设置为0。

3）继续往下执行。

二元信号量up加法操作：

1）将信号量的值设置为1。

2）叫醒在该信号量上面等待的第1个线程。

3）线程继续往下执行。

使用二元信号量进行互斥的形式如下：

down（）

＜临界区＞

up（）

由于二元信号量的取值只有0和1，因此上述程序防止任何两个程序同时进入临界区。二元信号量还可以用来管理线程的执行顺序，例如，下述程序保证进程B将在进程A之前执行完毕：

－初始化信号量为0

－进程A　进程B

－down（）；做工作……；

－继续执行……；up（）；

二元信号量具备锁的功能，实际上它与锁很相似：down就是获得锁，up就是释放锁。但它又比锁更为灵活，因为在信号量上等待的线程不是繁忙等待，而是去睡觉，等待另外一个线程执行up操作来叫醒。因此，二元信号量从某种意义上说就是锁和睡觉与叫醒两种原语操作的合成。

有了信号量，我们就可以轻而易举地解决生产者和消费者的同步问题。具体说来就是，我们先设置3个信号量，分别如下：

-mutex：

→一个二元信号量，用来防止两个线程同时对缓冲区进行操作

→初始值为1

-full：

→记录缓冲区里商品的件数

→初始值为0

-empty：

→记录缓冲区里空置空间的数量

→初始值为N（缓冲区大小）

我们的生产者和消费者程序如下：

const int N=100；　//定义缓冲区大小

typedef int semaphore；　//定义信号量类型

semaphore mutex=1；　//互斥信号量

semaphore empty=N；　//缓冲区计数信号量，用来计数缓冲区里的空位数量

semaphore full=0；　//缓冲区计数信号量，用来计数缓冲区里的商品数量

void producer（void）void consumer（void）

｛int item；｛int item：

while（TRUE）｛while（TRUE）｛

item=produce＿item（）；down（full）；

down（empty）；down（mutex）；

down（mutex）；item=remove＿item（）；

insert＿item（item）；up（mutex）；

up（mutex）up（empty）；

up（full）；consume＿item（item）；

｝｝

｝｝

该程序解决了前一个版本的问题吗？很显然，上述程序中生产者和消费者不可能同时睡觉而造成死锁。因为两个人同时睡觉就意味着：full=0（生产者才睡觉），并且empty=0（消费者睡觉的条件）。那么empty和full能够同时为0吗？当然不会，因为初始值是empty=N而full=0。要使empty=0，生产者就必须生产，而一旦生产者开始生产，full就不能为0了。所以两个不会同时睡觉。

这样上述程序既保护了缓冲区不会被生产者和消费者同时访问，又防止了生产者或消费者发送的信号丢失。生产者生产了多少商品，信号量full就取多大的值，这就相当于前一个版本里面发送的信号的个数。因为消费者等待的地方就是full这个信号量，因此，生产者生产了多少商品，就可以最多这么多次叫醒消费者。反之亦然，消费者消费了多少商品，信号量empty就记录了多少数量，也就可以多少次叫醒生产者。这样就解决了信号丢失问题。

到现在我们可以问一个问题了，就是为什么我们需要3个信号量呢？一个二元信号量用来互斥，一个信号量用来记录缓冲区里商品的数量不就可以了吗？缓冲区里空格的数量不是可以由缓冲区大小和缓冲区里商品的数量计算得出吗？为什么需要一个full和一个empty来记录满的和空的呢？这是因为生产者和消费者等待的信号不同，它们需要在不同的信号上睡觉。

8.6　锁、睡觉与叫醒、信号量

到现在我们看到，锁解决了同步问题，但带来的是循环等待，我们不满意。为了消除循环等待，我们发明了睡觉与叫醒。但睡觉与叫醒又带来了死锁，因此，我们发明了信号量。本章前面已经对锁的出现进行了大篇幅的介绍。由此可以看出，操作系统的各种原语操作并不是毫无联系的，而是一环扣一环的，具有严密的逻辑连贯性。

那么信号量是否就是我们的终极原语了呢？它是否就没有任何问题了呢？

我们先来看一下，在前面的程序中，两个down的操作如果顺序颠倒，有什么问题呢？例如，我们将消费者的两个down操作颠倒，即先down mutex，然后down full。假定消费者来了，先down mutex，因为mutex此时取值为1，这个down操作通过，并且mutex的取值将变为0。消费者然后down full，由于full目前的值为0，因此消费者在等full这个信号量时睡觉了。这时生产者来了，它先down empty，发现empty是N，于是顺利通过，接着down mutex，但是mutex的取值此时是0，因此生产者将在mutex上等待。这样生产者等待消费者释放mu-tex，而消费者则等待生产者up full。两人均无法继续推进，死锁产生。

同理，如果将生产者的两个down操作颠倒，也同样会产生死锁。

但如果将两个up操作颠倒，有什么问题吗？例如，将生产者的两个up操作颠倒，先up full再up mutex。由于信号量能够记住所有的信号，因此颠倒up操作将不会改变程序的正确性，但却可能使得程序的效率下降。例如，在做了上述修改后，生产者在mutex之间的临界区里面的操作增加了，这样，所有等待mutex的进程的等待时间就延长了。另外，如果系统在生产者up full之后，切换到被叫醒的消费者，那么消费者被叫醒后又将重新在mutex上等待。这样也就多了一层开销。这些延时和开销在只有两个信号量时还不太会觉察到，但信号量多了，这种效率的下降就会明显。也许有的读者认为这点效率的不同无关紧要，那下面的这个真实故事将改变读者的看法。

在20世纪90年代前，美国电话电报公司（AT＆T）一直独霸美国的通信行业。绝大部分美国人均使用AT＆T公司作为自己的电话服务公司。而后来者MCI（后来变为环球电信WorldCom）公司成立后，就面临着如何从AT＆T公司抢夺客源的问题。自然，价格战是免不了的。但光靠价格战是不够的。MCI公司于是做起了研究。他们发现一个顾客拿起电话，拨完号后，等待对方应答的耐心是4秒钟。如果4秒钟内没有人应答，则75%的人会将电话挂断，不打了。MCI公司的进一步研究发现，AT＆T公司的长途电话接通时间几乎总是超过5秒钟，有时候甚至要等十几秒钟才接通。这样MCI公司的机会就来了。那就是在4秒钟时间内接通顾客的电话。

但问题是，4秒钟接通顾客的电话做得到吗？从顾客拨号开始，就需要在数据库内进行查找，将被叫方的地址和所在服务局的信息调出来，并与主叫方的地址和服务局信息进行关联，选出最佳的路由，然后接通电话，这需要速度非常高的存储系统的支持。而市面上的存储系统都无法满足在小于4秒钟内读取大量数据的苛刻要求。MCI公司找来找去，终于找到了一家EMC公司，其麾下的Symme-trix高速智能存储系统满足其要求。就这样，MCI公司因为接通时间短于用户的心理等待极限而获得了大块的市场，EMC公司也因为能够生产高速存储器而成为MCI公司的最大存储设备供应商。由此可见，效率的高低有时候是决定性的因素。

由此我们也可以看出，使用信号量原语时，信号量操作的顺序至关重要。稍有不慎，就可能发生死锁。而这还是在只有3个信号量的情况下。如果一个程序使用10个、几十个信号量，程序员将很难搞清楚正确的顺序到底是什么，而写程序将变成一个巨大的挑战。事实上，如果一个程序的信号量繁多，死锁或者效率低下几乎是可以肯定的。

那有没有办法改变这种状况，使得编程序不是那么大的一个挑战呢？有办法。办法就是管程。

8.7　管程

前面说过，信号量存在程序编写困难或程序执行效率低下的问题。那我们就想，如果能够将信号量的这些组织工作交给一个专门的构造来负责，程序员不就解脱了吗？于是乎我们发明了管程。管程的英文单词是monitor，即监视器的意思。它监视的就是进程或线程的同步操作。

管程是一个程序语言级别的构造，即它的正确运行由编译器负责保证。这就是计算机里面的一条哲学原理：你不行的时候，把困难交给别人。

具体来说，管程就是一组子程序、变量和数据结构的组合。言下之意，把需要同步的代码用一个管程的构造框起来，即将需要保护的代码置于begin monitor和end monitor之间，即可获得同步保护。在任何时候只能有一个线程活跃在管程里面。那谁来保证这一点呢？编译器。编译器在看到begin monitor和end monitor时知道其间的代码需要同步保护，在翻译成低级代码时就会将需要的操作系统原语添上，使得两个线程不能同时活跃于同一个管程内。图8-16描述的就是一个管程的例子。

在管程里面使用两种同步机制：锁用来互斥，条件变量用来控制执行的顺序。从某种意义上说，管程就是锁加上条件变量。那么什么叫条件变量呢？条件变量就是线程可以在上面等待的东西，而另外一个线程则可以通过发送信号将在条件变量上的线程叫醒。因此，条件变量有点像信号量，但又不是信号量，因为不能对其进行up和down操作。

管程的中心思想是运行一个在管程里面睡觉的线程。但是在睡觉前需要把进入管程的锁或信号量释放，否则在其睡觉后别的线程将无法进入管程，就会造成死锁。本书前面说过，在临界区里面做的事情要越少越好，那自然不能在里面睡觉了。但在这里恰好相反。这是因为，在正常情况下，只有一个线程在临界区里，因此，在临界区待的时间越长，别的线程等待的时间就越长。但在这里情况就不一样了。因为允许别的线程进入管程，所以我们可以睡觉。

图　8-16　管程实例

实现锁的释放和睡觉这两件事情必须是原子操作，即中间不能有空当，否则将造成两个线程同时活跃在管程里，这样就违反了关于管程的约定。当然了，这种违反并不会造成程序错误，因为其中一个线程的下一步操作是睡觉，不会与另外的线程争夺共享资源。

下面我们来看看如何使用管程来实现消费者和生产者的同步（见图8-17）。我们在管程内部定义了两个操作：生产者往缓冲区插入商品的操作和消费者从缓冲区取出商品的操作。由于这两个操作使用共享资源——缓冲区，因此需要同步。因此我们将其放在管程里面。

从图8-17中看到两个条件变量full和empty，但没有看到锁。那锁在哪里呢？前面说过，管程的互斥保证由编译器负责，即编译器在编译上述代码段时把与锁有关的部分加上。因此，应用程序员不用担心这个问题。

管程里面的两个操作wait和signal的语义分别如下：

wait（x）以原子操作完成下述3个步骤：

1）释放锁。

2）将本线程挂在条件变量x的等待队列上。

3）睡觉，等待被叫醒。

signal则与我们前面讲过的一样，将等在指定条件变量上面的第1个线程叫醒。在叫醒方面，管程还提供另外一个所谓的广播（broadcast）原语，其语义是将指定条件变量上面的所有等待线程全部叫醒。

这里需要注意的是，在一个线程调用wait、signal或者broadcast之时，该线程必须持有与管程相连的锁。

由于生产者对缓冲区进行操作的函数insert

图　8-17　生产者和消费者的管程内部部分

和消费者对缓冲区进行操作的函数remove均处于管程ProducerConsumer里面，而管程又具有自动防止两个或多个线程同时活跃于其内的功能，因此缓冲区的访问，即缓冲区计数器count的修改将都是互斥的。

生产者生产商品的部分和消费者消费商品的部分处于缓冲区活动之外。生产者在生产出一件商品后，调用处于管程里面的insert函数来将商品放入缓冲区。而消费者则先调用管程里面的remove函数从缓冲区里面获取一件商品，然后在管程外面慢慢消费商品。图8-18则显示的是生产者和消费者在缓冲区外活动的部分。

图　8-18　生产者和消费者的管程外部部分

下面我们来仔细分析一下生产者和消费者在缓冲区的工作流程。假如生产者先开始运作，生产出一件商品，然后调用insert函数。而insert函数则首先判断count是否等于N，如果等于N，则将自己挂在条件变量full的等待队列上，线程将切换到消费者。否则执行下一步操作，将商品放入缓冲区，缓冲区计数count加1。然后判断count是否大于等于1，如果是，则发送信号给条件变量empty。此时，如果有消费者在该变量上等待，则将被叫醒。

如果消费者来到，它首先调用remove函数。而remove函数则先判断count是否等于0。如果是就将自己挂在empty条件变量的等待队列上，线程将切换到生产者。如果不是则执行下一步操作，将商品从缓冲区取出，count减1。然后判断count是否小于等于N-1。如果是，则发送信号可能将等在full上面的生产者叫醒。

乍一看，这个解决方案与本章前面介绍过的sleep和wakeup非常相似，只不过在那里，用的是sleep而不是wait，是wakeup而不是signal。那这个方案是否也存在sleep和wakeup方案的缺点，也就是死锁呢？

答案是否定的。这是因为在使用睡觉和叫醒原语实现生产者和消费者同步的方案中，没有使用锁。这样，在一个线程判断是否需要睡觉和真的去睡觉这两个操作中间存在空当，从而对方可以在此时切入，造成信号的丢失，可能导致死锁。但在管程机制下，这种空当没有了。虽然判断是否需要等待和等待是两条语句，但请记住：进入管程需要获得该管程的锁（由编译器提供），因此，在一个准备等待的线程真正进入等待之前，由于其持有的管程锁尚未释放，另外的线程是无法执行管程里面的任何代码的。而在锁释放之时，相关线程已经进入等待状态。此时如果一个线程发送信号，自然将接收到，而不会丢失。

这里需要注意的是：如果一个线程发出释放等待线程的signal，则此时将有两个线程同时活跃于管程内。而这违反了关于管程的约定。为了防止出现这种问题，管程机制特别约定：signal语句是一个线程在管程里面的最后一个操作。这样，即使理论上有两个线程同时活跃于管程内，但实际上也只有一个线程活跃。因为另一个线程的下一步操作已经在管程之外，从而维持我们关于管程的约定。

MESA和HOARE管程

前面说过，当一个线程发出signal后，在理论上将有两个线程同时活跃在管程内。但我们知道，管程只有一把锁。如果两个线程同时活跃，那谁将持有管程的锁呢？

第1种办法自然的想法，由于发送signal的线程执行的是管程里面的最后一条语句，不如让其继续执行，从而转到管程外面（因为其下一步操作就是在管程外），这样管理里面不是只有一个线程了吗？即叫醒者继续持有锁，并在离开管程时将锁释放，此时被叫醒者将获得管程的锁，从而可以继续执行。这种处理方法自然，但是保守。因为如果这样的话，线程就没有必要提前叫醒等待的线程。为什么不等到执行到管程外面再叫醒呢？

第2种办法是赋予被叫醒者优先级，即在发送signal时同时释放锁，让被叫醒者获得锁。从而在signal后，第1个运行的线程将是被叫醒的线程。叫醒者只能在被叫醒者运行完毕或由于其他原因释放锁之后才能继续运行。由于这种方式是HOARE提出的，因此这种给予被叫醒者优先的管程称为HOARE管程。

前面两种方法都是在管程设计时就确定了谁将有优先权：前者将优先权赋予叫醒者，后者则将优先级赋予被叫醒者。这两种方法因为都是在管程设计时就确定了，十分不灵活，它们限制了操作系统的作用。对于操作系统设计人员来说，我们希望下一步执行哪个线程由操作系统决定。即让两个线程竞争这把锁。这样，操作系统就可以在竞争中发挥作用，使用各种机制动态地调整每个线程获取锁的优先级。这种管程就称为MESA管程。

MESA管程的signal处理方式如下：叫醒者在发出signal后释放锁；被叫醒者与叫醒者同时竞争这把锁。谁先获得锁，谁先执行。

由于MESA管程给予了操作系统以重要角色，因此它获得了大多数操作系统的认可。

8.8　消息传递

那么管程有什么问题没有？

有。管程最大的问题是对编译器的依赖。因为我们需要编译器将需要的同步原语加在管程的开始和结尾。而这是一个令操作系统人员不放心的选择。俗话说：“相信别人，就等着灾难的发生吧。”另外，研究编译的人也不想在这上面花费心血，他们有编译方面的许许多多的问题还没有解决。而且在实际中，多数的程序设计语言也并没有实现管程机制。

另外，管程只能在单台计算机上发挥作用。如果想在多计算机环境下（或者网络环境下）进行同步，那就需要其他机制了。这种其他机制就是消息传递。

消息传递是通过同步双方经过互相收发消息来实现。它有两个基本操作，发送send和接收receive。它们均是操作系统的系统调用，而且既可以是阻塞调用，也可以是非阻塞调用。

-send（destination，＆message）；

-receive（source，＆message）；

而同步需要的是阻塞调用。即如果一个线程执行receive操作，就必须等待收到消息后才能返回。也就是说，如果调用receive，则该线程将挂起，在收到消息后，才能转入就绪。

图8-19描述的是使用消息传递实现的生产者和消费者同步问题：

图　8-19　使用消息传递实现的生产者和消费者同步

在图8-19中，生产者每生产出一件商品，就需要从消费者那里获取一个空的盒子（m），然后将产品装进盒子里，再把装有产品的盒子发送给消费者。消费者的工作过程刚好相反，先发送N个空盒子给生产者；然后等待生成者将生成的商品发送过来，消费之后，将空盒子发送过去。只要当前既有空盒子，又有满盒子，则生产者和消费者都可以独自运行。如果所有盒子都满了，生产者将在执行下一个receive操作时阻塞，直到消费者消耗掉一件商品并发回空盒子为止。如果在某个时候盒子全空了，则消费者在执行下一个receive时将阻塞，直到生产者生产出至少一件商品并发过来一个满盒子为止。

生产者和消费者就这样通过消息的传送进行同步，既不会死锁，也不会繁忙等待。而且，无须使用临界区等机制。更为重要的是，它可以跨计算机进行同步，即可以对处于不同计算机上的线程实现同步。由于这些优点，消息传递是当前使用非常普遍的线程同步机制。（当然，它更是一种通信机制，记得前面讲过的消息队列吗？）。

那么消息传递有什么问题没有？有。最大的问题就是消息丢失和身份识别。消息在一台计算机内部传递时丢失的可能很低，但在网络间传输时丢失的可能就很大了，这是因为网络的不可靠性所致。而身份识别指的是如何确定收到的消息就是从你想要的对象那里发出的。

当然，通过设计各种网络协议，如TCP协议，可以将网络数据传输的可靠性提高。但即使是TCP，也不是100%可靠。身份识别则可以使用诸如数字签名和加密等技术来弥补。

使用消息传递的另外一个缺点就是效率。往返发送消息存在系统消耗。另外，数据传输也存在延迟。如果网络速度很慢怎么办呢？

8.9　栅栏

栅栏（barrier）是本章最后要讲的一个通信原语。顾名思义，栅栏就是一个障碍。到达栅栏的线程必须停止下来，直到除去栅栏后才能往前推进。该原语主要用来对一组线程进行协调。因为有时候一组进程协同完成一个问题，所以需要所有进程都到同一个地方汇合之后一起再向前推进。例如，在进行并行计算时就会遇到这种需要，如图8-20所示。

图　8-20　栅栏（来源：参考文献[3]）

例如矩阵乘法。在不优化的情况下，矩阵乘法的时间复杂性n立方（n为矩阵维数），而Stressen的优化算法则是将矩阵乘法分解为4个小矩阵的乘法，从而达到n2.71效率。而这个算法的思想是先计算出4个小矩阵乘法的结果，然后通过加减运算而获得最后的结果。如果将4个小矩阵乘法用4个线程来执行，则最后的加减运算必须等待4个线程的乘法运算都结束后才能往前推进。而等待4个线程都到达同一个状态的机制就可用栅栏。

思考题

1.进程之间也需要同步吗？如果需要，请说明理由，并详细阐述其同步需求与线程同步有何异同？如果不需要，请说明理由。

2.互斥应该满足什么条件？

3.线程可以在管程里面睡觉吗？为什么？

4.请列出各种同步原语出现的时间逻辑顺序。

5.有一个同学提出了一种不使用信号量而又可以解决生产者-消费者问题中sleep和wakeup原语发生的死锁。该方法就是再加一把锁来将“检查是否睡觉”和“睡觉”两个语句变为一组原子操作，从而防止信号丢失而造成死锁。即

void producer（void）void consumer（void）

……

lock（）lock（）

if（count==N）if（count==0）

sleep（）；sleep（）；

unlock unlock（）

……

请问该方法可行吗？

6.简要论述锁、睡觉与叫醒和信号量之间的逻辑关系。

7.假定在进程通信中使用一种类似邮箱的机制。当一个进程试图往一个满箱中放东西的时候，或者从一个空箱中拿东西的时候，该进程并不阻塞。而是得到一个错误返回码。该进程立即重复上述过程（往邮箱中放东西或从邮箱中拿东西），直到成功为止。请问这种机制会造成竞争吗？为什么？

8.有一个同学认为本书给出的管程模式存在很大缺陷。于是他提出了改进：将wait、signal和broadcast 3种原语去掉，代之以一种新的原语：waitun-til。该新原语以一个布尔表达式作为参数，例如，waituntil x+y＜z，而锁的功能不变。请问你怎么看待这种改进的观点？

9.下面的程序使用原子加载和存入操作来实现互斥：

//全局变量

blocked[0]=false；

blocked[1]=false；

turn=0；

//Thread 0

while（1）｛

blocked[0]=true；

while（turn！=0）｛

while（blocked[1]）do；

turn=0；

｝

//critical section

blocked[0]=false；

｝

//Thread 1

while（1）｛

blocked[1]=true；

while（turn！=1）｛

while（blocked[0]）do；

turn=1；

｝

//critical section

blocked[1]=false；

｝

请问该实现正确吗？如果正确，请证明。否则，请给出反例。

10.假定n个线程要访问m个独立的共享对象。而你必须保证没有数据损坏发生。你保证的方法就是在对共享数据的所有访问上锁。

a）请问防止数据损坏最少需要多少锁？

b）如果要获得最大的并发性，需要多少锁？

c）最大化并发性能否降低系统性能？

11.共享卫生间

某单位为节省经费，决定建造男女共用的单性卫生间。为满足社会风化要求，卫生间的使用需要满足如下条件：在任何时候不同性别的人不能同时在卫生间里。

任务就是写一个程序来模拟卫生间的使用。可以使用的工具是Mesa管程。需要编写下述4个函数：

woman＿wants＿to＿enter（），

man＿wants＿to＿enter（），

woman＿leaves（），

man＿leaves（）.

在这些函数里面可以使用原语lock（）、unlock（）、signal（）、wait（）和broadcast（）来控制对卫生间的使用。假定同一性别的人进入卫生间的数量不受限制。

12.第2个版本的卫生间共享

重做第10题的卫生间共享问题。这次把优先级赋予当前使用卫生间的性别。例如，如果卫生间里面已经有女士在使用，新来的女士即可以直接进去，即使有男士在卫生间外面等待。

13.第3个版本的卫生间共享

重做第10题。这次需要保证公平，并防止饥饿：如果女士在卫生间，则只要没有男士等待在外面，新来的女士皆可以进入卫生间。如果有男士等待，则新来的女士就不能进去，而必须等待在男士后面。当卫生间里的最后一位女士离开时，等待的男士可全部进入。

14.读者写者问题

中国航信的航空订票数据库系统是中国国内所有航空公司的共享数据库系统。所有的旅行社订票均使用该系统进行查询、预订和出票。由于旅行社数量众多，在任意一个时间都可能有多个进程对该数据库进行操作。所有读写数据库的进程分为读者和写者：读者读数据库而已，写者则对数据库进行更新。为保持数据一致性，该数据库的访问需满足如下限制：

a）多个读者可以同时对数据库进行（读）操作。

b）如果有一个写者进程在对数据库进行（写）操作，则其他进程都不能对数据库进行操作。

请写出读者和写者的伪代码程序。

第9章　死锁应对之哲学原理

引子：迷雾笼罩的加拿大

1999年9月4日，作者一个星期前通过密歇根大学计算机科学与工程博士学位论文答辩，因接受EMC公司的工作，踏上了前往美国波士顿的旅程。驱车从安娜堡到波士顿有两条主要路线可供选择：南线从安娜堡南下，经俄亥俄州的托利多后向东，经克利夫兰后穿过宾夕法尼亚州的人烟稀少的中部地区，进入纽约后向北，经纽黑文、哈特福德而进入波士顿；北线从安娜堡出发后，向东经底特律进入加拿大，过温莎、伦敦、多伦多后，经尼亚加拉大瀑布进入美国的水牛城（布法罗市），然后一路向东，经罗彻斯特、雪城、阿伯尼抵达波士顿。

北线路径比南线路径短约80千米，且途经壮观的尼亚加拉大瀑布，而加拿大路上的车也较少，似乎是一条很不错的选择。于是，作者驱车向东进入了加拿大。一路上阳光灿烂，天空也格外透明。但行进在加拿大后不久，路况突然变得糟糕起来，一场罕见的大雾弥漫在空气里，能见度一下子降到只有1米～2米的距离。由于看不清路，也不知道路的左右两边在什么地方，只好紧跟前面的车尾灯慢速前进。就这样摸索了一阵子后，突然，前面的车不动了。

而这一停就是3个小时。后来终于知道，大雾引发了加拿大历史上最大的车祸，连续15辆车追尾，将整条401号高速公路堵塞。

这样，因为路途短而选择的路径却因交通死锁而变成了一条更为漫长的路径……交通死锁的后果就是陷在里面的人烦躁不安，并有可能造成人不能按时到达目的地或完成预计使命。

计算机也会发生死锁，而计算机的死锁并不比人类社会常见的交通死锁好多少。本章就讨论计算机死锁及其防治策略。

9.1　为什么会发生死锁

大部分读者可能都见过交通阻塞，一大堆汽车因为争夺行路权，互不想让而造成阻塞。又或者因车辆发生故障抛锚或两辆车相撞而造成道路阻塞（见图9-1）。在这种情况下，所有的车都停下来，谁也无法前行，这就是死锁。除了交通之外，人类活动中到处充满着死锁的概念。例如，一个机构里的领导集团对某个问题意见相左，无法达成协议，从而造成机构行为瘫痪。死锁的发生，归根结底是因为对资源的竞争。因为大家都想要某种资源，但又不能随心所欲地得到所有资源，在争夺的僵局中，导致任何人无法继续推进。

图　9-1　生活现实中的交通死锁

在一个多道编程的环境里，一个系统里存在多个线程，而这些线程共享该计算机系统里的资源。因为资源竞争而造成系统无法继续推进就难以避免了。

这里所说的资源就是一个程序工作时需要的东西：磁盘驱动器、锁、信号量、数据表格等。资源既可以是硬件，如CPU、内存、磁盘等，也可以是软件，看不见摸不着，如锁、信号量等。根据资源是否可以抢占分为：可抢占资源和不可抢占资源。可抢占资源当然是可以从持有者手中强行抢夺过来的资源，且不会发生系统运行紊乱；不可抢占资源则不能从持有者手中强行抢夺。如果强行抢夺，则将造成系统运行错误。

当然，从绝对概念上说，没有什么资源是不可抢占的。因此，不可抢占只不过是相对意义上的概念。目标是保证每个程序都能正确运行。如果抢占一个线程所持有的资源后，还能找到某种方式让该程序正确运行下去，则该资源就是可抢占的；否则，就是不可抢占的。

例如，CPU是可抢占资源。因为可以将一个线程强行从CPU下拽下来，换上另一个线程来运行。被抢占的线程可以在稍后继续运行，并正确结束。而CD刻录机就是不可抢占资源，如果在刻录光盘的中途将某个线程赶出来，接着刻录另一个线程的数据，则该张光盘将成为废品。锁、打印机等也属于不可抢占资源。

9.2　死锁的描述

我们知道，线程的执行需要资源，它们都要求以某种顺序来使用资源。如果请求被拒绝了，那就等待。线程使用资源的顺序通常如下：

1）请求资源。

2）使用资源。

3）释放资源。

线程在资源请求没有批准的情况下必须等待。这种等待有两种应对方式：一是阻塞等待；二是立即返回，执行别的事情，等以后再请求。当然也可以失败退出，终止线程。就像我们在没有资源的情况下停止工作一样。

如果线程采用第2种方式（立即返回），则不会发生死锁，因为没有等待。但如果采用第1种方式，则死锁就有可能发生。例如，如果有n个线程，T_1，……，Tn，n个资源，R_1，……，Rn。其中Ti持有资源Ri，但又请求资源Ri+1，这样就将形成死锁。可以用有向图来表示资源的占用和需求关系。以方框代表资源，圆圈代表线程。从资源指向线程的实线表示该资源已被该线程获得，从线程指向资源的虚线表示该线程在等待该资源，由此可以画出图9-2。

图　9-2　n个线程因循环等待资源而形成死锁

这样画出来的图称为资源使用图。从图9-2中可以看出，每个线程都在等待某一个资源，因此没有线程可以推进，从而形成死锁。

到现在可以给死锁下一个正式定义了：如果有一组线程，每个线程都在等待一个事件的发生，而这个事件只能由该组线程里面的另一线程发出，则称这组线程发生了死锁。

这里的事件通常是资源的释放。在死锁状态中，没有线程可以执行、释放资源或被叫醒。例如，有线程A和线程B，如下：

线程A　　　　　线程B

lock（x）；lock（y）；

lock（y）；lock（x）；

……

unlock（y）；unlock（x）；

unlock（x）；unlock（y）；

如果线程A和线程B交替执行，则线程A首先执行，获得锁x；然后线程B执行，获得锁y；但在线程B试图获得锁x的时候将被拒绝（因为线程A持有锁x），因此将等待锁x；这个时候线程A再执行，试图获得锁y，因为锁y被线程B持有，线程A将等待锁y上。至此，线程A和线程B皆不能执行，也皆不能释放锁（因为已经等待另一把锁），从而造成死锁，如图9-3所示。

图　9-3　线程A、B因竞争锁x、y而形成死锁

当然，上述代码并不一定会发生死锁。如果线程A先执行，获得x和y两把锁之后线程B再执行，则将不会发生死锁。事实上，上述代码发生死锁的可能性远低于不发生死锁的可能。但在讨论死锁问题时，我们需要关注的是死锁的可能，而不是死锁的概率。

9.3　死锁的4个必要条件

从上面的论述中可以看出，死锁的发生必须具备一定的条件。最低限度，读者可以看出来，死锁的一个必要条件似乎是循环等待。在这里图9-2和图9-3都可以佐证。除此之外，死锁还需要具备别的条件吗？当然。死锁的发生必须满足4个条件，分别细说如下。

条件1：死锁发生的必要条件是资源有限。即一个系统里面的资源数量有限，以致无法同时满足所有线程的资源需求。这个条件非常直观，如果每个线程都有足够资源同时推进，自然不会发生死锁。就像人类社会，如果每个人的资源需求都获得满足，想要什么就能获得什么，那你还不满意吗？自然不会发生竞争、争斗和战争。遗憾的是，资源是有限的，你运气好就等到资源，运气不好就等不到，而且这种不好的运气是时常发生的。

这个条件也称为资源互斥条件。即资源不能共享，在一个时候只能由一个线程使用。这个和资源有限是等价的。如果资源可以同时被多个线程使用，则将不会发生死锁。

条件2：死锁的另外一个必要条件是持有等待。即一个线程在请求新的资源时，其已经获得的资源并不释放，而是继续持有。

线程的工作过程是请求某些资源，进行工作，然后请求新的资源，继续工作。当工作结束时释放所有资源。如下列代码所示（这里假定第1阶段的工作量是有限的）：

-阶段1.while（not done）｛

-acquire some resources；

-Work；

-｝

-阶段2.release all resources；

如果不是这样，死锁也不能发生。因为一个线程在请求资源时，其并没有持有任何资源，自然就不会阻挠别的线程运行。阻挠别的线程运行的线程必定已经获得了其所需的全部资源，因而能够顺利执行到结束，并最终释放资源。

条件3：死锁的另外一个条件是不能抢占。如果可以抢占一个资源，则也不会发生死锁。例如，在图9-3中，如果线程A可以从线程B手中抢占锁y，则死锁不会发生。从这里可以推断出，凡是可以抢占的资源，均不会成为死锁的原因。例如，我们不会因为对CPU的争夺而产生死锁。

条件4：就是我们已经提过的循环等待条件。这是死锁的最后一个条件。如果你等我、我等你，大家都这样等着对方，就产生了死锁。这时如果画出系统里线程的资源占用和请求图（即资源使用图），应该能看到一个循环，如图9-2和图9-3所示。

9.4　哲学家就餐问题

讨论死锁所用的一个经典例子是“哲学家就餐问题”。哲学家每天只做两件事情：思考和吃饭。他们每天不停地思考人生的哲理，如人从什么地方来，人为什么是现在这个样子，人类往哪里去等深刻的问题。当然，思考久了就会感到饥饿，而饥饿了就要吃饭。但吃饭是有规矩的。

吃饭的规矩就是：哲学家围坐在一个圆桌边，每个人的左右两边均放着一根筷子。如果要吃饭，需要获得左右两边的筷子（不能用一根筷子吃饭），如图9-4所示。

我们很自然地得到一个算法，对于每一个哲学家，运行如下的算法：

1）等待左边的筷子可用，然后拿起左边的筷子。

图　9-4　哲学家就餐问题

2）等待右边的筷子可用，然后拿起右边的筷子。

3）吃饭。

4）放下两根筷子。

显然，如果每个哲学家穿插着执行，将出现每个哲学家都拿起左边筷子，而等待右边筷子的情况，死锁将发生。那有没有办法防止哲学家出现死锁呢？有。下面我们就来论述死锁的应对之策。

9.5　死锁的应对

如何应对死锁呢？只要看看人类是如何应对生活中的死锁（难题），就可以推测出操作系统是如何应对死锁的。那么人有哪些办法来应对生活中的难题呢？从高级境界来看，只有两种策略，即：

●允许死锁发生。

●不让死锁发生。

每种策略又有两种子对策。对于第1种策略，其两个子对策就是：

1）假装没有看见，不予理睬。

2）在死锁发生后，想办法予以解决。

对于第2种策略，其两种子策略为：

1）通过生活中的仔细检点，避免难题出现。

2）通过将发生死锁的必要条件消除，杜绝死锁的发生。

操作系统应对死锁的策略与人类应对难题的策略一样，也是两大种、四小种。下面我们分别予以论述。

9.5.1　顺其自然：不予理睬

这是人类面对难题时经常采取的办法，或者是大部分人遇到困难时采取的办法。我们不想花力气解决难题，或者没有能力解决难题，或者认为解决难题的代价超过难题本身带来的后果。为此，我们可能采取视而不见的办法来处理各种棘手问题。

此种策略就是操作系统不做任何措施，任由死锁发生。这看上去是一种非常糟糕的策略，很多人可能认为没有什么实际操作系统采取的是这种策略。但这种策略真的很糟糕吗？

如果很糟糕，就不会有很多人在面对问题时选择这种策略了。实际上，这种策略是大多数人在大多数情况下采取的策略。老子说过，无为而治，就是这个策略。你什么都不用做（实际上并不是什么都不做，而是尽量少做），事情慢慢就朝着有利的方向发展。

从哲学角度来说，世界上本没有问题，是你认为有问题，才有问题。学生考试不及格，要补考；补考也不及格，毕不了业。毕不了业是问题吗？当然不是。只有你认为是问题时它才是问题。比尔·盖茨大学就没毕业，但这并没有阻碍他成为世界首富。Larry Page也没有毕业（指研究生未毕业，他学士从密歇根大学毕业），不是创办了Google吗？如果你毕业了，就不能成为比尔·盖茨或Page，因为你已经毕业了，已经和他们不一样了。

死锁也一样。只有你认为它是问题时，它才是问题。而我们研究商业操作系统的人不认为这是什么大问题，因为经过分析发现，死锁发生的频率不太高（当然这点有争议），所以不必管它。另外，防止死锁的代价很高，防止死锁比重启100次代价还高，因此不如直接重启。如果死锁发生，重启即可。这就是为什么Windows、Linux和其他商业操作系统都没有采取死锁防止措施，这就是为什么你的电脑经常死机的原因。

在操作系统设计时我们经常不得不进行折中，是想尽量方便，偶尔出点错误呢？还是保证系统完全正确，但是费了很大力气？当然是前者。计算机科学的一个原则就是寻求方便。当然，如果牵涉的是高可靠性系统、实时控制系统，那就另当别论了，那绝对不允许死锁。

9.5.2　先礼后兵：死锁检测与修复

虽然刚刚说过，防止死锁非常麻烦，但是我们很愿意采取不予理睬的策略来应对死锁。但是在某些时候，还得防止死锁，例如对待高可靠性系统、实时系统等。

另外，如果你是一个不厌其烦的人，或者你是一个有决心、有意志力的人，当然就不会允许在面对困难时无所作为了。你会绞尽脑汁，想尽办法来解决难题。而这也正是我们操作系统解决死锁的第2种策略：死锁检测与修复。

1.检测死锁

如果想纠正死锁，自然得知道是否已经死锁了。所以死锁修正的第一件事是要检测死锁。这个似乎很简单。死锁的原因是资源竞争，只要我们对资源的拥有和对资源的请求都清楚了，问题就解决了。或者说，就是将线程的资源占用和需求关系用一个有向图表示出来，然后查看图中有没有循环。如果有，就发生死锁；如果没有，就没有发生死锁。

这个说起来很容易，但是要检测却不容易。看图9-5所示的资源关系图，你看出死锁了吗？

图　9-5　从资源关系图上判断死锁的发生并不容易

很显然，确定一个有向图是否含有循环并不是一件容易的事。事实上，这种检查算法的时间复杂性是n^3。如果在每次资源分配发生变化时，做一次这样的检查，系统效率将出现明显下降。因此，为了操作系统的效率，我们不会直接在图上进行操作。

一种效率较高的算法是利用矩阵。这种算法用到两个矩阵：一个叫资源分配矩阵，另一个叫资源等待矩阵。矩阵的每一行代表一个线程，每一列代表一种资源。在资源分配矩阵中，行列交叉的数值代表该线程已经拥有该资源的数量；在资源等待矩阵中，行列交叉的数值代表特定行还需要特定资源的数量。图9-6和图9-7分别描述了资源分配和资源等待矩阵。

图　9-6　资源分配矩阵

图　9-7　资源等待矩阵

除此之外，我们还维持两个矢量：一个系统资源总量矢量，表示系统中所有资源的总数是多少；另一个是系统当前可用资源矢量，代表系统现在还有多少可用的资源，如图9-8和图9-9所示。

图　9-8　系统资源总量

图　9-9　系统当前可用资源数量

有了上面的矩阵和矢量，我们就可以通过简单的矩阵运算来判断系统是否发生了死锁。怎样判断呢？先考虑什么时候发生死锁？每个线程都不能推进。什么时候不能推进？就是要求的资源不能满足。你把可用的资源拿来与资源等待矩阵的每一行进行比较，你就知道谁不能满足。如果减出来，每一个线程都有负数，那就是发生了死锁。例如，通过将图9-9的资源可用矩阵与图9-7的资源等待矩阵的每一行相减，都将出现负值，因此，由图9-6到图9-9所表示的系统将要发生死锁。通过这种办法你可以发现死锁，比在有向图中找循环容易。

2.死锁的恢复

现在死锁的检测已经实现了，那怎么从死锁状态恢复呢？自然也有好几种办法。

首先可以抢占。即将某个线程所占的资源强行拿走，分配给别的线程。当然了，被抢占的线程很有可能不能正确运行。不过这就是纠正死锁的代价。这种策略的一个重要考虑是选择哪个线程作为牺牲品，而这往往不是容易做出的决策。一种选择是终止占用资源最多的线程，以期尽可能多地释放资源，不过这种办法的后果很难预料。

另外一个选择是更进一步，不只是抢占某个线程的资源，而是将整个线程“杀掉”。这其实并不会更加残忍。因为抢占一个线程的资源有可能已经造成该线程无法再正确运行了。所以，干脆“杀掉”。与前一种办法一样，后果也难以预料。

最后一个办法是上翻（rollback），即将整个系统翻转到过去的某个状态，大家从那个状态重新来过。那么这就要定期记录系统。这是一件很麻烦的事情。对于一个负责的系统来说，上翻到一个可靠的状态十分困难。而且，更为重要的是，系统并不是在任何时候都可以上翻的。

那么死锁的检测与恢复这个办法可行吗？实际上根本行不通。在检测与恢复两个部分都存在巨大困难。首先，使用资源分配与等待矩阵来判断死锁是否可靠？如果减运算后每一项都有负数，死锁是不是就真的发生了？答案是否定的。

这种判断只能说是死锁有可能发生，但并不能肯定死锁会发生。因为也许某个线程因某种原因突然退出，从而不会发生死锁。这样，死锁没发生你却说它发生了，造成误判。另外，还可能出现死锁发生了你却说没发生。我们不是有n个线程，有可能你有几个线程已经死锁了，但是有的线程还能推进。这样从矩阵上判断并没有发生死锁。

第2个问题是，这个矩阵可能是一个巨大的矩阵。如果是多用户的话，线程数可能不计其数。因此，这个矩阵有可能非常大。并且，资源每发生一次变化，就要更新这个矩阵，这样所要花费的计算资源也将很高。不过这还不是关键的问题。

这种策略最致命的问题是，检查死锁的线程自己发生了死锁。此时，死锁检查程序已经无法推进，自然无法检查死锁是否发生。

既然这种策略存在诸多弊端，那只能走另一条路，即不让死锁发生！

9.5.3　先发制人：死锁的动态避免

死锁的检测与修复虽然是一种积极的应对死锁的办法，但却不是什么高明的策略。因为让死锁发生再来检测与修复已经属于后发制人了。死锁的消极后果已经产生。即使修复也已经浪费了时间、降低了效率，甚至造成了其他损失。

因此，我们就想，能不能更加积极主动一点，不要等死锁发生了再亡羊补牢，而是在运行中就小心谨慎，不让死锁发生。这就是死锁防治的第3种策略：动态避免。

进行动态避免的原则也很简单：在每次进行资源分配时，必须经过仔细计算，确保该资源请求批准后系统不会进入死锁或潜在的死锁状态。潜在的死锁状态指的是尚未发生死锁的状态，但接下来的执行将一定产生死锁，因此这种状态又称为“不安全状态”，与之相对的状态称为安全状态。详细来说，安全状态是指从该状态开始，我们能够找到一种资源分配方法和顺序，使得所有线程都能获得其需要的资源，从而不会产生死锁。而不安全状态则是从该状态开始，无论我们怎样，也不能找到一种资源分配方法和顺序来满足所有线程的资源需求。假如我们有一种资源的数量为10个。我们有3个线程在运行。每个线程需要资源的最大数和当前已经占用的资源数如表9-1所示。

请问这状态是否为安全状态？答案是安全状态。因为从这状态开始，我们可以先给线程B分配两个资源来满足线程B的总需求，这样线程B将能够运行结束，然后释放其所持资源。这时总可用资源数将为5，正好可以再全部分给线程C。线程C的总需要满足后也可以顺利执行完毕，从而将所持资源释放。这样总可用资源数将变为7。我们可以将其中的6个分配给线程A，这样线程A也可以顺利执行完毕。这种分配过程如图9-10所示。

图　9-10

如果在表9-1的状态下，如果线程A请求再增加一个资源，我们不能批准。因为批准该请求将使系统进入到表9-2所示的潜在死锁状态。

在表9-2的状态下，这时虽然还没死锁，但是死锁已经不可避免了。因为已经不存在任何一种资源分配方法和顺序能够完全满足所有线程的资源需求。读者也许看出来，我们可以将剩下的2个资源分配给线程B，线程B不就可以顺利执行完毕吗？确实。但问题是线程B完成后系统将只有4个可用资源，已经无法满足线程A或者线程C中任何一个线程的资源需求。因此，系统将就此进入死锁状态。

定义了安全状态后，死锁动态避免的策略就很简单了，就是要防止系统进入不安全状态。手段就是每次需要进行资源分配时，就计算一下该分配是否会将系统带入不安全状态。如果是，就否决相关资源请求；否则，就批准。就像下棋，在理想状态下，我们每个步骤都应该计算一下是否必然会导致输棋。如果不会，这一步就可以走。

动态避免的优点是无需等待死锁的发生。而是在死锁有可能发生时采取先发制人的措施，断然拒绝可能引导系统进入潜在死锁的资源请求。就像发现对方有敌意时采取先发制人的手段。由于系统从来不进入死锁状态，死锁所带来的各种问题自然不复存在。

但动态避免的缺点也十分明显。这就是计算一个状态是否安全不是一件容易的事。从前面的表中来说也许这种计算不是十分困难。但问题是，我们只是对一种资源、3个线程的超简单系统进行了计算。如果一个系统资源种类繁多，线程个数庞大，则这种计算将变得十分复杂和费时。不过这还不是动态避免最致命的缺陷。

假定我们有的是时间，等待也不是什么无法忍受的事。但动态避免策略的另一个问题却是你再有耐心也解决不了的。仔细看前面的表格：每个线程所需的最大资源数都是已知。但是，我们怎么能够知道一个线程的最大资源需求呢？这不是需要预测将来吗？这当然是很困难的。也许有读者会说，不能准确预测将来的最大资源需求，可以粗略估算。只要估算值不低于实际需要值就可以了。

问题是，在无法准确计算的情况下，人类一般会超额估算自己的资源需求，这种超额估算带来的后果非常严重。首先是资源浪费，因为超额估算使得本来可以分配给更多人的资源被少数线程占用。另外一个更为严重的问题是超额估算可能造成死锁误判。因为安全和不安全状态的判断依据之一是最大资源需求。如果该数值估算过大，超过线程的实际资源需求，将造成在实际上安全的情况下，系统被判为不安全，从而造成可以执行的任务也得不到执行。

9.5.4　斩草除根：死锁的静态防止

到目前为止，我们已经论述了3种死锁的应对策略，都不是很令人满意。第1种策略虽然省时省力，但不受用户欢迎，也不是一个负责研究者所应采取的态度。第2种策略虽然较第1种策略积极，但毕竟允许死锁发生，且死锁侦测存在种种困难，也不是我们追求完美的人所喜爱的。第3种策略较前面两种又进了一步，已经不允许死锁发生了。不过，这种策略却存在实现困难的问题，设计实现和时间成本上都不理想。而且，从应对的境界上说，还是属于头痛医头、脚痛医脚的层次，即看到什么地方似乎要出问题了，才采取应对行动。

孙子兵法说：上兵伐谋，其次伐交，再下攻城。死锁的检测与修复只不过是攻城的下策，而死锁的动态避免只能算是其次伐交的中策。那我们有没有应对死锁的上策呢？有，就是死锁的静态防止。

这个策略的中心思想是清除死锁发生的土壤，而死锁发生的土壤就是死锁的4个必要条件。如果消除死锁发生的4个必要条件中的任何一个，则死锁将无法发生。下面我们就来看一下如何消除这些必要条件。

9.6　消除死锁的必要条件

9.6.1　消除资源独占条件

先看第1个条件：资源的有限和独占性。消除这个条件有两个办法，将资源无限增加或把所有资源变为共享。当然，我们在实际上并不需要将资源无限增加，而只要增加到能够满足所有线程的资源需要为止。但这一点并不实际。因为资源是有限的，没有什么东西是无限的，而且增加资源就要增加成本，因此此计不行。

那将资源变为共享是否可行呢？这要看是什么资源。有的资源看上去是独占资源，但却可以通过间接来实现共享，例如磁盘和打印机。对于打印机来说，我们不允许任何线程直接占有，而是设计一个“精灵”程序daemon来管理。这个程序daemon占用打印机，所有打印任务都必须由它发出。任何一个线程想要打印，不是直接请求打印机，而是将打印要求发给这个daemon指定的地方即可。这个daemon负责将打印任务完成。这很像我们前面说过的批处理。

不过这种办法并不适合所有的资源。例如，键盘输入就无法共享。

9.6.2　消除保持和请求条件

消除这个条件的办法很简单，就是一个线程必须一次请求其所需要的所有资源，而不是一般情况下的请求一点资源，做一点事情；到需要下一个资源的时候再请求一点，获得资源后再继续推进。由于一个线程一次就获得了其所需的所有资源，该线程自然就可以顺利执行，从而不会发生死锁。如下所示：

-阶段1a.acquire all resources；

-阶段1b.while（not done）｛

-work；

-｝

-阶段2.release all resources；

这种办法的缺点就是一次将所有资源拿齐，太过浪费。因为有的资源要到最后才需要，但也得在一开始就占用。显然不利于资源的有效利用。这种做法的另一个问题就是在一开始就需要知道一个线程所需要的所有资源，这是很困难的。

一种变通的办法是还像以前那样请求资源，即在需要资源的时候才请求，但加上一个条件：如果请求的资源被拒绝，则该线程需将其现在已经拥有的资源也释放掉。这样，因为阻塞的线程不占用任何资源，死锁自然也得以消除。如下所示：

-阶段1.

-while（not done）｛

-try acquire some resource；

-if（acquisition fails）

-release all resource currently held；

-else

-work；

-｝

-阶段2.release all resources；

这种办法避免了上述问题，但也存在缺点。主要是在获得某个资源失败后，需要释放已经占用的所有资源，而这有可能造成问题。例如，如果一个线程已经获得一些锁，但在锁里的工作又没有做完。如果这时释放锁，很可能造成前功尽弃，从而造成浪费。

9.6.3　消除非抢占条件

另外一个办法就是把第3个条件去掉，即允许抢占资源。也就是说可以从一个线程手上将资源抢夺过来。典型的例子有CPU和内存空间。一个线程可以将CPU或内存空间从另一个线程手上抢过来，从而避免了因CPU和内存空间的竞争而造成死锁。而这恰恰是操作系统里面CPU调度和内存管理的一个重要功能。

不过，该策略也有局限性。因为不是所有的资源都可以被抢占而不产生不良后果。例如，锁就不能抢占。如果从一个线程手上将锁抢过来，后果有可能是不堪设想的。

9.6.4　消除循环等待条件

死锁的最后一个必要条件是循环等待。而出现循环等待是因为线程请求资源的顺序是随机的，即一个线程可以先请求资源A再请求资源B，也可以先请求资源B再请求资源A。这样，如果两个线程按照不同的顺序请求A、B两个资源，死锁就有可能发生。但如果我们规定对A、B两个资源的使用必须按照先A后B的顺序请求，则死锁就不能发生。

假定一个系统有1、2、3这3种资源。如果我们要求请求资源的顺序必须是1→2→3，则系统将不会发生死锁。在给定了这种顺序后，如一个线程只使用一种资源，则直接请求该资源即可；如果某个线程需要请求两个或以上的资源，必须按照给出的这个顺序获取资源。例如，如果一个线程需要使用1、3两个资源，则必须先请求资源1，再请求资源3，即使该线程先使用资源3，后使用资源1。这种按规定顺序请求资源的做法真的能消除死锁呢？

以上述例子来说明，一旦某个线程占用了资源3，则说明该线程的所有资源请求都已经满足，它将顺利完成而不会发生死锁。而在其完成释放资源3和其他占用的资源后，其他等待线程就可以获得这些刚刚释放的资源，而获得资源3的线程将顺利完成。如果没有线程需要资源3，则占用资源2的线程将顺利进行，以此类推，该系统无法发生死锁。

这方面的典型例子是锁的使用。如果两个线程需要同时使用两把锁，则如果它们按照顺序请求这些锁，死锁将得以避免。下面所示的A、B两个线程将不会发生死锁：

线程A　线程B

lock（x）；lock（x）；

lock（y）；lock（y）；

……

unlock（y）；unlock（y）；

unlock（x）；unlock（x）；

这种按顺序请求资源的策略能够成功的原因是，其打断了循环等待的死锁条件。

9.7　银行家算法：冒险的代价

银行家算法，顾名思义，是仿照银行发放贷款时采取的控制方式而设计的一种死锁避免算法（见图9-11）。该算法的策略是实现动态避免死锁。要明白此算法，就要明白银行的贷款策略是怎样的。

银行的目的当然是要让利润最大化，风险最低化。为控制风险，在给一个客户发放贷款之前，先要审核该客户的信用额度。信用额度是一个客户所需贷款的极限，即能从银行取得的最高贷款。信用额度由客户提出请求，银行在审核各方面情况后决定是否批准。在批准客户的信用额度后，客户对资金的使用是按阶段的，即客户并不一定第1次就申请全部信用额度的贷款，而是其信用额度的一部分。以后随着需要，再行申请。

为简单起见，我们这里假定客户的信用良好，能够按期还款。因此，银行审定信用额度考虑的因素就是银行是否有这么多钱满足客户的信用额度请求。而这个判断非常简单：只要客户的信用额度不超过银行的全部流动资产，即予以批准；否则予以拒绝。

在面对客户请求时，银行有两种策略可以选择。第1种策略是只要银行还有钱就发放贷款。第2种策略是需要考虑发放该贷款后银行面临的风险，只在没有风险的情况下，才发放贷款。那么银行发放贷款的风险是什么呢？在客户均守信用的情况下，银行唯一的风险是无法满足所有客户的信用额度（最大贷款额度），从而导致无法收回贷款（因为客户的贷款要求可能未得到满足，无法完成任务并返还贷款）。

下面我们以一个例子来说明银行家算法。假定某银行的全部流动资金为10 000元。客户A申请4000元的信用额度。由于A申请的额度没有超过银行的流动资金，因此予以批准。客户B申请6000元的信用额度，银行同理予以批准。客户C来要求10 000元的信用额度，银行也同样予以批准。客户D申请12 000元的信用额度，则因为该额度超过银行的全部流动资金，银行将予以拒绝。

图　9-11　银行家算法来源于银行给客户发放贷款所用的策略

那么银行现在批准了A、B、C 3个人的信用额度申请。假定A、B、C向银行提出下列实际贷款申请：

●A要求贷款2000元。

●B要求贷款4000元。

●C要求贷款2000元。

如果按照第一种策略，即只要有钱就贷，银行将批准所有人的贷款申请，这样银行将剩下2000元。但这2000元却可以满足A、B、C 3个客户的所有信用额度。例如，银行可以将这2000元贷给A, A的信用额度将得到满足。在A返还贷款之后，银行的流动资产将变为4000元。此时银行可再贷款2000元给B。B因获得足额贷款（满足信用额度）完成任务并返还全部贷款。这样银行流动资产将变为8000元。此时银行可将这8000元悉数贷给C，这样所有客户的最大贷款额度都得到满足。当然，银行也可以先贷给B, B完成任务后再贷给A, A完成后在贷给C。这两种顺序到底按哪一种执行无关紧要。紧要的是我们能找到一种贷款顺序，使得A、B、C的信用额度都能得到满足。

如果按照第2种策略，即只要发放贷款不会影响银行满足所有客户的信用额度，银行将同样批准上述3份贷款请求（我们前面已经讲过，在批准上述3份贷款申请后，银行有办法使得所有客户的最大贷款额度得到满足），因此与第1种情况一样。这样看起来，这两种策略似乎没有什么区别。但真的没有区别吗？如果A、B、C来银行提出下列贷款申请：

●A要求贷款2000元。

●B要求贷款4000元。

●C要求贷款3000元。

则使用不同的策略，情况将不一样。

如果按照第1种策略，银行将批准所有人的贷款申请，这样银行将剩下1000元。而用这1000元，银行无法满足任何一个客户的最大贷款额度请求。银行有可能出现无法收回贷款的情况（因为客户无法完成任务），从而造成银行呆账（死锁）。

如果按照第2种策略进行贷款，则A、B的请求将获得批准，因为在批准这两个请求的情况下，银行仍能找到一种贷款顺序以使得所有的信用额度得到满足。例如，银行可将剩下4000元里的2000元贷给A，在A完成任务返还贷款后，再贷给B。在B返还贷款后再贷给C。

但是第2种策略将拒绝C的请求。这是因为批准C的请求将使银行面临风险。在C的请求获得批准后，银行将只剩下1000元。而利用这1000元，银行无法满足任何一个客户的最大贷款额度，从而可能导致客户无法完成任务，自然无法返还贷款，最后形成呆账（死锁）。

当然，也许一个客户并不需要满额贷款（即其信用额度）就可以完成任务，这样上述情形下也许不会发生死锁。但银行不能进行这样的危险假设。万一情况不是这样，银行将倒闭。

由此可见，为控制风险，银行家采用的应该是第2种策略，即批准A、B的请求，但拒绝C的请求。银行并不是一定批准每次的贷款申请。而是在每次接到贷款申请时，银行都要考虑发放该贷款会否造成银行无法提供足够资金供所有客户完成任务。只有在批准贷款请求不会导致银行银根不足时，该贷款请求才能批准。因此，银行采取的策略实际上是死锁的动态避免。

银行在每次决定是否发放贷款时，需要判断发放该贷款是否会让银行进入一个非安全状态，即无法找到一种贷款顺序以满足所有客户的最大贷款额度。如果在一个状态下我们能找到一种办法（贷款顺序）让所有客户的信用额度都得到满足，该状态就是安全的，反之就是不安全的。例如，批准上面例子中B提出的4000元贷款所进入的状态就是安全的，因为还存在一个贷款顺序可保证所有客户的信用额度获得满足，因此B的请求能得到批准。而如果批准C提出贷款3000元的请求，系统将进入不安全状态，因此C的请求被拒绝。

总结来说，银行家算法是一个动态避免死锁算法，通过对资源的仔细分配以避免死锁。其特点是可以超额批准客户的信用额度，即所有客户的信用额度之和可以超过银行的全部资本，这就是杠杆（leverage）。

我们前面说过，动态避免的缺陷就是需要知道你将来需要什么，而由于我们没有什么有效的办法计算出一个线程所需的资源额度，因此在实际的操作系统中没有采用这种动态避免方法。但是银行家却有这能力解决这个问题。他们通过一种复杂的公式计算出你的信用额度。大部分时候这种计算比较保守，可以避免银行进入死锁状态。当然他们也有算错的时候，2007～2008年度的美国次级贷就是这种错误计算的一个实例。因为他们算错了客户的信用额度，使得很多人还不起款，从而导致美国的金融危机（见图9-12）。

图　9-12　冒险的代价：美国第四大投资银行雷曼兄弟因银行家算法使用过度于2008年倒闭

9.8　哲学家就餐问题之解

在讲述了死锁的各种应对方式后，我们现在可以来解决哲学家就餐的问题了。在死锁的四种应对方式里面，哪一种策略最适合呢？

第1种方式无所作为显然不行，因为我们要求不能让哲学家们死锁。他们每日为我们人类思考，我们自然不能让他们因死锁而没有饭吃。因此这种应对方式不予考虑。

第2种方式死锁检测与修复也不行，因为我们的题意要求不让死锁发生，而不是在死锁后修复。因此这种方式也不予考虑。

第3种方式动态避免，通过对资源的仔细分配来避免死锁行不行？好像可以，我们可以在一个哲学家要求拿一根筷子时，判断他是否可以拿这根筷子。而判断的方式则是，如果已经有哲学家在吃饭或者发放这个筷子后还有多余的筷子，则允许拿起，否则予以拒绝。不过这种方式不太容易实现。

第4种方式死锁的静态防止，可以吗？我们来看看静态防止的4种方式。

第1种方式增加资源或将资源变成共享，这个不行。我们说过，只有5根筷子，不能增加，也不能让两个人共用一双筷子来吃饭。因此放弃这种方式。

第2种方式可以抢占，这个也不行。我们总不能从一个哲学家手里抢筷子，这对哲学家太不尊重，显然不合时宜。

第3种方式是杜绝循环等待，即按照一定的顺序拿起筷子。我们可以对筷子编号：1、2、3、4、5号筷子。拿筷子的人必须按照这个顺序拿，这样就不会死锁。

第4种方式是杜绝保持并等待，即不让一个人手里拿着一根筷子，同时还等着另一根筷子。我们可以要求要么同时拿起两根筷子，要么一根都不拿。这样就不会死锁。

由上述分析可见，解决哲学家吃饭问题有3种可行方式，分别如下：

●静态防止中的杜绝循环等待方案：对筷子编1、2、3、4、5号筷子。拿筷子的人必须按照这个顺序拿。

●静态防止中的杜绝保持并等待方案：要求同时拿起两根筷子，要么一根都不拿。

●动态避免：在哲学家拿一根筷子时，判断他是否可以拿这根筷子，而判断的方式是，如果已经有哲学家在吃饭或者发放这个筷子后还有多余的筷子，则允许拿起。否则拒绝。

本书仅以第2种方式，即消除保持并等待的死锁必要条件来实现哲学家吃饭的问题。我们用信号量来实现。定义哲学家的3个状态为：0思考，1饥渴，2吃饭。定义一个临界区互斥用信号量。给每个哲学家单独定义一个信号量。如果一个哲学家需要阻塞，则阻塞发生在该信号量上。如果哲学家想吃饭，就拿起两根筷子。如果没有拿到就睡觉；拿到就吃，吃完就放下。

下面为使用该方式防止哲学家死锁的实现方案。

#define NumPhilosopher5//哲学家人数

#define THINKING　0//思考状态

#define HUNGRY　1//饥饿状态

#define EATING　2//吃饭状态

typedef int semaphore；

int state[NumPhilosopher]；//记录每个哲学家当前状态的数组

semaphore mutex=1；//互斥信号量

semaphore s[NumPhilosopher]；//每个哲学家等待一个单独信号量

void philosopherRoutine（int philosopher）//哲学家程序

｛

while（true）｛

think（）；

take＿chopsticks（philosopher）；//同时获得两根筷子，否则阻塞等待

eat（）；

put＿chopsticks（philosopher）；//同时放下两根筷子

｝

｝

void take＿chopsticks（int philosopher）

｛

down（mutex）；

state[philosopher]=HUNGRY；

test（philosopher）；//试图拿起两根筷子

up（mutex）；

down（s[philosopher]）；//如果没有拿起筷子，则阻塞等待

｝

void put＿chopsticks（int philosopher）

｛

down（mutex）；

state[philosopher]=THINKING；

//测试左面的哲学家是否可以吃饭

test（（philosopher+NumPhilosopher-1）%NumPhilosopher）；

//测试右面的哲学家是否可以吃饭

test（（philosopher+1）%NumPhilosopher）；

up（mutex）；

｝

void test（int philosopher）//测试是否可以同时拿起两根筷子

｛

if（state[philosopher]==HUNGRY＆＆

state[LEFT]！=EATING＆＆state[RIGHT]！=EATING）｛

//可以拿起两根筷子，改变哲学家状态到吃饭状态，发出叫醒信号

state[philosopher]=EATING；

up（s[philosopher]）；

｝

｝

上述程序伪代码中的关键函数是test。如果test函数测试成功就获得了两根筷子，而且将自己所等待的信号量的值增加1（up（＆s[i]））。这样在take＿chop-sticks函数中，最后的down（＆s[i]）的操作将顺利通过，从而吃饭。如果test里没有获得筷子，则take＿chopsticks的down（＆s[i]）操作将阻塞该哲学家。

吃完饭的哲学家则负责将阻塞等待的哲学家叫醒，这体现在put＿chopsticks函数里面的两个test函数调用上面，而与检测左右两个哲学家是否可以吃饭的顺序则没有关系。

9.9　讨论：死锁的思考——综合治理

本章讨论了死锁产生的原因及其应对方法。一般来说，没有人会喜欢死锁，但死锁也不是一无是处。它至少可以提醒我们资源的竞争所导致的恶果。

在应对死锁的策略上，我们讲解了4种策略：静态防止、动态避免、不予理睬和检测修复。用人口控制来作比喻，就是不控制婴儿的出生；允许生育，但想办法处理出生的婴儿；不准结婚来静态防止生育和可以结婚但不许生育来进行动态避免。

4种策略各有优缺点，但总体说来，检测修复与动态避免两种策略成本过高（实现的程序复杂性成本和运行时的时间成本），很难在实际系统中采用。相对来说，静态防止与不予理睬是较为合理的策略。通过对这两种策略的组合使用，现代商业操作系统对死锁进行了一定的应对。例如，通过对CPU、内存实施可抢占的静态防止策略，对磁盘、打印机等实施假脱机的共享，我们有效地防止了在CPU、内存、磁盘、打印机上发生竞争产生死锁的可能性。对于一些软件资源，如锁，我们则按照规定顺序请求，从而防止在这些资源上发生死锁，而剩下的其他资源竞争造成的死锁，我们就不予理睬了。

就是因为这种不予理睬，现代商业操作系统发生死锁的频率相对人们的期望值来说还是较高。例如，用过Windows和Linux操作系统的人几乎没有不经历死锁的经历。解决死锁的办法也很简单：重新启动系统或者终止部分线程，至于重启或终止线程所造成的不良后果则只能由用户承担了。

9.10　讨论：死锁、活锁与饥饿

多道编程或者多线程编程除了要应对死锁之外，还有一个问题必须面对：饥饿。更准确地说是资源饥饿。资源饥饿指的是某个线程一直等不到它所需要的资源，从而无法向前推进。这就像一个人因饥饿而无法成长一样。

刚听上去饥饿像是一个与死锁不同的问题。但仔细考虑，却发现它实际上是死锁的一种通例。因为处于死锁状态的所有线程均无法获得其需要的资源，所以都处于饥饿状态。死锁实际上就是饥饿。对于处于饥饿状态的线程来说，它因为无法往前推进，又有一点死锁的味道。因此，饥饿有时也称为死锁的“孪生兄弟”。

不过，这里需要注意的是，饥饿和死锁还是有所不同的。处于死锁的线程必须多于1个，且处于一个死锁线程组的线程所需资源均为该组里其他线程所持有。而处于饥饿的线程可以只有1个，并且其所需要的资源可以被任何线程所占有。

除此之外，饥饿还有另外一种特例：活锁。在活锁状态下，处于活锁线程组里的线程状态可以改变，但是整个活锁组的线程无法推进。活锁可以用两个人过一条很窄的小桥来比喻：为了让对方先过，两个人都想给对方让路而闪身到一边，但由于两个人都同时进行此种动作，有可能两个人都同时运动到左边，然后又同时运动到右边。这样，虽然两个人的状态一直在变化，但却都无法往前推进。由此可见，活锁是死锁的通例。

从上述分析可以看出，死锁、活锁、饥饿是一个包含与被包含的关系。死锁是活锁的特例，而活锁又是饥饿的特例。在多道编程时，我们不只要应对死锁，还需要应对活锁和饥饿，而由于它们之间存在类似性，死锁应对的手段也可以在一定程度上用来应对活锁和饥饿，因为活锁和饥饿是死锁的通例，且死锁是无法完全避免的，活锁和饥饿也是不可完全避免的，如图9-13所示。

9-13　死锁、活锁和饥饿之间的关系

思考题

1.进程之间也会发生死锁吗？如果会，请说明理由，并详细阐述其死锁应对策略与线程死锁应对策略有何异同？如果不需要，请说明理由。

2.死锁必须具备的条件有哪些？分别予以阐述。

3.忽略死锁似乎不像一个好的策略，但它在商业操作系统中得到了广泛的应用，为什么？

4.银行家算法属于哪种死锁应对策略？

5.死锁的动态避免和静态防止的主要区别是什么？

6.死锁的根本基础是什么？计算机系统有可能完全杜绝死锁吗？

7.银行家算法在现实生活中经常使用，为什么使用类似策略的死锁应对策略在操作系统中却无法行使呢？

8.睡觉理发师问题：一个理发店有一个理发师，一张理发椅子，n张等待椅子（可以假定为n＞1）。当没有顾客的时候，理发师就睡觉。当一个顾客来到店里时，如果理发师在睡觉，则叫醒理发师；如果理发师正在给人理发，则坐在等待椅子上等待；如果等待的椅子都满了，则顾客就离开理发店而不理发了。请问这种安排有发生死锁的可能吗？如果不能，请予以证明。如果可能，什么情况下会发生死锁？并请设计一个避免死锁的方案。你采取的是哪种死锁应对策略？

睡觉理发师

9.四方恋爱问题：假如我们有两男：尤尔、夏士和两女：左怡、尚珊。左怡和尚珊都喜欢玫瑰，谁送他们玫瑰，她们就跟谁谈恋爱。尤尔和夏士都则喜欢名表，谁送他们名表，他们就跟谁谈恋爱。如果两位男士给同一位女士送玫瑰，或者一位女士同时给两位男士送名表，则两位男士将大打出手（死锁）。如果两位女士给同一位男士送名表，或者一位男士同时送给两位女士玫瑰，则两位女士将大打出手（死锁）。请你帮助他们建立一对一的恋情关系，并在建立过程中防止大打出手（死锁）。

10.饥饿和死锁是多道编程要防止的两个问题。但这真的是两个问题吗？

11.本章讨论的哲学家就餐问题有多个解决方案，请给出一个不同于书中解答的方案。

12.本章给出的哲学家就餐问题解决方案是否为最优方案？请说明理由。

13.有同学提出，本章给出的哲学家就餐问题解决方案可能导致饥饿，即某个或某些哲学家永远得不到足够的筷子来就餐。你同意该同学的看法吗？如果你同意，那么本解决方案是否不公平？如果你不同意，请说明你的理由。

四方恋爱问题

14.给出一个可能导致活锁的编程实例。

第10章　锁的实现

引子：高登绳结

公元前356年7月下旬的一天，亚历山大出生在马其顿的佩拉城（Pella）。而这一天，位于小亚细亚的以弗所城的戴安娜寺庙突发大火而烧毁。根据传说，这一天毁灭亚洲的力量降生到世上……

可向他询问任何政策；

他将解开高登绳结，就像解开自己的吊袜带。[1]

——莎士比亚

公元前334年，在大败大流士的波斯军队后，亚历山大进兵到达小亚细亚的Phrygian。亚历山大在Phrygian首府Gordium看到了著名的高登绳结。这个复杂的绳结绑在一辆战车上。传说只要谁能够打开这个绳结，谁就能成为世界的主人。自古以来，无数人试图破解这个绳结，却终究没有成功。

亚历山大来到绳结面前，注视着绳结良久。突然，他拔出随身佩戴的短刀，挥手劈下，将绳结斩成两半，绳结随即打开。

后来，亚历山大果然征服了当时西方人所知的“整个世界”。

本章讨论的不是如何砍断绳结，而是如何打上绳结，使得亚历山大这样的人无法一刀砍断。

锁的实现哲学

在第8章中，我们引入了多种进程/线程同步机制。这些机制虽然各不相同，但都有一个特点，即同步原语均是原子操作。我们看一下闭锁的两个步骤：

1）等待锁变为闲置状态。

2）获得锁。

很显然，锁的闭合两个步骤之间存在别人插入的可能。但如果这种情况发生，则锁的机制将失去作用。因此，在闭锁的两个步骤中，不能允许任何线程或进程插入其间。实际上，闭锁操作在编程微指令后，根本不止两个步骤，而是十几个甚至更多步骤。事实上，所有同步原语在微指令级都是由多个步骤构成。那操作系统是如何保证这些同步原语的原子性呢？即在执行同步原语的中途是怎么防止别的进程或线程插入执行呢？

自然，如果没有硬件提供原子操作，只在软件层上是不可能设计出原子操作的。就像没有地基，就无法建造高楼一样。操作系统之所以能够构建锁之类的同步原语，原因就是硬件已经为我们提供了一些原子操作：中断禁止和启用（inter-rupt enable/disable）、内存加载和存入（load/store）和测试与设置（test＆set）指令。禁止中断这个操作是一个硬件步骤，中间无法插入别的操作。同样，中断启用、内存加载、内存存入、测试与设置均为一个硬件步骤的指令。在这些硬件原子操作之上，我们便可以构建软件原子操作：锁、睡觉与叫醒、信号量等。

当然，硬件提供这些原子操作不是因为它们预见到研究操作系统的人将来有这个需要，而是硬件（计算机）设计师需要这些原子操作来对其设计进行各种测试，操作系统对其利用只不过是一个副产品而已。下面我们就来看锁是如何实现的。

由于使用内存加载和存入来实现锁与使用测试与设置来实现锁类似，我们将忽略内存和加载，而只讨论使用中断禁止与启用和测试与设置来实现锁。

[1]古代西方男人流行穿吊袜带。

10.1　以中断启用与禁止来实现锁

要防止一段代码在执行过程中被其他进程插入，我们就要考虑在一个单处理器上，一个线程在执行中途被切换是通过什么途径来实现的。到现在我们知道，要切换进程，必须发生上下文切换，而发生上下文切换只能有两种可能：一是一个线程自愿放弃CPU而将控制权交给操作系统调度器，从而发生上下文切换；二是一个线程被强制放弃CPU而失去控制权。自愿放弃通过调用yield之类的操作系统系统调用来实现；而强制放弃则需通过中断来实现，操作系统主要是通过周期性的时钟中断来获得CPU控制权的。由于原语执行过程中，我们不会自动放弃CPU控制权。因此要防止进程切换，就要在原语执行过程中不能发生中断。由此，我们想到，通过禁止中断，并且不自动调用让出CPU的系统调用（如yield之类），就可以防止进程切换，就能将一组操作变为原子操作。

接下来的一个问题是，如果禁止中断就能将一组操作变为原子操作，那还要锁干什么？不如直接在程序中进入临界区之前禁止中断，在离开临界区的时候再启用中断。

这种做法的问题是危险。因为将操作系统赖以工作的基础机制交给用户来控制是十分不明智的。万一用户的水平有限，禁止中断后忘了启用，这个系统就完了。而且，这就等于给黑客一个攻击的入口。

所以，用户程序不能进行中断的启用和禁止操作。理论上可以，但实际上不能。因此，合理的做法就是由操作系统提供一个锁给用户使用，而锁的正确性由操作系统保证。

下面我们就来看一下使用中断禁止实现锁的程序，见图10-1。

图　10-1　锁的实现1：lock中断启用与禁止

闭锁的第一个操作是禁止中断，记住，这是一个硬件原子操作。如果成功禁止了中断，接下来就检查value是否等于FREE，如果是，就表明这个资源没有被其他进程占用，我们就将其设置为忙，然后启用中断（记住，这也是一个硬件原子操作）。这样就完成了闭锁操作。如果value不是FREE，则我们循环等待这个值变为FREE。但在循环等待的过程中，我们使用了一对操作，分别是启用中断和禁止中断。这是为何呢？

这是因为，如果value不是FREE，就说明有别的线程占用了value。我们需要等待占用value的线程使用完value后将其设置为FREE。因为我们禁止了中断，别的线程已经无法获得CPU来执行，自然无法将value设置为FREE。因此，我们加入了启用/禁止中断操作对，寄希望于在启用和禁止中间那个占用value的进程切换进来，完成操作后释放value。如果不这样，那就永远只能等在那里，别人也不会释放锁。

unlock的操作就是首先禁止中断，将value设为FREE，再启用中断，如图10-2所示。

图　10-2　锁的实现1：unlock中断启用与禁止

这里的问题是，将value设置为FREE需要中断禁止的保护吗？当然需要，因为value=FREE这个赋值语句不是原子操作，所以需要禁止中断来保护。否则，值"FREE"可能在传往value的中途被冲掉，造成锁的释放失败。

以中断启用与禁止来实现锁的优点是原理简单，容易理解，也容易实现。但存在的问题也很明显。首先，频繁地禁止中断有可能造成对重要事件的处理不及时。其次，在锁的实现中留给其他进程获得CPU的机会也不大，这可能是个很大的问题。因为持有锁的进程可以切换的地方是在启用中断和禁止中断两句原子操作之间。由于是原子操作，因此硬件上只有一个操作，而在这两个原子操作之间恰好插入进来并不是一件概率很大的事情。当然，循环久了，总会有机会插入。但循环多久才发生插入是很难预料的事情。

不过，情况真是如此吗？如果读者自行编写代码来进行验证就会发现情况并非如此。读者可能发现的情况是对中断的响应并没有多大的影响，在启用和禁止之间发生线程切换也并不是什么小概率事件。但这是为什么呢？这个问题在本书稍后进行回答。

10.2　以测试与设置指令来实现锁

除了禁止和启用中断外，我们还可以使用测试与设置来实现锁。下面我们看一看如何使用测试与设置和繁忙等待来实现锁。现代处理器基本上都提供一条所谓的“读-修改-写入”的原子指令，该操作以不可分割的方式执行如下两个操作：

1）将内存指定位置的存储单元的内容读到一个寄存器。

2）将新的值写入刚才的内存单元。

而测试与设置（test＆set）指令就是一条类似的指令，但略有不同，它以不可分割的方式执行如下两个步骤：

1）设置操作：将1写入指定内存单元。

2）读取操作：返回指定内存单元里原来的值（写入新值1之前的内容）。

图　10-3　测试与设置指令

如果以程序来实现，则如图10-3所示。

在介绍了测试与设置指令后，我们就可以实现锁了，如图10-4所示。

图　10-4　使用测试与设置指令实现lock

test＿and＿set（value）的操作是将1写入变量value里，并将写1之前value的值返回。如果锁是打开的，即value是0的话，该指令将value设置为1，获得锁并退出循环。如果锁是闭上的，即value的值是1，则返回的值为1，循环继续。该指令将value设置为1并不改变value的状态（value本来就是1）。该循环将一直持续到成功获得锁为止。

图　10-5　使用测试与

开锁的操作很简单，将value设置为0即可，如

图10-5所示。

设置实现unlock

也许读者会问，上面的value=0的赋值语句怎么不需要保护呢？难道不怕别人插在中间造成问题？答案是否定的。因为调用unlock的进程一定是已经获得锁的进程。别的进程由于没有锁，无法对value进行操作。当然了，这里我们要求所有的进程在调用unlock之前必须先调用lock，否则我们的这种保障就不能实现。

另外，赋值0的操作需要的常数是0，可以直接在总线上产生，也能确保不会出现x=1和x=2的问题。

如果将上述高级语言构造变成汇编语言形式，就更清楚了，如图10-6所示。

图　10-6　测试与设置指令的汇编实现

此种实现方式与10.1节介绍的方式有相同之处，就是都使用了繁忙等待。但在这种实现方式下，其他进程切换过去的机会比10.1节的方式要大。我们只要比较一下二者代码的展开即知：

使用中断启用和禁止　使用测试与设置

判断value是否等于FREE test＿and＿set（value）==1

启用中断enable interrupts test＿and＿set（value）==1

禁止中断disable interrupts test＿and＿set（value）==1

判断value是否等于FREE test＿and＿set（value）==1

启用中断enable interrupts test＿and＿set（value）==1

禁止中断disable interrupts test＿and＿set（value）==1

……

在左边的程序中，每3个语句出现一次切换机会；而在右面的程序里，每个语句后面都是一次切换机会。因此，右面的程序的执行效率将高于左面的程序。也就是测试与设置强于中断启用和禁止。不过，需要指出的是，这种区别是很小的，因为中断禁止所禁止的并不是中断的发出，而是对中断的响应。由于中断一旦发出，就一直处于发出状态，此时只要中断被启用，将马上得到响应。因此，使用中断启用与禁止所留下的空当绰绰有余。

10.3　以非繁忙等待、中断启用与禁止来实现锁

前面介绍的两种锁的实现方式看上去简单，也很容易理解，但都有一个问题。这个问题就是繁忙等待。本书前面章节多次说过，繁忙等待浪费资源，并且有可能造成优先级倒挂和死锁。因此，我们自然想到对前面介绍的两种方法进行改善。改善的思路是不进行繁忙等待，而是在拿不到锁的时候去睡觉，等待别人的叫醒。这样，锁的实现思路如下：

1）使用中断禁止，但不进行繁忙等待。

2）如果拿不到锁，等待进程放弃CPU并进入睡觉状态，以便持有锁的进程可以更好地运行。

3）当锁释放的时候将睡觉进程叫醒。

这样我们获得如图10-7所示的锁的实现方式。

图　10-7　使用非繁忙等待的中断禁止与启用来实现锁操作：错误实现

闭锁的第一个操作是禁止中断，然后检查value是否为FREE，如果是，我们将value设置繁忙，启用中断，获得锁而退出。如果value不是FREE，就把自己加到等待该锁（value）的队列中，然后切换到别的进程，最后启用中断。

开锁的实现也很直截了当，先禁止中断，然后将value设置为FREE。然后检查是否有进程等待该锁。如果有，将等待线程从等待队列移到就绪队列，并将value设置为繁忙（因为将等待进程移到就绪队列相当于将锁给了该进程）。然后启用中断。如图10-8所示。

图　10-8　使用非繁忙等待中断禁止与启用来实现锁释放操作

上述锁的实现程序乍一看似乎很有道理，可仔细一想却发现有问题。有什么问题呢？

问题是，切换到别的进程的语句在启用中断指令前执行，由于切换到另一个进程后，该程序就无法再执行，那么后面的中断启用指令自然就不能执行了。那这个有什么问题吗？当然有。因为我们是在中断处于禁止状态下切换到别的线程的，如果别的线程没有执行中断启用或者自动放弃CPU给另一个线程，系统将进入死锁状态。因此，这个问题必须解决。

那么我们很自然地会想到，在lock程序里，什么时候启用中断呢？仔细分析可以发现，一共只有3个地方可以启用中断，分别是：

●在将自己放到等待队列之前启用中断。

●在将自己放到等待队列之后，但在切换到另一线程之前启用中断。

●在切换到另一个线程之后启用中断。

前面分析已经表明，第3个地方没有意义，因为执行不到这条指令。那我们就来看看另外两个地方。先看第1个地方：在将自己放到等待队列之前启用中断。

这个机制能工作吗？自然不能。因为这种安排与我们前面讲过的用sleep和wakeup原语实现消费者-生产者同步时完全一样，将发生信号丢失而造成死锁。例如，线程A调用lock未能获得锁，进入else部分。如果在启动中断后立即发生中断，持锁的线程获得机会运行并释放锁。由于此时尚无线程处于锁等待状态，因此释放锁的动作将不会释放任何线程。在此之后，线程A获得运行机会将自己放到等待锁的队列。而此时由于锁已经释放，线程A将无法从等待中被叫醒。死锁发生。因此第1种方式不行。

那第2种方式如何呢？把它加到等待队列之后，切换到别的进程之前，启用中断，这个怎么样？遗憾的是，这种办法也不行。因为加到等待队列之后，其实就是要sleep了，但是还有两句要执行，允许中断，再切换。有什么问题？你把自己加到等待队列之后，你在进程表里是什么状态？sleep。你在允许中断后又被抢占了，你变成了什么状态？变成就绪状态。你进了两个队列，又在等待队列，又在就绪对列，那到底在哪里呢？这就是很大的问题了。就算将来不引起严重后果，也已经违反了操作系统的规则，即任何时候一个进程只能处于一种状态。就像哈姆雷特的独白：活着还是死去（to be or not to be）。要么这样，要么那样。不能同时又这样又那样。

当然，也许有读者认为，在启用中断后如果发生线程切换，当前线程进入就绪队列时，等待状态就抹掉了，因此，不存在所谓的一个线程同时处于两种状态之说。但如果真是这样，那就相当于没有将自己放在等待队列了，自然后面的操作也不会正确。

那么问题出在什么地方呢？问题就是将自己放在等待队列和切换到别的线程这两个操作应该是一组原子操作，不能在中间中断。这也就是说，启用中断操作不能在中间。那么剩下的唯一可能就是闭锁操作不启用中断，而是留给别的线程去启用中断。

这听上去似乎很不负责任。不过除此之外，还有别的办法吗？如果一定要使用中断启用和禁止，并且不能繁忙等待，则这是唯一的办法。这种办法的工作原理如下：

1）等待线程在调用切换操作时将中断留在禁止状态。

2）下一个执行的线程负责在返回到用户代码前启用中断。

3）当唤醒一个等待线程时，中断仍然处于（又设置成）禁止状态。

也就是说，我们要求所有线程遵守下列约定：

●所有线程承诺在调用线程切换时将中断留在禁止状态。

●所有线程承诺在从切换返回时将中断重新启用。

图10-9描述的是我们的解决方案。

图　10-9　使用非繁忙等待中断禁止与启用来实现锁操作：正确方式

在图10-9中，线程B目前持有锁。在切换前禁止中断，切换后，控制权到达线程A手里。线程A先启用中断，然后返回到用户代码，在中断处于启用的状态下执行用户程序部分。然后线程A需要使用某种临界资源，因此调用lock。而lock的第1件事是禁止中断，然后进行锁的状态检查等操作。因为锁被线程B所持有，线程A自然进入等待状态。线程A在将自己放到等待队列后，调用switch切换线程。线程B获得CPU控制，相当于从switch返回，因此启用中断，并返回到用户代码。在执行完用户代码的工作后，线程B调用unlock函数来释放自己手中持有的锁。unlock做的事情就是将线程A从等待队列移到就绪队列。然后线程B再次调用yield来让出CPU。而yield的第1件事就是禁止中断，然后切换线程。线程A获得控制权后，相当于从switch返回，因此启动中断。此时线程A已经获得锁，可以进入临界区了。

这里需要注意的是，中断的启用和禁止是在系统调用（lock和yield）里面实现的，即由操作系统实现的。因此，用户并不会增加额外的负担。用户只管调用lock和yield等系统调用，而操作系统会确保什么时候禁止和启用中断。

这样我们就在无须繁忙等待的情况下，使用中断启用和禁止实现了锁的机制。那这个机制有什么缺点吗？显然，这是一个危险的系统。因为所有操作系统的系统调用都必须遵守一个约定，即在进入系统调用前必须禁止中断，而在返回到用户代码前则必须启用中断。而这将依赖于各个系统调用之间的自愿配合。这里无法强制。因此，这个机制对系统调用设计者的技术水平和信用都有很大的依赖。而我们知道，依赖别人是非常危险的。

10.4　以最少繁忙等待、测试与设置来实现锁

虽然10.3节介绍了如何在非繁忙等待情况下，使用中断启用和禁止来实现锁。但这个实现实在太危险了。那我们还是觉得用测试与设置可能会更好一点。但使用测试与设置来实现锁不能完全避免繁忙等待。因此，我们的目的就是尽可能降低等待的时间。

我们的中心思想是：只用繁忙等待来执行闭锁的操作。如果不能获得就放弃CPU。这样就要求我们使用一个额外的变量，如图10-10所示。

图　10-10　测试与设置指令，最少繁忙等待锁的实现

这个程序和10.3节图10-7介绍的程序一样，只不过中断禁止操作变成了test＿and＿set操作，而中断启用操作则变成了将guard赋值0的操作。不过，就是这一点变化，其效果是十分深远的。

首先来看着这个实现是否正确。如果guard的值为1，则这个lock操作将不停地循环等待。直到guard变为0为止。这个时候，检查锁的值（value）。如果是FREE，将其设置为忙碌（BUSY），将guard设置为0，获得锁而退出lock系统调用。

如果value的值等于繁忙，先将guard设置为0，再将自己加到等待队列上，然后切换到别的线程。在这种情况下，则存在另一个线程持有value这把锁。线程在做完自己的事情后，将调用unlock来释放锁。而unlock首先测试guard，如果没有人持有，该线程将value设置为FREE，然后检查是否有线程在锁上等待。在我们这种情况下有。这时它就将等待线程移动到就绪队列，然后将锁交给被叫醒的线程（value=1），然后将guard设置为0。

因此，上面锁的实现是正确的。那另一个问题是，与10.2节锁的实现方式相比，为什么繁忙等待大大缩短了？这是因为我将等待对象从value改成了guard，而guard所保护的范围远远小于value要保护的临界区。因为guard要防止的只是不要同时拿锁而已，一旦拿锁的动作完成（不管是否拿到锁），guard都将被设置为0。而这个范围很小，在拿到锁的情况下，guard保护的只有if（value==FREE）的条件判断和value=BUSY的赋值语句。在没有拿到锁的情况下，guard保护的语句只有将自己加到等待队列的一段代码。

拿到锁后的临界区操作并不由guard保护。不管临界区有多大，guard维持繁忙的时间不会受到丝毫影响。因此，一个进程在guard上的循环等待时间都几乎是恒定的，并且很短，只有几句语句，所以大大缩短了繁忙等待时间。

这种实现策略的根本思想就是循环等待不是等在lock，而是等在guard上。从而将临界区的工作时间从需要等待的时间中消除了，进而在实现锁的同时大大降低了繁忙等待时间。当然，如果同时出现3个以上线程同时竞争guard的情况，则有1个线程等待时间要长一些。但第3者多等也是应该的。而且就算是第3者，也不会等待很久时间。因为第1个释放guard之后，第2个得到guard，然后获得lock以后也释放了guard，所以第3者也不会等很久。你等多久，不就等几句语句的运行时间吗？所以这个等待时间可以忽略不计。

不过细心的读者可能发现图10-10的实现中存在一个问题。就是和使用中断禁止与启用时图10-8一样的问题。假如在将自己放在等待队列后突然发生线程切换，那么本线程也将同时处于等待和就绪两个队列。这个问题是怎么解决的呢？

解决办法也很简单，就是将执行lock这个系统调用的进程的优先权提高，以使这种情况发生的概率降低。当然，完全避免是不可能的。这也就是为什么操作系统在运行中偶尔会出问题，如死锁。

unlock的操作非常简单明了，没有什么特别之处。先在guard上进行测试与设置，通过后即释放锁，再检查是否有线程等在该锁上。如果是，就直接把锁交给线程（通过移动等待线程到就绪队列和将value设置为繁忙）。然后将guard设置为0即可。

*unlock（）｛

while（test＿and＿set（guard））｛

｝

value=FREE；

if（any thread is waiting for this lock）｛

move waiting thread from waiting queue to ready queue；

value=BUSY；

｝

guard=0；

｝

这里需要注意的是，我们把锁直接交给队列里等待的线程，因此，被唤醒的线程无须再竞争锁，而是直接进入临界区进行操作。这样做的效率显然高于再次竞争锁的办法。这就好像总统直接禅位给你，你就不用竞选了，自然节省了很多力气。

10.5　中断禁止、测试与设置

我们讲解了如何使用中断禁止、测试与设置两种硬件原语来实现软件的锁原语。这两种方式比较起来，显然测试与设置更加简单。因此，其使用的范围更加普遍。除此之外，测试与设置方法还有一个优点，那就是可以在多CPU环境下工作，而中断启用和禁止则不能。

在单处理器上，如果禁止了中断，自然别的线程就无法获得CPU控制权。但在多CPU情况下，即使禁止了某个CPU的中断，另一个CPU却可以继续执行，即我们不能保证其他CPU不进行上下文切换。当然，我们可以同时修改几个CPU的中断启用/禁止位来防止其他CPU继续运行。这样禁止所有上下文切换了吗？确实防止了上下文切换，但是却付出了更大的代价。

这是因为，如果我们一条指令把所有CPU的中断都禁止了，则这些CPU之间的独立性将大打折扣。而使用多CPU的目的就算想让它们能够独立执行。

另外，就算我们想这样做，也实现不了。因为硬件不提供一条指令禁止所有中断，而使用多条指令来禁止所有CPU上的中断则不能保证原子性。所以不提倡这种方法。

那么多CPU环境下使用测试与设置怎么能工作呢？这是因为test＆set是针对内存单元的，多CPU有全局内存，所以能够工作。我们在后面介绍旋锁的时候，将再次提到这一点。

思考题

1.你能否用加载和存入原子指令实现锁？请给出答案。

2.在图10-1所示的锁的实现中，enable interrupts和disable interrupts两条指令紧邻似乎是无聊之举，因为它们的效果相互取消。真的是这样吗？

3.一同学认为用在切换时将中断保持为禁止状态（见图10-9）的办法来解决图10-7所面临的问题太过复杂，不如干脆将图10-7中的切换语句删除，留待操作系统的调度程序进行切换。这样就不存在在何处启用中断的难题。即将上锁程序变为如下：

lock（）｛

disable interrupts；

if（value==FREE）｛

value=BUSY；

｝else｛

add thread to queue of threads waiting for this lock；

｝

enable interrupts；

｝

请问，上述锁的实现是否可行。如果可行，证明其可行性。如果不可行，给出反例。

4.一同学提出一个解决使用测试与设置指令、非繁忙等待实现锁时（见图10-10）所面临的一个线程同时存在两个状态的问题。该方法非常简单，即让操作系统调度器在取得控制权后，检查一下刚刚被中断的线程是否在锁的系统调用里面。如果是，则不予切换线程，即将控制权再交回给被中断线程，使其可以继续执行下一句指令，从而切换到别的线程。请问这种方法能够奏效吗？如果能，它有什么缺点？如果不能，它存在什么问题？5.本章论述的锁的实现机制能否应用到多计算机环境？给出你的理由。

6.调研任意一款商用操作系统的锁的实现机理，并与本章内容进行比较，你能得出何种结论？

7.有人提出另一种使用测试与设置来实现锁的方法如下：

lock（）｛

while（test＿and＿set（guard））｛

｝

if（value==FREE）｛

value==BUSY；

｝else｛

add thread to queue of threads waiting for this lock；

switch to next runnable thread；

｝

guard=0；

｝

unlock（）｛

value=FREE；

if（any thread is waiting for this lock）｛

move waiting thread from waiting queue to ready queue；

value=BUSY；

｝

｝

这种实现的特点是在unlock里不对guard进行测试与设置，而是直接对value进行操作。请问：这种实现方式能否正确运行？为什么？

第四篇　内存原理篇

“64KB内存空间即可以满足所有人的内存需要。”

——比尔·盖茨

本书前面说过，计算机的一个基本功能是计算。计算机除了需要使用硬件外，还需要两种信息：数据和代码。它们是计算机硬件CPU的输入，没有这些，CPU也是“巧妇难为无米之炊”——无法进行计算。那么这些东西从哪里来呢？或者说这些信息存放在什么地方呢？显然，存放这些东西的地方需要能够很快地将数据读出写入，以便能够与CPU合作完成计算机赋予的功能。这个地方不是别的，就是计算机的主存储器，通常简称为内存。对内存进行管理是操作系统对CPU进行管理的重要支持，也是操作系统实现进程和线程模型所不可回避的议题，（还记得实现进程模型所需的两个手段吗？）本篇即对这个重要支持部分进行讲解。

本篇包括第11～14章的内容。第11章讲述内存管理的环境、虚拟内存、操作系统在内存中的位置、程序在内存里的位置（固定加载地址、固定分区、可变分区）、基本内存管理机制（地址翻译、基址极限、交换）、闲置空间管理等内容。第12章讲解的内容包括基址极限的问题、分页管理、页表、页面翻译过程、分页管理系统的优缺点、多级页表、地址翻译速度、锁住页面、内存抖动和页面尺寸设计。第13章对页面更换算法的来龙去脉、欲达到的目的、各种具体的页面更换算法进行细致讲解。第14章的内容包括页式管理系统的局限、分段管理系统、分段的优缺点、段号与寻址位数，并对否定之否定在内存管理模式发展过程中的作用进行讨论。

内存管理从根本上说是操作系统对存储设备进行的抽象和装扮

本篇最为重要的核心思想是虚拟内存。通过这种机制，操作系统为程序员或用户提供了4种抽象：

●程序的地址独立性

●地址空间的保护

●内存空量的巨量或无限扩大

●内存访问速度的大幅度提升

操作系统为实现虚拟内存而使用的“原料”却很简单：一点点缓存，一些主存和便宜的磁盘；其采用的机制也简单至极：动态地址翻译！

第11章　基本内存管理

引子：让别人无路可走

微软（Microsoft）作为一家全球化的软件公司，可以说是众人皆知，家喻户晓。也许很多人不知道，在20世纪90年代初，微软的处境并不好。事实上，由于微软推出的Windows 3.0系列、Windows 95、Windows 98皆是在DOS外面包裹的一层图形界面，而不是真正的操作系统，导致了忠于UNIX及MacOS的人士对上述Windows“操作系统”的猛烈抨击。那个时候，在很多相关的学术及工业会议上，上台作报告的人总会或多或少地对微软进行一番攻击。工业界及学术界人士的共识就是：微软研究的根本就不是操作系统，说Windows是操作系统那是对操作系统的侮辱。

虽然受到各方责难，微软却不动声色地继续自己的“事业”。暗地里悄悄改变着自己的“操作系统”，真正做到了“走自己的路，让别人说去吧”。

1993年7月，微软推出了Windows NT操作系统。这是一个真正的支持多道编程和多用户的操作系统。该系统的推出改变了微软的声誉和生存处境，并在后续版本的推波助澜下，击败了一个又一个对手，使Win-dows成为世界上使用最为广泛的桌面操作系统。微软的对手们不再嘲笑，而是恐惧，并在恐惧中一步一步地缩小自己的阵地。在这个时候，微软的座右铭从“走自己的路，让别人说去吧”变为“走自己的路，让别人无路可走”！

在这种环境下，终于有人认识到，Windows NT里面的"NT"不是新技术New Technology的缩写，而是《新约》New Testament的缩写。因为对于微软来说，这是它与世人定的“新约”，而世人都将不得不按照这个“新约”行事，如图11-1所示。

图　11-1　德（Redmond）的微软：从百家诟病到万人敬仰

64KB内存空间真的可以满足所有人的内存需要吗？读完本章后你就会有自己的答案。

11.1　内存管理的环境

本书前面章节已经说过，操作系统的两个角色分别是魔术师和管理者。在管理者这个角色里，除了CPU之外，内存是操作系统管理的另外一个重要资源。

程序要运行，必须先加载到内存。但在很久以前，准确地说是在操作系统出现以前，程序并不需要加载到内存就能运行。实际上，在那个已经久远的年代里，程序曾经存放在卡片上，计算机每读一张卡片，就运行一条指令。因此，程序是直接从卡片到执行。

但这种从外部存储媒介上直接执行指令的做法效率极低，且灵活性很差。因此，人们发明了内存储器来将需要运行的程序先行加载，再自动执行，从而提高效率和灵活性。这也导致了“存储的程序”概念的出现，而存储的程序概念又导致计算机及软件系统的革命性变化。此后，人们对内存的要求越来越多，越来越高。

理想状态下，程序员或用户对内存的要求是：大容量、高速度和持久性。但程序员面临的物理现实却是一个由缓存、主存、磁盘、磁带等组成的内存架构。在这个内存的现实架构中，缓存的特点是低容量（相对主存来说）、高速度、高价格；主存的特点则是中容量、中速度和中价格；磁盘则属于大容量、低速度、低成本的存储媒介；磁带则通常更持久但速度更慢。图11-2给出的是内存架构的物理现实。

图　11-2　内存架构

很显然，这样一个存储架构与程序员或用户对内存的要求相差甚远。要以这个存储架构为基础来提供程序员所需的内存抽象，我们需要一个巧妙有效的内存管理机制。

内存管理机制负责对内存架构进行管理，使程序在内存架构的任何一个层次上的存放对于用户来说都是一样的。用户无须担心自己的程序是存储在缓存、主存、磁盘还是磁带上，反正运行、计算、输出的结果都一样。而内存管理实现这种媒介透明的手段就是虚拟内存。用我们多次讲过的“抽象”来说，虚拟内存就是操作系统提供给用户的另一个“幻象”。这个幻象构建在内存架构的顶端，给用户提供一个比物理主存空间大许多的地址空间。

11.2　内存管理的目标

对内存管理来说，我们要问的第一个问题当然是内存管理要达到的目标或目的。

我们说过，内存管理就是要提供一个虚幻的景象，就像钱，虚的东西，实际上一文不名，但是大家认为它有这个价值。比如游戏“第二生命”（second life），很多人在玩这个游戏。该游戏的核心是给你机会让你将生命重新来过，等于给了你第二次生命。比如你后悔考上海交通大学，或者你怀疑到上海交通大学是个错误。可是你又不敢在真实生活里退学。于是，你到这个游戏来重新尝试一次不同的选择，一次不同的生命。在游戏里面你可以上其他的大学，或者不上大学，直接去做成功人士。也许你觉得游戏里面的生命太虚了，可是玩家不这么认为。对于某些玩家来说，他的一切需求，金钱、地位、声望等皆可以在游戏中得到满足。而现实中，他们的渴望得不到满足，反而觉得空虚。

由此可见，一个东西的价值在于能否满足我们的渴望和需要。如果能，这个东西就有价值。那么内存管理就是要提供一个有价值的虚幻。用术语来说就是抽象。那么内存管理要提供哪些抽象呢？或者说，内存管理要达到什么目标呢？

首先，由于多道程序同时存放在内存中，操作系统要保证它们之间互不干扰。所谓的互不干扰就是一个进程不能随便访问另一个进程的地址空间。这是内存管理要达到的第一个目标。

那还有没有别的目标呢？我们看一下程序指令执行的过程。程序指令在执行前加载到内存，然后从内存中一条条指令读出，然后执行（从硬件层来看，指令的“读取-执行”循环是计算机的基本操作）。每条指令在执行时需要读取操作数和写入运算结果，要读取操作数，就需要给出操作数所在的内存地址，这个地址不能是物理主存地址。这是因为该程序在何种硬件配置的机器上运行并不能事先确定，操作系统自然不可能对症下药地发出对应于某台机器的物理主存地址。因此，指令里面的地址是程序空间（虚拟空间）的虚拟地址（程序地址）。即程序发出的地址与具体机器的物理主存地址是独立的。这是内存管理要达到的另外一个目标。

综上所述，内存管理要达到如下两个目标：

●地址保护：一个程序不能访问另一个程序地址空间。

●地址独立：程序发出的地址应与物理主存地址无关。

这两个目标就是衡量一个内存管理系统是否完善的标准。它是所有内存管理系统必须提供的基本抽象。当然，不同的内存管理系统在此二者之上还提供了许多其他抽象。本书将在后面论及这些抽象时逐一说明。

11.3　虚拟内存的概念

虚拟内存的概念听上去似乎有点太虚拟，但其实质则并不难理解。我们知道，一个程序如果要运行，必须加载到物理主存中。但是，物理主存的容量非常有限。因此，如果要把一个程序全部加载到物理主存，则我们所能编写的程序将是很小的程序。它的最大容量受制于主存容量（还要减去操作系统所占的空间和一些临时缓存空间）。另外，即使我们编写的每个程序的尺寸都小于物理主存容量，但还是存在一个问题：主存能够存放的程序数量将是很有限的，而这将极大地限制多道编程的发展。

那如何解决物理主存容量偏小的缺陷呢？最简单的办法就是购买更大的物理主存。而这将造成计算机成本的大幅飙升，可能很多人都会买不起计算机。

那有没有办法在不增加成本的情况下扩大内存容量呢？有，这就是虚拟内存。

虚拟内存的中心思想是将物理主存扩大到便宜、大容量的磁盘上，即将磁盘空间看做主存空间的一部分。用户程序存放在磁盘上就相当于存放在主存内。用户程序既可以完全存放在主存，也可以完全存放在磁盘上，当然也可以部分存放在主存、部分存放在磁盘。而在程序执行时，程序发出的地址到底是在主存还是在磁盘则由操作系统的内存管理模块负责判断，并到相应的地方进行读写操作。事实上，我们可以更进一步，将缓存和磁带也包括进来，构成一个效率、价格、容量错落有致的存储架构。即虚拟内存要提供的就是一个空间像磁盘那样大、速度像缓存那样高的主存储系统。如图11-3所示。而对程序地址所在位置（缓存、主存和磁盘）的判断则是内存管理系统的一个中心功能。

值得特别指出的是，虚拟内存是操作系统发展历史上的一个革命性突破（当然，它也是使操作系统变得更加复杂的一个主要因素）。因为有了虚拟内存，我们编写的程序从此不再受尺寸的限制（当然还受制于虚地址空间大小的限制）。

虚拟内存除了让程序员感觉到内存容量大大增加之外，还让程序员感觉到内存速度也加快了。这是因为虚拟内存将尽可能从缓存满足用户访问请求，从而给人以速度提升了的感觉。从这个角度来看，虚拟内存就是实际存储架构与程序员对内存的要求之间的一座桥梁。

图　11-3　虚拟内存所提供的抽象

当然，容量增大也好，速度提升也好，都是虚拟内存提供的一个幻象，实际上并不是这么回事儿，但用户感觉到是真的，这就是魔术。我们前面讲过，操作系统的一个角色就是魔术师。

11.4　操作系统在内存中的位置

从根本上来说，计算机里面运转的程序有两种：管理计算机的程序和使用计算机的程序。正如本书前面多次提到，操作系统就是管理计算机的程序。而管理者本身也需要使用资源。其中的一个资源就是内存空间。内存管理的第一个问题是操作系统本身在内存中的存放位置。应该将哪一部分的内存空间用来存放操作系统呢？或者说，我们如何将内存空间在操作系统和用户程序之间进行分配呢？

最简单的方式就是将内存划分为上下两个区域，操作系统和用户程序各占用一个区域，如图11-4所示。

图　11-4　仅有RAM时操作系统与用户程序的内存分配

比较起来图11-4a的构造最容易理解。因为操作系统是为用户提供服务的，在逻辑上处于用户程序之下。将其置于地址空间的下面，符合人们的惯性思维。另外，操作系统处于地址空间下面还有一个实际好处：就是在复位、中断、陷入等操作时，控制移交给操作系统更方便，因为操纵系统的起始地址为0，无须另行记录操作系统所处的位置，程序计数器清零就可以了。清零操作对于硬件来说非常简单，无须从总线或寄存器读取任何数据；而图11-4b的布置虽然也可以工作，但显然与人们习惯中操作系统在下的惯性思维不符。

当然，除了上述两种分配方式外，如果愿意，我们也可以将操作系统和用户程序分拆，形成穿插的分配方式。只不过这样做没有半点好处，白白增加管理的复杂性。

由于现代的计算机内存除了RAM之外，可能还备有ROM。而操作系统既可以全部存放在ROM里，也可以部分存放在ROM里，这样又多出了两种分配方式，如图11-5所示。

图　11-5　备有ROM时操作系统和用户程序之内存分配

图11-5a模式下操作系统放在ROM里面的好处是不容易被破坏，缺点就是ROM要做得大，能够容纳整个操作系统。由于ROM比较贵，通常情况下是备有少量的ROM，只将操作系统的一部分放在ROM里，其余部分放在RAM里。因此，这两种分配模式以图11-5b为佳。

图11-5b分配模式还有另外一个好处：可以将输入输出和内存访问统一起来。即将输入输出设备里面的寄存器或其他存储媒介编入内存地址（在用户程序地址之上），使得访问输入输出设备如同访问内存一样。这种输入输出称为内存映射的输入输出。如果要访问的地址高于RAM的最高地址，则属于I/O操作，否则属于正常内存操作。

这样，根据操作系统是否占用ROM或我们是否采用内存映射的输入输出来分，存在两种模式：

●操作系统占用RAM的底层，用户程序占用RAM的上层。

●操作系统占用RAM的底层和位于用户程序地址空间上面的ROM，用户程序位于中间。

第2种模式又分为3种情况：

●没有使用内存映射的输入输出，ROM里面全部是操作系统。

●使用了内存映射的输入输出，ROM的一部分是操作系统，另一部分属于I/O设备。

●使用了内存映射的输入输出，ROM全部属于I/O设备。

例如，CP/M操作系统的内存布局模式就是上述第1种情况，其BIOS和CP/M内核均处于ROM里面，而shell和用户程序处于RAM里，如图11-6所示。

CP/M是微计算机控制程序（Control Pro-gram for Microcomputers）的缩写，它是一个运行在Intel 8080和Intel 8085微机上的早期操作系统。

图　11-6　CP/M操作系统内存布局

多数现代操作系统采用的是第2种模式：即ROM里面包括操作系统一部分和I/O, RAM里面则包括操作系统的其他部分和用户程序。Solaris 10操作系统采用的则是第2种模式（见图11-4b）。即操作系统在上面，用户程序在下面。

11.5　单道编程的内存管理

最简单的内存管理是单道程序下的内存管理。在单道编程环境下，整个内存里面只有两个程序：一个是用户程序，另一个是操作系统。由于只有一个用户程序，而操作系统所占用的内存空间是恒定的，我们可以将用户程序总是加载到同一个内存地址上。即用户程序永远从同一个地方开始执行。在这种管理方式下，操作系统永远跳转到同一个地方来启动用户程序。这样，用户程序里面的地址都可以事先计算出来，即在程序运行前就计算出所有的物理地址。这种在运行前即将物理地址计算好的方式叫做静态地址翻译。

这种内存管理方式是如何达到内存管理的两个目的的呢？首先看地址独立。固定地址的内存管理达到地址独立了吗？那就看看用户在编写程序时是否需要知道该程序将要运行的物理内存知识。显然不需要。因而用户在编程序时用的虚地址无须考虑具体的物理内存，即该管理模式达到了地址独立。那么它是如何达到的呢？办法就是将用户程序加载到同一个物理地址上。通过静态编译即可完成虚地址到物理地址的映射，而这个静态翻译工作可以由编译器或者加载器来实现。

那么内存管理的另一抽象，即地址保护，达到了吗？那要看该进程是否会访问到别的用户进程空间，或者别的用户进程是否会访问该进程地址。答案是不会。因为整个系统里面只有一个用户程序。因此，固定地址的内存管理因为只运行一个用户程序而达到地址保护。

固定地址的内存管理单元非常简单，实际上并不需要任何内存管理单元。因为程序发出的地址已经是物理地址，在执行过程中无须进行任何地址翻译。而这种情况的直接结果就是程序运行速度快，因为越过了地址翻译这个步骤。

当然，固定地址的内存管理其缺点也明显：第1个缺点是整个程序要加载到内存空间中去。这样将导致比物理内存大的程序无法运行。第2个缺点是，只运行一个程序造成资源浪费。如果一个程序很小，虽然所用内存空间小，但剩下的内存空间也无法使用。第3个缺点是可能无法在不同的操作系统下运行，因为不同操作系统占用的内存空间大小可能不一样，使得用户程序的起始地址可能不一样。这样在一个系统环境下编译出来的程序很可能无法在另一个系统环境下执行。

11.6　多道编程的内存管理

本书已经多次阐述单道编程的缺点，为了克服单道编程的缺点，我们发明了多道编程。随着多道编程度数的增加，CPU和内存的利用效率也随着增加。当然，这种增加有个限度，超过这个限度，则因为多道程序之间的资源竞争反而造成系统效率下降。

虽然多道编程可以极大地改善CPU和内存的效率，改善用户响应时间，但是天下没有免费的午餐，这种效率和响应时间的改善是需要付出代价的。

这个代价是什么呢？当然是操作系统的复杂性。因为多道编程的情况下，无法将程序总是加到固定的内存地址上，也就是无法使用静态地址翻译。这样我们就必须在程序加载完毕后才能计算物理地址，也就是在程序运行时进行地址翻译，这种翻译称为动态地址翻译。图11-7描述的就是动态地址翻译的示意图。

图　11-7　动态地址翻译：用户进程发出的虚拟地址由MMU翻译成物理地址

也许有读者会想，我们可以在内存固定出几个地址出来，比如说4个。这样我们可以同时加载4个程序到内存，而这4个程序分别加到这4个固定的地址，这样不就可以进行静态地址翻译了吗？但是谁能提前知道某个特定的程序将加载到4个固定地址的哪一个呢？而且，这样做还将带来地址保护上的困难。既然所有的程序皆发出物理地址，该地址是否属于该程序可以访问的空间将无法确认。这样，程序之间的互相保护就成了问题。

那么多道编程的内存管理是如何进行动态地址翻译的呢？那得看内存管理的策略。多道编程下的内存管理策略有两种：固定分区和非固定分区。

11.6.1　固定分区的多道编程内存管理

顾名思义，固定分区的管理就是将内存分为固定的几个区域，每个区域的大

图　11-8　共享队列的固定分区

小固定。最下面的分区为操作系统占用，其他分区由用户程序使用。这些分区大小可以一样，也可以不一样。考虑到程序大小不一的实际情况，分区的大小通常也各不相同。当需要加载程序时，选择一个当前闲置且容量够大的分区进行加载，如图11-8所示。

在这种模式下，当一个新的程序想要运行，必须排在一个共同的队列里等待。当有空闲分区时，才能进行加载。由于程序大小和分区大小不一定匹配，有可能形成一个小程序占用一个大分区的情况，从而造成内存里虽然有小分区闲置，但无法加载大程序的情况。如果在前面加载小程序时考虑到这一点，可以将小程序加载到小分区里，就不会出现这种情况（或者说至少降低这种情况发生的概率）。这样，我们就想到也许可以采用多个队列，即给每个分区一个队列。程序按照大小排在相应的队列里，如图11-9所示。

图　1-9　分开队列的固定分区

这样不同的程序有不同的对，就像在社会中不同的社会阶层有不同的待遇一样。当然，这种方式也有缺点，就是如果还有空闲分区，但等待的程序不在该分区的等待队列上，就将造成有空间而不能运行程序的尴尬处境。

11.6.2　地址翻译的方法

在多道编程环境下，由于程序加载到内存的地址不是固定的（有多个地方可加载），我们必须对地址进行翻译。那么如何来翻译呢？

我们看到，一个程序是加载到内存里事先划分好的某片区域，而且该程序是整个加载进去。该程序里面的虚地址只要加上其所占区域的起始地址即可获得物理地址。因此，我们的翻译过程非常简单：

物理地址=虚拟地址+程序所在区域的起始地址程序所在区域的起始地址称为（程序）基址。

另外，由于有多个程序在内存空间中，我们需要进行地址保护。由于每个程序占用连续的一片内存空间，因此只要其访问的地址不超出该片连续空间，则为合法访问。因此，地址保护也变得非常简单，只要访问的地址满足下列条件即为合法访问：

程序所在区域的起始地址≤有效地址≤程序所在区域的起始地址+程序长度由此可见，我们只需要设置两个端值：基址和极限，即可达到地址翻译和地址保护的目的。这两个端值可以由两个寄存器来存放，分别称为基址寄存器和极限寄存器。在固定分区下，基址就是固定内存分区中各个区域的起始内存地址，而极限则是所加载程序的长度（记住，不是内存各个分区的上限）。

这样，每次程序发出的地址需要和极限比较大小；如果大于极限，则属非法访问。在这个时候我们将陷入内核，终止进程（在个别操作系统上，也可能进入一个异常处理的过程）；否则将基址加上偏移获得物理地址，就可以合法访问这个物理地址。

if（虚拟地址＞极限）｛

陷入内核

终止进程（core dump）

｝else｛

物理地址=虚拟地址+基址

｝

由此可见，实现基址极限管理的硬件也很简单：一个加法器和一个比较器即可。这样，对每个程序来说，它仿佛独占了一个内存空间从0到极限的计算机，如图11-10所示。

图　11-10　基址极限的概念

那么我们怎么知道一个程序有多大呢？编译过后，我们就可以得到这个程序的大小。基址和极限是很重要的两个参数，只有内核能够改变它们。如果要切换程序，只需将保存基址和极限的寄存器的值按照新程序的情况重新设置即可。

11.6.3　动态地址翻译的优点

动态地址翻译虽然增加了系统消耗，不如静态地址翻译效率高，但其带来的优点远远超过静态地址翻译。第1个优点是灵活。因为实施了动态地址翻译，我们就无须依赖编译器或加载器来进行静态地址翻译，可以将程序随便加载到任何地方，极大地提高操作系统操作的灵活性。第2个优点是，它是实施地址保护的“不二法门”。要想进行地址保护，就必须对每个访问地址进行检查，而动态地址翻译恰恰就能做到这一点。第3个优点则更为重要，它使虚拟内存的概念得以实现。虚拟内存就是子虚乌有的内存，这个内存的空间可以非常大，比物理空间大很多。那么虚拟内存的根本是将内存扩展到磁盘上，就是将磁盘也当成内存的一部分。从这里我们可以获得一个重要的推论，就是一个程序可以一半放在磁盘上，一半放在内存上。这样，从物理上讲，一个程序发出的访问地址有可能在内存，也有可能在磁盘。

但是计算机怎么能知道这个地址所指向的数据在内存上还是在磁盘上呢？这无法在静态地址编译时就知道的。唯一的方法就是动态地址翻译。在每次内存访问的时候，虚拟地址就是用户每次看到的地址，这个地址只是一个抽象，它需要由内存管理单元进行翻译，变成物理内存地址才能使用。由于这个翻译是在程序执行过程中发生，因此称为动态地址翻译。有了动态地址翻译，编译器和用户进程就再也不用考虑物理地址了。

在动态地址翻译环境下，一个虚拟地址仅在被访问的时候才需要放在内存里，在其他时候并不需要占用内存。由于动态地址翻译可以动态地改变翻译参数或过程，因此可以在程序加载到不同的物理位置时，或不同虚拟地址占用同一物理地址时，做出正确翻译。在使用基址和极限管理模式下，不同程序进入物理内存时，我们只需要变更基址和极限寄存器的内容即可。

11.6.4　非固定分区的内存管理

前面说过固定分区的内存管理优点就是简单，直截了当。最大的分区就是能容纳你这个程序的分区。其缺点：一是程序大小和分区大小的匹配不容易令人满意；二是很僵硬，如果有个程序比最大的分区大怎么办呢？三是地址空间无法增长，如果程序在运行时内存需求增长怎么办？很容易想到固定分区为什么有这个缺陷，因为分区是固定大小。这样，我们自然想到用非固定分区的方式来管理多道编程的内存空间。

非固定分区的思想很简单：除了划分给操作系统的空间外，其余的内存空间是作为一个整体存在的。当一个程序需要占用内存空间时，就在该片空间里面分出一个大小刚刚满足程序所需的空间；再来一个程序，则在剩下的空间里面再这样分出一块来。在这种模式下，一个程序可以加载到任何地方，也可以和物理内存一样大。例如，一开始内存里只有操作系统。这时候进程A来了，我们从最底下分出一片与进程A大小一样的内存空间；然后进程B来了，我们在进程A上面的大片空间分出一片与进程B大小一样的内存空间；然后进程C来了，我们就在进程B上面再分出一片与C大小一样的内存空间。这样，进程A、进程B、进程C的起始地址都不是固定的，如图11-11所示。

这种非固定分区内存管理的机制也很简单，就是在11.6.3节中讲过的基址和极限。对每一个程序配备两个寄存器：基址寄存器和极限寄存器。所有访问地址都必须在这两个寄存器值框定的空间里，否则就算非法访问。

图　11-11　非固定分区的多道编程的内存管理

仔细的读者可能已经看出，非固定分区这种管理方式存在一个重大问题：每个程序像叠罗汉一样累积，如果程序B在执行过程中需要更多空间，怎么办？例如，一个程序在执行过程中不断产生新的数据从而造成数据所需空间的增长，而不断进行嵌套函数调用的时候又会造成所需栈空间的增长。解决的办法当然是在一开始给程序分配空间时就分配足够大的空间，留有一片闲置空间供程序增长用，如图11-12所示。

图中A、B、C 3个程序分到的空间都大于其实际使用的空间。因此，程序的扩展得到支持。只要程序增长不超过所分配的空间，程序的增长将不受羁绊。

不过，在分配增长空间后需要考虑一个问题。一个程序的空间增长通常有两个来源：数据和栈。如何处理这两种空间增长的关系会对整个程序的扩展性产生影响。

最简单的方式是数据和栈往一个方向增长。这种模式下，我们事先给数据部分和栈部分分别留下增长空间。这样的优点是两者独立性高，缺点是空间利用率可能较低。例如，如果数据在下，栈在上，当栈长到程序所分配空间的顶时，就无法再长了。即使下面的数据部分还有闲置空间也不能利用。反之，如果数据部分长到了栈的底后也无法再增长，即使栈的上方还有空间。这种情况如图11-13所示。

当然，我们可以通过移动栈底来解决上述问题。但这样成本就高了，而且十分复杂，容易出错。一个更简单的办法是让数据和栈往相反方向增长。这样，只要本程序的自由空间还有多余，不仅可以进行函数调用，又可以增加新的数据，可以最大限度地利用这片自由空间。这是UNIX采取的策略，如图11-14所示。Windows内存管理则为栈限制了边界，超出该边界将造成程序错误并导致程序终止。

图　11-13　栈和数据同向增长

图　11-14　栈和数据反向增长

不过，这里还有个问题：操作系统怎么知道应该分配多少空间给一个程序呢？怎么知道该程序会进行多少层嵌套调用，产生多少新的数据呢？如果为保险起见分配一个很大的空间，就有可能造成浪费；而分配小了，则可能造成程序无法继续运行。

那还有没有别的办法呢？有，给该程序换一个空间。就是当一个程序所占空间不够时，我们将其倒到磁盘上，再加载到一片更大的内存空间。这种将程序倒到磁盘上，再加载进内存的管理方式称为交换（swap）。

11.6.5　交换

交换就是将一个进程从内存倒到磁盘上，再将其从磁盘上加载到内存中的过程。这种交换的主要目的是为程序找到一片更大的空间，从而防止一个程序因空间不够而崩溃。交换的另一目的，是实现进程切换，也就是将一个程序暂停一会儿，让另一个程序运行。不过使用交换进行进程切换的成本颇高，一般不这样做。

图　11-15　进程A因空间增长而交换到更大的空间里

例如，在图11-15c（同图11-11）的状态下，进程A因为空间大小而无法继续执行，我们便将进程A交换到磁盘上，等待大片空间的出现（见图11-15d）。这时进程D因为占用空间小，可以执行，因此把进程D加载进来（见图11-15e）。进程B执行结束后空出一片空间，和本来的剩余空间合并后形成了一个可以容纳进程A的空间（见图11-15 f），这时我们将进程A再加载进来，形成图11-15 g的状态。

交换和非固定分区一样，每个程序占用一片连续的空间，操作系统使用基址和极限来进行管理。但由于一个程序在执行过程中可能发生交换，其基址和极限均有可能发生变化。但这种变化对于内存管理来说，并不增加多少难度。只要每次加载程序的时候将基址和极限寄存器的内容进行重载即可。

11.6.6　重叠

我们前面说过，如果一个程序在执行过程中占用空间增大了，我们可以通过交换给它找一个更大的空间来执行。这种情况下该程序的增长无法超过物理内存空间的容量。如果一个程序超过了物理内存，还能运行吗？

答案也许出乎意料。能。这个办法就是所谓重叠（overlay）。重叠就是将程序按照功能分成一段一段功能相对完整的单元。一个单元执行完后，再执行下一个单元，而一旦执行到下一个单元，就不会再执行前面的单元。所以我们可以把后面的程序单元覆盖到当前程序单元上。这样就可以执行一个比物理内存还要大的程序。

但是这相当于把内存管理的部分功能交给了用户，是个很拙劣的方法。况且也不是每个人都能够将程序分成边界清晰的一个个执行单元的。而且，从根本上说，这不能算是操作系统提供的解决方案。

11.6.7　双基址

如果我们运行两个一样的程序，只是数据不同，我们自然想到能否让两个程序共享部分内存空间。例如，如果我们同时启动两个PPT演示文稿，我们希望PPT的程序代码部分能够共享。但在基址极限这种管理模式下，这种共享无法实现，如图11-16所示。

图　11-16　使用基址极限难以实现程序共享

那么有什么办法共享这段代码又不容易出错呢？答案就是设定两组基址和极限。数据和代码分别用一组基址和极限表示，这就解决了问题。

11.7　闲置空间管理

在管理内存的时候，操作系统需要知道内存空间有多少空闲。如何才能知道有哪些空闲呢？这就必须跟踪内存的使用。跟踪的办法有两种：第1种办法是给每个分配单元赋予一个字位，用来记录该分配单元是否闲置。例如，字位取值0表示分配单元闲置，字位取值1则表示该分配单元已被占用。这种表示法就是所谓的位图表示法，如图11-17所示。

图　11-17　内存分配位图

从图中可以看出，内存空间最前面的5个分配单元已经被占用，接下来是4个分配单元则处于闲置状态，可以供程序使用。其他以此类推。

位图表示法的优点是直观、简单。在搜索需要的闲置空间时只需要找到一片连续0个数大于等于所需分配单元数即可。例如，如果我们需要找一片4个分配单元大的闲置空间，则通过扫描位图表，找到连续4个0即可。将这片空间分配给需要的程序，并将相应的位图值设置为1。

另外一种办法是将分配单元按是否闲置链接起来，这种办法称为链表表示法。对于图11-17的位图所表示的内存分配状态，如果用链表表示则如图11-18所示。

图　11-18　内存分配的链表表示

图中的P代表程序，即当前这片空间由程序占用。后面的数字是本片空间的起始分配单元号和大小。而H代表的是空洞，即这是一片闲置空间。例如，图中的第1个链表项表示一个大小为5个分配单元的程序，起始地址为第0个分配块。第2个链表项表示一片大小为4个分配单元的闲置空间块，起始地址为第5个分配单元。其他以此类推。

在链表表示下，寻找一个给定大小的闲置空间意味着找到一个类型为H的链表项，其大小大于或等于给定的目标值。不过，扫描链表速度通常较慢。为提高查找闲置单元的速度，有人提出了将闲置空间和被占空间分开设置链表，这样就形成了两个链表的管理模式。

位图表示和链表表示的比较

位图表示和链表表示各有优缺点。如果程序数量很少，那么链表比较好，因为链表的表项数量少。例如，如果只有3个程序在内存中，则最多只需要7个链表节点。但是如果你的程序很稠密的话，那么链表的节点就很多了。

位图表示法的空间成本是固定的，它不依赖于内存中程序的数量。因此，从空间成本上分析，到底使用哪种表示法得看链表表示后的空间成本是大于位图表示还是小于位图表示而定。

从可靠性上看，位图表示法没有容错能力。如果一个分配单元为1，你并不能肯定它应该为1，还是因为错误变成1的。因为链表有被占空间和闲置空间的表项，可以相互验证，具有一定的容错能力。

从时间成本上，位图表示法在修改分配单元状态时，操作很简单，直接修改其位图值即可，而链表表示法则需要对前后空间进行检查以便做出相应的合并。例如，在图11-18所示的情况下，如果程序中间的那个程序（占用位置从9开始，长度为3）终止，则链表将如图11-19所示。如果是最前面的程序终止，则链表将如图11-20所示。

图　11-19　空间释放时链表项的合并操作（一）

图　11-20　空间释放时链表项的合并操作（二）

当然，我们还可以从查找时间上进行分析。这个留给读者作为练习。

思考题

1.基本的内存管理模式有哪几种？其特点分别是什么？

2.内存管理提供的抽象有哪些？请尽可能多地列出。

3.单道编程下的内存管理模式是如何实现地址独立的？

4.位图表示和链表表示两种方法在查找闲置空间时的效率如何？

5.有学生提出，在固定分区的多道编程的内存管理下，只需要在编译时给出一个参数，即该程序将被加载的内存分区，便可以通过编译器来事先计算出每个程序虚地址的物理地址。这样在加载后，就不再需要动态地址翻译了。请问该建议怎么样？

6.固定分区管理模式下，使用静态地址翻译能否满足地址独立的目标？地址保护呢？

7.固定分区是如何达到内存管理的两个抽象：地址独立和地址保护？请详细说明。

8.在将I/O设备映射到内存时，能否将其映射到用户程序地址空间之下或者中间？

9.动态地址翻译是否需要硬件支持？为什么？

10.固定分区和非固定分区，哪种模式下程序增长更难？为什么？

11.在多道编程下，我们说过需要动态地址翻译。因为编译器不知道该程序将被加载到何地址上。但有学生提出，可以使用加载器进行静态地址翻译。因为在加载的时候我们已经知道加载的起始地址了。请问这种做法行得通吗？

12.实现虚拟内存的根本方法是什么？

13.使用位图和链表来管理闲置内存空间在时间效率上孰优孰劣？为什么？

第12章　页式内存管理

引子：虚拟概念的变现

1900年12月12日晚，一个寒冷的冬夜，位于美国纽约第5大道的大学俱乐部宴会厅，80多位来自美国各地的金融权贵济济一堂。他们受J.爱德华·西蒙（J.Edward Simmons）和查尔斯·斯徒特·史密斯（Charles Stewart Smith）二人的邀请，到这里参加二人为来自匹兹堡的查尔斯·斯瓦伯（Charles M.Schwab）举行的答谢宴会。参会的人员包括金融巨鳄约翰·皮尔庞·摩根（John Pierpont Morgan）、西部黑社会赌枭约翰·盖茨（John W.Gates）、芝加哥证券大鳄莫尔兄弟（Bill Moore和Jim Moore）、纽约大牌律师埃尔波特·盖里（Elbert H.Gary），以及斯第尔曼（Stillman）、哈里曼（Harriman）、范德比特（Vanderbilts）等显贵家族的掌门。

斯瓦伯将在答谢宴会上发表简短的致词。事先，西蒙和史密斯已经警告斯瓦伯的答谢致词不要超过15分钟，因为这些金融权贵对冗长的演说没有耐心。他们来这里纯粹是为了给西蒙和史密斯一个面子。摩根甚至只准备待不到15分钟就离开。

作为钢铁传奇人物安德鲁·卡奈基（Andrew Carnegie）的头号随从，查尔斯·斯瓦伯却口若悬河地谈到了其欲将美国各地的钢铁小作坊合并为一个“钢铁巨无霸”的计划。其演讲天花乱坠却又逻辑严谨，语法错误频出却又充满智慧，其为这个虚幻钢铁联合公司描述的美妙前景将在场的各等人士彻底征服。原计划15分钟的致词变成了持续了90多分钟的演讲，确立了组建美国联合钢铁公司的意向。在随后的时间里他破天荒地说服了摩根和卡奈基这一对冤家走到了一起，共同缔造了美国钢铁公司（United States Steel）。消息传出，大西洋彼岸的欧洲愤怒了，美国的各种投机人兴奋了。股票操纵家基尼（Keene）为美国钢铁公司上下奔走，募集了公司成立需要的资本。当时世界经济界的巨无霸的美国钢铁公司（见图12-1）就这样诞生了。

图　12-1　美国钢铁公司总部大楼

内存管理就是要将各种媒介组建成一体，形成一个巨无霸似的虚拟存储系统。

12.1　基址极限管理模式的问题

到目前为止，本书已经介绍了几种基本的内存管理方法，分别是固定加载地址的内存管理、固定分区的内存管理、非固定分区的内存管理和交换内存管理。固定加载地址的内存管理只适合于单道编程，而其他3种则可用于多道编程。这3种适用于多道编程的内存管理模式均使用同一种实现机制：基址与极限。

基址与极限的工作原理是将程序发出的虚拟地址加在基址而获得物理地址。如果地址超过指定的极限，则视为地址出界而禁止访问，否则访问正常进行。

在我们介绍过的3种多道编程的内存管理模式里，以交换内存管理最为灵活和先进。它可以解决因程序所需空间增长而无法继续运行的困难，又可以实现动态多道编程，可谓是多道编程内存管理“三剑客”里面的第一高手。但这个第一高手也不是什么问题都没有的。事实上，这种策略存在很多重大问题，而其中最重要的两个问题是空间浪费和程序大小受限。

12.1.1　空间浪费问题

随着程序在内存与磁盘间的交换，内存将变得越来越碎片化，即内存将被不同程序分割成尺寸大小无法使用的小片空间。例如，假定我们运行8个程序：A、B、C、D、E、F、G、H，其启动、内存需要和交换过程如下：

★A启动，需占用内存200KB，分配空间1000KB～1199KB。

★B启动，需占用内存100KB，分配空间1200KB～1299KB。

★C启动，需占用内存300KB，分配空间1300KB～1599KB。

★A结束，释放内存空间1000KB～1199KB。

★D启动，需占用内存50KB，分配空间1000KB～1049KB。

★E启动，需占用内存100KB，分配空间1600KB～1699KB。

★C结束，释放内存空间1300KB～1599KB。

★F启动，需占用内存200KB，分配空间1300KB～1499KB。

★G启动，需占用内存50KB，分配空间1500KB～1549KB。

★H启动，需占用内存200KB，无法分配空间！

在上述前7个进程执行序列后，当前内存中尚有200KB的闲置空间，分别处于地址1050KB～1199KB和1550KB～1599KB。但因为不连续，所以无法容纳进程H。而进程H的空间需求只有200KB！

这种散布在进程之间的闲置空间称为外部碎片。这是因为从进程的粒度来看，这种碎片处于进程空间的外面。这种碎片化过程也称为“外部碎片化”。

图12-2显示的是外部碎片化的示意图。在图12-2f的情况下，内存空间形成碎片，无法容纳新的进程H，尽管内存总闲置空间可以容纳进程H。

随着进程的进进出出，外部碎片将浪费大量的内存空间。我们可以采取一些措施来降低外部碎片的危害，例如，在寻找空间容纳新的进程时，可以按照某种算法，如最先适用（first fit）或者最佳适用（best fit）来进行。最佳适用就是找到一个能够容纳新程序的最小空间。而最先适用就是找到第1个可以容纳新进程的空间。实践证明，最先适用比最佳适用更好。虽然最佳适用的名字听上去很好，但因为每次都是最小适应，使得多出来的空间反而更加难以再次利用。

图　12-2　交换系统中外部碎片的产生过程

但不管是最佳适用还是最先适用，这些算法都不能消除外部碎片。当然，如果实在不行，我们可以进行碎片整理，即通过移动进程在内存里面的位置将空闲空间连成一片。但是这种操作需要将进程导出到磁盘上，再重新加载，效率十分低下。在进行碎片整理的过程中，系统的响应延迟将显著增加。因此，这种方法并不让人感觉兴奋。

12.1.2　程序受限问题

除了外部碎片外，交换的内存管理模式还存在几个重大问题：地址空间增长困难。这有两层意思：一是指空间增长效率低下；二是空间增长存在天花板限制。

本书前面讲过，通过交换，可以让程序的大小增长。即先把程序倒到磁盘上，再在内存寻找一片更大的空间将程序倒进来，从而扩展程序所占的空间。由于磁盘操作耗时，这种交换出去，再找一片更大的空间来增长程序空间的做法效率非常低。

但是空间增长的低效率还不是交换所面临的唯一问题。即使我们可以忍受效率低下，但还有另外一个问题：交换所能带来的空间增长有限。这个限制就是单一程序不能超过物理内存空间（减去操作系统所占部分），尽管多个程序的总空间可以超过物理内存空间。

12.1.3　解决之道

我们有没有什么办法解决交换存在的这些问题呢？那得先分析交换这些问题的根源是什么。空间碎片化的根源是每个程序的大小不一样，这样在空间分配时不存在一致性。解决的办法自然是将空间按照某种规定的大小进行分配。只要将虚拟内存与物理内存都分成大小一样的部分，我们称为页，然后按页进行内存分配，就可以克服外部碎片的问题。

程序增长有限则是因为一个程序需要全部加载到内存才能运行。而解决的办法就是使一个程序无须全部加载就可以运行。用分页也可以解决这个问题：只将当前需要的页面放在内存里，其他暂时不用的页面放在磁盘上，这样一个程序同时占用内存和磁盘，其增长空间就大大增加了。而且，分页后，如果一个程序需要更多的空间，给其分配一个新页即可（而无须像之前介绍的那样将程序倒出倒进，从而大大提高空间增长效率）。

因此，分页似乎就是我们解决交换缺陷的“不二法门”。

12.2　分页内存管理

为了解决交换系统存在的缺陷，出现了分页系统。分页系统的核心就是将虚拟内存空间和物理内存空间皆划分为大小相同的页面，如4KB、8KB或16KB等，并以页面作为内存空间的最小分配单位，一个程序的一个页面可以存放在任意一个物理页面里。这样，由于物理空间是页面的整数倍，并且空间分配以页面为单位，将不会再产生外部碎片。同时，由于一个虚拟页面可以存放在任何一个物理页面里，空间增长也容易解决：只需要分配额外的虚拟页面，并找到一个闲置的物理页面存放即可。

在分页系统下，一个程序发出的虚拟地址由两部分组成：页面号和页内偏移值，如图12-3所示：

图　12-3　虚拟地址构成

例如，对于32位寻址的系统，如果页面大小为4KB，则页面号占20位，页内偏移值占12位。

为了解决程序比内存大的问题，我们可以允许一个进程的部分虚拟页面存放在物理页面之外，也就是磁盘上。在需要访问这些外部虚拟页面时，再将其调入物理内存。由此，交换系统的所有缺陷均被克服。

12.2.1　地址翻译

从上面的分析可以看出，分页系统要能够工作的前提是：对于任何一个虚拟页面，系统知道该页面是否在物理内存中，如果在的话，其对应的物理页面是哪个；如果不在的话，则产生一个系统中断（缺页中断），并将该虚页从磁盘转到内存，然后将分配给它的物理页面号返回。也就是说，页面管理系统要能够将虚拟地址转换为物理地址。该翻译过程如下所示。

if（虚拟页面非法、不在内存或者被保护）｛

陷入到操作系统错误服务程序

｝else｛

将虚拟页面号转换为物理页面号

根据物理页面号产生最终物理地址

｝

因此，分页系统的核心是页面的翻译，即从虚拟页面到物理页面的映射。而这个翻译过程由内存管理单元（MMU）完成。MMU接收CPU发出的虚拟地址，将其翻译为物理地址后发送给内存。内存单元按照该物理地址进行相应访问后读出或写入相关数据，如图12-4所示。

图　12-4　虚拟地址到物理地址的流向图

内存管理单元对虚拟地址的翻译只是对页面号的翻译，即将虚拟页面号翻译成物理页面号。而对于偏移值，则不进行任何操作。这是因为虚拟页表和物理页表大小完全一样，虚拟页面里的偏移值和物理页面里的偏移值完全一样，因此无须翻译。

那么内存管理单元是通过什么手段完成这种翻译的呢？当然是查页表。对于每个程序，内存管理单元都为其保存一个页表，该页表中存放的是虚拟页面到物理页面的映射。每当为一个虚拟页面寻找到一个物理页面后，就在页表里面增加一个记录来保留该虚拟页面到物理页面的映射关系。随着虚拟页面进出物理内存，页表的内容页不断发生变化。

在程序发出一个虚拟地址给内存管理单元后，内存管理单元首先将地址里面页号部分的字位分离出来，然后判断该虚拟页面是否有效，是否存放在内存，是否受到保护。如果页面无效，即该虚拟页面不存在或没有在内存，也就是说该虚拟页面在物理内存里面没有对应。如果该页面受到保护，即对该页面的访问被禁止，则产生一个系统中断来处理这些特殊情况。对于无效页面访问，需要终止发出该无效访问的进程。对于合法但不在物理内存中的页面，我们通过缺页中断将该虚拟页面放进物理内存。对于受保护的页面，同样终止该进程。

这里有一点要解释的就是非法虚拟页面。什么叫做虚拟页面非法呢？程序在加载前所使用的一切地址均是虚拟地址，即程序存在于虚拟空间。而虚拟空间的大小与系统的寻址长度有关。例如，32位寻址的系统虚拟地址空间为232-1，即一个程序最多可以有232-1条指令。但是一个程序可能没有这么多条指令。事实上，大部分程序都不会占满整个虚拟空间。这样，就有一部分虚拟空间是空的，即程序没有使用的部分。这部分空间就是非法虚拟空间。如果一个程序访问了这片空间，即判定为非法，而无须再判断该虚拟页面是否在物理内存。比如一个程序大小为100KB，如果该程序试图访问虚拟地址100KB以上的地址，即视为非法访问。进行这种访问的程序即为程序错误或失效。

如果页面有效且在物理内存，又没有受保护，则使用该虚拟页面号作为索引，找到页表中对应该虚拟页面的记录，读取其对应的物理页面号，如图12-5所示。

图　12-5　虚拟地址到物理地址的翻译过程

那么内存管理单元是怎么知道一个页面是否有效，是否被保护，是否在物理内存呢？这个简单，将这些信息储存在页表里面即可。这样，页表不只是用来进行翻译，还用来进行页面的各种状态判断，因此，页表在分页系统里面的地位举足轻重。

12.2.2　页表

上面已经说过，页表在分页内存管理系统的地位十分关键。页表的根本功能是提供从虚拟页面到物理页面的映射。因此，页表的记录条数与虚拟页面数相同。例如，对于32位寻址的虚拟地址，如果页面大小为4KB，则虚拟页面数最多可以达到220，即1 048 576个虚拟页面。那么页表的记录条数就为1 048 576条。

内存管理单元依赖页表来进行一切与页面有关的管理活动。这些活动包括判断某一页面号是否在内存里，页面是否受到保护，页面是否非法空间等。因此，页表除了提供虚拟页面到物理页面的映射外，还记录这些相关信息。页表的一个记录所包括的内容如图12-6所示。

图　12-6　页表记录内容举例

缓存禁止位用来指示该页面是否允许存放在缓存里。访问位记录该页面是否被访问过（被读过或者被写过）。修改位记录该页面自从加载到物理内存后是否被修改过。保护标识位记录该页的受保护情况，如是否允许读、写、执行等。在内存否则记录该虚拟页面是否已经在物理内存里。物理页面号则是该虚拟页面对应的物理页面（如果该虚拟页面在物理内存的话）。

访问位和修改位是内存管理单元进行页面置换时依赖的信息。

当然，一个记录条通常还会有一个保留区（reserve area），图12-6没有给出该保留区。但它通常是存在的。设置保留区的目的是为以后有需要时增加信息用的。如果没有保留区，万一想对分页系统进行某种改善，而该种改善又需要在页表中记录额外的信息，则就没有办法实施了。除非重新设计页表记录内容。但这样一来成本就太高了。因此，设计任何数据结构时应该留有余地，这样才有改善的空间。

由于页表的特殊地位决定了它由硬件直接提供支持，即页表是一个硬件数据结构。图12-7列出的是一个简化了的虚拟页面与物理页面的映射。左面的页面属于虚拟空间，右面的页面属于物理空间，页面大小为16KB。

图　12-7　页表举例

从该页表可以看出，第0个虚拟页面在物理的第2个页面里。如果我们访问虚拟页面0，则实际访问的物理页面2。页表里面的X表示该虚拟页面不在内存里。如果访问的页面不在内存，那么将产生缺页中断。例如，如果我们访问虚拟页面6，则将发生缺页中断。缺页中断服务程序将负责在磁盘上找到需要的虚拟页面，并在物理内存里面寻找一个闲置的页面来存放该虚拟页面，然后更新页表。之后，对该虚拟页面的访问就可以正常进行了。

12.2.3　页面翻译过程

图12-8给出的是地址翻译的一个简单演示。

在该图中，CPU发出虚拟地址0010000000000100。由于页面大小为4KB，后面的12位为页内偏移值。前面的4位则为页面号。按此分解后，我们得出该地址在虚拟页面2，页内偏移值4的地址上。将这两部分地址分开，以2作为索引到找到页表的第2个记录，发现该记录所对应的页面在物理内存，其物理页面号是110，即6。这样，我们再将物理页面号与页内偏移值合并，获得物理地址为：110000000000100。

12.3　分页系统的优缺点

我们在引入分页系统时已经讲过，分页系统不会产生外部碎片，一个进程占用的内存空间可以不是连续的，并且一个进程的虚拟页面在不需要的时候可以放在磁盘上。这样，在分页系统下，进程空间的增长和虚拟内存的实现都解决了。

除此之外，分页系统的另一个优点是可以共享小的地址，即页面共享。我们只需要在对应给定页面的页表项里做一个相关的记录即可。

当然，分页系统也存在缺点。第一个缺点就是页表很大，占用了大量的内存空间。例如，对于32位寻址、页面尺寸为4KB的分页系统来说，页表将有1 048 576个记录。每个记录又要占用多个字节，这样，一个页表所占的内存空间相当大。

那么有没有办法减少页表的尺寸呢？可以选择多级页表的方式。既然一个程序可以分解为一个个页面，并将部分页面存放在磁盘上而降低其内存占用空间，页表也可以采用同样的方式处理，即将页表分为一个个页面，不需要的页面也放在磁盘上。内存里只存放需要的页面。

12.3.1　多级页表

在多级页表结构下，页表根据存放的内容可分为：顶级页表、一级页表、二级页表、三级页表等。顶级页表里面存放的是一级页表的信息，一级页表里面存放的是二级页表的信息，以此类推，到最后一级页表存放的才是虚拟页面到物理页面的映射。一个程序在运行时其顶级页表常驻内存，而次级页表则按需要决定是否存放在物理内存。

例如，如果使用两层页表的话，虚拟地址的前10位可作为顶级页表的索引，中间10位可作为次级页表的索引，最后12位可作为页内偏移值如图12-9所示。

图　12-9　双级页表下页表号和页内偏移值的字位分配举例

这样，当CPU发出一个虚拟地址时，我们将虚拟地址一分为三，用最前面10位的值找到顶级页表的对应记录，得到所需要的次级页表。用中间10位的值作为索引在刚才获得次级页表里找到对应的记录，得到对应的物理页面号。然后将物理页面号与页内偏移值链接起来获得最后的物理地址，如图12-10所示。如果我们需要的次级页表不在物理内存，那么系统将产生缺页中断，而缺页中断即可将所需次级页表带入内存。

多级页表为什么占用的内存空间少呢？因为大部分次级页表会放到磁盘上，而放在内存里面的页表较少。因此，内存占用少。

多级页表有什么缺点呢？它降低了系统的速度。因此每次内存访问都变成多次内存访问。对于二级页表，一次内存访问变成了三次内存访问。如果次级页表不在内存，还需要加上一次磁盘访问。这样，系统的速度将大为下降。对于级数更多的页表来说，内存访问速度额下降将更加明显。

12.3.2　反转页表

图　12-10　顶级页表里面存放的是次级页表的信息

那有没有可能在不增加页表级数的情况下降低页表所占的空间呢？有，使用反转页表。正常的页表存放的是从虚拟页面到物理页面的映射；而反转页表存放的是物理页面到虚拟页面的映射。这样，由于物理内存比虚拟内存小很多，页表的尺寸将大为减少。例如，假如虚拟地址为64位，页面尺寸为4KB，物理内存为256MB，则虚拟页面数为252，而物理页面数却只有216，那么使用反转页表后，页表记录项从252减少到216。节省的空间是非常巨大的。

由于反转页表存放的是物理地址到虚拟地址的映射，而CPU发出的地址却是虚拟地址，这就造成页表查找困难。不过，这个问题可以通过散列来解决，即将虚拟页号散列到物理页号，然后以这个散列出来的物理页号作为索引在反转页表里面查找。但由于虚拟页面远多于物理页面的缘故，多个虚拟页号将很可能散列到同一个物理页号对应的记录里。这样，在散列后，仍然需要检查该虚拟页面是否在物理内存内，而这种检查需要进行多次内存访问。如果使用开放式散列，则散列表的尺寸将随着程序使用的虚拟页面数的增加而增加。

12.4　翻译速度

地址翻译因增加了内存访问次数而降低了系统效率。如果只使用单级页表，则每次内存访问变为两次内存访问，速度的下降还尚可以忍受。但如果使用多级页表或反转页表，则每次内存访问将变为多于两次的内存访问，这样效率的下降将非常明显。由于内存访问是每条指令都需要执行的操作，这样将造成整个系统效率的下降。那有没有什么办法改善翻译的速度呢？有。因为程序的运行呈现所谓的时空局域性，即在一段时间内，程序所要访问的地址空间有一定的空间局域性。如果一个页面被访问，则该页面的其他地址可能也将被随后访问。这样，我们可以将该页面的翻译结果存放在缓存里，而无须在访问该页面的每个地址时再翻译一次。这样就可以大大提高系统的执行效率。

这种存放翻译结果的缓存称为翻译快表（Translation Look-Aside Buffer, TLB）。TLB里面存放的是从虚拟页面到物理页面的映射，其记录的格式与内容和正常页表的记录格式与内容一样如图12-11所示。这样，在进行地址翻译时，如果TLB里有该虚拟地址记录，即“命中”，就从TLB获得其对应的物理页面号，而无须经过多级页表或反转页表查找，从而大大提高翻译速度。如果TLB里面没有该虚拟页面号，即“未命中”，则需要按正常方式读取页表内容。

图　12-11　TLB示意图

如果CPU发出的虚拟地址属于虚拟页面800，则我们从TLB里面直接获得对应的物理页面号13，而无须经过多级页表或反转页表。但我们怎么知道800在TLB里呢？由于TLB里面不能按虚拟页面号进行索引，唯一的办法是一个记录一个记录地按顺序查找，以确认我们所需的虚拟页面是否在TLB里面。但这样的话，我们使用TLB的意义就不复存在了。因为搜索TLB所花费的时间可能已经远远超过查找多级页表所花的时间了。

那上述问题的出路在哪里呢？本书在论述锁的时候，讲过一个哲学原理：软件没有办法构建原子操作，只能把硬件请出来。在这里，我们看到，软件没有办法在以一次内存访问完成TLB查找，到这里卡住了。解决方案就是使用硬件。我们在TLB里面进行的比较不是一个个地顺序比较，而是同时比较，即将所有的TLB记录与目的虚拟地址同时比较，因此只需要一次查找就能确定一个虚拟页面号是否在TLB里。这种设计需要同时配备多套比较电路，比较电路的套数需与TLB的大小一样。这也就是为什么TLB非常昂贵。

当然，在TLB未命中的情况下，我们既可以将页表相应记录存入TLB，然后再由TLB来满足地址翻译需求（即重新启动指令），也可以直接由页表来满足翻译请求，同时将翻译结果存入TLB。这两种方式提供的抽象是不一样的。前者提供的抽象是所有翻译皆由TLB完成，而后者提供的抽象则是翻译过程既看到TLB，又看到正常页表。方式的差异将影响模块化的设计思路。

显然，采用多级页表的分页系统的效率将取决于TLB的命中率。如果命中率很高，则系统效率高；如果命中率低，则系统效率低。例如，Linux使用的是三级页表（见图12-12）。按照常理来说，这将使得系统的执行效率大大降低，但许多人并没有感觉到Linux特别慢，这就是因为Linux的TLB命中率高。据某些资料宣称其命中率达98%。不过，Linux在运行图形界面时却相当慢。（这是为什么呢？）

图　12-12　Linux使用三级页表

TLB通常由CPU制造商提供，但TLB的更换算法则有可能由操作系统提供。

12.5　缺页中断处理

在分页系统里，一个虚拟页面既有可能在物理内存，也有可能保存在磁盘上。如果CPU发出的虚拟地址对应的页面不在物理内存，就将产生一个缺页中断。而缺页中断服务程序负责将需要的虚拟页面找到并加载到内存。那么缺页中断程序是如何知道虚拟页面在磁盘的什么地方呢？它当然不知道。但它知道产生缺页中断进程所对应的源程序文件名和产生缺页中断的虚拟地址。这样，缺页中断服务程序首先根据虚拟地址计算出该地址在相应程序文件里面的位移量或偏移量（off-set），然后要求文件系统在这个偏移量的地方进行文件读操作。那么知道读多少内容吗？当然知道了，读一个页面的数据！而文件系统当然知道如何根据程序的文件名和偏移值来读取数据（这部分内容将在第16章介绍）。

下面是缺页中断的处理步骤：

1）硬件陷入内核。

2）保护通用寄存器。

3）操作系统判断所需的虚拟页面号。

4）操作系统检查地址的合法性。

5）操作系统选择一个物理页面来存放将要调入的页面。

6）如果选择的物理页面包含有未写磁盘的内容，则首先进行写盘操作。

7）操作系统将新的虚拟页面调入内存。

8）更新页表。

9）发生缺页中断的程序进入就绪状态。

10）恢复寄存器。

11）程序继续。

12.6　锁住页面

如果发生缺页中断，就需要从磁盘上将需要的页面调入内存。如果内存没有多余的空间，就需要在现有的页面里选择一个页面进行替换。使用不同的更换算法，页面更换的顺序将各不相同。但不管使用何种算法（将在第13章介绍），每个页面都存在被替换的可能。

这听上去像是一个很公平的事情。问题是有时我们需要把页面锁在内存，不想被交换出去。比如包含用于存放输入数据缓冲区的页面。如果这个页面被替换了，输入数据来的时候就没有地方写，将造成系统效率下降。此外，如果一个页面非常重要，我们知道它将被经常访问，也可以把它锁住，从而防止不必要的页面替换。

如何把页面锁在内存里呢？很简单，只需要对该页面做出特殊标记即可。即我们在页表的相应记录项里增加一项标志。如果该标志被设置，缺页中断服务程序在选择被替换的页面时将跳过该页面。

12.7　页面尺寸

分页系统的一个考虑因素就是页面应该设计为多大？如果太大，可能造成浪费。因为一个程序的最后一个页面很有可能是不满的。最好的情况是一个程序的大小正好是页面大小的整数倍；最坏的情况则是页面的整数倍多1条指令，多出来的这条指令就要占用一个页面，造成一个页面的绝大部分空间浪费。在平均情况下，最后一个页面有半个页面被浪费。这种浪费称为内部碎片，即一个进程内部的碎片空间。（还记得外部碎片吗？）这样，页面越大，内部碎片就越大，浪费就越多。

例如，如果虚拟地址空间比较稀疏，大多数地址都是非法的。这样，用大页面造成的浪费将更为明显。当然，对一般的用户程序来讲，其地址空间一般是连贯的，不大可能出现断开的空间。而中间出现断开的程序，最典型的例子是编译器。编译器程序的虚拟地址空间里面有许多不连续的空间。如果被编译的程序尺寸小，则中间的很多空间都将是非法空间。如果页面尺寸很小，则浪费减少了，可以较好地容纳各种数据结构。但页表尺寸将增大，或者多级页表的层次增多。由此可见，页面大小并不能随意确定，必须考虑各种参数的折中。如何找到一个合适的尺寸呢？具体来说，就是要在页表大小和内部碎片之间进行平衡。

我们既不希望太大的内部碎片，又不想页表太大。那就先算一下因页表和内部碎片造成的系统消耗。假定p表示页面大小，e表示页表一个记录的大小，s表示程序的平均尺寸，则整个系统浪费的空间可由下面的表达式计算：

其中s·e/p是一个程序页表所占的内存空间，而p/2则是一个程序平均浪费的页面空间（半个页面）。对上述表达式求极小值就可以得出页面尺寸p的最优大小为：

可变页面尺寸

当然，如果需要，我们也可以使用可变尺寸的页面。例如，如果我们服务的环境既有许多很大的程序，也有许多很小的程序，则使用一种固定尺寸的页面不一定是最有效率的。这时，我们可以考虑使用可变尺寸，即不同的程序可以使用不同的页面大小，尽可能地降低系统的空间浪费。

但是可变页面页面策略的缺点也十分明显，首先是操作系统对内存的管理将更为复杂。其次，我们也很难正确地判断每个程序使用何种页面尺寸最为合适。最后，可变页面尺寸也不是一定就能消除内部碎片的。因此，可变页面尺寸策略听上去动听，实际上并不中用。历史上的Multic操作系统就因为支持可变页面尺寸而受人诟病。

12.8　内存抖动

在更换页面时，如果更换的页面是一个很快就会被再次访问的页面，则在此次缺页中断后很快又会发生新的缺页中断。在最坏情况下，每次新的访问都是对一个不在内存的页面进行访问，即每次内存访问都产生一次缺页中断，这样每次内存访问皆变成一次磁盘访问，而由于磁盘访问速度比内存可以慢一百万倍，因此整个系统的效率急剧下降。这种现象就称为内存抖动，或者抽打、抽筋（tras-hing）。

当然，我们可以通过仔细设计页面更换算法来降低内存抖动的概率，但却不能完全避免。例如，如果一个系统运行着很多进程，而且这些进程都比较大，即每个程序所占的虚拟空间都较大。这样就有可能造成某个程序频繁需要的页面不能完全放在内存里，造成该程序运行时的内存抖动。又例如，虽然系统只有一个程序，但是该程序频繁访问的页面数超过物理内存的页面数，这样也将造成内存抖动。

发生内存抖动时，系统的效率将与停滞差不多，几乎看不到任何进展的迹象。为什么呢？这就好像人在抽筋的时候，不大可能有心思去做成什么事情一样。如果页面不断地换出去调进来，CPU的资源将完全耗费在缺页中断上，无法进行任何有效工作。用户体验到的就是计算机停止了工作，而硬盘灯一直亮着。

那么我们有什么办法解决内存抖动呢？这要看是什么原因造成的抖动了。

如果是因为页面替换策略失误，当然可以修改替换算法来解决这个问题。如果不是页面替换策略的问题，而是因为运行的程序太多，造成至少一个程序无法同时将所有频繁访问的页面调入内存，则需要降低多道编程的度数。通过减少同时运行的程序个数而使得每个程序都有足够的资源来运行而不产生抖动。例如，我们可以把其中一些进程全部交换到磁盘上，把空出来的空间给其他尚在内存的进程使用。这种降低多道编程度数的做法也称为负载平衡。

负载平衡在生活中应用的例子很多。你到一家很繁忙的餐馆吃饭，如果里面没有空位，你就不能进去，而是等在外面。

当然，有时候即使进行负载平衡也不一定能够消除内存抖动。例如，如果是一个进程的频繁访问页面就超过物理内存的页面数，即使降低多道编程度数，系统仍将抖动。这个时候解决的办法只有两个：一是终止该进程，永远不许其运行；二是增加物理内存的容量，就是开疆拓土。只要有钱，总可以买更多的内存。不过，即使这个办法也是有限度的。因为，一个体系结构能够支持的内存容量总是有限度的。

比莱迪异常

前面说过，如果出现内存抖动，我们可以通过降低多道编程的度数来解决。减少同时运行的程序个数，使得每个程序所占有的物理页面数增加。我们正常的思维是，给一个进程分配的物理页面数增加了，它的缺页中断数就应该减少。但真的是这样吗？答案可不一定。

我们来看一个例子。假定我们使用请求分页（demanding page），即页面只在需要的时候才调入内存，而不是在一开始就调入若干个页面。某个程序共有5个虚拟页面：0、1、2、3、4。对这5个页面的访问顺序为：0、1、2、3、0、1、4、0、1、2、3、4。假定我们分配给该进程的物理页面数为3，页面更换策略为先来先倒，即在没有多余空闲的物理页面供使用时，最先进入内存的页面被替换掉。

由于一开始内存中没有页面，因此访问第0个页面将产生缺页中断。中断服务程序将该页面调入内存。访问第1、第2页面时情况一样。各发生一次缺页中断，但无需更换页面。访问页面3时就有问题了，必须更换页面，那么把最老的第0页面替换。这样一直访问下去，我们看到整个访问序列将产生9次缺页中断。如图12-13所示。

如果我们分配给该进程的物理页面数增加1页到4页，按理该进程产生的缺页中断次数应该减少，但事实是，其缺页中断次数非但没有减少，反而增加了一次，到10次，如图12-14所示。这种增加物理页面数反而导致缺页次数增加的现象称为比莱迪异常（Beladys anomaly）。

图　12-14　比莱迪异常：分配4个物理页面时缺页10次

这里需要注意的是，比莱迪异常并不能说明我们就不能给进程增加物理页面数，或者增加物理页面数就一定会导致缺页中断次数增加。事实上，比莱迪现象不是一种常见现象，而是一种异常现象。因此，只要需要，我们仍然给进程增加物理页面数，而且也期望着其缺页次数随着物理页面数的增加而降低。只不过是在我们这样做的时候要注意比莱迪异常。如果我们发现在增加物理页面数后程序的效率不升反降，则有可能发生了比莱迪异常。这个时候的应对策略是继续增加物理页面数，直到该现象消失为止。

当然，我们也可以改变页面替换算法来避免比莱迪异常。例如，改先进先出算法为LRU或工作集算法即可避免比莱迪异常。这些页面替换算法是第13章要讨论的议题。

思考题

1.与基本内存管理模式比较，分页管理模式的最大优点是什么？

2.在一个页式内存系统下，一个程序能达到的最大尺寸是多大？

3.如果给一个进程增加分配的物理页面数，该进程发生的缺页中断将会减少吗？

4.什么是驱动分页系统出现的关键动机？

5.设有一个32位寻址的分页系统，页面大小为16KB，假定页面号处于最左面，页内偏移值处于最右面，请问该系统需要多少位来表示页面号和页内偏移值？该系统能访问的最大虚拟页面号是多少？

6.在分页系统中进行上下文切换时，哪些数据需要切换？请列出3种。

7.页表是存放在内存里还是磁盘上，为什么？

8.有人建议使用一个代数公式来进行虚拟页面到物理页面的映射，从而无须使用页表而节省页表所占的内存空间。请问你对这种建议做何评价？

9.谁判断一个虚拟页面是否在内存？谁又判断虚拟页面是否合法？

10.为什么一个进程会让自己的虚拟页面非法？

11.比莱迪异常出现的原因是什么？它是一个悖论吗？

12.操作系统在缺页中断处理的角色是什么？

13.多级页表策略是否会造成内存访问效率下降？解决办法是什么？

14.设计一个实验来比较Windows和Linux的用户响应时间体验，详细说明实验的方法和观察步骤。你从实验中得出的结论是什么？

第13章　页面更换算法

引子：黑洞理论的替换

2004年7月21日，爱尔兰都柏林，第17届广义相对论和引力国际会议上，当主持人宣布下面将由史蒂文·霍金（Stephen Hawking）作大会报告时，会场上响起了雷鸣般的掌声。

在众人的注目下，史蒂文·霍金坐着轮椅慢慢地上了演讲台。他面无表情，身体一动不动。在没有张开嘴的情况下，发出了掷地有声的惊天话语。史蒂文·霍金提出了一种新的“黑洞”理论。而该理论与霍金及世界上的理论物理学家们长久以来所持有的黑洞理论相悖。

长久以来，物理学家们，包括霍金自己都认为，任何跨越黑洞（一种质量无限大，体积无限小的天体）事件地平线（指黑洞与其周围空间的边界）的信号都会凝固，而从我们的宇宙中永久消失。因此，从可观察的角度看，所有的黑洞都是一样的（当然，其物质含量、电荷负载和角速度可能不同）。但该理论有个很大的问题：不管进入黑洞的是什么或多少，黑洞释放出来的辐射都是一样的。作为推论，如果一个纯量子状态被送入黑洞，黑洞返回的将是一个普通的混合状态（非纯量子状态），而这与当前的量子力学理论相违背。

霍金的新理论很简单：黑洞并不会吞噬进入它的信号，而是完全将这些信息发射出来，见图13-1。

图　13-1　黑洞不黑：霍金的新理论替换了为物理学家所熟知的黑洞理论

当一个理论过时或者出现悖论时，我们就用一个新的理论来替换它。

当一个页面不再有用时，我们也用一个新的页面进行替换。准确地说，我们不是因为一个页面过时而用一个新的页面来替换，而是因为需要使用新的页面而用一个过时的页面作为替换牺牲品。

13.1　页面需要更换

第12章介绍了分页系统。分页系统克服了交换系统的各种缺点：外部碎片、难以增长、程序不能大于物理内存等。但是天下没有免费的午餐，虽然优点很多，但那也是需要付出代价的。这个代价就是页面的更换。在交换系统下，一个程序作为一个整体加载到内存。因此，在运行时，无须再从磁盘上加载任何东西。而在分页系统下，一个程序的所有页面并不一定都在内存中，因此，在执行的过程中就有可能发生页面不在内存的情况。

如果访问的页面不在内存中，则系统将产生缺页中断。缺页中断服务程序将负责把位于磁盘上的数据加载到物理内存来。如果物理内存还有空闲页面，那就直接使用空闲的页面。但如果物理内存已满，则需要挑选某个已经使用过的页面进行替换。

那么挑选哪个页面合适呢？

13.2　页面更换的目标

显然，如果挑选的页面是之后很快又要被访问的页面，那么系统将很快再次产生缺页中断。因为磁盘访问速度远远慢于内存访问速度，缺页中断的代价是非常大的。因此，挑选哪个页面进行更换不是随随便便的事情，而是有要求的。也就是说，我们需要精心设计一种算法来进行页面的更换。那么要设计这种算法，首先得知道我们的目的是什么。

页面更换时挑选替换页面的目标是什么呢？当然是降低随后发生缺页中断的次数或者概率。因此，我们选择的页面应当是在随后相当长时间内不会被访问的页面。最好是再也不会被访问的页面。同时，如果可能，我们应该选择一个没有修改过的页面。这样，替换时就无须将被替换页面的内容写回磁盘，从而进一步加快缺页中断的响应速度。

为了达到这个目的，人们设计出了各种各样的页面替换算法。

从哲学的层次上来看，所有的算法都归结为两个大类的一类：

●公平算法

●非公平算法

所谓的公平算法就是在某种程度上对所有的页面一视同仁，并不考虑每个页面在一个程序中的不同作用。与之对应的则是非公平算法。在这类算法里面，每个页面的独特性被当做否替换的考虑因素。因此，需要保存每个页面的一些个体信息，以便算法决策。

至于这两种思想孰好孰坏，则仁者见仁，智者见智。公平是人类的普遍追求，而有区别对待在某种程度上也是人类的内心期望。因此，一方面我们追求公平；另一方面又希望自己获得特殊待遇。当我们认为自己做出了比别人更多的贡献时，自然期望得到优待。对内存页面而言，也是一样的道理。

公平算法主要包括下面4种：

●随机算法

●先来先出（FIFO）算法

●第二次机会算法

●时钟算法

非公平算法则包括如下5种：

●最优算法

●NRU算法

●LRU算法

●工作集算法

还有一种混合算法，它既想保持公平，又含有区别对待的考虑：工作集时钟算法。

下面我们就来一一介绍上述算法。

13.3　随机更换算法

最简单的算法当然就是随机算法，或者说没有算法。在需要替换页面的时候，产生一个随机页面号，而替换与该页面号对应的物理页面。

显然，这种算法计算需替换页面号时速度很快。但是效果如何呢？

1998年，美国某机构做了一项研究。他们请一批完全不懂股票的中学生来选股，并和专业的股评家进行对比。中学生选股的方法很简单，就是在墙上写上一大堆股票的名字，然后站在很远的地方掷飞镖，掷到哪只股票就买哪只股票。股评家的选股方法当然是各种基本面分析、技术面分析、内幕消息等。但是对比的结果则令人大跌眼镜，中学生选的股票和股评家选的股票的表现差不多。由此推断，随机选股是一种不错的选择。

那么我们的随机选页算法是不是也不错呢？遗憾的是，结果不太令人满意。事实上，随机选页所选出的被替换的页面不太可能是随后相当长时间内不会被访问的页面。也就是说，这种算法难以保证最小化随后的缺页中断次数。事实上，这种算法的效果相当差。

那么为什么随机选股能获得与股评家相当的表现，而随机选页却性能很差呢？这似乎存在一个矛盾。但实际上是没有矛盾的。我们说随机选股和股评家选股的结果差不多，并不是说随机选股的结果好，而是说随机选股的结果很差，和股评家选股的结果一样差！

13.4　先进先出算法

既然随机算法效果不怎么样，那我们就需要进行改进。先进先出就是我们的第一个改进算法。先进先出的英文缩写是FIFO（First In First Out）。顾名思义，该算法的核心是更换最早进入内存的页面。先进先出是任何人都能想到的办法，因为它是人类的天性。

FIFO的实现机制是使用链表将所有在内存的页面按照进入时间的早晚链接起来，然后每次置换链表头上的页面就行了。新加进来的页面则挂在链表的末端。如图13-2所示。

图　13-2　按照进入内存早晚构建的页面链表

在图13-2里，如果下次需要寻找页面来替换，A将成为替换页面。

FIFO的优点是简单且容易实现。但这个绝对的公平方式容易降低效率。例如，如果最先加载进来的页面是经常被访问的页面，那么这样做很可能造成常被访问的页面替换到磁盘上，导致很快就需要再次发生缺页中断，从而降低效率。

13.5　第二次机会算法

由于FIFO只考虑进入内存的时间，不关心一个页面被访问的频率，从而有可能造成替换掉一个被经常访问的页面而造成效率低下。那么我们对FIFO改进的方向就是考虑一个页面是否是经常被访问的因素。改进的手段就是在使用FIFO更换一个页面时，需要看一下该页面是否在最近被访问过。如果没有被访问过，则替换该页面。如果该页面在最近被访问过（通过检查其访问位的取值），则不替换该页面，而是将该页面挂到链表末端，并将该页面进入内存的时间设置为当前时间，并将其访问位清零。

这样，对于最近被访问过的页面来说，相当于给了它第二次机会。就相当于人犯了错误我们并不一棍子打死，而是给其改过自新的机会。如果图13-2中的A页面最近被访问过，即其访问位R的值为1，则使用第二次机会算法后，链表的格局如图13-3所示。

图　13-3　第2次机会算法下，链表格局的变化

这样，我们考虑替换的页面将变为B。如果B页面的访问位为0，则替换B页面。否则，将B页面移到末端，再考虑C页面。这样，依此类推，直到找到一个其访问位为0的页面为止。

读者也许会问，如果每个页面都是最近被访问过，那怎么办呢？答案很简单。因为我们给一个页面第二次机会时已经将其访问位清零。因此，最多轮转一圈后，碰到最先给予机会的页面，此时其访问位已经是0，因此可将其替换。例如，在图13-2中，如果页面B、C、D、E、F、G的访问位均为1，则被替换的页面仍将是A。

也许还有人会问，页面A放到后面的时候，其访问位清零了。但有没有可能A的访问位在你第二次访问的时候又变为1了呢？如果可能，则该算法有可能不能终结。这个问题留给读者思考。

13.6　时钟算法

第二次机会算法既简单、公平，又容易实现。只不过，每次给予一个页面第二次机会时，将其移到链表末端需要耗费时间。另外，页面的访问位只在页面替换进行扫描时才可能清零，所以其时间局域性体现得不好，访问位为1的页面可能是很久以前访问的，时间上的分辨粒度太粗，从而影响页面替换的效果。为了改善这些缺点，人们想出了时钟算法。

在时钟算法里，我们把页面排成一个时钟的形状。该时钟有一个针臂。每次需要更换页面时，我们从针臂所指的页面开始检查。如果当前页面的访问位为0，即从上次检查到这次，该页面没有被访问过，将该页面替换。如果当前页面被访问过，那就将其访问位清零，并顺时针移动指针到下一个页面。我们重复这些步骤直到找到一个访问位为0的页面。图13-4描述的就是时钟算法。

在图13-4里，指针指向的是页面F。这样第一个被考虑替换的是页面F。如果页面F的访问位为0，F将被替换。如果页面F的访问位为1，则F的访问位清零，指针移到页面G。

可能有读者会问，这个算法和第二次机会算法不是一样的吗？都是访问位为0就更换，访问位为1就再给一次机会。但二者真的一样吗？当然不是。

图　13-4　时钟算法举例

首先，它们的数据结构不一样。第二次机会使用的是链表，而这里使用的是索引（整数指针）。这样其使用的内存空间不一样：第二次机会需要使用额外的内存，而时钟算法可以直接使用页表。使用页表的好处是无需额外的空间。更大的好处是页面的访问位会定期自动清零。这样将使得时钟算法的时间分辨粒度较第二次机会算法高，从而取得更好的页面替换效果。

当然，第二次机会算法也可以使用页表，即在页表的记录里面增加一个域存放指针。这样，第二次机会算法和时钟算法的空间效率将相同。不过，如果真这样的话，我们完全可以说，时钟算法的精髓就是第二次机会算法，只不过使用的是不同的数据结构，从而造成效率不同。

既然时钟算法的精髓就是第二次机会算法，其缺点就和第二次机会算法一样：过于公平，没有考虑到不同页面调用频率的不同，有可能换出不应该或不能换出的页面，还有可能造成无限循环。

13.7　最优更换算法

前面介绍的4种算法均属于“公平算法”。即所有的页面都或多或少地给予公平待遇，没有页面获得特殊待遇。但是这种公平实现方式，会使效率受到一定影响。这是因为个体对整个系统的贡献没有被区别对待，造成贡献大的和贡献小的待遇一样，自然就会影响整个系统的效率。

毫无疑问，一个程序里面的不同页面对程序的影响是不同的。有的页面被频繁访问，而有的页面则访问次数较少。如果我们在选择替换页面时将其同等考虑，自然很难选出一个在今后最长时间都不会被访问的页面。因此，我们需要对页面的个体特性进行考虑。

对页面的个体特性进行考虑就是要对不同的页面区别对待。依据其在程序中的作用不同而在选择替换页面时给予不同的待遇。这样就形成了下面要介绍的4种非公平算法。

在非公平的算法里面，最理想的页面替换算法是选择一个再也不会被访问的页面进行替换。如果不存在这样的页面，那至少选择一个在随后最长时间内不会被访问的页面进行替换。这样，我们就可以保证在随后发生缺页中断的次数最小或者概率最低，这种替换算法就是最优的替换算法。

那我们怎么知道一个页面随后多长时间不会被访问呢？当然不知道。既然如此，最优更换算法就是一种无法实际实现的算法。那么我们为什么要介绍最优算法呢？那是为了定义一个标杆，以此来评判其他算法的优劣。例如，张三和李四各设计了一个页面替换算法，如何判断它们谁优谁劣呢？与标杆比较即可。谁的算法与最优算法更接近，谁的算法就好；反之就差。

13.8　NRU算法

前面讲过，最优更换算法无法在实际中实现。随机算法效果很差；FIFO算法和第二次机会算法从根本上是一种公平的算法，即页面都有相同的概率被替换。显然，并不是所有页面对程序的贡献都是一样的，因此，这种公平的替换算法不太可能反映出各个页面在程序中的不同地位，其效果也不可能是最佳的。

那我们就在第二次机会算法上进行改进。第二次机会算法选择的是最先进入内存且访问位为0的页面进行替换。改进的办法就是选择一个最近没有被访问的页面来替换，但在所有的最近没有使用的页面里，不是按照最先进入来划分，而是按照各个页面的修改位和访问位的组合来进行划分。这种算法称为最近未使用算法（Not Recently Used, NRU）。

顾名思义NRU就是选择一个在最近一段时间没有被访问过的页面进行替换，做出这种选择是基于程序访问的时空局域性。因为根据时空局域性原理，一个最近没有被访问的页面，在随后的时间里也不太可能被访问，而NRU的实现方式就是利用页面的访问和修改位。

我们知道每个页面都有一个访问位和一个修改位。凡是对页面进行读写操作时，访问位设置为1。当进程对页面进行写操作时，修改位设置为1。根据这两个位的状态来对页面进行分类的话，可以分成4种页面类型：1、2、3、4，如表13-1所示。

也许有读者会问，第2种状态怎么可能呢？一个页面被修改却没有被访问。难道被修改不是被访问吗？答案是第2种状态是完全可能的。当然，修改需要访问。那什么情况下会出现页面没有被访问但是又被修改的状态呢？

只有一种可能性，就是系统对访问位会定期清零，对修改位却不能清零。访问位定期清零是保证这个访问位不失去意义的手段。如果不定期清零，则一段时间后所有的页面都是被访问的。这样，这个访问位就没有任何意义了。与此同时，修改位却不能清零，因为操作系统在页面换出时要用它判断页面是否需要写回磁盘，清零后就会出错。这样，一个被访问和修改的页面在访问位定期清零后就会变成没有被访问但被修改的状态。

由此可以看出，我们在考虑一个页面的状态时有一个时间概念，即我们只考虑一段时间内的访问情况。因为所谓访问和未访问都需要局限在一段时间内讨论才有意义，如果考虑无限长的时间段，页面都是被访问状态。那我们就无法判断了。这个时候就得看谁是最近被访问的，以前很久被访问的就可以忽略了。这个可以通过定期清零访问位实现。

那么有了这个分类后，NRU算法就是按照这4类页面的顺序寻找可以替换的页面。它首先看看内存页面里有没有第1种页面，有的话就将找到的第1个这种页面作为替换对象。如果没有，则在第2种页面里面寻找；若还没有，则在第3种页面里面寻找；以此类推，如果所有页面皆被访问和修改过，那也只能从中替换掉一个页面。因此，NRU算法总是会终结。

那么这种方法实现了NRU算法的目标了吗？我们来看看这4类页面的关系。在这4类页面中，第3、4类页面的访问位为1，而第1、2类页面的访问位为0。这说明第3、4类在最近一次页面清零之后被访问过，第1、2类在最近一次页面清零之后没被访问过。因此，第3、4类比第1、2类被使用过时间更近，应该优先选择第1、2类页面作为替换页面。在第3、4类之间，则无法分清楚谁被使用的时间更近，但为了避免磁盘写操作，我们优先选择替换第3类页面。在第1、2类之间也有同样的考虑，但优先选择第1类还有一个额外的理由：第2类肯定被访问过，而第1类既可能被访问过后清零了，也可能从未被访问过。由此，形成了1、2、3、4的类型选择顺序。按照这个顺序来选择替换页面，确实符合NRU算法的目标。

当然，这种分类比较笼统，在同一类页面里，我们没有办法分辨出哪一类被访问时间更最近一些。即在某些情况下，我们替换的可能并不是最近没有使用的页面。换言之，在最差情况下，时间的分辨粒度粗到一个访问位清零的周期。

13.9　LRU算法

对NRU算法的改进就是LRU算法。LRU算法是最近使用最少算法（LeastRecently Used）。我们不仅考虑最近是否用过，还要考虑最近使用的频率。此处我们使用的哲学理念仍是拿过去的数据来预测将来：如果一个页面被访问的频率低，那么以后很可能也用不到。

由LRU算法的定义可以看出，LRU算法的实现必须以某种方式记录每个页面被访问的次数，而这是个相当大的工作量。最简单的办法就是在页表的记录项里增加一个计数域，一个页面被访问一次，这个计数器的值就增加1。这样，当需要更换页面时，只需找到计数域取值最小的页面替换即可，该页面即是最近最少使用的页面。

当然，这种实现存在很多疑问。首先，它不一定保证是最近最少使用的页面被选中。最少被使用可以肯定，但“最近”则不一定。因为计数器记录的时间段不一定是最近。这要看我们的最近定义的是多长时间。另外，由于物理计数域的长度总是有限的，值到最大后将发生溢出，而回到0。这样值最小的不一定是最少使用的，反而可能是最多使用的。

不过，我们何必一定要精确地实现LRU算法呢？研究计算机的人又不像研究数学的人那么精确，而且上述的实现还真是差不多。由于计数域长度有限，我们可以采取定期清零的操作来将时间段限制在我们所喜欢的“最近”。在这个人为确定的最近里面，计数器最小值的页面当然就是最近最少使用的页面了。事实证明，这种近似方法和真实最优情形很接近。

LRU算法的另一种简单实现方式是用一个链表将所有页面链接起来，最近被使用的页面在链表头，最近未被使用的放在链表尾。在每次页面访问时对这个链表进行更新，使其保持最近被使用的页面在链表头。

这两种简单的LRU算法实现方法都存在明显的缺点。每次内存访问都对链表进行更新效率十分低下，因为这种更新需要考虑不同页面之间的关系。使用计数器虽然在每次内存访问时只需修改一个页面的数据即可，但却需要使用的计数器个数与虚拟页面数一样多，空间成本巨大。另外，在遴选页面来替换时，需要比较所有页面的计数器的取值，时间成本也很大。

那么LRU算法实现还有没有比较现实的方式呢？

13.9.1　使用矩阵实现LRU算法

比起链表或页表来实现，使用矩阵实现LRU算法就要好不少。顾名思义，矩阵法，就是使用一个矩阵来记录页面的使用频率和时间。该矩阵为n×n维，n是相关程序当前驻内存的页面数。在一开始矩阵的初值为0。每次一个页面被访问时，例如第k个虚拟页面被访问时，我们进行如下操作：

1）将第k行的值全部设置为1。

2）将第k列的值全部设置为0。

在每次需要更换一个页面时，选择矩阵里对应行值最小的页面更换即可。此处的行值是指把该行所有的0和1连起来看做一个二进制数时的取值。

例如，假定某个程序有4个虚拟页面，分别是0、1、2、3。该程序虚拟页面的访问顺序是：

0，1，2，3，2，1，0，3，2，3。那我们来看一下该实现是如何工作的。

随着页面访问的进行，矩阵的状态变化如表13-5a至图13-5k所示。

图　13-5　矩阵的状态变化

图　13-5　（续）

在上述访问序列结束后，我们看到，行值最小的是第2行，即虚拟页面1是最近最少被访问的页面。如果这个时候需要更换页面，则第一个虚拟页面将被替换。

那么第1个虚拟页面究竟是不是最近最少使用的页面呢？我们再看一下页面的访问顺序：0，1，2，3，2，1，0，3，2，3。从这个顺序可以看出，0、2、3这3个页面都在第一个页面后被访问过，因此，第一个页面确实是最近最少使用的页面。

那么这种实现方式为什么正确呢？或者说这样做为什么能达到LRU算法的效果呢？我们来看，在一个页面被访问时，我们将该页面所对应的行设置为1，在第1步操作后，我们已经保证该页面对应的行值为最大之一。而第2个操作将该页面对应的列置为0，则保证了该页面对应的行值为唯一的最大。由于每个页面访问都会将某一列置为0，这样，越长时间没有被访问的页面，其对应的行元素里面被置为0的列个数就越多，即它对应的行值将越小。

使用矩阵法相对于链表法和页表法的优点是我们可以使用矩阵的许多理论来迅速判断哪个矩阵行值最小。而且，使用矩阵来实现LRU算法的成本比使用计数域要低，因为这个矩阵的行列数并不需要是虚拟页面数，而是在内存中的实际页面数，因此要小多了。

但该矩阵仍然太大，因为总的存储位是页面数的平方。另外，我们将最近的时间限定在最近的9次访问的话，那么应该被更换的是页面0。因为在最近9次访问里，页面0只被访问过1次，而其他页面均被访问过2次或以上。但按照矩阵法的结果是页面1被更换。由于每次页面访问时，矩阵的置1操作都覆盖了之前的信息，所以矩阵法不能体现长时间访问次数的累积。

由此可以看出，一个实现是真理还是谬误，需要根据相对时间来判断。

13.9.2　使用移位寄存器实现LRU算法

虽然使用矩阵法实现LRU算法已经较链表法和页表法有了很大的改善，但成本仍然较高。虽然实际驻内存的页面数比虚拟页面数要少，但毕竟一个n×n维的矩阵还是相当地占空间。有什么办法可以进一步降低空间需求呢？我们想到了使用移位寄存器。

我们给每个存放在内存的页面配备一个移位寄存器。该寄存器用来记录该页面被访问频率和最近的属性。所有移位寄存器的初值皆为0。然后在每个规定长度的周期内，将移位寄存器的值往右移动一位，并将对应页面的访问位的值加到该移位寄存器的最左位。

这样，当需要寻找一个页面进行更换时，只需要找到对应移位寄存器值最小的页面即可。该页面即是最近最少使用的页面。例如，假定某程序有6个页面在内存中，页号分别为0、1、2、3、4、5。其对应的6个移位寄存器初值皆设置为0，如图13-6a所示。

在第一个规定周期内，页面0、2、4、5的访问位为1，页面1和3的访问位为0。我们将移位寄存器往右移动一位，并将各个页面的访问位加到相应移位寄存器的左面，就得到图13-6b的状态。在第2个规定周期内，页面0、1、4的访问位为1，而2、3、5的访问位为0，我们重复同样的操作获得图13-6c的状态。假如第3个规定周期内页面0、1、3、5的访问位为1，2、4的访问位为0，则获得图13-6d的状态。第4个周期内页面0、4被访问过，其他页面的访问位为0，得到图13-6e的状态，第5个周期内页面1、2被访问过，其他页面的访问位为0，得到图13-6f的状态。

图　13-6　使用移位寄存器实现LRU

如果这个时候需要更换页面，我们更换哪个？第3个页面所对应的移位寄存器里面的值最小，因此页面3将被替换。那么第3个页面究竟是不是最近最少使用的页面呢？

那我们来数一数，在这5个周期里，页面0被访问过4次，页面1被访问过3次，页面2被访问过2次，页面3被访问过1次，页面4被访问过3次，页面5被访问过2次。因此，页面3被访问的次数最少。因此，从这5个周期的时间段来看，页面3确实是最近最少被访问的页面。因此，我们确实实现的是LRU算法。

那么这个实现和矩阵法相比有什么优势？

首先，在硬件上节省了空间。因为移位寄存器个数虽然与内存页面数一样，但每个寄存器的长度却没有页面数多。矩阵法需要n×n维的空间，而这里是n×L维。这里的L是移位寄存器的长度。因此，只要移位寄存器的长度少于进程在内存的页面数，这种实现方式就有空间上的优势。而通常情况下，移位寄存器的长度确实远远少于内存里面的页面数。不难理解，移位寄存器的长度的意义就是决定“最近”这个时间段的长度。至于溢出的位，都属于超时的信息，不再纳入考虑。

其次，这种实现也节省时间。在矩阵法里，每次页面访问均需要更新矩阵，而移位寄存器法却不需要每次访问都进行更新。我们只在规定长度的时间内统计更新一次。例如，如果每1秒钟发生100次内存访问，则矩阵法在一秒钟需要更新100次，但在移位寄存器法里，我们完全可以每1秒钟更新一次，或者每5秒钟更新一次。因此更新的粒度（周期）是非常灵活的。而正因为这个灵活性，使得我们的时间成本大大降低。

当然，这种实现方法也有缺点。俗话说：成也萧何，败也萧何。更新周期灵活所付出的代价就是记录的精确性不如矩阵法。在某些特定情况下，这样选出的页面有可能不是最近最少使用的页面。

细心的读者也许发现，在图13-6f中，0号页面对应的移位寄存器的值比2号页面对应的寄存器值小，但0号页面在5个周期内被访问过4次，而2号页面却只访问过2次。即访问次数少的页面的值比访问次数多的页面的值要大。这似乎有问题。

但仔细一想，也完全合理。虽然0号页面访问的次数多于2号页面，但2号页面访问的时间却更接近于当前。因此，如果把这个最近的范围缩小，则0号页面相对于2号页面就是最近最少使用的页面。即本实现将最近放在比次数更优先考虑的地位。这是一个常见的“衰老算法”的思想，越是发生时间久远的事件，它对决策的影响越小。

大家知道，这个是用移位寄存器实现的，造价高。即使是单价降低了，成本仍然很大，因为需要的寄存器数量较大。

13.10　工作集算法

LRU算法虽然很好，但实现成本高，并且时间代价大。因此，一般的商用操作系统并没有采纳LRU页面更新算法。

那么LRU算法为什么会成本高呢？仔细分析可以发现，其成本之所以高，是因为我们想要分辨出不同页面中哪个页面是最近最少使用的。即需要对每个页面保持某种记录，并在每次页面访问发生时或者周期性地对这些记录进行更新，从而造成空间和时间成本居高不下。

在面对这种时间和空间上的双重困难时，也许我们应该反思一下：我们真的需要为每个页面保留每次访问的记录才能找到一个合适的替换页面吗？

答案是否定的。由于不可能精确地确定哪个页面是最近最少使用的，那就干脆不用花费这个力气，只维持少量的信息使得我们选出的替换页面不太可能是马上又会使用的页面即可。这种少量的信息就是工作集信息。

工作集概念来源于程序访问的时空局域性。即在一段时间内，程序访问的页面将局限在一组页面集合上。例如，最近k次访问均发生在某m个页面上，那么m就是参数为k时的工作集。我们用w（k, t）来表示在时间t时k次访问所涉及的页面数量。

显然，随着k的增长，w（k, t）的值也随着增长；但在k增长到某个数值后，w（k, t）的值将增长极其缓慢甚至接近停滞，并维持一段时间的稳定，如图13-7所示。

图　13-7　工作集与最近访问次数的关系（来源：参考文献[3]）

由此可以看出，如果一个程序在内存里面的页面数与其工作集大小相等或超过工作集，则该程序可在一段时间内不会发生缺页中断。如果其在内存的页面数小于工作集，则发生缺页中断的频率将增加，甚至发生内存抖动。

因此，我们的目标就是维持当前的工作集的页面在物理内存里面。每次页面更换时，寻找一个不属于当前工作集的页面替换即可。而在这些不属于工作集的页面中间，无须顾虑哪个页面访问次数多或少，或者最后一次访问离当前时间谁近谁远，因为它们被替换出去都不会造成什么不良后果，从而大大降低页面替换算法的实现复杂性。这样，我们在寻找页面时只需将页面分离为两大类即可：当前工作集内页面和当前工作集外页面。这样，只要找到一个非当前工作集的页面，即可将其替换。

工作集算法的实现如下：

为页表的每个记录增加一项信息用来记录该页面最后一次被访问的时间。这个时间不必是真实时间，只要是按规律递增的一个虚拟时间即可。同时我们设置一个时间值T，如果一个页面的最后一次访问在当前时间减去T之前，则视为在工作集外，否则视为在工作集内。

这样，在每次需要替换页面时，扫描所有的页面记录，并进行如下操作：

1）如果一个页面的访问位是1，则将该页面的最后一次访问时间设为当前时间，并将访问位清零。

2）如果页面的访问位为0，则查看其访问时间是否在当前时间减去T之前。a）如果在，则该页面将是被替换页面，算法结束。

b）如果不在，记录当前所有被扫描过页面的最后访问时间里面的最小值。

扫描下一个页面并重复1、2两个步骤。

该算法如图13-8所示。

图　13-8　工作集算法

如果在所有页面扫描后没有找到一个被替换的页面，则所有页面中最后一次访问时间最早的页面将被替换。这也是第b步记录当前最小值的原因。

该算法选择的是否是一个工作集外的页面呢？如果一个页面的访问位为0，表示自从上次扫描以来，该页面没有被访问过。如果该页面最后一次访问的时间在T时间以前，那么就说明该页面不在工作集内。这里需要注意的是，工作集既可以用访问次数表示，也可以用时间来表示，它们之间可以相互转换。因此，该算法选择的的确是一个工作集外的页面。当然，如果不存在一个最后一次访问时间在T之前的页面，则该算法选择的就有可能不是工作集外的页面，但选择的也是工作集里的最早页面。

这里需要注意的是，该算法只扫描页表一次。如果所有页面的访问位都为1的话，则表示分配给该程序的内存不够。最好的解决办法不是替换页面，而是给该程序增加一个物理页面。

那么该算法的T设置为多少呢？或者说我们怎么知道每个进程的工作集有多大呢？工作集有时空局域性，如果时刻都发生变化，那么就没有工作集了。所以它必须在某段时间是比较固定的。即随着访问次数的增多，访问的页面数也在增多，但是到了后面，你会发现，访问了100次的页面，你的页面始终集合在某个集合里。当然，如果你的程序是顺序的，也可能打破这个规则。但在正常情况下，你有50次访问，都是这10个页面，100次也是这10个页面。通过测试，我们可以求出这个临界的次数。在得到这个次数后，就可以将时间T算出来。只有算一下访问这么多次，需要多长时间即可。

工作集算法的优点是实现简单，只需在页表的每个记录增加一个虚拟时间域即可。因此，其空间成本不大。而且，这个时间域不是每次发生访问时都需要修改，而是在需要更换页面时，页面更换算法对其进行修改，因此其时间成本也不大。

13.11　工作集时钟算法

前面刚刚说过，工作集算法优势明显，但还是有缺点。缺点就是每次扫描页面进行替换时，有可能需要扫描整个页表。而我们知道，并不是所有页面都在内存里，因此扫描过程中的一大部分时间将是无用功。另外，由于其数据结构是线性的，造成每次都按同样的顺序进行扫描，这样就对某些页面不太公平。就好像评选优秀学生，如果大家的表现都同样优秀，就按照学号顺序来确定人选的话，那学号靠后的同学总是吃亏。

我们可不可以在保留其优点的同时对其进行改进呢？答案是肯定的。方法就是将工作集算法与时钟算法结合起来，设计出工作集时钟算法，即使用工作集算法的原理，但是将页面的扫描顺序按照时钟的形式组织起来。这样每次需要替换页面时，从指针指向的页面开始扫描，从而达到更加公平的状态。而且，按时钟组织的页面只是在内存里的页面，在内存外的页面不放在时钟圈里，从而提高实现效率。

鉴于其时间和空间上的优势，工作集时钟算法被大多数商业操作系统所采纳。

13.12　页面替换策略

前面的内容讨论了页面替换的各种算法，但是任何算法都可以应用到不同的数据集上。在页面替换算法的考虑中，算法应用的对象可以分为全局和本局两类，从而形成两类替换策略：全局策略和局部策略。

在全局策略下，算法应用的对象是物理内存里面的所有页面，即我们是从所有的页里面按照选定的算法选出替换页面。这个被选出的页面既可以是当前发生缺页中断的进程的页面，也可以是另外一个进程的页面。这种策略可能影响其他进程的内存使用。

在局部策略下，算法应用的对象是当前进程的所有页面，即我们是从当前进程中物理页面按照我们选定的算法选出替换页面。这种策略不会影响其他进程的内存使用。

全局策略的优点是系统的总页面缺失率低，但程序运行不稳定。因为程序无法控制自己的缺页率。一个进程在内存中的页面使用不仅取决于该进程的页面走向，也取决于其他进程的页面走向。因此，相同程序由于外界环境不同会造成执行上的很大差别。

局部替换策略的优点是更加公平，程序运行更加稳定。因为一个进程的内存使用情况不会干扰其他进程的内存使用，但缺点是不能充分利用系统的整体资源，可能造成不同进程之间在页面使用上的不平衡：即有的进程有页面富余，而有的进程却频繁缺页。

13.12.1　固定与可变驻留集

使用何种页面替换策略决定了一个进程在内存所占页面的数量是否固定。局部策略由于只选择本进程的页面进行替换，所以一个进程所占的物理页面数将保持不变，即进程驻留内存的页面数是固定的。全局页面替换策略由于动态地改变一个进程所占物理页面数，进程驻留内存的页面数是可变的。从另一方面说，凡是支持固定驻留集的页面替换算法只能使用局部替换策略，凡是支持可变驻留集的页面替换算法只能使用全局页面替换策略。

13.12.2　初始页面数确定

为每个进程分配内存块的算法主要有3种：均分法、比例法、优先权法。

均分法将内存空间在多个进程间平均分配。若有m块内存页面和n个进程，则每个进程分m/n块。每次增加一个进程，就需要重新分配一次。比例法则根据程序大小按比例给每个程序分配内存空间。例如，

分给进程的页面数=（进程地址空间大小/全部进程的总地址空间）×可用页面总数

这种分配方法考虑了程序的个体特性，比均分法更恰当。但同样，每次增加一个进程时需要重新计算和分配内存页面。

优先权法则将一个进程的优先级考虑进来，从而加速高优先级进程的执行。例如，在使用比例分配法时，分给进程的页面数不仅取决于程序的相对大小，而且取决于该程序优先级的高低。在请求分页的策略下，一个进程在初始化时不分配任何页面，而是在需要使用页面的时候一个页面一个页面地增加。当进程的缺页频率降低到一定程度后，页面数将不再增加。

思考题

1.页面替换算法的根本追求是什么？

2.提出最优页面替换算法的意义是什么？

3.第二次机会算法有没有可能不能终结？

4.LRU算法的哪种实现硬件成本最低？

5.考虑一个400字的程序的下述内存访问序列：

10，11，104，170，73，309，185，245，246，434，458，364

假定采用页式虚拟内存管理，页面大小为100字，内存中有2个物理页面可供程序使用，且在开始时物理页面没有被任何进程占用。

1）若采用FIFO替换算法，那么有关该访问序列的缺页中断次数是多少？

2）若采用LRU替换算法，那么有关该访问序列的缺页中断次数是多少？

6.在工作集算法里，每个页面的最后使用时间是在每次访问时都设置呢，还是只在缺页中断被扫描到时才设置呢？为什么？

7.比较各种页面替换算法，说明为什么工作集时钟算法最受人们青睐。

8.比较初始页面数确定的4种方法的优劣，你选择哪一种？你还能想出别的方法吗？

9.分析讨论时间概念在页面替换算法中的作用。

10.工作集算法的提出是因为我们在观念上实现了突破，请问这种观念突破是什么？

11.虽然本章讨论了10种页面替换算法，但页面替换算法还有很多，例如，

有一种称为“栈算法”的页面替换算法。该算法的替换策略就是栈的操作策略：后进先出。即每次替换页面时，选择最后进入内存的页面进行替换。试分析这种策略的优劣，并重做第5题。使用栈算法的缺页中断次数是多少？

第14章　段式内存管理

引子：否定之否定

否定之否定作为辩证法的三大规律之一，时时刻刻影响着我们。体现在人生上，就是一个人总是在不断地自我否定中前进。这种推进看似在原地转圈，不停地回到同一个点，实际上是在螺旋式上升，每一次回到起点时都不是简单的重复，而是上升到了更高的层次和境界。

佛家所谓的“看山是山，看水是水”，到“看山不是山，看水不是水”，再到“看山还是山，看水还是水”的演变模式正是否定之否定的一个具体体现。入道之初，看世界的眼光原始而朴素，所以“看山是山，看水是水”；修行到一定境界时，隐隐约约发现了一些隐藏于真相背后的东西，从而体验到“看山不是山，看水不是水”；等到一切都顿彻顿悟之后，又忽然发现“山还是山，水还是水”。这种认识看似回到了原点，其实已经经过了两次否定，与先前的朴素认识“山还是山，水还是水”已经不可同日而语了。道家同样深谙否定之否定哲学，其太极图里就包含着否定之否定，如图14-1所示。

在治学上，否定之否定更是经常发生的。开始研究某个事物的时候，可能会觉得简单，以为没什么可研究的；研究一些内容后忽然发现，原来认为简单的内容其实很复杂；等到完全搞清楚以后，才发现其实真的很简单。对操作系统的研究就是如此，初学操作系统时只看到操作系统是管理计算机硬件的软件系统，十分简单；研究一段时间后，又觉得操作系统从某种程度上说是人生的缩影。有人从操作系统看到了人生，有人看到了哲学，似乎每个人发现的都是冰山一角，此时觉得操作系统博大精深，充满奥秘；而当穷尽一生之力去研究操作系统，深入理解了操作系统后，很可能发现原来操作系统就是计算机软硬件资源的管理者，只不过这种看法已经是站在更高的层次上罢了。

在生活中，人们常常会面临各种各样的选择。在做出一个自以为正确的选择后，经历了各种环境的检验，发现自己选错了，于是做出了相反的选择，去尝试另外一条路。在那条路上走了一段时间，忽然发现，原来最初的选择才是正确的，于是再次否定了自己，回到原来的路上，不过，这次的选择是经过实践检验后的理性选择，已经不是开始的简单判断了。

图　14-1　道家同样深谙否定之否定哲学，其太极图里包含着否定之否定

人们就这样一而再、再而三地否定自己。可以说没有否定，就没有真正的进步。

14.1　分页系统的缺点

第13章阐述了分页内存管理。它克服了交换系统的所有缺点，但它自己有什么缺点吗？

页表太大？这个缺点用多级页表克服了。多级页表速度慢？这个问题用TLB解决了绝大部分。页面来回更换？这个缺点用页面更换算法解决了大部分。固定页面大小呢？这不应该算是一个缺点，因为可变页面大小的操作系统不仅难以选择最优的页面大小，而且会变得很复杂。内部碎片算是一个小小的缺憾，但总比交换系统的外部碎片强，一个进程的内部碎片所浪费的空间平均起来只有半个页面，相对于分页系统的诸多优点来说，这个缺点似乎微不足道。

那么分页系统还有其他缺陷吗？有。其中的一个是共享困难。虽然在理论上可以按页进行共享，似乎粒度很细，但实际上这根本就是不现实的。原因是一个页面的内容很可能既包括代码又包括数据，即很难使一个页面只包含需要共享的内容或不需要共享的内容。只要一个页面里面有一行地址是不能共享的，这个页面就不能共享。而一个页面里面存在至少一行不能共享的地址是完全可能的。这样，想自由地共享任何内容几乎就变得不可能了。

如果说上述缺点尚可以容忍，但有一个缺点却是无法容忍的，同时也是分页系统无法解决的。这个缺点就是一个进程只能占有一个虚拟地址空间。在此种限制下，一个程序的大小至多只能和虚拟空间一样大，其所有内容都必须从这个共同的虚拟空间内分配。

那这是缺点吗？很多人不认为这是缺点，因为大部分人不认为我们有需要为任何一个程序配置多个虚拟空间。但事实是这样的吗？

我们来看一个例子：编译器的工作过程。我们知道编译器在工作时需要保持多个数据结构：词法分析树、常数表、代码段、符合表、调用栈。保持多个数据结构本身并无任何问题。问题出在这些数据结构可以独立增长和收缩。即在编译器扫描过程中，这些数据结构里面数据可以增多或减少，从而造成该数据结构所需内存空间的变化（见图14-2）。

图　14-2　编译器的各个逻辑段在虚拟空间的示意图

如果编译器只在一个虚拟空间活动，则所有的数据结构或表格均在一个虚拟空间分配，这样就必然发生不同的数据结构占据虚拟空间的不同部分的情况。这样，当一个数据结构空间需要增长时，就会碰到处于其上的其他数据结构而造成无法增长。那么如果某个数据结构无法增长，例如词法树所占空间无法增长，则编译过程将失败。

当然，编译器在工作时会为每个数据结构多分配一些空间来容纳其随后的增长。而且这些空间的大小都经过经验数据测试，可以应对大多数情况下数据结构增长的需要。但是，谁也不能保障不会碰到一个很大的程序，以致某个数据结构的增长空间不够的情况。例如，如果一个程序里定义的变量奇多无比，则符号表所占的空间将大幅增加。

那么怎么解决这个问题呢？记住，这里的碰撞可是在虚拟空间中的碰撞，而不是物理空间的碰撞。解决的办法自然是增加虚拟空间的容量。但是虚拟空间的大小受寻址宽度的限制而无法增长。剩下的办法就只能让一个程序使用多个虚拟空间！

由于分页系统只使用一个虚拟地址空间，自然解决不了这个问题。那么要解决这个问题显然需要一种新的内存管理模式。这种新的模式就是分段管理系统。

14.2　分段管理系统

分段管理就是将一个程序按照逻辑单元分成多个程序段，每一个段使用自己单独的虚地址空间。例如，对于编译器来说，我们可以给其5个段，占用5个虚地址空间，如图14-3所示。

图　14-3　给编译程序5个虚地址空间以解决空间增长困难的问题

这样，一个段占用一个虚地址空间，就不会再发生空间增长时碰撞到另一个段的问题。从而避免因空间不够而造成编译失败的情况。如果某个数据结构对空间的需求超过整个虚地址能够提供的空间，则编译仍将失败。不过出现这种可能的概率恐怕不会比太阳从西边出来的概率高出多少。

细心的读者也许已经发现，分段管理很像我们在内存这一部分开始时所介绍的基本内存管理如固定分区、非固定分区、交换等。只不过在基本内存管理中，每一个程序被分为一段，而这里则是一个程序被分为多个段。因此，从这个意义上说，分段管理实际上是基本内存管理的一般化，而基本内存管理是分段管理的特例：只分一个段。事实上，我们把基本内存管理的一段式管理称为纯粹分段，而一个程序分为多个段的分段管理称为逻辑分段。图14-4对这两种分段模式进行了比较。

图　14-4　纯粹分段和逻辑分段的比较

一个程序占据多个虚拟地址空间，那么不同的段有可能有同样的虚拟地址空间。如果要区分一个虚拟地址所在的段，我们必须在虚拟地址前面冠以一个前缀，即该地址所在的段号。也就是说，在分段情况下，一个虚拟地址将由段号和段内偏差两个部分构成，如图14-5所示。

图　14-5　分段管理下虚拟地址由段号和段内偏差两个部分构成

那么分段管理的实现手段是什么呢？既然分段与前面的基本内存管理有着类似之处，其实现手段自然也可以仿照，而纯粹分段的管理机制是基址与极限。我们的逻辑分段自然也可以使用基址与极限来实现。只不过纯粹分段只有一段，而逻辑分段有多个段，因此，我们需要的是一组基址与极限对。而每一对基址极限用于其中一段的管理（见图14-6）。

图　14-6　逻辑分段的管理机制是一组存放在段表里面的基址与极限

在逻辑分段下，每一个虚拟地址段可以加载到物理内存的任何空间。我们只需要在加载后将对应的基址寄存器与极限寄存器的值进行相应调整即可，如图14-7所示。

图　14-7　逻辑分段下每一个虚拟段可以加载到内存的任何位置

这样，如果需要某一段，那我们就用这一段的基址来搜索。我首先根据段号来选择对应段的基址，再将段内的位移偏差加到该基址上即可（见图14-8）。如果该偏差超过极限，则算作出界。翻译的过程如下：

if（段内偏差大于该段的极限）｛

陷入内核进行错误处理

｝else｛

从段表找出该段的基址

物理地址=基址+段内偏差

｝

从上面的翻译过程可以看出，分段管理的一个重要数据结构就是段表。和页表类似，该表存放的是虚拟段号到该段所在内存基址的映射。如果一个段不在内存中，则该段号对应的基址将不存在。这里需要注意的是，由于段的数量很少，通常为3～5段，段表的尺寸非常小。

这里我们要问的一个问题是，段号是否占用寻址位数。如果是，则每个逻辑段实际上不能占用一个完整的虚拟地址空间。但由于段数很小，通常为3～5段，段号所占的位数不会超过3位，因此，每一个逻辑段可以使用的虚地址空间仍然非常大。

图　14-8　段式管理下物理地址生成示意图

如果我们想分配给逻辑段完整的虚地址空间，则可以将虚拟段号存放到一个特殊的寄存器里，即不占用寻址字位。或者将其隐含在指令的操作码里面。这样只要看到操作码，我们就知道其所属段号。

14.3　分段的优缺点

逻辑分段的优点十分明显。首先，每个逻辑单元可单独占一个虚拟地址空间，这样使得编写程序的空间大为增长，几乎可以编写出没有尺寸限制的程序来。其次，由于段是按逻辑关系而分，共享起来就非常方便。不会出现分页系统下一个页面里面可能同时包含数据和代码而造成共享不便的问题。由于不同的逻辑段使用不同的基址与极限对，我们可以对不同的段采用不同的保护措施。最后，对于空间稀疏的程序来说，分段管理将节省大量的空间。因为空余的部分不用分配任何虚拟空间；而在分页下，空余的空间仍然需要分配虚拟页面，只不过该页面非法而已。而在上下文切换时要更换的内容很简单：段表。因为段表很小，这种切换非常容易。

分段管理的缺点也十分明显。既然是分段，就存在前面基本内存管理时介绍过的缺点：外部碎片和一个段必须全部加载到内存。

一般来说，一个程序的所有逻辑段是同时需要的。如果实行分段管理，则由于每个段必须全部加载到内存，这就造成了我们在基本内存管理时已经遇到过的一个问题：一个程序必须同时全部加载到内存才能执行。当然，解决这个问题可以使用重叠（overlay）。但我们已经尝过分页系统好处后再使用overlay显然有“优汰劣胜”的味道。

那么我们的解决办法是什么呢？分页。但这次的分页不是前面的直接对程序进程分页，而是对程序里面的段进行分页。这就形成了所谓的段页式内存管理模式。这种段内分页是前面讲过的页式内存管理的否定之否定。

14.4　段页式内存管理

段页式管理就是将程序分为多个逻辑段，在每个段里面又进行分页，即将分段和分页组合起来使用。这样做的目的就是想同时获得分段和分页的好处，但又避免了单独分段或单独分页的缺陷。如果我们将每个段看做一个单独的程序，则逻辑分段就相当于同时加载多个程序。

那么段页式内存管理是如何实现的呢？

由于段页式管理模式是在段里面分页，而每个段占据一个虚地址空间，这就意味着一个程序将对应多个页表。那么一个程序如何管理多个页表呢？简单！在分页系统里已经介绍过多级页表。在这里我们只需要将这些页表作为次级页表，而在次级页表上面增加一层段表（相当于多级页表里面的顶级页表）。由段号在段表里面获得所应该使用的页表，然后在该页表里面查找物理页面号，如图14-9所示。

如果需要，次级页表又可以再分为两个或多个层次，形成层次更为丰富的段页式层次结构。当然，随后层次的增加，内存访问的效率将下降。但我们前面已经讨论过了，可以使用快表TLB来绕过段表和页表的查找，从而加快速度。不过，这里的快表必须将段号也包括进来。

图　14-9　段页式的页表管理与多级页表结构类似

那么段表里面的每个记录包含哪些内容呢？这个答案显然随着系统的不同而不同。不过，一般来说，包括的内容至少有段长、对应页表在内存的地址、页面大小、保护标志等。例如，Multics段页式管理系统的段表记录包含的内容，如图14-10所示。

图　14-10　Multics段表记录内容

对应页表的地址、段长、页面大小、包含标志的意思都直截了当，无须解释。但Multics段表记录里面还有一项“是否分页”的标志。该标志用来表明该系统是否分页。0值表示分页，1表示不分页。如果不分页，那就是纯逻辑分段。即Mul-tics支持不分页的分段管理。

当然，将分页与分段两种方法组合使用时得非常小心。否则非但不能汇集二者的好处避免二者的缺陷，反而变成汇集二者缺陷而掩盖二者益处了。

那么段页式管理模式是否汇集了分段和分页的好处，但又避免了两者的坏处呢？这个留给读者去思考。

当前的商用计算机均支持段页式内存管理模式。例如，奔腾就支持段页式内存管理。其段表有两个：一个全局段表供系统程序使用，一个局部段表供用户程序使用。奔腾结构通过一个选择器（selector）来选择全局或局部段表。此外，奔腾也支持纯粹分段。详细说明请参阅奔腾指令集结构手册。这里不再赘述。

14.5　段号是否占用寻址字位

本章前面介绍过，分段管理就是将一个程序分为多个逻辑上有一定独立性的段，每个段占一个完整的虚拟地址空间。这样的话，每个段里面的指令所使用的虚地址将与寻址位数相等，即段内偏差与寻址位数一样。但是我们又知道，在分段模式下，一条指令的虚地址包括段号和段内偏差。那么段号将不占用虚地址空间的字位。

例如，对于Multics段页式管理系统来说，其虚地址格式如图14-11所示。

图　14-11　Multics虚地址结构

Multics的段号为18位，页号为6位，页内偏差为10位。加起来一共是34位。难道Multics是34位寻址？由于34不是2的指数次方，使用34位寻址实在匪夷所思。莫非是Multics的设计人员R.M.Graham不知道2的指数次方作为寻址位数的巨大优势？

当然不是。事实上，Multics的寻址位数为16位。即页号的6位加上页内偏差的10位。而段号的18位并不占用寻址空间，而是在寻址空间之外。

那么段号不占虚地址空间位数是如何实现的呢？这个简单：使用单独的寄存器来存放段号，或者将段号隐含在指令操作码里，即每条指令都隐含了自己到底属于哪一个逻辑段。

我们也可以这么想，在一个程序就是一个分段的时候，我们写了一万个程序，是不是要因此把地址位增加呢？当然不是。在段式内存管理模式下，一个程序分成5段了，就可以看做5个程序，而运行5个程序并不需要增加寻址位。

也许有些读者会觉得奇怪，虚地址里面的段号怎么能不占虚地址位数呢？其实这并不奇怪。我们前面介绍过，分段管理时每个段的虚地址空间是独立的，它们之间没有任何距离上的关系。如果将段号放在虚地址空间内，则每个段之间将存在距离上的关系，即某个段在另一个段前面或者后面。这样段之间的关系就有了限制，且每个段的大小就会比虚地址空间小。

回到Multics寻址上。由于其支持分段和分页，Multics有一个段表，而每个段有个页表。我们从段号里面找到应该使用哪个页表，然后从页表里面找到逻辑页面对应的物理页面。再将物理页面号和页内偏差合并形成物理地址。所有段页式的系统使用的都是这种方式。这种段表加页表的结构已在图14-9中体现。

如果我们的地址完全是以页号和页内偏差构成的，那么我们的段就可以放在任何地方。从这一点说，段的唯一目的就是区分不同的页表。

14.6　讨论：否定之否定的嵌套——纯粹分段与逻辑分段、分页与段页

在一开始，人们根据直觉，将一个程序作为一整段进行管理，从而形成了纯粹分段（固定加载地址、固定分区、非固定分区、交换）管理模式，也称为基本内存管理模式。这种模式由于直观，人人都能想到，曾经大为流行。

但纯粹分段存在重大缺陷。由于此种模式下一个程序只有一段，从而导致内存空间增长困难、外部碎片、程序不能超过物理内存容量、一个程序必须同时加载到内存才能执行（除非使用overlay）等诸多缺点。而为了克服这些缺点，我们引入了页式内存管理模式。

虽然页式内存管理模式克服了纯粹分段的一系列缺点，但又存在共享不方便、一个程序只能在一个虚地址空间增长的问题。而为了解决这个问题，我们引入了逻辑分段。逻辑分段是纯粹分段的否定之否定阶段。虽然都是分段，但已经不在一个层次上。逻辑分段将一个程序按逻辑关系分解为多个段，从而扩大了程序可以使用的虚拟地址空间并解决了共享难的问题。

但逻辑分段终究还是分段，自然又引入了分段的缺点。而要克服这些缺点，自然又想到了分页。这样我们就来到了段页式管理模式。这就形成了另一次否定之否定。

这种从纯粹分段到分页，再到逻辑分段，再到段内分页的演变过程，如图14-12所示。

图　14-12　纯粹分段-分页-逻辑分段-段页式的否定之否定演变

从图14-12可以清楚地看出，内存管理模式经历了两次互相穿插的否定之否定过程。而正是这种否定之否定，才推动了内存管理模式的进步。

思考题

1.阐述单道编程内存管理与分段管理的异同点。

2.段式内存管理的段号保存在什么地方？

3.一个程序可以占用多个虚地址空间吗？如果能，如何办到？如果不能，那又为什么？

4.如果不使用TLB，在一个段页式系统下，从内存读取一条指令至少需要几次内存访问？为什么？

5.请列出各种内存管理模式的时间逻辑顺序。

6.在段式系统下，一个程序能达到的最大尺寸是多少？

7.段页式内存管理是否具有分段和分页的优点，但又避免二者的缺点呢？请详细阐述。

8.否定之否定哲学是如何在内存管理模式的变化上体现的？9.段页式内存管理有什么缺点吗？请详细阐述。

10.讨论段式、页式和段页式内存管理的优缺点，并说明何时应该使用段式管理，何时应该使用页式管理，何时应该使用段页式管理模式？

第五篇　文件原理篇

大成若缺，其用不敝；大盈若冲，其用不穷

在第四篇中介绍过，计算机运行时所需要的数据和代码从主存储器或者内存而来。如果计算机不在运行状态时，这些数据和代码保持在何处呢？我们当然可以试一下内存，但问题是内存不是持久性的存储介质，它是进程运行过程中临时分配的存储空间。一旦进程结束运行，其所占内存空间就会被释放，存放在里面的数据和代码也将消失。而且，内存需要电能支持才能保持数据（指一般的内存），一旦断电，所有数据都将丢失。因此，数据和代码要想长久保持，就需要一个更为持久的地方来存放，这个持久地方就是磁盘。而对磁盘进行管理就是操作系统的另一个重要使命。如果管理得当，磁盘、内存和CPU将协作完成计算机的使命。

计算机管理磁盘采用的手段或者抽象就是文件系统，这是本篇讲解的议题。

本篇内容包括第15～18章的内容。第15章讲述的内容包括磁盘结构、磁盘访问速度、磁盘的操作系统界面、磁盘访问过程和磁盘调度。第16章讲述为什么需要文件系统、什么是文件系统、文件系统的目的、文件的基本知识、文件的存储结构、文件类型、文件访问、文件属性、文件操作、文件夹、相对与绝对路径、共享与链接、内存映射的文件等内容。第17章的内容包括文件系统分布、文件的实现、文件夹的实现、共享文件的实现、磁盘空间的管理等。第18章的内容包括文件安全性能（文件访问控制、访问控制表、能力表）、文件可靠性能（持久性、一致性、日志、交易、随影、一致性检查）和文件系统的效率性能（提前读取、减少磁臂移动距离、日志结构的文件系统LFS）。

文件系统从根本上说是操作系统对磁盘进行的抽象和装扮

本篇最重要的核心是文件夹及其实现，因为它是文件系统运作的根本。通过文件夹，我们才能找到我们需要的文件，完成从抽象到现实的转换。

第15章　磁盘操作

引子：EMC——从不可能到可能

2001年秋，随着互联网泡沫的破灭，许多公司机构削减了对信息技术的投入费用。并且希望将已经购买的设备发挥最佳效益。但就在这时，这些公司发现，他们在互联网兴盛时代购买了许多不同公司提供的不同信息技术产品，而这些信息技术产品之间互不兼容，尤以智能存储设备为最。

市面上提供存储设备的厂商很多，其中著名的有EMC（见图15-1）、IBM、COMPAQ（后被HP购买）等，但这些厂商的产品之间不能兼容。例如，EMC的存储设备需要使用EMC的软件来控制，而IBM的存储设备需要IBM的软件来控制，COMPAQ的设备当然需要使用COMPAQ的软件来控制。这样产生的一个问题就是一个公司需要同时操控好几套软件。而要想在这些不同存储平台之间进行数据自由流动就更加麻烦了。因此，一个自然而然的市场需求就是将这些平台变为互相兼容，即这些存储设备都由一个控制软件来控制。

要控制一个设备，需要通过该设备的API进行编程来管理。于是EMC公司就提议EMC、IBM、HP等公司将自己存储设备的API全部公开，供任何开发存储软件的人员使用。但是IBM和HP都不同意，称其拥有专利保护，市场的需求眼看就要受挫。

在这时，EMC却公开宣称说即使IBM和COMPAQ不公开他们的API, EMC也能写出控制其存储设备的软件。当时许多人都觉得这是天方夜谭，但过了一段时间后，EMC推出了其Auto IS系统。这个系统确实能够控制其他制造商的存储系统，并进一步提升了EMC公司在存储技术上的世界领先地位。

图　15-1　EMC公司的存储系统产品

计算机是处理数据的机器。而数据就需要有地方存放。在计算机环境下，可供数据存放的地方并不太多，其中一个主要的存储数据的媒介就是磁盘。

对于大多数计算机领域的人来说，磁盘通常被看做一种外部设备。可是，对于现代操作系统来说，磁盘是不可或缺的。虽然早期的操作系统可以基于磁带，但由于操作系统复杂性和性能的不断提升，用磁带作为操作系统的载体已经不合时宜，取而代之的是磁盘。由于操作系统需要存放在磁盘上，且操作系统内的文件系统也主要是基于磁盘。因此，从某种程度上说，磁盘是操作系统不可分割的一部分，理解磁盘将对理解操作系统的原理具有重要意义。

虽然现在有研究人员试图将操作系统建于内存中，即基于内存的操作系统（memory based operating systems），但这种操作系统尚处在实验室阶段，并未形成商业气候，一般的读者也不太可能与这些操作系统打交道。对于一般用户来说，他们看到的操作系统都是存放在磁盘上的。

操作系统为磁盘提供的抽象就是：文件及文件系统。或者说，文件系统就是磁盘的抽象。

15.1　磁盘组织与管理

由于文件系统是磁盘的抽象，因此不了解磁盘就很难深刻理解文件系统。因此，本章首先对磁盘进行一个简要的介绍。由于本书是一本关于操作系统的书，对磁盘的解释点到为止，即我们只介绍磁盘里面与文件系统设计有关的方面，将省略与文件无关的部分。

需要注意的是，磁盘远比本章介绍的要复杂，有兴趣的读者可以参阅计算机组成、存储技术与系统或专门介绍磁盘技术方面的书籍。

那么什么是磁盘呢？通俗地讲，磁盘就是形状像盘子的磁性存储介质。这种存储介质的特性是持久，正常情况下数据存放在上面不会消失；磁性、圆形是它的显著特征，所以称为磁盘；成本低廉，所以可以大量使用。对于现代计算机来说，磁盘已经成为不可或缺的一个组件。一个没有磁盘的计算机对于很多人来说是很难想象的（当然，没有磁盘的计算机还是有的，但多数读者恐怕不会有机会与这样的计算机打交道）。

15.2　磁盘的结构

一块磁盘实际上并不只有一块盘片，而是由多块盘片组成，每块盘片的正反两面皆可以存放数据。每个盘面上都配有一个读写磁头，而所有的读写磁头连在一根共享的磁臂上。当磁臂运动时，所有的磁头均作相同的运动。盘片则以常速不停地旋转。这个旋转速度通常为每分钟3500转到10 000转，即大约每6毫秒到17毫秒旋转一圈。图15-2给出的是将磁盘外壳切开一块所看到的结构。

图　15-2　磁盘的物理结构

除此之外，一块磁盘还含有信号和界面电子系统。通过这些电路和电子系统，我们得以将系统用户与系统的运作原理隔离开来。例如，对于一个磁盘驱动器用户来说，他无须了解磁性表面上数据字位的读写操作是如何完成的。现代的磁盘都带有一个微控制器，如IDE磁盘。这种磁盘提供较为复杂的功能，如纠错、数据分拆与组装、磁盘调度等。也有的磁盘非常简单，仅仅提供字位流的读写，而其他复杂的功能则交给集成在计算机主板上的设备控制器来做。

典型的磁盘驱动器外围尺寸有两种：5.25英寸宽×7英寸长×3英寸高和2.5英寸宽×4英寸长×0.5英寸高。较小外围尺寸（后面的这个尺寸）的磁盘为当今手提电脑和笔记本电脑所使用。2002年，高端磁盘驱动器的容量从100GB～200GB不等，并且这些磁盘驱动器的容量在2006年以前将以每年50%的速度递增。到2008年年末，磁盘驱动器的容量已经达到了2TB（terabytes，1TB=1000GB）。从读写磁头传送出来的数字位流的数据传送速度可达3GB/s以上，而高速磁盘驱动器则可以以超过1GB/s的速度往外输出数据。

在每一个盘片表面上只有一个读写磁头。该磁头由少量的空气垫层浮起，悬浮在盘面上方约几个微米的高度。磁头在盘面上的移动操作由一个伺服机构（ser-vomechanism）负责控制。读写磁头可以在6毫秒到17毫秒的时间内遍历磁盘的整个活动区域。这个时间与磁盘旋转一圈的时间一样。不管一个磁盘驱动系统有多少个读写磁头、多少磁道或多少盘面，在任何时候只能有一个磁头处于活动状态，即从盘面输出的数据流或输入盘面的数据流总是以位串方式出现。

15.3　盘面的结构

为方便存储数据，人们将每块盘面分为磁道和扇面。磁道是一个个的同心环。而每个磁道又被分为扇面（也称为扇区）。数据则以扇面进行存储。扇面也是磁盘I/O的最小单位，如图15-3所示。

扇面的内容通常分为3部分：标题、数据部分和ECC纠错信息，如图15-4所示。

扇面的标题通常包含同步和位置信息。同步信息允许磁头的定位电路将磁头保持在磁道的中心位置，而位置

图　15-3　磁盘的盘面结构图

信息则允许磁盘控制器决定扇面的身份。如果是读取操作，则磁头将捕捉存放于盘面上的信息；在写入操作时，则将信息写入盘面。图中的扇面结构里有一个12 B的ECC信息，该信息的功用是对这个512 B的数据段提供错误侦测和纠正。

图　15-4　扇面内容分布

有的书籍会将同步信息从标题部分分出来，并加上一个尾区，从而形成五个部分的扇面结构，如图15-5所示。

图　15-5　5个区的扇面结构

对于不同的磁盘系统来说，扇面里面每个区的大小可能不一样。但数据部分则通常都是512 B。至少该部分应该是2的指数次方大小。

一般来讲，磁盘驱动器的制造商会将磁盘进行初始化或格式化，即将原始的磁道和扇面信息存入盘面，并对盘面进行检查以判断数据是否可以写入每个扇面或从其读出。如果某个扇面确认被损坏，即在使用ECC的情况下也不能使用该扇面时，则格式化过程将对这个扇面作出标记，这样操作系统就不会在这个扇面上进行任何读写操作（避免使用这个扇面）。

一个磁盘驱动器的所有盘面上处于同一位置的磁道构成磁柱。如果一个磁盘驱动器有8个盘片，则这个磁盘的一个磁柱由16个磁道构成（每个盘片有正反两面），而这16个磁道离磁盘的中心旋转轴的距离均一样。

15.4　磁盘驱动器的访问速度

磁盘在读写数据时的访问速度称为磁盘的动态属性。这是衡量一个磁盘性能的重要数据。数据访问时间或速度的计算并不简单，而这种计算不仅依赖于磁盘的旋转速度，而且还依赖于在访问开始时读写磁头所处的位置。数据访问时间也有几种衡量办法。

寻道时间（seek time）指的是把读写磁头移动到所要求的磁道位置所需要的平均时间。实际的寻道时间则依赖于在读写请求收到时读写磁头所处的位置和磁头需要移动的距离。但由于没有办法在收到访问要求时获知这些数据，因此我们常常使用平均寻道时间这个概念。平均寻道时间则是通过测量来决定。该数值将有赖于磁盘驱动器组件的物理尺寸和磁头进行加速和减速的快慢程度。寻道时间通常在8ms～20ms内，并且在近年来没有多少变化。

磁道到磁道的访问时间（track-to-track time）衡量的是磁头从一个磁道移动到一个相邻磁道所需要的时间。这个时间通常在2毫秒～3毫秒内。该时间在有的资料上也称为柱面切换时间（cylinder switch time）或磁道切换时间。

旋转延迟时间指的是在磁头到达所要求的磁道位置后，等待所要求的扇面旋转到磁头下方的平均时间。若平均来讲，所需要的扇面将在磁头刚刚移动到所需磁道时处于离磁头半圈远的距离，旋转延迟通常为旋转时间的一半。当前磁盘驱动器的旋转速度所处的范围为每分钟3500转到10 000转，因此旋转延迟的范围在3ms～8.57ms内。

平均访问时间是寻道时间加上旋转延迟。

爆发速度衡量的是在磁头到达所需扇面后，磁盘驱动器输出或接收数据的最大速度。这个数值等于磁头发送数据位流的速度（如果系统里其他部分也能以此速度产生或接收数据）。

可持续数据速度则指的是在一段可持续时间内数据可以被访问的速度。这个参数在设计那些必须进行大文件实时访问（如视频或音频数据）的系统时非常重要。如前面所说，这个数值可以从磁盘驱动器的规格上进行估算，但该数值也需要经过实际测试的验证。不可预见的因素有可能影响这个可持续数据的速度。例如，许多磁盘驱动器周期性地进行磁头校正操作，在这段时间内，磁头将在盘面上移动并进行校正，因此不能在这段时间内接收任何数据读写要求。如果磁头校正在可持续文件传输中间发生，则其造成的数据访问中断可能是难以接受的。一些制造商正在推出“多媒体”磁盘驱动器。这种磁盘系统在进行可持续数据访问时将暂停任何磁头校正操作。

例如，假定某一磁盘有8个盘面，每个盘面有1024个磁道，每一个磁道则有64个扇面。我们还假定磁盘的扇面尺寸为1KB。平均寻道时间为8ms，磁道到磁道的访问时间为1.5ms，磁盘驱动器旋转速度为7200rpm。对处于一个磁柱上的磁道进行访问时无须移动读写磁头。

1.请问该磁盘驱动器的容量为多少？

答：容量=8×1024×64×1MB=512MB

2.请问该磁盘驱动器的平均访问时间为多少？

因为：旋转延迟=旋转时间/2=[（60×1000）/7200]/2ms=4.17ms

所以：平均访问时间=寻道时间+旋转延迟=（8+4.17）ms=12.17ms

3.如果每个扇面的尺寸为512B，请估算该磁盘驱动器传送一个5MB尺寸的文件所需的时间。

答：为方便计算，假定这个文件为连续存放，即用完一个磁柱后才用下一个磁柱，而在一个磁柱上，磁道一个一个地连续存放，而在磁道上，扇面一个一个地连续存放。假定其起始地址为扇面号#0，盘面号#0，和磁柱号#i。由于1MB=1024KB，而一个扇面为512B，一个5MB尺寸的文件将需要10 240个扇面。该文件占住的空间将从磁柱i，盘面0，扇面0开始，到磁柱i+19，盘面8，扇面63结束。我们还假定磁盘驱动器的缓冲区的尺寸为无限大（即系统其他部分能以磁盘数据传输速度相同的速度接收数据）。

在上述假设下，磁盘驱动器需要8ms的时间进行寻道操作，即找到磁柱i需要8ms的时间，4.17ms时间用于等待0号扇面旋转到磁头下方，8×（60/7200）×1000×66. 67ms的时间来读取该磁柱上所有8个磁道的数据。然后，磁头将花费1.5ms的时间移动到下一个相邻的磁道（即磁道切换时间）。假定在访问每个新磁道时都有一个旋转延迟，则我们有：

访问时间≈[8+20×（4.17+66.67+1.5）-1.5]ms≈1453.3ms

4.请问该磁盘驱动器的爆发传输速度为多少？

15.5　操作系统界面

对于磁盘来说，操作系统的一种功能是将磁盘驱动器系统里面的各种细节屏蔽起来，这样用户和应用程序都无须知道数据到底是如何存放在磁盘上的。操作系统在对磁盘进行管理时通常以磁盘块作为最小单位。而定位一个磁盘块则通过磁盘块地址进行。磁盘块通常为扇面数的整数倍，更为常见的是2的幂次方倍，如32个扇面或64个扇面等。

磁盘块地址类似于访问内存时需要提供的内存地址，操作系统访问磁盘块的时候需要提供磁盘块地址。不过磁盘块地址比内存地址更复杂，它包括多个部分，具体细节因操作系统的不同而不同。但典型的操作系统通常都会标明磁道（磁柱）、扇面、和盘面（磁头）3个参数。以这种方式给出的地址称为逻辑块地址（Logical Block Address, LBA）。图15-6给出的就是一种LBA地址。

图　15-6　磁盘界面的逻辑块地址示意图

LBA用于将磁盘读写请求传送给磁盘控制器。在磁盘读取请求里，所要读取信息的起始地址由磁道号（磁柱号）、扇面号、和盘面号（或磁头号）组成，而扇面计数则给出了该次磁盘访问将要读取的扇面数量。磁盘控制器则将这些参数转换为一系列的低层磁盘操作。在对磁盘进行读取时，操作系统负责将该数值写入磁盘驱动器界面里指定的I/O端口。另外5个额外的、宽度均为一个字节的寄存器则用来驱动读写命令和状态信息。（这里之所以使用“逻辑”这个术语是因为LBA不一定与磁盘上面的块存储区域的真实物理地址对应；而磁盘控制器将负责进行从LBA到物理块地址的映射。）

下列步骤描述的是从磁盘上读取一个扇面的过程：

1）操作系统将要读取的LBA传送给磁盘驱动器并启动读取命令。

2）磁盘驱动器通过将磁头移动到正确的位置，并启动处于指定盘面上的磁头来搜索指定的磁道。在磁头的移动过程中，读取磁头将不断地检查下面的扇面号直到找到所要求的扇面为止。

3）磁盘控制器将扇面数据和ECC信息传送到一个处于磁盘界面里的缓冲区。这里的ECC信息是在数据流动的动态过程中进行计算而得出的。

4）磁盘驱动器向操作系统发出“数据就绪”信号。

5）操作系统从磁盘界面里的缓冲区读取数据，既可以按一个字节一个字节的方式来读取，又可以启动DMA命令来读取。

15.6　磁盘调度算法

正如我们前面说过，影响磁盘读写时间的因素有3个：

●寻道时间

●旋转延迟

●数据传输时间

而在这3者中，前两者为机械运动，数据传输主要为电子运动。显然机械运动的速度远低于电子运行的速度。在两个机械运动部分，寻道时间又较长。因此，在上述3个因素中，寻道时间居于支配地位。为了提高磁盘的读写效率，需要降低磁盘的寻道时间，实现的手段则是磁盘调度。磁盘调度的算法主要有以下几种：

●先来先服务FCFS（First Come, First Serve）

●短任务优先STF（Shortest Task First）

●短寻道优先SSF（Shortest Seek First）

●电梯调度ES（Elevator Scheduling）

●提前查看电梯调度ESLA（Elevator Scheduling with Look Ahead）

●单向电梯调度OWES（One Way Elevator Scheduling）

15.6.1　先来先服务

先来先服务是一种自然公平的调度策略。先来后到，谁也没有特权。例如，给定图15-7的一组磁盘请求，其中X表示需要读写的扇面，下面的数字为磁道号（这里由于不考虑旋转延迟，数据所在的扇面号在此忽略）。假定磁头的初始位置为10号位，而磁盘请求发生的顺序为0、23、6、16、2、21、8、9、12。则在先来先服务调度下，总寻道的道数为：

10+23+17+10+14+19+13+1+3=109

图　15-7　先来先服务策略并不一定产生最短的总寻道时间

可是大家都清楚，这种讲究自然公平的策略效率十分低下，因此很少采纳。改进的办法就是对每个磁盘读写任务进行区别对待。

15.6.2　短任务优先

短任务优先就是谁的磁盘读写数据量最少，谁就优先。由于磁盘的访问时间主要取决于寻道和旋转延迟，因此读写的数据量对整个磁盘读写时间的影响并不大。因此这种策略意义不大。

15.6.3　短寻道优先

短寻道优先则考虑当前磁头离谁的数据最近，谁就优先。由于寻道在磁盘访问时间中占的比重最大，此种策略似乎正中要害，能够缩短磁盘访问时间。如果还是使用图15-7的例子，则使用短寻道优先策略的访问磁道号顺序将为：9、8、6、2、0、12、16、21、23，总寻道道数为1+1+2+4+2+12+4+5+2=33，比FCFS的109个磁道大为减少。

不过，这种缩短并不是绝对的。例如，如果当前的磁盘读写操作如图15-8所示，则磁盘读写请求的执行呈现的是一种左右摆动的模式。这种情况下总寻道数=1+3+7+15+31=57。

由于左右摆动幅度越来越大，系统花在寻道上的时间迅速增加。而改进的办法就是不要左右摆动，而令其单向运动，这就是电梯调度策略。

图　15-8　短寻道优先策略并不一定产生最短的总寻道时间

15.6.4　电梯调度

电梯调度策略很简单，先满足一个方向的所有请求，再满足所有反方向的请求，这样循环往复。如果还是图15-7的请求，则磁盘读写的执行呈现的是图15-9所示的模式。在电梯调度策略里，磁头往每个方向运动时（由里往外和由外往里），皆扫描到头。

在磁盘请求与图15-8一样的情况下，使用电梯调度的总寻道数为：1+4+16+1+24+8=54，比短寻道优先节省3个磁道的寻道时间，如图15-9所示。

图　15-9　电梯调度是对短寻道优先策略的改进

对本策略进行仔细分析发现，其运行模式与电梯的运行模式并不完全相同，而是一扫到底。从一个方向扫描到头再反转方向，这也许并不是最有效的。如果一个方向已经没有请求了，我们可以提前调头，而无须等到扫描到末端才进行。这种改进后的算法就是提前查看电梯算法。

15.6.5　提前查看电梯算法

如果一个方向的请求全部满足后，即反转运行。无须扫描到底。这种算法就是每次往某个方向移动时必须确保该方向还有请求未满足。否则即刻调转方向。这样效率将得到提高。

在磁盘请求与图15-9一样的情况下，使用提前查看电梯调度的总寻道数为：1+4+16+23+8=52，比普通的电梯调度算法节省2个磁道的寻道时间，如图15-10所示。

图　15-10　提前查看电梯调度是对普通电梯调度策略的改进

这种正反两个方向交替运动并不一定最优，另外一种改进办法是单向电梯调度，即只向一个方向扫描。当该方向没有剩余请求时，则回到0道，再进行同样的扫描。如果在磁盘请求与图15-10一样的情况下，使用单向电梯调度的总寻道数为：1+4+16+8=29，比提前查看电梯调度算法节省23个磁道的寻道时间，如图15-11所示。

图　15-11　单向电梯调度是对提前查看电梯调度策略的改进

15.7　讨论：固态盘

前面的讨论告诉我们，磁盘效率低下的瓶颈是寻道和旋转延迟。寻道和旋转操作之所以效率低下是因为这两项运动是机械运动：磁臂的直线来回运动和磁盘的旋转运动。目前，磁臂移动速度和磁盘旋转速度都已经达到或接近极限，再行提高将导致定位精度的降低。因此，提高磁盘的运行效率只剩下一个办法，那就是将机械运动从磁盘的结构中去除。

那么我们是否可以将这些机械运动去除呢？

答案是肯定的。内存不就是没有机械运动的存储介质吗？如果我们用与内存相同或相似的存储介质来构建磁盘，不就去除机械运动了吗？而使用与内存介质相同或相仿的存储介质构建的磁盘就是所谓的固态盘（Solid State Disk, SSD）。“固态”的原始意义是半导体驱动器，但在今天的存储工业界已经演化为表示没有机械运动部件的驱动器。由于没有移动的部件，整个驱动器似乎是固定的，因而称为固态盘。目前的固态盘使用的是与内存相同的介质。由于内存有SRAM和DRAM的区别，因此固态盘自然也可以分成这两类。

由于没有移动的机械部件，固态盘具有很多普通硬盘不具备的优点：

●可靠性高（到底高多少还有待于进一步的压力试验）。

●没有噪音（由于没有风扇，因此不存在风扇发出的噪音）。

●访问速度高，接近内存的访问速度。

●热耗低于普通硬盘，更省电。

●由于不需要旋转，其启动时间短。

另外，由于没有机械运动部分，固态盘的空间利用率很高，至少是普通硬盘的两倍。另外，由于没有磁头磁臂之类的移动，所有存储单元的访问速度均衡，固态盘也无需任何调度。

当然，有利必有弊，固态盘虽然有着上述优点，但也存在一些缺点。其中最大的问题是成本。固态盘的单位容量成本为普通硬盘的10～20倍。这对于小容量的固态盘来说，可能算不得什么，但对于大容量的固态盘来说，这个成本十分可观。而人们制造固态盘的一个目的就是追求大容量，因此这个成本让很多人承受不了。

其次是“非均衡磨损”问题。这是由对固态盘不同部分的使用频率不同而导致。这个问题可导致固态盘的生命周期缩短。为了克服非均衡磨损，固态盘通常采用所谓的“磨损平衡”（wear leveling）策略，即将修改后的数据存放在新的位置，而这将导致安全漏洞。另外，固态盘的读写速度差异大于普通硬盘的读写速度差异，这对于那些要求读写速度相当的应用来说是一个大问题。当然这个问题可以通过降低读操作速度来达到。

固态盘是一个仍然在快速发展的技术，尚缺乏统一的标准，性能也参差不齐。再加上成本高昂，大部分操作系统几乎不支持，以致固态盘的应用范围十分有限。不过，随着混合磁盘技术的发展，将固态盘作为普通硬盘的缓存的概念得到了更多的支持。各种软件供应商也在增加对固态盘的支持，例如，微软的exFAT文件系统就特别增加了对固态盘的支持，Sun公司的ZFS文件系统也能够较好地利用固态盘的优势。EMC公司的智能存储系统则是第一个支持固态盘的智能存储系统，可以按照客户的要求配置存储系统里面的固态盘容量。

15.8　讨论：智能磁盘系统

本章讨论的磁盘为非智能磁盘。这种磁盘本身提供的仅仅是存储容量，而这个存储容量的管理需要由磁盘之外的操作系统或者卷管理器来负责。除了非常细微或程度很轻的读写错误外，这种磁盘一般不能检测和恢复本身的错误。而一旦磁盘崩溃，整个系统就宣告完结。与此相对的另一种磁盘，即智能磁盘（smart disk）。这种磁盘具有一定的智能，除了提供存储容量外，还能够进行自我检测并从某些错误中恢复。当错误的频率和烈度达到一定的临界点时，这种磁盘能够向系统发出警报，以便系统管理员及时更换磁盘，避免由磁盘崩溃所造成的数据损失。当然，这种功能需要操作系统提供支持。

由于大部分桌面操作系统不支持在线磁盘更换，这种在线更换的能力通常由所谓的智能存储系统提供。智能存储系统是存储设备里面的顶级大师，通常由许多智能和非智能的磁盘构成，存储容量一般在TB（1TB=1024GB）级，现在更常在PB（1PB=1024TB）级，所以智能存储系统有时也称作海量存储设备。这类存储设备备有自己的中央处理器和高速缓存，有自己的操作系统，能够独立于服务器而存在和运行。全球著名的存储系统供应商EMC供应的就是这种高速、海量、智能、独立、兼具灾备功能的存储系统。对智能存储系统有兴趣的读者可参阅《有备无患：信息系统之灾难应对》一书的“智能存储系统”一章。

思考题

1.盘片、磁道和扇面的含义分别是什么？

2.磁盘访问速度的主要决定因素是什么？

3.磁盘对于操作系统的重要性体现在什么地方？

4.一个扇面主要包括哪些内容？

5.操作系统为什么要避免读写坏扇面？

6.针对图15-7中的例子，分别计算在电梯调度策略、提前查看电梯调度策略、单向电梯调度策略下总寻道道数。哪一种策略更好？为什么？

7.假定某一磁盘有8个盘面，每个盘面有2048个磁道，每一个磁道则有256个扇面，磁盘的扇面尺寸为512B。请问该磁盘驱动器的容量为多少？

8.假定某一磁盘有6个盘面，每个盘面有1024个磁道，每一个磁道有128个扇面，磁盘的扇面尺寸为1KB。平均寻道时间为5.6ms，磁道到磁道的访问时间为1ms，磁盘驱动器旋转速度为10 000rpm。对处于一个磁柱上的磁道进行访问时无须移动读写磁头。请问该磁盘驱动器的容量为多少？该磁盘的爆发数据传输速度是多少？该磁盘传送一个10MB尺寸的文件所需的时间是多少？

9.除了磁盘调度外，你还能想到什么办法提高磁盘的访问速度吗？

第16章　文件系统

引子：掩饰的极致

16世纪以前的欧洲，是一个不讲究品味和优雅的大陆。但这个情形在法国国王路易十四（见图16-1）登上法国王位后发生了变化。路易十四对法国宫廷懒散和不修边幅的风气十分厌恶，要求大臣和侍从修饰边幅，着装优雅。他制定了男人和女人的着装，例如，男人的卷发应该到齐肩的长度，女人的裙摆则应该齐地；他设定了各种社交的礼仪，如男人对女人不能高声叫嚷；男人女人在公众场合都需要化妆。路易十四对异味尤其敏感，下大力气开展了香水的研究。他要求宫廷技师每日必须调治一种不同的香水供他使用。如果不能达到这个要求，就要上断头台。

在路易十四的不懈努力下，法国终于形成了一种追求品味、追求优雅、追求浪漫的氛围，并被欧洲大陆的其他国家模仿和效法，也为法国的香水和浪漫名扬世界奠定了基础。人的外观就这样被美化，而本性的丑恶也被暂时忘记。

图　16-1　法王路易十四

理所当然，人对美和品位的追求自然渗透到计算机操作系统的设计当中，而文件系统体现得尤为突出……

16.1　为什么需要文件系统

本书的前面多个章节阐述了CPU管理和内存管理这两个操作系统的核心功能。那么只有CPU管理和内存管理还不够。因为所有应用程序之所以运行是因为需要处理数据。一个应用程序既可能需要读入数据，也可能需要输出数据。那么这些数据就需要存放在某个地方。什么地方可以用来存放这些数据呢？

当然，内存是一个可能用来存放数据的地方。例如，一个进程在运行时有自己的地址空间，而这个地址空间就可以用来存放该进程需要的数据。但是这个地方存放数据存在诸多问题，如它容量有限，不持久，且难以共享。一旦该进程结束运行，地址空间将失效，里面的数据也无法再被任何人访问，而且即使进程尚未结束，如果突然断电，也会造成数据丢失。

那么解决的办法就是将数据存放在一种持久的媒介上。具体来说，就是磁盘。磁盘的特点是大容量、低成本、持久、可共享。即使一个进程结束运行，后来的进程或用户也可以从磁盘上访问数据。即使发生断电，磁盘上的数据也不会丢失。

但是使用磁盘存在一个巨大的问题：对于一般的用户来说，使用磁盘是非常困难的，他们不知道如何驱动一个磁盘，以及计算数据在磁盘上的存放位置。就像我们在第15章讨论过的，了解磁盘的各项技术细节将使用户不堪重负。当然，我们可以直接写程序以驱动程序，但这样恐怕没有多少人能够编写程序。

那用什么办法解决这个问题呢？答案是：抽象。

我们已经多次强调，操作系统就是一个魔术师，其提供给用户的就是各种幻象：抽象。进程抽象的是什么东西呢？就是CPU，虚拟内存抽象的是内存。对于磁盘来说，操作系统提供给用户的帮助就是在磁盘外面包裹一层容易使用的抽象，用户直接与这层抽象打交道，而无须了解磁盘的技术细节。

操作系统为磁盘提供的抽象就是：文件及文件系统，或者说，文件系统就是磁盘的抽象。

16.2　文件系统

文件系统是操作系统提供的另一个抽象。它是一个子虚乌有的东西，看不见、摸不着，但却可以使用的介于磁盘与用户之间的界面，如图16-2所示。

图　16-2　文件系统是操作为磁盘提供的一个抽象

文件系统令磁盘变得容易使用，从而将用户从数据存放的细节中解放出来：用户不需要知道内容存放在什么地方，也不需要知道如何存放，更不需要知道磁盘到底是如何工作的。因此，文件系统就是磁盘的一个抽象。当然文件系统不一定在磁盘上，也可以在光盘上。但由于绝大部分文件系统是基于磁盘的，本章讨论的文件系统主要针对磁盘。针对其他媒介的文件系统与磁盘文件系统非常类似，读者可以很容易推断或理解。

简单地说，文件系统将其接触的磁盘物理特性转换为用户看到的路径名和文件名。用户对磁盘进行访问只需要给出文件名和路径名即可，而无须知道磁柱、磁道、扇面、数据块等信息。

但如果仅仅停留在文件系统是磁盘的抽象，这一说法并没有给我们太多的信息：它没有告诉我们文件系统是如何工作的，它是如何被使用，它是如何实现的。因此，我们必须再进一层次才能获得这些信息。文件系统到底是什么，若从不同的角度来回答会有不同的答案。

文件系统的主要特性就是存储大量的信息，多个进程可以同时访问一个文件，进程结束也不会影响文件的持续存在。

16.3　文件系统的目标

与内存管理系统类似，文件系统也需要达到两个目的：地址独立和地址保护。地址独立就是一个文件在产生的时候无须担心其存放的磁盘地址，即文件数据的产生与文件将来存放的磁盘地址相互独立。而地址保护则需要对文件的访问进行一定的限制，即不是任何人都可以访问任何文件的。注意，这里的保护与内存地址保护是有区别的。内存管理下地址保护指的是一个进程不能访问另一个进程空间，而这里的保护不是一个文件不能访问另一个文件空间，而是一个文件的访问是有限制的。地址独立将在本章论述，而地址保护将在后续章节论述。

16.4　文件的基本知识

本章首先对文件的基本知识进行讲解，以使读者对文件及文件系统有一个感性的认识。而这种感性的知识是建立在用户观察的角度上，即我们从用户的角度来讨论文件系统，或者说用户看到的文件系统是什么形式。从用户角度分析，文件的地址独立和地址保护是如何实现的。

16.5　从用户角度看文件系统

从用户的角度来看，对用户来说，他关心的是这个文件是什么东西：比如说什么是一个文件和对文件可以进行何种操作，即用户关心的是“怎么用”；而对操作系统设计人员来说，他们关心的则是文件是如何构造的，如何跟踪磁盘上的自由空间，具体的磁盘结构，比如一个逻辑块有多少个扇面，即设计人员关心的是“怎么实现”。

下面我们先来看看用户看到的文件是什么。用户第一个感兴趣的问题就是文件是如何命名的，因为任何概念或实体都需要有个名字。

16.5.1　文件命名

我们如何命名欲访问的文件呢？通常来说，我们给出一个符号名，也就是文件名以便于访问一个文件。这个文件名既可以是英语名、中文名，也可以是图像名（图标）。例如"file"、“文件1”等都是合法的文件名。

当用户给出一个文件名，操作系统就可以根据这个名字找到对应文件的内容。具体来说，就是找到该文件存放在磁盘上的所有数据块。操作系统如何在磁盘上找到所有这些数据块呢？很简单，将该文件名翻译成其数据块在磁盘上存放的地址。由于磁盘的寻址单位是扇面，而这些磁盘地址实际上就是一个个的扇面号。由于一个文件可能占用多个扇面，因此文件在磁盘上的地址通常有多个。根据文件在磁盘上的组织方式不同，这种从名字到磁盘地址的翻译将有所不同。具体的实现将在第17章论述。

命名规则则因文件系统的不同而不同。一个文件名通常有2～8个字符，但也可以长达255个符号。早期的操作系统通常将文件名长度限制在8个字符之内，并且只能使用字符集合的一部分作为文件名的构成。例如，空白符在过去不能出现在文件名内。现代的操作系统则允许文件名长度达到255个字符，且允许使用的字符集合也扩大了许多。一个文件名到底可以使用何种字符因操作系统不同而不同。读者可参阅具体操作系统的使用手册。

16.5.2　扩展名

除了正式名字外，它们还可以有扩展名。扩展名通常用来表明文件的类型，如文本文件、二进制文件等。例如file.c表明该文件是一个c源文件；file2.cpp表明该文件是一个C++源文件；file3.exe表明该文件是一个可执行文件。

这里需要注意的是，扩展名对文件类别的指示仅仅是指示性的，并不具有强制性。例如，在UNIX操作系统下，扩展名仅仅用于提醒用户，系统并不遵守。即只要一个文件是可执行文件，即使其扩展名不是.exe，该文件也能在UNIX下执行。但有些系统对扩展名进行了强制服从，即扩展名必须和文件类型相同，否则无法使用。例如Windows，如果一个文件的扩展名不是.bat、.exe、.com，则该程序无法执行，即使该文件确确实实是一个可执行文件。

16.5.3　内容寻址

那么除了给出文件名之外，还可以给出文件的内容，供操作系统查找使用。什么样的系统是以这种方式实现的呢？数据库。当然，现在也有专门以内容寻址为访问方式的存储系统，比如美国EMC公司的Centera存储系统就是所谓“内容寻址的存储系统”。在这种寻址模式下，用户无须提供文件名，而是提供文件里的某些内容来进行访问。

内容寻址在某些时候非常方便，比如你想寻找电脑上的某张照片。由于照片太多，况且名字又不具有可记性（数码相机照出来的照片都使用一种毫无意义的命名规则，很难记住），你只能一张一张查看。但如果有内容寻址的存储器，则寻址就可以方便很多。例如，我们可以说需要寻找3个人合影的照片，就可以将扫描范围大大缩小。

16.5.4　文件内容组织

文件里面存放的是数据，而数据和数据之间有可能存在某种关系。这种关系是否由文件系统捕捉就决定了文件内容的组织形式。从哲学的高度看，文件内容的组织有两种：关系导向型组织和非关系导向型组织。

1.关系导向型组织

关系型导向型文件的内容的组织形式就是将数据之间的关系记录在文件里面，文件的构造与数据之间的关系存在某种对应关系。例如，记录流存放方式下，数据以一个记录一个记录的方式顺序排列，并不捕捉每个记录之间的关系；在树型存放方式下，数据不只是按记录存放，且捕捉记录之间的关系，但记录之间的关系是一种层次结构，不形成环；在网络存放方式下，记录之间的关系可以形成环。

这3种组织形式如图16-3所示。

图　16-3　关系导向型文件内容的组织形式

2.非关系导向型组织

非关系导向型文件内容的组织形式不考虑数据里面的任何结构。对于此种方式来说，数据就是一个数据流，没有记录，没有关系。这个数据流既可以是字位流，也可以是字节流，也可以是数据块流。它们之间的不同就是这个“流”的单位不同，如图16-4所示。

图　16-4　非关系导向型文件内容组织形式

那么在这么多种组织方式中，哪一种最好呢？或者说哪一种是最受欢迎的呢？

那我们就来分析一下。在记录方式下，一个记录就是数据的最小构成单位。比如，对于一个存放学生信息的文件来说，一个记录可能包括姓名、学号、班级、专业等信息。对文件的访问按记录为单位进行，也就说每次读取要读取一个或多个记录，不能在记录中间开始读写操作。因此，以这种方式构建的文件系统就必须维护或者知道很多信息，例如，一个数据记录有多大？数据记录里面的关键值处于什么位置？只有掌握了这些信息，文件系统才知道读写n个记录时到底是读写多少个字节，才能根据给定的关键值正确读写相关的数据记录。

在树型或网络型存放方式下，文件系统要知道的信息就更多了，除了记录本身的信息外，还需要知道记录之间的关系，从而造成文件系统更加复杂。

相对关系导向型组织方式，非关系导向型方式就简单多了。在这类模式下，文件系统无须知道关于数据的任何细节。对于这种方式来说，数据就是0101这种数据流。至于这个0101代表的是什么东西，文件不必知道，也不想知道。在这种方式下，文件系统将大大简化。

那么复杂的文件系统和简单的文件系统，我们需要哪一种呢？

简单为美：一切都应该尽可能简单，但没有更简单。

——爱因斯坦

在关系导向型组织方式下，文件系统需要知道数据的很多细节，这将使得文件系统复杂。这种复杂性带来了两个巨大的负面效应：一是文件系统本身的设计将更为费时、费力；二是一个文件不能在另一个文件系统下被访问，因为另一个文件系统的记录格式可能不同。这样势必造成文件可靠性和兼容性存在问题。

更关键的是，这些细节不是文件系统的责任范围，而是应用软件或用户的职责范围。因为这些细节直接涉及文件里面数据的语义。前面说过，文件系统的目的是让磁盘更加容易使用，而不是为用户管理数据的语义，即这种管理语义的能力超出了文件系统设计的初衷。况且，由文件系统掌握语义也不安全。在这种情况下，用户的数据无法得到保障，因为任何人可以打开任何文件。

因此，从可靠性、安全性和兼容性来说，关系导向型组织方式都不合适。

对于非关系导向型文件内容组织来说，由于操作系统辨认的是数据块、字节或字位，数据的语义并没有包含在组织方式里，任何操作系统都可以读写所有的文件。因为所有操作系统都知道一个字节或字位是多少。用户要读多少个字节，操作系统就读多少个字节。这就解决了兼容性的问题。至于读出的一串010101010到底是什么意思，操作系统就不用管了，也不应该管。因为对数据进行翻译是应用程序的事情。这样，应用程序可以根据需要定义和翻译数据的意义，还可以进行编码和加密，安全性也大为提高。而且，操作系统不管语义，其所要维护的信息就很少，设计起来简单，可靠性自然也就更高。

到这里，读者应该可以看出来哪一种方式最受欢迎了吧。没错，就是字节流方式。因为它简单，而追求简单正是操作系统设计的努力方向。现代的商业操作系统基本上都使用字节流方式来组织文件内容。历史上曾经出现过关系导向型文件系统，例如，VMS的文件系统就是以记录流方式存放的。

也许有读者会问，既然越简单越好，那为什么不采用字位流呢？字位不是比字节更简单吗？是的，字位听上去比字节更简单，但这是从逻辑上看。从计算机物理现实看，情况就不是这样了。因为通用计算机的寻址最小单位是字节（有的支持半字节寻址，所谓的nibble addressing，有些嵌入式操作系统则支持字位寻址，但本书仅讨论通用计算机），而不是字位。对于计算机来说，读取一个字位比读取一个字节困难得多。因此，从计算机的角度来看，字节是最为简单的了。

爱因斯坦说的话里面的后半句是“但没有更简单”，对于字位来说，它就是比简单还简单，这样反而不行。

总体来说，字节流的文件系统的好处是更加方便、操作系统的代码更加可靠、更加的灵活、用户程序编写也更加方便。例如，用户程序可以对数据进行编码。如果用户不想让别人读懂文件内容，就可以对文件进行加密，而加密和编码丝毫不会影响操作系统对文件的读写。这些优势皆是关系导向型组织方式所缺乏的。

16.5.5　文件类型

下面看看文件的类型，大部分操作系统支持多种文件。根据文件的内容是用户数据还是文件系统本身的数据，文件分为3种：

●目录

●一般文件

●块文件

目录是记录文件的文件，即它的内容是关于别的文件。一般文件用于保存数据。对于一般文件来说，根据其内容的组织方式不同，可以分为两种文件：文本文件和二进制文件。文本文件存放的是没有经过处理的数据，即以ASCII码表示的数据。任何编辑器都可以打开这种文件。二进制文件是经过编码的文件，普通编辑器打不开，必须用专门的应用软件才能打开。另外，加密文件也是二进制文件。

块文件既不是关于别的文件，也不是关于用户数据，而是关于输入输出设备的。具体来说，块文件模拟的是输入输出，或者说是为输入输出提供的一个抽象。对于每个输入输出设备，我们以一个文件来表示。需要与该设备发生数据交换时就以该文件来替代。例如，往（Linux、Solaris操作系统下的）lp1文件写数据就相当于使用行式打印机1进行打印。通过块文件，输入输出和文件系统就统一了。

也许有读者会问，输入输出设备还能用文件替代？当然。文件系统其实就是为磁盘提供一个抽象。而我们知道磁盘可以看做输入输出设备。既然可以对磁盘提供一个抽象，为什么不能为其他的输入输出提供一种抽象呢？

16.5.6　文件格式

不同类型的文件有着不同的逻辑单元，而这些逻辑单元在空间上的关系也因文件类型的不同和操作系统（文件系统）的不同而不同。例如，可执行文件和归档文件（Archive文件）的格式就不相同。图16-5描述的就是可执行文件和归档文件在UNIX下的格式。

图　16-5　UNIX下可执行文件和归档文件的格式

对于可执行文件来说，它有一个标题（header）、一个代码部分、一个数据部分、一个重定位部分和一个符号表。而标题部分又分为多个逻辑单元：魔数、文本尺寸、数据尺寸、BSS尺寸、符号表尺寸、程序入点和各种标志位。这里各部分的信息除魔数外均不言自明。

魔数是用来告诉操作系统这个文件的类型的。对于可执行文件来说，这个魔数有一个固定的值。如果不是这个值，则该文件不能被执行。魔数是一个很重要的数字，如果把一个不是可执行的文件的魔数改成可执行文件的魔数，将会产生不可预料的后果。

当然，不同的文件类型也可能共享同一种文件格式。例如，Windows里面的PE格式就是对象文件（obj文件）、可执行文件、动态链接库、驱动程序等共用的一种格式。图16-6给出的是PE格式的简要描述。

图　16-6　Windows下可执行文件（PE）格式

16.5.7　文件访问

文件的访问类型只有两种：顺序访问和随机访问。

顺序访问就是从开头开始访问，按先来后到的顺序读取数据。不能在中间随便跳转，但可以进行快进或者快退。早期的操作系统只提供顺序访问。这是因为早期的计算机主要以纸带、磁带等作为存储媒介，而这种媒介比较适合顺序访问。对于需要随便跳转的随机访问，纸带和磁带都不太胜任。显然，顺序访问的缺点很多，你不能想访问什么地方就访问什么地方。就像看录像带一样，倒来倒去很不方便。

随机访问则可以按照任意顺序读取数据记录。我们先对文件进行选点（seek），到达指定的位置后再开始读写。读写一段数据后又可以再次选择新的位置。这种随机读写是数据库能够工作的基础。就像我们看光碟一样可以随机选择想看的段落。

现代操作系统无一例外地都提供随机访问，这是因为我们的存储媒介已经是可以随机读写的磁盘了。

16.5.8　文件属性

我们知道每个文件都有一个文件名，而文件里面存放的是数据。但除此之外，文件还有许多信息是我们感兴趣的。例如，文件是谁创立的，谁拥有这个文件，是不是可以随机访问，是不是随机文件，是否上锁（很少有人这么用），有何保护措施等。这些信息称为文件的属性。对于不同的操作系统来说，文件的属性种类和数量可以不同。但每一种属性都有自己的用途。表16-1列出的是常见的文件属性。

表16-1中列举的属性大部分都直接明了，部分需要解释，如下所示：

●临时文件标志用来指示该文件为临时创建的，用来存放中间结果，在创建该文件的进程结束后，该文件将被删除。

●锁标志用来指示该文件是否已上锁。为1时表明该文件当前被某个进程锁定，其他进程将不能打开该文件。利用对文件上锁，两个进程可以进行同步。

●记录长度、关键字位置、关键字长度几个属性针对的是记录流文件。对于字节流文件组织方式来说，这些属性是没有的。

●最后修改时间、最后访问时间等属性经常由备份软件用来判断是否是差分增量。

文件的属性可以被修改。修改的方式有两种：一是直接在shell下用工具程序（utilities）进行修改；二是在程序中使用系统调用（system call）进行修改。

16.5.9　文件操作

可以对文件进行的操作有：创建/删除、打开/关闭、读写添加、寻找访问位置、读取属性/设置属性、重命名等。

16.6　地址独立的实现机制：文件夹

前面说过，我们可以对文件进行读写等操作。那么给一个文件名，操作系统是怎么知道从什么地方读取文件的内容呢？或者说，文件在磁盘上的什么位置，操作系统是如何知道的呢？这当然需要一个数据结构来记录每个文件在磁盘上的地址，这个数据结构就是文件夹。

文件夹也称为目录夹（folder），它保存的不是用户数据，而是关于文件及文件系统的信息。简单地说，文件夹的角色就是跟踪文件，里面存放的是从文件到文件在磁盘的地址的映射，即“文件名→文件在磁盘上的地址”。文件夹对于文件来说，就相当于动态地址翻译对于虚拟地址的作用，即从虚拟地址到实际地址的一种翻译机制。

这样，当给予一个文件名时，我们从文件夹里可以找到该文件所在的位置，从而发出正确的读取命令。那么文件在磁盘上的地址是什么呢？这就要看文件在磁盘上是如何存放了。如果文件在磁盘上是连续存放的，即一个文件占用一片连续的磁盘空间，则文件在磁盘上的地址就是该文件的第一块数据块所在的磁盘扇面号。当然，文件的存放不只有连续存放一种模式，因此文件在磁盘上的地址还有别的形式，我们稍后解释。但总的一点就是，文件夹应该提供一种找出一个文件所有磁盘块的方法。

既然文件夹存放的是文件名到磁盘地址的映射，那么自然地我们可以以一个简单数组来实现。这个数组是以文本文件存放的，你可以自己写一个程序打开这个文件就可以看到里面的内容。而读取文件夹的内容就变成读取这个简单数组的内容。而这正是shell utility"ls"（UNIX、Linux、Windows Power Shell）和"dir"（Windows）所做的事情。

这里很重要的一点是文件夹本身也是文件。因此，我们可以像对待文件一样对待文件夹：使用同样的存储结构来存放文件夹的数据；文件夹里面的每个记录本身又可以是文件夹。

16.6.1　文件夹结构

由于文件夹里面又可以有文件夹，这样就形成了一个层次结构。这个层次结构的顶端就是根文件夹，也称为根目录，如图16-7所示。

图　16-7　文件夹的层次结构

根目录是一个文件系统的总起点，它在操作系统启动的时候加载到内存。从根目录开始，该文件系统里面的所有文件都可以找出来。由于根目录是整个文件系统的源点，如果根目录损坏，则整个文件系统都无法访问，也就说文件系统已经崩溃。（当然，我们可以通过文件恢复和修复等手段来获得对部分文件的访问能力，但这不是本书要探讨的问题。）

下面我们看一下如何从一个文件名找到其所在的磁盘地址。

假定我们要找的文件是：/zou/cs307/file.pdf。这个文件名最前面的代表的就是根目录。因此，我们首先从根目录开始寻找。根目录里面可能保护许多文件和文件夹，其中的一个文件夹就是zou，而根目录里面存放有zou文件夹所在的磁盘地址。我们获得的磁盘地址读取zou的内容（一个文本数组），将其打开。

我们发现，打开的zou文件夹里又包括另一个文件夹cs307及其在磁盘上的位置。我们从给出的磁盘位置上读取文件夹cs307的内容（另一个文本数组），发现里面包括一个文件名为file.pdf的文件及其对应的磁盘地址。我们从给出的磁盘地址上就可以读取文件file.pdf的内容。这样从根目录开始，我们需要访问磁盘3次才能得到文件file.pdf的内容。

16.6.2　相对路径与绝对路径

16.6.1　节的例子给出的文件名"zou/cs307/file.pdf"包括从根目录开始的一个完全路径，因此称为绝对路径。绝对路径是从根目录的视角看一个文件的访问路径。与此相对的是相对路径，即从某个工作目录，如用户当前所处的目录的角度看某一个文件的访问路径。例如，如果我们目前已经在目录"zou/cs307/"下，则只需要给出文件名file.pdf即可访问该文件。

使用相对路径的好处是没有必要寻遍整个目录夹，可以节省磁盘访问次数，从而提高文件访问效率。前面讲过，从根目录开始需要3次磁盘访问才能得到文件file.pdf的内容；但如果在工作目录"zou/cs307/"下直接给出文件名file.pdf，则只需要进行一次磁盘访问即可。因为当前目录的内容是已经打开的，即已经加载到内存的，我们无须磁盘访问即可查看当前目录内容，发现file.pdf的文件及其对应的磁盘地址。我们只需要按照该地址对磁盘进行一个访问即可获得file.pdf的内容。

但需要注意的是，相对路径不一定总是比绝对路径节省磁盘访问次数。如果你要访问的文件是当前这个目录的“直系后代”，则将节省磁盘访问次数；否则，即如果不是“直系后代”，你就可能多花费时间。例如，在图16-7的目录树里面，如果当前目录是“目录夹1”，则使用相对路径访问“文件1”将节省磁盘访问次数，而访问“文件2”则比绝对路径更费时间。这一点我们从相对“文件2”的相对路径和绝对路径名就可以看出：

●文件2的相对路径名：“./目录2/文件2”。

●文件2的绝对路径名：“目录2/文件2”。

16.6.3　共享与链接

就像到达一个目的地通常有多条路可走一样，访问一个文件也可以选择多条路径。实现多条路径依靠的是链接。我们前面说过，从目录夹1下面访问文件2，无论使用绝对路径还是相对路径，均需要经过根目录。而从目录夹2下面访问文件2则无须经过根目录。自然，从目录夹2访问文件2比起从目录夹1访问文件2来说效率要高。

那有没有办法使得从目录夹1下访问文件2的效率与从目录夹2下访问文件2的效率一样呢？有，这个办法就是从目录夹1建立一条到文件2的链接。链接的建立使用操作系统的Link系统调用，该调用给出一个目录名和一个文件名，在操作后，从该目录到该文件将存在一个“直系下属”关系，我们从该目录可以直接访问该文件。也就说，打开该目录我们可以看到该文件。

链接使得文件的共享相当方便。尽管我们可以通过文件拷贝来实现共享，但那种共享不是即时共享，即一个人修改文件内容后，需要再次拷贝才能被另外一个人看见。而使用链接就无须这种循环往复的拷贝。修改的内容另一个人可以直接看到。

切断链接可以使用操作系统的unlink系统调用。Link和unlink的实现我们将在第17章论述。

16.7　文件系统调用

从用户角度来看，操作系统提供的文件系统界面就是文件系统调用。用户可以在程序中通过这些系统调用对文件系统进行读写操作。这些系统调用包括打开文件、关闭文件、读文件内容、写文件内容等。例如，图16-8给出的就是使用文件系统调用实现文件拷贝的简单程序。

图　16-8　使用文件系统调用实现文件拷贝

上述文件拷贝程序的工作原理很简单：打开源文件和目标文件，循环往复地将源文件的内容读到一个缓冲区，然后将缓冲区的内容写到输出目标文件里。当拷贝结束后，关闭源文件和目标文件。上述程序中使用的文件系统调用包括fin.open、fout.open、fout＜＜、fin.get等。

这里需要注意的是，输入文件的打开模式为只读，而输出文件的打开模式是只有创立这个文件的人才能读写，其他人均无访问权限（输出模式0700代表的就是这个意思）。

那么上述程序有一个问题，不知读者是否看出？在正确性方面没有问题，但在效率上却不太令人满意。这是因为该程序的系统调用次数太多，而系统调用需要陷入到内核。另外，每次系统调用都需要进行磁盘操作，而磁盘操作的效率远远低于内存操作的效率。解决这个问题的办法是可以将缓冲区变大，从而降低系统调用和磁盘访问的次数。但缓冲区越大，需要的内存就越大，将造成程序的可执行性下降。而且，就算缓冲区很大，文件还可以更大，还是不能从根本上解决效率低的问题。

那有没有其他办法解决效率低下的问题呢？当然有，就是不使用系统调用。而是采用我们下面将要讲述的内存映射的文件访问。

16.8　内存映射的文件访问

解决读写文件效率低的中心思想就是把磁盘访问变成内存访问。实现这种访问转变的手段就是内存映射的文件访问。

内存映射的文件访问原理很简单：把需要访问的文件映射到一个进程的虚拟地址内。这样，访问该虚拟地址就相当于访问文件。这样文件访问就变成内存访问。例如，假定我们将一个32KB大小的test文件映射一个进程的虚地址1024KB处。那么读写1024KB的地址就是读写test这个文件的第一个字节。而当该进程中止的时候，与文件对应的这个内存内容将被冲洗（flush）到磁盘上，如图16-9所示。

图　16-9　内存映射的文件访问模式

这样做的好处就是磁盘访问变成了内存访问，效率自然提高。例如，还是我们前面的文件拷贝，这次不用文件系统调用来实现，而是使用内存映射的文件访问来实现。例如，我们可以将文件a和b均映射到同一个进程的不同虚拟地址上。这样读a文件对应的虚地址将把a的内容从磁盘上拷贝到内存，再把内容写到b对应的虚地址上即可。

不过需要提醒的是，内存映射的文件访问不是为了文件拷贝而提出的，而是为了文件的共享。具体来说，我们可以将同一个文件映射到两个或多个进程的虚地址空间。这样每个进程对相应的虚地址空间进行访问时就是对同一个文件进行访问。

思考题

1.我们是否可以像对待文件数据一样任意读写文件夹呢？为什么？

2.请比较关系导向和非关系导向的文件组织方式，分别论述它们的优缺点。3.文件组织的最普遍使用的方式是什么？为什么？

4.文件链接是做什么用的，它是如何实现的？

5.请调查1～2个商用操作系统，看看哪些字符不能用在文件名里，并简要说明原因。

6.内存映射的文件访问是怎么回事？它有什么特点？人们为什么引入这种概念？

7.文件系统的地址独立是怎么回事？它是通过何种机制实现的？

8.如果没有使用文件系统，对磁盘的读写操作应该如何进行呢？

9.相对路径是否总是比绝对路径节省磁盘访问的次数？为什么？

10.请编译本章给出的文件拷贝程序，并找出fin.get和fout＜＜两个函数最终使用的操作系统调用。

第17章　文件系统实现

引子：成功中的失败

公元前480年4月20日，波斯帝国（Persia Empire）国王泽尔世（Xerxes）在首都苏萨的启示殿宣誓发兵讨伐希腊，一并扫除那些令人不满的民主（见图17-1）。为了将军队从小亚细亚运到希腊，泽尔世决定做一件常人看起来不可能做到的事情：在达达尼尔海峡上建造两座大桥供军队通过。泽尔世召集了腓尼基的工程师们，命令他们一个月之内将桥架起。

在腓尼基工程师们的指导下，波斯军队把674艘舰船首尾相连，分成两列横置在宽度近两公里的海峡上。军士们用粗大的绳索将舰船紧紧捆绑在一起，在甲板上铺了木板，然后在木板上铺上树枝，覆盖泥土，经压实后形成了两座坚固桥梁，从而将小亚细亚和欧洲大陆连接起来。就在大桥即将竣工时，一场突如其来的暴风雨却将这两座宏伟的大桥摧毁了。希腊的守护神似乎在显灵。

为了击败希神，泽尔世想出了另一个办法。他命令一位希腊工程师来重新建造大桥！在希腊工程师的指导下，波斯军队再次搭建了两座连接亚洲和欧洲的舰船大桥。

公元前480年5月29日，泽尔世往海峡里面投放了一个金杯、一个金碗和一把金杖，庄严宣布大桥落成。随后，波斯军队和辎重分别从两座桥上走过达达尼尔海峡。泽尔世站在附近一座山顶上，目送着漫山遍野的波斯军队离开家乡，开赴遥远的希腊战场。泽尔世没有豪情壮志，更没有兴高采烈，而是潸然泪下。

泽尔世的好友，波斯王子之一的么木侃（Memucan）忙问其故。泽尔世回答说：“看我们的波斯勇士，虽然人数众多，声势浩大，但他们终将告别人世。每念及人生短暂，我便不禁泪如雨下。”在这种悲情的伴随下，泽尔世和他攻无不克的波斯勇士终于登上了欧洲大陆。

图　17-1　波斯帝国国王泽尔世

显然，人们对事情的取向是基于其对事务状态的判断。不同的判断自然导致不同的行为。在文件实现上也一样，不同的看法和判断导致了文件实现的不同机制的出现。

设计师的文件视角

第16章介绍了从用户角度看待文件系统，即文件系统的感性知识。但对于操作系统设计人员来说，单有感性知识是不够的。我们需要的是文件系统的实现细节，因为这就是操作系统设计人员所看到的层次。本章从操作系统设计人员看待文件系统，也就是文件系统的实现。对于操作系统设计人员来说，他们关心的问题是：

●文件系统如何分布的。

●文件是怎么实现的。

●文件夹是怎么实现的。

●共享文件是怎么实现的。

●磁盘空间是如何管理的。

前面讲过，文件主要在磁盘上实现。当然你也可以将文件系统放到其他介质上。但不管是什么介质，其原理大同小异。这里以磁盘为介质进行说明。

买过磁盘的人都知道，磁盘买来后要做的第一件事情是对磁盘进行分区和格式化。那么磁盘为什么要进行分区呢？或者说我们必须分区吗？

分区的理由多种多样。首先，分区可以方便我们使用磁盘，因为不同的分区可以建立不同的文件系统。其次，分区有安全性上的优势，因为一个分区毁坏了，另一个分区仍然可以使用。最后，分区还有可靠性上的优势，因为一个分区的故障不影响另一个分区的运行。这些分区理由虽然是好的理由，但都不是必须分区的理由。

必须分区的理由是对磁盘空间的使用。因为计算机的内存字长度通常有限，而磁盘地址需要存放在内存字里面。这样，操作系统能够访问的磁盘地址数量就是一个有限数。这个有限数将限制操作系统能够访问的磁盘空间大小。如果一个磁盘容量超过这个上限，则多余的空间将无法访问。假如内存字的长度为16位，这样操作系统能够表示的磁盘地址数为216，也就是65 536个磁盘地址。假定磁盘的数据块（每个地址）的大小为512B，则操作系统能够访问的最大磁盘空间为33 554 432B，即大约32MB的空间。

当然，我们可以加大磁盘数据块的尺寸使得能够访问的磁盘空间增大。但这样带来的坏处是磁盘空间的浪费，因为很多文件占不到一个磁盘块。再说，即使这样，也只不过增大能访问的磁盘空间，并不能从根本上解决问题。但如果使用分区，则可以将磁盘分解为大小为32MB的多个分区，这样整个磁盘就都可以被访问到。

那么为什么一个分区只能建立一个文件系统呢？

17.1　文件系统的布局

一个磁盘包括一个个扇面，编号从0递增，整数计数。第0个扇面在整个文件系统中占有重要意义。该扇面存放的是主引导记录（Master Boot Record, MBR）。该记录的内容是一个小小的程序，用来启动计算机。如果该扇面损坏，则整个磁盘就无法使用。这时唯一的办法就是将磁盘拿到硬件制造商那里对磁盘扇面重新编号。

在MBR后面紧接着的是磁盘分区表。磁盘分区表里给出的是磁盘的所有分区及其开始和终结地址，其中一个分区为主分区。操作系统就装载在这个分区中。主分区里面最前面的是引导记录（Boot Record）。

在计算机启动时，处于主板ROM里面的BIOS程序首先运行。BIOS在进行一些基本的系统配置扫描后对磁盘的扇面0进行读操作，将MBR里面的程序读到内存并运行。MBR程序接下来找到系统主分区，并将主分区里面的Boot Record加载并运行。Boot Record里面的内容是一个小程序，该程序将负责找到操作系统映像，并加载到内存，从而启动操作系统。所有的文件系统都必须按照这种格式存放，操作系统才能正常启动。

在Boot Record记录块后面的内容就因情况而异了。一般来说，在该记录块后面的磁盘内容布局因文件系统的不同可以不同。但在通常情况下，紧接着引导记录后面的是一个超级数据块（Super Block），该块里面存放的是关于该文件系统的各种参数，如文件系统类型、文件系统数据块尺寸等。在Super Block后面则是磁盘的自由空间，其后面是I-NODE区，再后面是文件系统根目录区。分区的最后存放的是用户文件和文件夹区。

文件系统的布局如图17-2所示。

图　17-2　文件系统布局（来源：参考文献[3]）

这里需要提醒的是，上述文件系统布局仅仅是一个例子。不同的文件系统布局是不一样的。例如，对于FAT系列的文件系统来说，I-NODE区是不存在的。因此，在讨论具体文件系统时，读者需要参阅该文件系统的使用手册。

17.2　文件的实现

我们说过，对于操作系统设计人员来说，我们关心的是文件是如何实现的。那么文件的实现意味着什么呢？或者文件的实现究竟是什么意思呢？

文件的实现，归根结底，就是要能够把文件的内容存放在合适的地方，并能够在需要时很容易地读出这些数据。这样，文件的实现要解决的就是如下几个问题：

●给文件分配磁盘空间。

●记住这些磁盘空间的位置。

●将文件内容存放在这些空间。

给文件分配磁盘空间就是要按照用户要求或文件大小分配恰当容量的磁盘空间，记录这些空间的位置对将来的文件访问至关重要；将文件内容存放到这些空间中则可以通过磁盘本身的驱动器实现。上述3点均需要了解数据在磁盘上的存放方式。

数据在磁盘上的存放方式，就像程序在内存中存放的方式那样，有下面两种：

●连续空间存放方式

●非连续空间存放方式

其中非连续空间存放又可以分为链表方式和索引方式。

17.2.1　连续空间存放方式

这是任何人均能想出来的办法。在一个文件需要磁盘空间时，为其分配一片连续的磁盘空间，这样一个文件的数据紧密相连，读写起来非常方便。在这种模式下，一个文件占住一片连续的数据块，这种连续的块通常叫做盘区（extent）。连续存放方式的读写效率很高，因为一次磁盘寻道就可以读出整个文件。数据库文件就要求连续存放。所以装数据库的话最好在一个空的磁盘上装，如果你装在一个放了很多东西的磁盘上你就会发现系统运行很慢。

使用连续存放的方式必须知道一个文件所需要的空间大小。所以使用这种方式必须事先声明所需的最大空间是多少。文件系统则按照这个声明在磁盘上寻找一片足够大的自由空间并把它分配给该程序。而这种寻找自由空间的任务通常由一个适配算法来负责。图17-3描述的就是连续存放方式下文件A、B、C、D、E的一种可能分配方式。

图　17-3　连续存放的文件实现

由于文件的连续存放方式与程序在内存中的连续存放方式非常相似，因此其优缺点也非常相似。具体来说，文件的连续存放的优点就是简单：从文件名到文件地址的映射变为从文件名到文件第一个数据块的地址映射。即只要给出I-NODE地址和文件大小，就可以寻找到所有的文件数据块。这个和内存管理中的基址和极限很相似。另外一个优点是读写效率高。

文件连续存放的缺点自然也与内存的连续分配的缺点一样：空间浪费（磁盘碎片）和不易扩展。在图17-3中，如果文件D被删除，磁盘上就留下一块空缺。这时，如果新来的文件小于其中的一个空缺，我们就可以将其放在相应空缺里。但如果该文件的大小大于所有的空缺，但却小于空缺大小之和，则虽然磁盘上有足够的空缺，但该文件还是不能存放，如图17-4所示。

图　17-4　连续存放的文件实现

当然，我们可以通过将现有文件进行挪动来腾出空间以容纳新的文件。但在磁盘上挪动文件非常费时，恐怕令人无法忍受。

除了磁盘碎片（空间浪费）外，另一个缺点是文件扩展十分不便。例如，图17-3中的文件D想扩大一下，需要更多的磁盘空间，有什么办法吗？自然没有什么好办法。唯一的可能是将文件D放到磁盘上另一片空间更大的连续空间里；或者将与D相邻的文件挪开为D让出空间。不管哪种办法，效率都非常低。

那么有没有什么比较好的办法解决上述问题呢？有，就是改变文件的存放方式。因为这些文件均是因为文件块需要连续才造成的。因此，我们的解决办法就是非连续空间存放方式。

17.2.2　非连续空间存放方式

非连续空间存放方式就是一个文件的数据块之间不需要在磁盘上占据连续的一片空间。这样，我们需要一种办法来寻找到文件的所有数据块。那么有什么办法可以做到这一点呢？答案是链表。

使用链表的解决办法很简单。在每个数据块里面留出一个指针的空间，用来存放下一个数据块所在的地址。这样一个数据块连着一个数据块，从最前面的数据块开始就可以顺着指针找到所有的数据块，如图17-5所示。

图　17-5　某文件的链表存放方式

在链表存放方式下，文件的映射就是给出文件第一个磁盘快的位置。这种存放方式比起连续存放方式的优点是可以利用碎片。但缺点呢？访问速度慢。尤其是随机访问，访问任何一个数据块均需要从头数据块开始一个指针一个指针地找下去。如果有任何一个指针损坏，则将无法重构整个文件（但如果是连续存放则不存在这个问题，或者说重构要容易得多）。

这种方式的另一个问题是增加了存储开销，因为指针要占空间。那除此之外还有什么缺点？这个缺点你可能需要深入了解后才能知道。大家都知道，计算机里面的尺寸都与2的指数次方有关系，因为计算机里2的整数次方比较容易处理（方便读写）。那么一个磁盘块是多少个字节呢？当然应该是2的整数次方个。如果使用磁盘块里面的一部分空间来存放指针，那一个数据块里面存放的数据就很有可能不是2的指数次方了。这将造成数据处理的效率下降。

那么我们有什么办法克服上述问题呢？

答案是肯定的。假定我们是文件系统设计人员，我们有什么办法可想呢？把所有指针从单个数据块抽取出来，全部放在一起，形成一张表不就解决问题了吗？这样，要想知道一个数据块的位置，只需要查找该表即可。而且，该表可以存放在内存里面。这样既解决了数据块里面数据不是2的指数次方的问题，又解决了随机访问速度很慢的问题。

这张存放文件数据块指针的表称为文件分配表（File Allocation Table, FAT），如图17-6所示。

文件分配表的每个记录为物理磁盘块编号，每个记录存放的是下一个数据块所存放的物理磁盘块编号。例如，如果文件A的第一个数据块存放在物理磁盘块3上面，那么FAT里面索引为3的记录里面存放的则是A的第2个数据块存放的物理磁盘块，在图17-6里面是10，即文件A的第2个数据块存放在物理磁盘块10里。FAT里索引为10的记录的内容是18，说明文件A的第3个数据块存放在物理磁盘块18中。FAT里索引为18的记录内容是13，说明文件A的下一个数据块在物理磁盘块13里。FAT里索引为13的内容为6，说明下一个数据块在第6个物理磁盘块里。FAT里索引为6的内容为15，说明下一个数据块在第15个物理磁盘块里。FAT里索引为15的记录内容为-1，说明该文件没有下一个数据块，即文件已经结束。这样，根据FAT，我们得出文件A所占的物理磁盘块顺为：

3→10→18→13→6→15

图　17-6　文件分配表

这样，我们只需要记住文件A的起始物理磁盘块为3即可。剩下的数据块均可以根据FAT获得。那么我们怎么知道文件A的起始物理磁盘块为3呢？还记得文件夹吗？这个起始地址存放在文件夹里面。

我们下面看一下FAT环境下的随机访问。如果我们要随机读写文件，则仍然需要从第1个数据块地址开始，在FAT里面顺着指针找到特定数据块所存放的物理磁盘块后才能读取该块数据。但这里的指针跟踪与使用链表时有着巨大的不同：这里的跟踪在内存发生，不是在磁盘上，因此效率大大提高。读写任何一个磁盘块均只需要一次磁盘访问。

这里要强调的一点是，无论是文件分配表还是链表组织，只是形式不同而已，所有链接指针都集中存放，这和我们下面要介绍的索引文件组织是有本质不同的。

17.2.3　FAT文件系统

使用FAT机制的文件系统称为FAT文件系统。而FAT文件系统又有FAT12、FAT16、FAT32 3种。这3种的区别在于用来表示磁盘地址的内存字位数。如果用12位来表示磁盘地址，则是FAT12，用16位就是FAT16。不过FAT32却并不是使用32位内存字位来表示磁盘地址，而是28位。但因为28听上去不像32那样像2的指数次方，所以把它叫做FAT32。

FAT的3种文件系统在Windows上均获得了广泛应用。当然也有些人认为其过时了，不实用了。因为FAT占有内存空间太大。

那么FAT是否占用大量的内存空间呢？这得看磁盘有多大了。因为FAT表的大小与物理磁盘大小和磁盘数据块大小有关。由于FAT的记录项与物理磁盘块数一样，磁盘越大，磁盘数据块越小，FAT越大。前面介绍过，页表很大是一个大问题。那么这里FAT很大当然也是一个大问题。当然，我们可以采取分页系统里采取的办法，例如通过只将FAT的一部分存放在物理内存，其他存放在磁盘上来减轻这个问题。但这种解决方案的代价则有可能造成文件访问效率的降低。

那还有什么办法改进呢？当然有。答案就是索引文件组织。

17.2.4　索引文件组织

要改进FAT系统，就要看FAT的问题出在什么地方。问题就是FAT太大。但仔细分析却发现，FAT虽然很大，但里面存放有用信息的记录不一定很多。例如，如果系统里面文件数量较少，或者个体文件的尺寸很小，则FAT里面的很多记录都是空的。这样将整个FAT放在内存里就显得有点不必要了。

那么我们想，如果能够将每个文件的所有数据块的磁盘地址收集起来，集中放在一个索引数据块里，而在文件打开时将该数据块加载到内存，以后访问任何一个数据块都可以从该索引数据块里面获得物理磁盘地址。这样，内存里面存放的只是我们需要使用的文件的数据块的所有地址，而不是各种不相干的文件的地址全部一次加载到内存。这样，FAT占用内存太多的问题就解决了。

这种索引数据块称为I-NODE。这里的I即是索引（Index）一词的缩写。当然，也有人认为I不是代表索引，而是代表信息（Information）。因为I-NODE里面存放不仅仅是文件地址索引，还有其他关于文件的信息，如文件属性等。图17-7描述的就是一个I-NODE。

图　17-7　I-NODE举例

使用索引组织方式时，用户先宣称文件大概有多大，文件系统则按照用户的声明分配一个含有相应数量指针空间的I-NODE，但是先不分配真正的磁盘空间。在真正需要分配数据磁盘空间时，每分配一个磁盘数据块，更新I-NODE里面的相应指针。

如果要访问一个文件，则首先将其对应的I-NODE打开。根据I-NODE的内容，我们就可以找到任何要访问的数据块。比方说，在图17-7里，如果我们要访问第3个数据块，则从该I-NODE里面找到3所对应的物理磁盘块编号679，再发出读第679磁盘块的命令即可。

细心的读者可能已经发现，这个I-NODE非常类似于内存管理时的页表。页表存放的是虚拟页面到物理页面的对应，而I-NODE存放的是逻辑数据块到物理数据块的对应。

既然I-NODE与页表相似，那页表除了存放地址映射外，还存放一些别的信息。I-NODE自然也可以在地址对应之外存放别的信息。这正是很多人认为I-NODE里面的I代表信息的原因。这些信息包括诸如文件创建时间、修改时间、是否为系统文件等。

索引文件由于也属于非连续组织方式，自然继承了链表和FAT方式的优点。而且，由于I-NODE按照个体文件而加载、打开、关闭，避免了将所有文件的地址同时加载到内存而造成内存空间紧张的困难。另外，随机访问很方便，因为只要从I-NODE获得所需数据块的物理磁盘地址，一次磁盘访问就可以搞定。索引组织的另一个优点是文件增长灵活。只要文件的尺寸不超过索引头里面预留的指针数×磁盘数据块大小，则文件可以自由地增长。只要在磁盘上找到一个自由磁盘块，分配给文件，再更新I-NODE以反映这个新的映射即可。

索引组织的缺点也与FAT方式类似：比较麻烦。因为每个数据块都需要选点，相当于一系列随机访问，即顺序访问效率不太高。

另外，虽然文件增长灵活，但如果一次文件的大小增长超过了预期，其需要的数据块数超过索引头预留的指针数时怎么办？例如，如果只分配了50个指针，文件尺寸在25 600B（假定数据块大小为512B）范围内时增长很容易；但如果超过这个范围就不容易增长了。我们当然可以重新分配一个更大的I-NODE，将原来I-NODE里面的数据拷贝到新的I-NODE，然后再分配新的磁盘空间，更新I-NODE。

不过这种做法有两个问题：一是需要进行数据的转移（从一个I-NODE移到另一个I-NODE），浪费时间；二是使得I-NODE大小可以任意变化，而这将令文件系统的管理变得复杂。

那么有没有什么较好的办法来解决这个问题呢？

17.2.5　多级索引组织

解决的办法有：多级索引组织。在分页系统里面，页表太大时使用多级页表。这里我们自然可以在文件太大时使用多级索引头，即使用间接I-NODE。

在这种模式下，一个文件使用的I-NODE数不是一个，而是多个。而这多个I-NODE又被安排成多级：顶级I-NODE、次级I-NODE等。其中顶级I-NODE存放的不是数据块对应的磁盘块地址，而是次级I-NODE的磁盘地址。而次级I-NODE里面存放的才是数据块对应的物理磁盘地址，如图17-8所示。

图　17-8　多级索引组织

不过，这种多级索引组织有一个缺点，就是访问数据块所需要的磁盘访问次数增加了。因为访问次级I-NODE需要一次磁盘访问，因此磁盘访问速度很低。这种次数的增加显然不好。另外，多级索引还有一个缺点，就是对于小文件来说，它们使用的磁盘数据块可能不会超过一个I-NODE里面的指针数。但由于使用多级索引，就浪费了I-NODE空间。

那么有什么办法解决这个问题吗？有，使用非对称多级索引。

17.2.6　非对称多级索引

既然单级索引不能适用于大文件，而多级索引又不适用于小文件，那么能想到的一种办法自然是单级和多级的有机组合：非对称多级索引。

在非对称多级索引组织下，索引既可以是单级，也可以是多级。到底是单级还是多级取决于文件的大小。所有的文件都有一个顶级索引节点（I-NODE）。不过这里比较特别的是该NODE里面的指针域被分为两个部分：一部分用来存放数据块所在的磁盘地址；另一部分用来存放次级I-NODE的地址。这样，如果文件尺寸较小，其所占数据块数没有超过I-NODE里面分配的数据块指针数，则其数据块磁盘地址全部存放在顶级I-NODE上。这样的文件将没有次级索引。如果文件较大，则除了顶级数据块磁盘地址外，还将有次级I-NODE，如图17-9所示。

例如，如果一个I-NODE里面可以存放有50个指针，则50个指针不是全部用来存储数据块对应的磁盘块地址，而是留两个指向磁盘块，称为间接磁盘块。而间接磁盘块上存放的则是数据磁盘块的地址。I-NODE里面指向数据块的指针称为直接指针，指向间接磁盘块的指针称为间接指针。

图　17-9　非对称多级索引

非对称多级索引对小文件来说速度快，空间省，对大文件来说可以容纳。但是它对大文件的访问速度提高了吗？从表面上看，没有。因为大文件需要使用多级I-NODE，进而需要多次磁盘访问。这和普通多级索引没有什么区别。

但真的是这样吗？这个时候如果我们懂一点心理学就会清楚多了。由于使用非对称多级索引，大文件的前面数个数据块的磁盘地址存放在顶级I-NODE里，后面的数据块的磁盘地址存放在次级I-NODE里。这样，访问前面的数据块显然比访问后面的数据块要快，其速度和单级索引一样。如果我们对这个特点加以利用，先读出前面的数个数据块并呈献给用户，在用户忙于处理前面的数据块时，再在后台读出后面的数据块。这样用户看完或处理完前面几个数据块，需要看到后面时，后面的也读出来了。这样用户的感觉是速度很快（像单级索引那样快）。因此，从用户体验看，非对称索引也提高了大文件的访问速度。

那么我们如何在顶级和次级之间分配指针域呢？即多少指针域用来存放直接磁盘地址，多少用来存放间接磁盘地址呢？这就要对文件系统的状况和人的心理进行研究。即研究两个，一个是用户的文件一般多大；另一个是用户的忍耐时间是多久。如果大多数文件都小，那就要多留一点直接块，让用户能够一次读出来。如果文件都大，那就多留间接空间，但却不能将直接空间挤压过小，使得直接访问的数据量小于用户正常阅读速度×访问次级数据块需要的时间，从而造成用户体验下降。

如果一级间接还不能容纳系统里的大文件，那就可以使用两级间接、三级间接、四级间接……当然，随着间接层次的增加，文件的访问速度将随之下降，最后可能达到无法容忍的地步。这个凡是打开过巨大文件的人都有这种经历。因此，在实际的商业操作系统里，间接的次数都有个上限。例如，UNIX的V7文件系统的间接层数为3。

而一旦间接层数设定，则一个文件系统里能够存放的文件最大尺寸也就确定了。

17.2.7　文件缓存

单级也好，多级也好，文件的读取相对于内存访问来说就是慢。对于慢我们有什么解决办法吗？答案就是使用缓存。

将文件里面经常要访问的内容存放在缓存里，使得文件的访问可以在缓存满足，而无须到磁盘上去读写。这种缓冲称为文件缓存。

17.3　目录实现：地址独立的实现

前面讲过，文件夹的任务是提供从文件名到文件地址的映射。那么对于不同的文件组织形式，文件的地址表示也不一样。

如果文件是连续存放的，我们只需要文件的第一个数据块的磁盘地址即可，后面的数据块紧接在该数据块后面。这种情况下文件夹里面存放的映射是到I-NODE数据块地址。

如果是链表组织形式，我们只需要文件的第一个数据块磁盘地址即可，后面的数据块可以通过前面数据块里面的指针获得。这种情况下文件夹里面存放的映射也是到I-NODE数据块地址。如果是FAT组织形式，映射仍然保持不变。我们可以从FAT里面找到后继数据块所在的物理磁盘地址。

如果使用的是I-NODE组织形式，我们只需要知道文件对应的顶级I-NODE地址即可。文件的数据块地址可以从I-NODE里面获得。这种情况下文件夹里面存放的映射是到I-NODE编号。

17.3.1　文件属性的存放

那么文件属性应该保持在什么地方呢？这也是根据文件组织方式的不同而异。如果是连续或链表组织形式，只有文件夹里面可以存放文件属性。但如果是I-NODE组织形式，则属性既可以存放在文件夹里，也可以存放在I-NODE里，如图17-10和图17-11所示。

图　17-10　将属性存放在文件夹里

图　17-11　将属性存放在I-NODE里

17.3.2　长文件名的存放

我们前面说过，文件夹存放从文件名到文件地址的映射。那么每一个文件将在文件夹里面占用一个记录。既然是记录，总有一个长度。为了便于读取文件，文件夹里面的记录长度通常是固定的。那么对于文件名很长的情况，即文件名长度超过记录能容纳的长度时，我们怎样存放文件名呢？

当然，最简单的办法是不允许长文件名。但这个办法很可能会激怒用户。

另外一个办法则是在目录夹里给长文件名的文件分配多个记录空间，用以存放长文件名。但这样就将造成每个文件占用目录夹记录数不一样的情况。而这将使得文件的读写操作复杂性增加。在这种存放模式下，每个文件夹记录的前面一个字应该存放该文件记录的长度，这样就可以告诉操作系统下一个文件记录开始的位置在哪里。但这种方式要求在查找文件时顺序扫描文件夹，并动态计算下一个文件开始位置，因此，效率十分低。图17-12描述的是可变长度文件记录的存放方式。

图　17-12　可变长度文件记录方式

除此之外，还有一个办法是保持文件记录的长度不变，将文件名存放在另一个分开的地方，如系统堆空间（磁盘上专门划分出来的一片空间）。这样，每一个文件记录里面保存一个指针指向堆里存放该文件名的地址。在此种模式下，由于文件记录长度不变，在扫描文件夹时无须动态记录下一个文件开始位置，效率将提高。图17-13描述的是堆存放方式。

17.3.3　文件共享

从用户视角看文件时，我们讲过符号链接可以让多个用户共享一个文件，但没有说明链接是如何实现的。现在介绍了文件的实现，我们就可以来看一下符号链接是怎么一回事了。

在使用符号链接后，一个文件可以从多个路径进行访问。即一个文件有多个路径名，从多个路径都可以找到该文件在磁盘上存放的地址，也就是说，该文件的映射情况保存在多个文件夹里。具体来说，就是当用户将一个文件链接到一个文件夹时，该文件夹里面会增加一条记录，用来保存该文件到文件地址的映射。而为了保持系统一致性，我们此时在文件的I-NODE里面记录其被链接的次数。图17-14描述的是从目录夹4链接文件5后的情况。

图　17-13　堆存放方式

图　17-14　文件链接

有了链接后，文件删除的操作需要进行修改。删除文件时不是马上将文件删除（不管是逻辑上还是物理上），而是将该文件对应I-NODE里面的链接计数减1。如果结果为0，就删除该文件（逻辑删除）；如果不为0，则不删除该文件。这是因为还有目录需要使用该文件。如果这个时候删除了，将出现所谓魅影，即一个文件在目录夹里面显示，但却不存在。

17.3.4　硬链接

上面讲到的链接方式将文件的地址映射直接加到链接目录下，这种链接也称为硬链接。这是因为另外一条路径断开后（如删除文件或者路径上的文件夹），其他的链接不会受到影响。

硬链接有一个大问题。假定用户A创建了一个文件file.pdf，并且用户B使用链接来进行共享。当用户B链接后，用户A决定删除该文件。但由于该文件的链接数大于1，用户A的删除操作只是将链接数减1而已，文件并不会被删除。这样用户B可以继续使用用户A的文件，但用户的A文件所造成的成本如占用的磁盘空间成本却要由用户A来负担，因为用户A是其创建者。这显然会令用户A感到不快。而且，这种链接使得文件的创建者没有权利断开已经链接用户的链接。

打个比方，一辆车一般有两把钥匙，你把一把钥匙拿给别人保管，如果你把车卖了，那么你是不能控制车了，但是那个保管钥匙的人还能开那辆车，所以这个就是硬链接的问题。

那有没有什么办法既可以提供链接，又赋予文件创立者更多的控制呢？有，答案是软链接。

17.3.5　软链接

软链接，顾名思义，就是这个链接是软的，经不起折腾。或者说，在创建者将文件删除后，链接用户将无法再访问该文件。那么软链接是怎么实现的呢？

要想将链接变软，就不能将文件的地址直接存放在链接目录的目录夹里。而如果不存放文件的磁盘地址，还有什么办法可以访问到该文件呢？当然是间接了。

就是在链接目录里存放到链接文件的原始路径名（创建文件时使用的路径名）。即从链接目录访问文件需要走原始路径过来。这样当原始路径断开时，该链接就自然也断开了。这种在原始路径断开后不能维持的链接就称为软链接。

例如，如果从目录B建立到目录A下文件file.pdf的链接，则我们在目录B下建立一个新的文件，比如说file，而文件file的内容是/A/file.pdf。

软链接从严格意义上说不是链接，因为它并没有直接连到文件上，而是保存了文件的原始访问路径而已。正因为此，Windows的快捷方式不是链接。因为快捷方式保存的就是路径名。那为什么不用硬链接呢？这是因为Windows上经常使用FAT文件系统，没有地方存放文件的链接计数，硬链接无法实现。当然，如果是NTFS，则可以使用链接。但为了保险起见，还是不用为妙。

17.3.6　切断链接

切断链接就是将目录夹里面相应的文件记录删除。对于软链接来说，就是将存放原始访问路径的文件删除。链接切断后，在目录里面将看不到原来被链接的文件。

17.3.7　链接带来的问题

链接虽然方便共享，但也存在诸多问题。最主要的问题是造成备份困难。在进行逻辑备份时，如果选择备份的文件夹里面存在符号链接，则从逻辑上来看，该文件存在多个路径。这样将造成该文件备份多次。但是备份多次并不是什么大问题，只不过浪费空间而已。

问题出在恢复的时候。由于被链接的文件备份了多个拷贝，在恢复的时候自然也会被恢复多个拷贝，这样将造成每个路径有自己的文件拷贝。这样就达不到共享的目的了。当然，我们可以对备份软件进行修改，使其能够辨认出符号链接，而只备份和恢复一个拷贝。但此种修改复杂性较大。

17.3.8　文件系统挂载

了解了文件的实现后，我们就可以理解文件系统是如何实现挂载的了。挂载是用来将一个文件系统并入到另一个文件系统的方法。挂载时需要提供被挂载的文件系统的根目录和挂载点。而挂载实际上就是修改挂载点的目录内容，增加一个记录将该文件系统的根目录I-NODE地址保存起来。图17-15描述的就是文件系统挂载的情景。

图　17-15　文件系统挂载

比如说，我们平时用的光盘、U盘、软盘等上面都存在一个文件系统，但是我们在平时是无法访问该文件系统的。要想访问，需要将它们插入计算机的相应设备或接口上。而这种插入就是挂载操作，使得光盘、U盘、软盘上的文件系统成为整个计算机文件系统的一部分，从而可以对其进行访问。

在Windows操作系统下，挂载点一般是“我的电脑”，这是Windows里面文件系统的根目录。但在新的Windows版本下，也可以挂载在非根目录下。但是在Windows下面很少有人将文件系统挂载到其他地方。

在UNIX和Linux中，挂载点可以是任何目录。

%ls/

afs/kernel/sbin/

bin@lib@template-server@

core lost+found/template-server-ro@

dev/nfs/tmp/

devices/opt/usr/

etc/platform/var/

export/proc/vol/

home/root/

17.3.9　卸载

挂上去的文件也可以卸下来，称为卸载。卸载即是将挂载点的目录内容中的相关记录删除。对于光盘来说，弹出光盘就是卸载光盘文件系统。对于U盘来说，拔出就是卸载。（不过需要指出的是，卸载却不一定需要拔出。）

对数据安全十分担心的人可以将文件系统卸下来，从而使得文件系统上的内容无法访问而达到安全的效果。当然最安全的方法就是根本不用电脑。

17.4　闲置空间管理

文件系统的主要任务是为用户提供一个方便、持久和可靠的存储媒介，并根据用户需要随时对文件的数据进行访问。为了能够让用户程序进行磁盘空间分配，文件系统必须保持磁盘空间使用情况的记录，即哪些空间被占用，哪些空间为闲置。而对闲置空间进行管理的机制与内存空间管理一样，也是位图和链表。读者可参考内存空间管理，这里不再赘述。

17.4.1　磁盘分配块

文件系统分配空间时是按照块来分配的，Windows操作系统里面将这种分配块称为簇。块或者簇的尺寸记录在超级数据块中。有了这个参数，我们就可以把字节数转换成块或簇数。

这里存在一个问题：分配块应该为多大？如果分配块尺寸太大了，浪费的空间多；如果太小了，数据传输速度慢（参见第15章）。显然，一个恰当的尺寸是某种折中的产物。而折中的参数就是一个系统里面文件的平均大小。这个分配块大小应当与文件的平均尺寸存在某种联系。图17-16给出的是在文件平均大小为2KB的情况下，磁盘分配块大小与磁盘利用率和有效磁盘数据传输之间的关系。

图　17-16　磁盘分配块与磁盘利用率和有效数据传输之间的关系（来源：参考文献[3]）

17.4.2　磁盘配额

我们大多数人在申请公共邮箱时都会被告知邮箱存储空间容量。这个容量就是我们的配额。在多用户环境下，文件系统通常给每个用户一个磁盘配额。这个配额既可以是以磁盘容量来表示，也可以用文件数量来标定。当然，更为常见的是两者同时使用。即一个用户只能创立一定的文件数，且其创立的所有文件加起来不能超过某个容量限度。

对容量配额的实现，不同的操作系统使用的方法不一定相同。在UNIX环境下，通常存在所谓的软配额和硬配额。软配额就是你可以临时超过，但必须在下次登录前解决这个问题，否则超出配额的文件将被删除。硬配额就是在任何情况下都不能超过，一旦超过，当前写入的数据都无法完成。磁盘配额的具体实现看参考具体的文件系统，这里不再赘述。

思考题

1.多级索引的文件系统有何优点？请予以阐述。

2.比较连续存放和链表存放方式，哪一种方式的可靠性高？

3.解决多级索引速度慢的问题可否像解决多级页表那样使用TLB？

4.软链接时我们是否需要记录文件被链接的次数？为什么？

5.FAT是为了改善链表结构而提出的。比起链表结构，使用FAT将大大加快随机访问的速度。但如果是顺序访问，则FAT似乎并不能提供效率上的提高。请问情况是这样吗？

6.有人说FAT在文件系统启动后调入内存，而磁盘上则仍保留一个拷贝。那么每次修改内存里面的FAT时是否需要修改磁盘上的FAT？如果修改，有何问题？如果不修改，又有何问题？你的策略是什么？

7.假定我们有一个文件系统，只有1个文件。假定磁盘块大小为1000B。而一个用户程序需要进行如下两个操作：

请求1：从该文件8000偏移量（offset）处读一个字节。

请求2：从该文件15 000偏移量处读一个字节。

该文件有一个I-NODE，而该I-NODE占一个磁盘块，且不在内存里。请问在下述文件结构下，不使用缓存的情况下，上述两个请求各需要几次磁盘访问？

a）顺序、连续分配方式。

b）非均匀双级索引I-NODE结构。I-NODE里面包含10个直接指针（指向文件的数据块）、2个间接指针（指向间接磁盘块）。假定间接磁盘块里面的每个指针占用4B。

c）如果一旦读取数据，就缓存起来，请问答案有何变化？

d）请问B）所支持的最大文件尺寸是多少？

8.现代UNIX支持重命名操作rename（char*from, char*to）系统调用。

该调用将文件名"from"重新命名为"to"。但是UNIX V6没有重命名的系统调用，其对重命名的实现是通过链接link和撤销链接unlink两个系统调用来实现。

a）请使用link和unlink来实现重命名操作，并阐述你的实现。

b）如果在执行rename（"x.c"，"x.c"）中途计算机崩溃，有可能出现什么情况？这里假定在该调用前"x.c"和"x.c"存在同一个目录里，但在不同的目录块里。

9.在索引文件系统里，建立新文件有两个操作：写文件数据和写I-NODE。请问这两个操作应该谁先谁后？为什么？

10.在索引文件系统里，写磁盘空间的位图表先于写I-NODE，请问这是什么原因？

11.能否将文件属性存放在文件数据块中，而不是存放在目录或I-NODE中？

12.对于非I-NODE的文件系统来说，文件链接能否实现？如果能，如何实现？文件的链接次数应存放在何处？如果不能，为什么？

第18章　文件系统性能

引子：从不可能到可能

2006年6月，沃伦·巴菲特将1000万股Berkshire Hathaway B类股票捐给了比尔和梅琳达·盖茨基金会。在当时这笔捐款价值约307亿美元。从而成为人类历史上最大的一笔慈善捐款。巴菲特自己也成为所谓的慈善资本主义革命的领军人物。截止到2007年，比尔和梅琳达·盖茨成为美国历史上第二慷慨的慈善家，慈善总捐款达到280亿美元。

至此，沃伦·巴菲特和比尔·盖茨两人成为世界人们心中的罗宾逊，全球媒体连篇累牍地对他们的“伟大善举”进行了铺天盖地地报道。一时间，人们对他们的崇高品行无不佩服得五体投地。对于一个不了解沃伦·巴菲特和比尔·盖茨过去的人来说，他们就是完美人类的化身。也许人们认为他们的品行端庄、道德高尚。

但是，令人困惑的是，道德高尚的人怎么可能积累如此巨大的财富呢？而对另外一些了解他们过去的人来说，他们怎么可能把这么多的财富给“别人”呢？

但是，许多看上去不可能的事情，其实并不是真的不可能。就像文件系统的演变一样。在文件系统的演变过程中，许多以前被认为是不可能的机制与构造，今天都成了现实。也许，读完本章后，巴菲特和盖茨捐款的“原因”就会显现在读者面前。

18.1　文件授权管理

第17章介绍过，地址独立和地址保护是文件系统需要达到的两个目标。第17章论述了文件系统的第1个目标——地址独立是如何实现的（地址独立通过文件或文件夹已经实现）。现在我们论述文件系统的第2个目标——地址保护是如何实现的。首先要注意的是，地址保护不是文件系统必须实现的功能。如果没有这个功能，用户可以承担起文件保护的角色。例如，用户可以将自己的文件放在一个单独的文件系统上，如U盘或移动硬盘。在工作完成后，将文件系统卸载从而使得自己的文件无法被其他人访问。但是，这种做法比较麻烦。另外，在一些大型主机上也不能实现。因此，由操作系统提供文件保护经常是更为可行的办法，有时甚至是唯一的办法。

那么地址保护如何实现呢？

我们在内存管理时讲过，多道编程一个重要课题是进程之间的相互保护，即一个进程不能访问另一个进程的空间。也就是说，一个进程的数据不能被另一个进程随便访问。这种进程地址空间保护可以通过页表（段表）和动态地址翻译实现。但是把这个数据存放到磁盘后，动态地址翻译就无法保护这些数据了。那么这些存放在文件里面的数据就面临被任何人或程序访问的危险。而这显然是不能容忍的。如果这个能够容忍，我们何必费力气来保护进程里面的数据呢？至少，无须费太大力气来做。

因此，对文件里面的数据进行保护，使得文件数据不能被随意访问就是文件系统必须解决的一个问题。而文件系统解决这个问题的方法就是文件系统的访问控制（access control）。那么访问控制是如何实现的呢？或者说，如果要对文件进行保护，使得一个文件不能被任何人随便访问，我们有哪些办法呢？

我们只要看一下人类社会对贵重设施的保护就清楚了。例如，军事重地或关键设施的保护通常可以分为两种模式：在军事禁区设置哨兵，欲访问军事禁区的人必须经过哨兵的检查，即哨兵根据某种规则来确认某个人是否可以访问禁区。另外一种办法是设置安全门，有权限访问的人都备有通过安全门的钥匙。如果某个人需要进入禁区，他只需拿出自己的钥匙将安全门打开即可。如果打不开，则不能访问。在这种情况下，一个人可以有多把钥匙，用来访问不同的受保护设施。

如果对上述情况进行提炼，我们发现，这两类保护措施是从两个不同的角度来实施的：哨兵是从被保护的设施角度出发，即控制措施附在设施上；而使用钥匙则是从用户角度来实施的，即访问控制在用户身上。

既然文件保护的设计者是人，自然我们采取的措施就很难超越我们保护军事禁区的手段。事实上，对文件的保护所采取的措施跟保护军事禁区的手段非常类似。

因此，我们对文件的保护也可以从两个角度出发：从文件的角度和从用户的角度。从文件的角度实施将访问控制附在每个个体文件上。从用户角度出发则将访问控制附在用户身上。

18.2　主动控制：访问控制表

主动控制将保护措施构建在被保护者身上。对于文件系统来说，我们为每个文件设置一个哨兵。这个哨兵对每个试图访问该文件的用户进行检查，看看其能否访问。而这个检查的规则存放在一张表里面。这张表称为访问控制表。这张表存放在内核空间，对于每个具有访问权限的用户设置一个记录。该记录记载着用户ID和具体的访问权限，如读、写、执行、拷贝等。凡是不在这张表上的用户将不具备访问资格。图18-1显示的是有着3个用户进程和3个文件的访问控制表。

图　18-1　访问控制表

在图18-1中，文件F1的访问控制表为"A：RW；B：X"，这意味着用户A可以对文件F1进行读写访问，而用户B可以对文件F1执行访问，即可以执行文件F1，而用户C则不能访问文件F1。对于文件F2来说，用户A的权限为读，用户B的权限为读写，用户C的权限为读。对于文件F3来说，用户A没有权限，用户B拥有读写执行权限，而用户C拥有读和执行权限。

这样，在每次访问前，操作系统将检查这个访问控制表来确认一个用户是否有权执行其所要求的操作。如果该用户ID在访问控制表里，且其权限与其所要求的操作匹配，则操作将被允许。否则，该操作将被终止。

这里需要注意的是，访问控制表是存放在内核空间，由操作系统进行设置与访问。用户不能直接修改访问控制表。

当然，访问控制表里的用户不一定非是个体用户，也可以是集体用户。例如，表18-1里面的用户名faculty就是一个用户组名，代表所有的教师。任何属于该组的用户都能够读文件Faculty＿Record。Sysadm也是一个用户组名，代表所有的拥有系统管理权限的用户。任何属于该用户组的用户均可以读写Student＿Record和Fac-ulty＿Record两个文件。

而且，这里的文件也不一定非是个体文件名，也可以是文件集合，如文件夹名。例如，表18-1里面的Faculty＿Record不一定需要是一个用户文件名，它也可以是一个文件夹名。这一点对于读者来说应该不奇怪。我们前面已经说过，文件夹就是文件，可以像对待文件一样对待文件夹。因此，对文件夹设置访问控制权限表就没有任何奇怪之处了。

访问控制表的优缺点

访问控制表的优点是容易理解和实现，对个体用户的权限赋予与取消也容易。只需要将该用户从访问控制表里面删除即可终止该用户对文件的访问，而缺点是效率低。每次访问一个文件时，我们需要搜索访问控制表。对于那些不支持用户组的系统来说，将需要给每一位个体用户进行权限赋予，而这将是十分繁琐的。

另外，访问控制表并不是十分可靠。它可以很容易攻破。攻破的办法也很简单：攻击者只要让系统认为他是某个拥有访问权限的用户即可。例如，我们知道UNIX下的sendmail程序是在根用户下运行，即它具有根用户的权限。一个攻击者可以对sendmail进行颠覆，在其中间插入攻击代码。而这段攻击代码将具有根用户的所有权限，因为系统认为该程序就是根用户的程序。

18.3　能力表

另外一种访问控制手段是将控制措施附在用户身上。在这种情况下，每个用户手上拿着一把钥匙。这把钥匙就是用户具有的能力。能力（capability）这个术语首先出现在邓尼斯（Dennis）和冯霍尔（Van Horn）发表于1966的一篇文章：《多道编程计算里的程序语义》（Programming Semantics for Multiprogrammed Compu-tations）。其基本思想是：如果一个计算机程序需要访问一个对象，该程序必须具备一个特殊的令牌。这个令牌记录该程序可以进行的操作，而该令牌称为能力。

由于一个用户可以拥有对多个对象的访问能力，这些能力放在一起就形成一张表，称为能力表。这张表为所有该用户具有访问权限的文件设置一个记录。该记录记载着文件名和具体的访问权限，如读、写、执行、拷贝等。例如，＜file2 R, file3 RW＞代表该用户对文件file2有读的权限，对file3有读写的权限。而凡是不在这张表上的文件该用户将不具备访问资格。图18-2显示的是3个用户和3个文件时系统维护的3张能力表。

在图18-2中，用户A的能力表为F1：R；F2：R，这意味着用户A可以对文件F1和F2进行读访问。用户B的能力表为F1：R；F2：RW；F3：RWX，意味着用户B可以对文件F1进行读访问，对F2进行读写访问，对F3进行读写执行访问。同理，用户C对文件F2有读的权限，对F3有读和执行权限。从图中我们也可以看出，用户A不能访问文件F3，用户C不能访问文件F1。

图　18-2　能力表

这样，在每次访问前，操作系统将检查这张能力表来确认一个用户是否有权限执行其所要求的操作。如果该用户所要访问的文件在其能力表里，且其权限与所要求的操作匹配，则操作将被允许。否则，该操作将被终止。

与访问控制表一样，能力表也保存在内核空间，由操作系统进行设置与访问。用户不能直接修改能力表。

当然，能力表里的文件名不一定非是个体文件名，也可以是文件集合。例如，图18-2里面的F1完全可以是一个文件夹名。这样，用户A对文件夹F1里面的所有文件具有读的权限。这一点对于读者来说应该不奇怪。前面已经介绍过，文件夹就是文件，可以像对待文件一样对待文件夹。因此，对文件夹设置访问控制权限表就没有任何奇怪之处。

而且，这里的用户也不一定必须是个体用户ID，也可以是用户集合，即用户组。例如，图18-3显示的就是用户组faculty的能力表。

图　18-3　用户组faculty的能力表

如果把文件看做一辆辆汽车，对文件的访问权限看做汽车发动机的钥匙，则能力表代表的是一个用户的汽车钥匙集合。如果一个用户拥有特定文件的权限，就相当于一个人拥有特定汽车的发动机钥匙，就能够开动汽车。

能力表的优缺点

首先，能力表的优点是效率高。如果用户要访问的文件是能力表所指向的文件，则无须进行任何检查。其次，能力表有较好的封装性。用户和他所能够访问的文件存放在同一个列表中。而缺点则是删除文件对象困难。如果要禁止所有用户对某个文件的访问，则需要对所有用户的能力表进行遍历。

不过，与访问控制表一样，能力表也可以被黑客攻破。比如说，黑客可以伪造一个能力表。而为了保护能力表，我们可以将其置于操作系统的管辖内。例如，使用tagged结构。tagged结构就是在每个内存字设置一个额外的字位，用来表示该内存字是否包括能力。如果该标志被设置，则对该字的写操作只能由操作系统进行。例如，IBM的AS/400就使用了这种tagged结构来实现能力。显然，这种实现需要硬件的支持。

我们也可以对能力表进行加密。这样可以将能力表置于用户空间，但用户需要解密钥匙才能使用能力表，从而防止篡改。

18.4　访问控制的实施

有了访问控制表或能力表或者两者，我们就可以对文件访问进行控制。每次一个用户对文件进行访问时，操作系统将打开用户的能力表和其欲访问的访问控制表。在访问控制表和能力表规模不大的情况下，操作系统通常直接使用这两种手段。但如果访问控制表或能力表非常大，那么执行访问控制的时间成本将增大。这时就需要进行某种优化以控制访问控制表或能力表所需的时间。这个手段就是保护域。

保护域

访问控制表和能力表的一个共同缺点就是针对个体的文件需要设置个体的访问控制。如果某些文件或对象的访问控制权限一样，这种个体区别的记录方式就显得有些浪费了。如果这个时候使用保护域就可以解决这个问题。

保护域就是将访问控制权限一样的文件和对象组织成同一个域。而访问控制权限与每个域直接相关。每个域的控制独立于其他域。每个进程都运行在一定的域里，从而具有访问该域里面文件和对象的权限。图18-4描述的是具有3个保护域的系统。

图　18-4　拥有3个保护域的文件系统

在图18-4中，file1和file2属于一个保护域，file3、file4、file5和printer1属于一个保护域，而file6、printer1和plotter2又属于一个保护域。一个进程在运行时必须处于某个域里。且在运行过程中可以改变保护域，如从用户空间切入到内核空间。

那么系统如何跟踪保护域及其相关权限呢？一个自然的措施是使用矩阵：矩阵的行代表域，列表示文件和对象。例如，图18-4的保护域系统可以用图18-5的矩阵来表示。

图　18-5　不包括保护域的保护矩阵

进程运行中的域切换也可以用矩阵实现，例如图18-6就表示了运行在域1的进程可以切换到域2（以进入表示）。

图　18-6　包括保护域的保护矩阵

使用保护域的优点是可以将保护权限相同的对象组成一个集合进行处理。缺点是实现方式可能浪费空间。例如，在图18-5和图18-6中有许多的空白域。如果这种浪费不可忍受，我们总是可以回到访问控制表和能力表两种办法上来。

需要特别提醒的是，本章论述的访问控制机制并不是只能应用于文件，也可以用来对其他计算机里的数据对象（即内存对象）进行保护。

18.5　其他文件安全措施

访问控制虽然能够保护文件不被没有授权的用户或进程非法访问，但这种保护措施十分脆弱，可以很容易被攻破。由于文件存放的数据可能对于用户来说非常重要，设计额外的保护措施就显得非常重要。这些措施中最主要的是加密，即根据某个给定算法和参数（密钥）对数据进行转换，使得转换后的数据无法辨认。要想正常阅读文件的内容，则需要先进行解密。加解密技术在过去的几十年中取得了很大的发展，现在仍然是很多人的研究课题。

一种较为新颖的数据保护方法是隐写文件或隐写文件系统（crypt file sys-tems）。隐写就是将要保护的数据嵌入到其他文件中而达到隐身的目的。例如，我们可以将一个文件甚至整个文件系统隐写在一系列音频文件中。另外一种更有意思的技术是数据隐藏，即将数据隐藏起来，使得病毒或者试图进行非法访问的人无法看到数据，甚至无法得知数据的存在。加密、隐写、隐藏技术的细节繁多，各有特点（或缺点），由于其通常不由操作系统实现（不是操作系统的一部分），而是存在于操作系统之外，因此详细论述已经超出本书的范围。有兴趣的读者可参阅相关的书籍（加密方面的资料汗牛充栋，隐写和隐藏方面的资料相对较少）。

18.6　文件系统性能

确保了地址独立和地址保护就实现了文件系统。但是实现了文件系统并不是一切就算结束。除了功能外，一个好的文件系统还需满足用户在性能上的要求。就像计算1000+1000一样，我们可以在1000上加上1000次1，结果虽然正确，但是效率无法让人接受。

文件的性能主要体现在两个方面：可靠性和速度。用于文件系统是用来存放数据的，其可靠性自然非常重要。否则存放的数据将没有人敢用。由于磁盘访问比内存访问慢很多，速度问题比起内存访问时更加重要。

18.6.1　文件系统可靠性

文件系统的可靠性体现在两个方面：持久性和一致性。持久性是指经久耐用，就是存放在文件系统的文件一万年都不变。一致性则指里面存放的数据必须是好的。那么什么样的数据是好的数据呢？就是说存放在一个系统里面的数据之间必须一致，且必须符合客观现实。例如，如果某个人的年龄是500岁，则这个数据就不是好的，因为它与客观现实相悖。如果一个人的职位为初级软件工程师，工资却是50万元，则这两个数据之间不匹配，因此也不是好数据。当然，数据不好不一定是文件系统的错误所导致。但作为文件系统的设计人员，我们必须保障不能因文件系统造成数据不好。

18.6.2　文件系统持久性

要想文件系统保存的数据持久，单单依靠文件系统本身并无法解决。因为不管文件系统本身做得多好，如果磁盘被人丢进大火里，上面的数据也不会持久。当然，造成数据损坏的可能性还有很多。那么实现持久性的手段主要是备份和复制。

数据备份就是制作多余的数据拷贝，并保存在分开的存储介质上。这样，源数据出现问题时，可以使用备份介质上的数据，从而达到数据持久的目的。

从数据的角度可以将备份分为两种：一种是逻辑备份，另一种是物理备份。逻辑备份就是备份用户选择好的文件或文件夹。在逻辑备份时，备份的基本单位是文件。而备份过程就是将文件一个个拷贝到备份介质上，如图18-7所示。

图　18-7　逻辑备份：图中只标有阴影的文件才被备份过去

物理备份则是备份整个磁盘。在物理备份时，备份的基本单位是字节或数据块。而备份过程就是将数据块一个个拷贝到备份介质上。这里的拷贝是绕过文件系统而进行的，即通过裸读和裸写而进行数据拷贝的，如图18-8所示。

图　18-8　物理备份：整块磁盘上的数据被裸读到另一块盘上，其结果是两块一模一样的盘

那么物理备份和逻辑备份哪个更容易呢？多数人可能会说物理备份更加容易。原因是它不需要知道文件系统的结构，只要能够辨认出字节即可。而辨认字节是非常容易的。

但情况果真如此吗？实则不然。

在现在的市场上，备份产品几乎可以说是不计其数。但绝大部分备份产品都是逻辑备份产品，而不是物理备份产品。这说明什么问题呢？逻辑备份的使用范围广？有可能。但它显示的另一个问题则是逻辑备份比起物理备份来说容易实现一些。

那为什么逻辑备份比物理备份容易呢？或者说物理备份为什么难呢？有几个原因。

首先，物理备份不经过文件系统，因此需要裸读磁盘，即绕过文件系统对磁盘数据进行读写。而裸读或裸写对很多人来说比在文件系统上读写要困难很多。逻辑备份使用文件系统进行读写，备份软件无须与磁盘驱动器的许多底层细节打交道。

其次，在进行逻辑备份的时候你知道一部分语意；在物理备份的时候你不知道它的语意。在这种情况下，如果备份中途出现错误，你将很难侦测到它的错误。即使侦测到，进行恢复也不容易。而逻辑备份则可以文件为单位进行正确性确认。如果出现错误文件，只需要重新备份有问题的文件即可。当然，在物理备份时可以数据块为单位进行错误纠正。

最后，物理备份由于是拷贝磁盘上的一切数据，将造成物理磁盘的卷标也被拷贝。这样将造成物理备份的备份磁盘和源磁盘卷标相同。由于备份磁盘在备份过程中发生卷标改变，将造成备份磁盘无法工作。另外，如果两个卷标相同的磁盘接入到同一部计算机，将造成系统故障或瘫痪。

根据备份方式的不同，备份可以分为很多种类。例如，备份方式有下列几种：

●主动和被动备份：以谁提要求来区分。

●在线和离线备份：以是否停止服务区分。

●实时和延时备份：以主备数据时差区分。

●等分和差分备份：以主备数据量差区分。

●增量和差分增量：以备份量和差分标来区分。

●分裂和并列备份：以备份是否减少来区分。

●近程与远程备份：以主备中心距离区分。

由于备份是一个很大的课题，并不是文件系统本身的研究范畴，本书将不再赘述。有兴趣的读者可以参阅作者的著作《有备无患：信息系统之灾难应对》一书。

18.6.3　文件系统一致性

前面说过，一致性要求数据是好的。而好的则意味着数据必须彼此之间保持一致，且不与客观现实相悖。这里需要强调的是，数据是好的与数据是最新的是两回事。最新的数据不一定是最好的数据，最好的数据不一定是最新的数据。例如，假定某个国家换了总统，但网页上还是前一个国家总统，那么这个数据还是好的，但不是最新的；或者网页上是新的国家总统，这个数据是最新的还是好的。什么样的数据是不好的呢？就像你说没有国家总统或者有两个国家总统显现在网页上。

美国有一部收视率很高的电视连续剧，叫做《星际探索》（Star Trek）。该连续剧里面常常出现一种远程传输（teleport）设备，能把人从一个地方转到另外一个地方。就是把人分解为分子，发送到另一个地方后将所有分子再重新组装。如果人在分解或发送到一半的时候机器死机了，那么组装出来的是什么东西就没有人知道了。不过可以肯定的是，组装出来将不会是正常的人。

如果文件系统设计不佳，其后果就有可能像teleport设备出现故障一样。不过其后果不是组装出一个人不人鬼不鬼的东西，而是有可能造成数据不一致。

例如，你到银行转账，从你自己账户上转10 000元钱到左怡的账户上。但这个操作并不是一步可以完成的，它通常分为两个步骤：

1）从自己账户上减去10 000元。

2）把左怡账户上增加10 000元。

如果系统在上述两个步骤中间崩溃，将出现你的账户已经减少了10 000元，可是左怡却没有收到钱。那么这时就出现数据不一致的情况，即数据是不好的。（当然，银行这时很高兴，因为银行得到钱了。）

不要以为上述情况不会在现实中出现。2007年下半年，有一个老年妇女在某银行进行转账时就出现了银行账户的钱已经减去30 000元，而对方账户上却没有收到钱。转账一个星期后还是这样。以致这个老年妇女跑到该银行大吵大闹。

很显然，出现了这种情况，用户将十分不高兴。当然，如果将上述两部操作顺序反过来，则出现中途崩溃时将造成银行损失。这样银行将很不高兴。当然，在现实生活中，出现问题时总是用户吃亏。因为银行软件总是确保在出现问题时，必须保证银行不吃亏。例如，如果总是先减去客户的钱，再增加客户的钱，就可以保证在任何情况下银行都不吃亏。

再来看文件系统里一个非常频繁的操作：建立一个新的文件。

那么建立一个新的文件需要哪些操作呢？有两个操作步骤：

1）建立文件的I-NODE，将I-NODE写入磁盘。

2）将I-NODE的磁盘地址和文件名写入该文件所属文件夹里。

如果在这些操作中间发生系统崩溃，则文件系统就有可能出现不一致。例如，如果在将I-NODE写入磁盘后，但把I-NODE的磁盘地址和文件名写入该文件所属文件夹之前发生系统故障，则将出现“孤儿文件”，即文件不在文件系统里出现（目录里没有），但却在磁盘上。如果将上述两个操作步骤顺序颠倒，则有可能出现“魅影文件”，即文件在文件系统里出现（在目录里有），却没有在磁盘上。

孤儿文件和魅影文件的危害很大。对于魅影文件来说，其问题有三。首先就是文件夹指向了一个没有被写入的I-NODE，如果对魅影文件进行读写，读写的东西将是不可预料的，而这种不可预料有可能造成数据损毁。另外，我们无法确定这个I-NODE里面的内容有没有被改写过。还有就是文件夹能容纳的目录项是有限的，那么魅影文件占用了文件目录项，从而使得真正有用的文件可能没有地方存放。对于孤儿文件来说，其问题是无法访问和占用磁盘空间。

又例如，我们移动一个文件时候有两步操作：

1）将文件从老的文件夹里删除。

2）将文件添加到新的文件夹。

如果在上述两个步骤之间发生问题，也将出现孤儿文件。如果将上述两个步骤顺序颠倒，则有可能出现两个文件拷贝，即“幻影文件”。注意，这里步骤1的删除指的是对文件夹的修改，并不是物理删除。从逻辑上讲，一个文件的删除在文件夹被修改后就实现了。物理删除是另外一回事。由于移动文件时需要从移动的目录夹里面删除信息，再在目的文件夹上增加信息，在逻辑上相当于删除和添加两个操作。当然，文件并没有被物理删除。对于有些文件系统来说，这和删除是不同的，因为真正的删除还需要修改空间表（space＿map）。

不管是幻影文件、魅影文件还是孤儿文件，都表明文件的一致性遭到破坏。那么如何保证文件系统的一致性呢？答案是使用日志（logging）、事务（transac-tion）和随影（shadowing）。

1.日志

日志就是将文件操作全部记录下来，存放在一个不同的地方，这个地方称为日志。当这些新数据全部写完后，在日志最后写上“完毕”（commit）。在这之后，我们就可以将日志里面的数据往源文件里写。这样就能确保文件数据的一致性。

例如，如果在写入“完毕”前的任何时候出现故障，则新的数据将被丢弃，源文件的数据保持不变。即原来是一致的数据仍然保持一致。如果在写入“完毕”后发生故障，例如在将数据往源文件里写的中途出现故障，造成只写了一半时怎么办？这也不是问题。在系统恢复后，将新的数据重新往源文件里写即可。可能有人会认为将日志写两遍会造成操作被执行两次而出现问题。但这是不可能的。因为日志里面记录的是操作结果，而不是操作。而结果数据写两遍还是一样的结果，因此不会产生任何问题。

2.事务

另一种确保文件系统一致性的手段是使用“事务”。一个事务是一组操作。事务机制可以确保这一组操作要么全部发生，要么一个都不发生。例如，如果有下面一个事务：

Begin transaction

disk write 1

disk write 2

disk write 3……

end transaction

则磁盘写操作1、2、3要么全部发生，要么一个都不发生。事务实际上就是将一组操作转变为原子操作，就像我们讲过的进程同步原语一样。只不过这里的同步是磁盘操作而已。

那么事务是如何实现的呢？答案很显然，与日志一样。即将所有的磁盘写操作记录在与源数据分开的地方。在“完毕”（commit）后，即可以将这些数据往源数据上拷贝。

细心的读者可能会发现，事务的开始与结束有点像锁同步机制的获得锁和释放锁。锁的两个操作是lock和unlock，事务的两个操作是begin和end。在lock和unlock之间的代码可以保证不会被别的线程执行，而在begin和end之间的磁盘写操作则保证要么全部完成，要么皆抛弃。但是事务是如何确保这种要么全部、要么没有的语义呢？

本书在讨论锁的时候讲过，锁的实现需要硬件支持，事务的实现也不例外，也需要硬件的支持。而这里的硬件支持就是一个扇面的读写。磁盘系统读写一个扇面的操作是一个原子操作，即该扇面要么全部写完或读完，要么就根本没有进行读写。这个硬件原子操作是由磁盘制造商提供的。有了硬件制造商提供的这个操作，事务的原子性就很容易实现了。在事务处理的最后一步是“完毕”（com-mit）操作。这个操作就是在磁盘上写入一个特殊的标志，告诉系统该事务处理正常完成。由于这个标志很小，所占空间少于一个扇面，其读写将具有原子性。如果我们看到有这个标志，则事务里面的所有读写操作都已经顺利完成，我们视其为有效。否则，所有操作均视为无效，即以前的操作全部作废。

3.随影

日志和事务虽然可以提供数据一致性保障，但他们有一个共同的问题，那就是磁盘写操作均记录在一个不同的地方，在完毕后需要将数据拷贝到源数据处。而在往源数据拷贝的过程中，源数据将处于不一致状态。需要等到数据拷贝完毕，源数据才归于一致。这样，在拷贝中间，源数据就不能被使用，凡是使用源数据的应用均应该暂停服务。如果拷贝过程中出了问题，则这种暂停的时间将更为漫长。对于某些应用来说，这种漫长的暂停是完全不能接受的。

而改变此种问题的解决办法就是随影。随影的原理很简单：保持两个数据版本。通过一个指针告诉用户和应用软件当前使用的是哪一个版本的数据。在需要更新数据时，将更新写到非当前使用的版本上。在更新完毕后，修改指针，使其指向新的数据版本。之后在切换下来的数据版本上进行更新。这样就能保障数据的一致性。

随影技术主要应用于数据库系统。由于新旧版本切换只涉及一个指针的切换，这种切换将十分迅速。由此造成的应用暂停可以忽略不计。而且，指针切换本身是一个原子操作，不会出现指针破损问题。这是因为指针的大小不会超过一个磁盘扇面的尺寸，而磁盘的一个扇面读写由硬件保证为原子操作。

随影的好处也不是没有代价的，而这个代价就是额外的磁盘空间。因为要保持两个数据版本。

4.文件系统和用户文件

很显然，确保文件系统的一致性是要付出代价的。日志、事务和随影都需要使用额外的空间和时间。如果每次文件操作均需要使用这些方式来保护，则文件系统的效率将显著下降。但我们又不能不保护文件系统的一致性。因为这是文件系统正确性的前提。

那有没有什么办法既可以确保文件系统一致性，又能够不显著降低系统效率呢？

有。那就是我们只是有选择地对某些文件进行一致性保护，而对另外一些则不予保护。我们要选择的文件必须是对文件系统非常重要的文件。那么什么文件对文件系统很重要呢？当然是文件系统的元数据，也就是文件夹、自由空间表格等。

因为这些元数据的丢失将造成文件系统不能正常运行。因此，我们对其进行保护。而对于用户数据则不进行一致性保护。因为用户数据损坏影响的是单个的用户或文件，并不会影响整个文件系统。而文件系统本身出现故障将影响所有的用户和文件。如果用户很关心自己数据的一致性和安全性，用户应该采取数据备份或容灾手段予以确保。而不应该依赖文件系统。

当然，如果在效率和复杂性允许的情况下，照顾一下用户的数据也不是不可以。但作为一个文件系统的设计者，我们的首要目的是确保自己设计的东西能够正常运转。因为用户数据丢了可以让用户再输入一遍。而如果源数据丢了，则很有可能无法恢复。这就像打战一样可以牺牲少数人，只要多数人能得到保护就可以。

这样，我们就可以一方面确保文件系统的一致性，一方面又不显著降低效率。

5.文件系统一致性检查

前面几节讲解了文件系统一致性以及如何保证文件系统一致性。那么如何判断一个文件是否一致，是否完整呢？

要进行这种判断，首先需要知道文件系统一致性到底体现在什么方面，或者说什么状态是一致状态？

对于文件系统来说，只要文件系统能够正常运转就说明文件系统处于一致性。当然，这不包括用户数据的一致性。具体来说，文件系统一致性包括如下几个方面：

●没有魅影文件。

●没有孤儿文件。

●文件I-NODE里面的链接计数与其出现在文件系统中不同位置的次数一致。

●没有消失的空间。

●没有额外的空间。

因此，对文件系统一致性进行检查就是对上述5个方面进行确认。而要对这些进行确认，当然需要保持足够的信息。对于前3点来说，这些信息保存在文件目录和I-NODE里面。

后面两点的意思就是一个磁盘块要么在闲置空间里，要么被占用，而不能同时处于两种状态。为了对后两点进行判定，我们需要维护两张表：一张闲置空间表，一张占用空间表。通过两张表相互比对来判断是否存在消失或额外的空间。例如，图18-9所示的两张表表示一切正常。一张表为0的位对应另一张表为1的位。说明该磁盘块只存在于一张表里面，即要么它处于闲置状态，要么它已经被分配给某个文件使用，而不是同时处于两种状态。

图　18-9　系统一致状态下的闲置空间表和占用空间表

而图18-10所示的情况则说明出现了问题，有一个磁盘块对应的两种表的位置皆为1，这说明该数据块既闲置，又被占用，即出现了“一仆二主”问题，这在正常情况下是不可能的。出现这种现象说明文件系统出问题了。

图　18-10　“一仆二主”状态下的闲置空间表和占用空间表

什么样的情况下会发生这种状况呢？如果系统释放一个磁盘块，我们首先将闲置空间的对应字位设置为1，表示该空间已经释放。然后我们需要将占用空间闲置表里的对应字位设置为0。但在设置为0前，系统因故不能继续，从而造成图18-10的问题。

图18-11所示的情况也说明出现了问题，有一个磁盘块对应的两种表的位置皆为0，这说明该数据块既非闲置，又未占用，即这个数据块消失了。这就是所谓的消失的空间问题（vanished space problem）。而出现此种现象的原因也可以类似推出。

图　18-11　消失的空间状态下的闲置空间表和占用空间表

如果我们只维持一张表，即将空间闲置表和空间占用表合二为一，1表示占用，0表示闲置，则在系统发生错误时将无法判断。例如，如果一个闲置磁盘块被误置为1，则这个磁盘块将不会再被使用，从而造成消失的空间问题。而这个问题因为只有一张表而无法得到诊断。如果一个占用磁盘块被误标为0，则将造成数据块被覆盖的危险，同理，因为只有一张表，系统将无法对此种问题进行判断。

18.7　文件系统效率性能

不管做什么，性能总是我们要考虑的一个因素。我们在第11章里面就已论及性能。TLB和缓存就是为了提高内存管理的性能或效率而设计的机制。那么在文件系统里，性能自然也是非常重要的，甚至比内存对性能的重视程度更高，这是因为文件磁盘访问比内存访问慢多了。

当然，最能够想到的提高磁盘访问速度的办法就是提高磁盘自身的访问速度，即制造更高速度的磁盘。但这种方法不是我们研究操作系统的人所能控制的，而是由磁盘制造商决定。而且，磁盘的旋转速度确实在稳步上升，从一开始的1500转到3600转到7200转再到20 000转，应该说已经提高了很多。旋转速度的提高既降低了旋转延迟，又提高了数据传输效率。但是磁盘访问的瓶颈并不是旋转延迟，而是寻道延迟。而寻道延迟与磁盘旋转速度没有关系。影响寻道延迟的因素是磁盘的机械磁臂的移动速度。虽然磁臂做的是直线移动，而直线运动可以做的很快，但对于磁臂来说却没有什么意义。这是因为磁臂寻道时需要准确地停在需要的磁道上。如果磁臂运动太快，将不能精准地停留在所需磁道，从而需要来回调整，反而变得更慢。因此，磁臂的移动速度很难不断提升，即磁盘自身访问速度的提升是有限的。

既然磁盘本身的速度提升有限，又不属于操作系统设计人员能够控制，那还有什么别的办法可以提升磁盘访问速度呢？我们在进程管理和内存管理部分讨论过几种提高系统性能的方法：合适的调度、缓存、提前读取等，而这些方法可以应用到文件系统上来：

●将经常访问的内容置于缓存里。

●减少磁盘访问时磁臂移动的距离。

●提前将需要的数据块读入内存。

18.7.1　文件缓存

文件缓存就是将文件内容的部分置于缓存，从而从缓存满足用户的文件访问请求，而无须每次都到磁盘上去读写。这样可以大大提高访问效率。而放入缓存的数据既可以是用户数据，也可以是元数据。由于元数据的使用频率很高，例如，文件系统的自由空间表格、文件头（I-NODE）、间接数据块（次级I-NODE）、文件夹等数据结构在整个计算机系统运转过程中需要经常使用，缓冲元数据比缓存用户数据效果将更加明显。

那么文件缓存的实现有两种方式：

●缓存在虚地址空间。

●缓存在实地址空间。

缓存在虚地址空间就是缓存在虚拟内存里，即将文件映射到进程的虚拟地址空间，即我们前面讲过的内存映射的文件。这样的话对文件的访问将变为对内存的访问。这种方式的优点就是简单，因为我们将工作交给了内存管理单元（MMU）。但缺点是不一定能够提高文件访问效率。因为MMU不一定会把我们需要的文件保存在内存里，它随时可能被交换或替换出去。另外，如果有多个进程使用该文件的话也会出现问题。

缓存在实地址空间就是缓存在物理内存里。这种方式需要由文件系统自己负责管理。即文件系统需要决定什么数据需要缓存，什么数据需要更换等。这样自然导致文件系统变得复杂，但是可以确保需要的数据总是存放在缓存里。

18.7.2　虚拟内存和文件缓存

那么使用文件缓存后，文件将部分处于内存，部分处于磁盘。这就和虚拟内存（VM）很相似：一部分程序处于内存，一部分程序处于磁盘。因此从磁盘的角度来看，虚拟内存和文件缓存（FC）看上去似乎一样。那我们何不将二者结合起来呢？例如，我们可以将文件缓存交给VM来管理，或者将VM交给FC来管理。

要回答上述问题，需要知道VM和FC的根本区别是什么。VM的根本目的是提供一个速度非常高、容量非常大的并不存在的内存空间。它从物理内存出发，为了增加内存空间而扩展到磁盘上；而FC是为了提高文件的访问效率而出现。它从磁盘出发，为了提高访问效率而将文件置于缓存。就是说VM和FC的根本目的不同，即有着本质区别。因此，其工作机制、语义和操作界面皆不相同。

正是由于这些机制、语义和操作界面的不同，使得它们不能相互替代对方的角色。例如，文件访问请求发出的是磁盘地址，如磁盘数据块编号。VM能辨别出磁盘地址吗？它知道一个磁盘块是多大？从什么地方开始？从该磁盘块开始如何找到下一个磁盘块吗？显然不能。而对于内存访问来说，其发出的是虚拟内存地址，试问文件系统认识这种地址吗？它知道页面号占几位？段位占几位？它怎么知道如何将虚拟地址转换为物理地址？当然不能。

也许读者会说，那可以把VM设计得能够辨认文件访问地址，或者将文件缓存设计得能够辨认分页、分段系统。理论上讲这样是可以做到的。但实际操作起来非常困难。首先，因为这样做将要求VM能够辨认所有的文件系统，你不清楚在实际环境中到底哪种文件系统会被挂载，而这是几乎不可能的。因为在VM设计实现后，人们还在继续开发新的文件系统。你不能不停地修改VM，更新操作系统来适应这些变化。而要把文件系统修改成分页或分段系统则更为困难。本来这个文件系统的可靠性就不那么令人满意，现在再加入这么个功能，又大大降低了文件系统的可靠性。

其次，这些修改将使系统变得非常复杂，而复杂的东西就不容易做得可靠。最重要的障碍就是违反了层次分离的原则。

18.7.3　提前读取

提前读取就是在每次访问磁盘时，我们多读一些数据出来。比如用户要求读取2个数据块，我们实际上读出10个数据块。因为按照时空局域性的原则，用户访问一个地方后，很可能会访问该地方后面的地方。因此我们提前将其后面的数据读出来。这样在用户随后需要访问这些数据的时候就可以从内存得到满足，从而提高文件访问效率。

由于磁盘访问的瓶颈是寻道和旋转延迟，而数据传输相对来说所占消耗很小。这样读出一个数据块和读出10个数据块所费时间几乎没有任何区别。因此，提前读取的代价几乎为0。

当然，提前读取只在用户对数据是顺序访问或者准顺序访问时效果最好，对于完全随机的访问效果则不太显著。

18.7.4　减少磁臂移动

磁盘访问的瓶颈是其机械运动部分，即磁臂移动和磁盘旋转，其中磁臂移动又占主导地位。我们前面说过，受运动精度限制，提高磁臂移动速度的空间有限。

那么剩下的节省时间的办法就是缩短磁臂移动距离。而缩短磁臂移动距离有两种办法：

●将文件头（I-NODE）和数据块放置在一起。

●将文件读写操作进行适当排序。

在介绍文件布局时我们说过，I-NODE是单独放在一起，与文件数据分开的。这样就造成读I-NODE和读文件数据之间需要进行磁盘寻道。而第1种办法的考虑因素是文件打开时需要读取文件头。而一般来说，文件头读取后立即或很快就需要访问文件内容。如果I-NODE和数据块放在一起，读取数据块时就不需要寻道。这样就会大大提高读取数据的速度（见图18-12）。

图　18-12　I-NODE分散存放与集中存放

不过，将I-NODE和文件数据块放在一起会在对文件一致性检查和清空磁盘的操作造成麻烦。由于在进行磁盘数据一致性文件检查时需要访问所有的I-NODE，如果它们放在一起效率就高，而分开放置需要一个一个I-NODE地找，效率就会很低。在删除磁盘上所有文件时也需要访问所有I-NODE。显然I-NODE的分散放置将降低这种操作的效率。图18-12描绘的是I-NODE集中存放和按组分开存放的两种模式。

第2种方法是将一系列的磁盘访问操作按照磁臂最小移动的原则进行排序。使得从一个访问到下一个访问的磁臂移动距离最短，从而提高磁盘的整体访问效率。关于磁盘访问的调度已经在本书第15章论述过，这里不再赘述。

18.8　文件系统设计分析：日志结构的文件系统

下面我们讨论一个文件系统设计案例，以加深对本章内容的理解。这个文件系统就是日志结构的文件系统（Log-Structured File System, LFS）。

日志结构的文件系统是美国加州大学伯克利分校（UCB）提出的一种文件系统。其目标是提高文件系统的效率。其工作原理的出现依赖于以下观测：随着CPU速度的提高和物理内存空间的增长，磁盘缓冲可以做得更大，使得越来越多的读操作可以从缓存得到满足。因此，大部分磁盘访问实际上只是磁盘写操作。

因此，要提高磁盘访问效率，就需要提高磁盘的写操作效率。那么磁盘的写操作效率的瓶颈在什么地方呢？当然是寻找适当的写位置。那么如何改善此处的效率呢？显然，如果我们在磁盘写操作时不用寻找写的位置，则磁盘写操作效率将大大提高。

UCB正是看到了这点而设计了日志结构的文件系统。在这种系统中，整个磁盘被看做一个巨大的日志。所有的磁盘写操作从日志的尾端开始，这样就避免了寻道操作。因为我们刚才说过，磁盘访问主要是写操作，而每次写从上次写毕的位置直接开始。当写到磁盘末尾时，我们再从磁盘头接着写下去。即将磁盘看做一个循环日志。

为了进一步提高写的效率，我们还可以结合缓存进行。即不是将所有的写操作随时进行，而是将写操作在内存里缓存起来。然后在系统闲暇时，或者周期性地将缓存起来的写操作写入磁盘日志。由于每次都写在新的地方，因此我们需要将I-NODE和文件内容一起写入。

由于I-NODE所处的位置随着日志的写入发生变化，LFS使用一个I-NODE表来记录每个I-NODE的当前位置。每次需要访问一个文件时，从目录里找到该文件的I-NODE位置，然后读取数据块并将其置于文件缓存里面。

由于每次文件修改后的内容都写入日志的末尾，即为该文件创建了一个新的版本，将造成一个文件在磁盘上存在多个版本。这样，磁盘空间将很快就会占满。下次需要空间时就会没有空间。为解决这个问题，LFS使用一个垃圾清理程序来清理已经废弃的文件。当然，这个垃圾清理程序只在磁盘空闲时或者磁盘空间已满时才运行。

那么LFS有没有提高文件系统的效率呢？要回答这个问题并不容易。

从表面上看，文件系统效率提高了。因为读从缓存满足，写的时候又不需要寻道，从而使得读写效率都获得提高。

那么LFS文件系统有什么缺点吗？当然有。“天下没有免费的午餐。”根据前面所述，LFS效率的提高需要满足多个条件：

●绝大部分读操作可以从内存满足。

●连续的写之间不会穿插磁盘读操作。

●磁盘的自由空间为连续的。

我们刚才说过，LFS效率提高的一个条件是磁盘的绝大部分读操作能在内存中满足。当时UCB做了很多试验，当读的80%在内存满足时，LFS的效率提高就会较为显著。但是只要你的读不能在内存中满足，那么这个文件系统的优点就不能体现。那么读操作当真可以绝大部分在内存满足吗？这一点似乎不难做到。因为每次打开文件时可以一次性将文件全部读到内存。这样以后对此文件的读操作皆可以从内存满足。

但是，文件缓冲真的像UCB声称的那样可以满足绝大部分读请求吗？这实际上并不一定。这得看一个系统同时访问的文件夹数量有多少？文件的大小是多少？读文件的目的是什么？这些因素可能使得UCB的声称大打折扣。

第2和第3两个条件是保证每次写操作时无须寻道。如果在两次写日志之间发生磁盘读操作，则磁盘很有可能移动了位置，从而造成下次写操作时需要进行寻道。如果磁盘空间不是连续，则需要跳跃写，也将发生寻道操作，从而导致写操作效率的下降。那么这两个条件是否可以保持满足呢？这可不一定。谁能保证磁盘的写操作之间不发生读磁盘操作？很有可能在写过一个文件后，需要打开另一个文件进行读操作，这样将造成磁头偏离日志的尾部。而随着每次文件写入，都会产生一个新版本，从而使前面的版本作废。问题是磁盘上可能作废的文件很多，但是磁头下面要写的部分却是没有作废的文件，这将严重妨碍LFS的顺利运转。

而且，由于I-NODE和数据块的位置在每次写操作时皆发生变化，I-NODE和文件夹的更新将比一般的文件系统更为频繁，管理起来自然更为复杂。另外，由于I-NODE分散存放，文件系统一致性检查和删除磁盘内容的操作将变得费时。

18.9　海量数据文件系统

随着计算机应用的不断扩大，文件系统所处理的数据量呈爆炸增长态势。当文件系统里面数据量增长到一定的程度，传统的文件系统将无法有效运转。例如，在NTFS下，如果单一目录下的文件数量超过十万个，文件系统的性能将急剧下降，达到几乎无法运转的地步。因此，针对大数据量的情况，文件系统的构造必须发生变化，即必须针对海量数据专门构造海量文件系统。

从原理上看，海量文件系统与普通文件系统并无太大区别，它所应该达到的目标仍然是磁盘抽象，提供地址独立与保护，但在性能上的挑战非常明显。挑战是因为数据量的增长突破了某一阈值，而这一阈值的突破导致传统文件系统的失效。这就是哲学上量变质变的规律在文件系统中的体现。

那么海量文件系统的构造与传统文件系统的不同在什么地方呢？要理解这一点，先要看看海量文件系统的几个特点：

●数据量大：由于数据量巨大，在巨大的目录里面迅速找到所要的文件（文件头及其数据块）就不是一件琐细的事。

●分布环境广：海量文件系统通常构建在分布式环境下，由于用来提供文件系统存储介质的可能是多个磁盘，且可能位于不同的节点上，如何判断所需文件位于哪个节点并迅速获取数据就变得很重要。这与构建在单一磁盘（阵列）上的普通文件系统相差甚大。

基于上述两个因素，海量文件系统在性能上面临巨大挑战。如果仍然使用普通文件系统那种将元数据与用户数据一起放置的办法，则搜索起来将费时费力。而避免这种窘况的办法就是将元数据与平面数据分开存放，从而减少搜索的数据量，以提升寻找所需文件的效率。如果元数据量本身也很大，则还需要将元数据分拆到多个节点存放。这种存放元数据的节点就称为元数据服务器。而存放平面数据的节点就是数据服务器。

当然，平面数据本身也可以按照某种规律进行划分（不是分区），将不同划分存放在不同的节点上，通过各种算法如散列来定位数据所在的节点。

如果上述手段还没有达到效率目标，或者我们还需要进一步提升处理的效率，则可以将目录数据从元数据中分离，构建专门的目录服务器，从而形成目录服务器、元数据服务器、平面数据服务器的3层架构。例如，Sun公司的Lustre分布式文件系统与EMC公司的多通道文件系统MPFS都是采取了元数据与平面数据分开放置的海量数据文件系统。

海量数据文件系统由于数据量变化所带来的挑战当然还有许多，如何构件合理的海量文件系统上未获得统一的认识，分析讨论各种实现方式超出了本书的目标范围，建议有兴趣的读者参阅有关公司的产品介绍和相关论文。

思考题

1.在ACL系统里，一个对象的拥有者如何注销一个用户对该用户的访问权限？

2.在能力表系统里，一个对象的拥有者如何注销一个用户对该用户的访问权限？

3.保护域是怎么一回事？它与访问控制表和能力表之间存在何种关系？

4.文件系统一致性是什么意思？如何检查一个文件系统的一致性呢？

5.文件系统一致性为什么不包括用户文件内容的一致性？

6.什么情况下文件系统会出现消失的空间问题？

7.内存映射的文件是否可以作为提高文件读取效率的办法？为什么？

8.对磁盘进行分区有哪些好处？有什么缺点吗？

9.设有多个不同的进程共享一个文件。每个进程有一个独特的优先级数。该文件可以同时被多个进程访问，但需要满足如下条件：所有同时访问该文件的进程的优先级数相加必须小于n。

a）请问，如果使用管程实现同步，请问条件变量是什么？

b）请同时给出伪代码的实现（记住在需要的时候使用wait和signal/broadcast）。

10.请仔细比较事务和锁两种机制的异同点。

11.LFS是通过何种机制提高文件系统的访问效率的？

12.讨论日志、事务和随影3种维持文件系统一致性机制的优缺点。

13.讨论虚拟内存和文件缓存之间的异同点，为什么不能把这两种机制合并？

14.维持文件系统持久性的手段有哪些？

15.文件缓存和内存映射的文件是否是一回事？为什么？

第六篇　1/O原理篇

善者，不善人之师；不善者，善人之资

有了计算，又有了存放数据的临时和永久地方，似乎计算机需要的一切都已经具备。但这是真的吗？我们知道，发明计算机的目的是让计算机为人类服务，而计算机要为人类服务就得有一种办法与人类进行沟通或者交互。而实现这种交互的手段就是计算机的输入和输出。一台没有输入和输出的计算机，其CPU、内存和磁盘再强大，对人们也没有太大用处，甚至说是毫无用处。就像一个人一样，如果其能力再强，大脑再聪明，只要他与外界的沟通渠道（听、说、读、写）被打断或有缺陷，就会被世人看做一个无用的人。

因此，要想计算机真的有用，就得有输入和输出。操作系统既然是计算机的掌控者，当然也需要对输入和输出进行控制。本篇即对计算机与外界进行沟通的输入与输出机制进行讲解。本篇仅有一章（第19章），讨论的内容包括输入输出的重要性和目的、输入输出硬件、物理I/O模式（专有通道I/O、内存映射的I/O、复合I/O、DMA）、输入输出软件、软件I/O模式（可编程I/O、中断驱动的I/O、DMA）、I/O软件分层、设备驱动程序等。

输入输出从根本上说是操作系统对各种外部设备进行的抽象和装扮

本篇最为重要的核心是输入输出的硬件模式与软件模式，以及这两种模式之间的对应关系。理解了这两个概念，其他部分就自然明白了。

第19章　输入输出

引子：约翰逊的输出——从没有到爆发

1709年，在英国的斯特福特郡（Staffordshire），一个小孩出生了。由于贫穷多病，这个小孩在年幼时左眼睛失明、右眼高度近视，并且左耳失聪和脸部畸形。

虽然这个小孩从小就异常坚强，但仍然摆脱不了命运的无情摧残。1728年，其母亲竭尽全力供他进入了剑桥大学，但一年后，由于贫穷，这个已经20岁的年轻人被迫辍学。望着自己唯一一双前露脚趾后露脚跟的鞋，年轻人陷入了长久的抑郁。他站在镇里的钟楼前面望着时钟却说不出时间，他对外界没有了任何反应。而外界也已经认定这个人得了痴呆症。

就在这个年轻人准备自杀的时候，一个女人却从他痴呆的表象中看到了不同，并毅然地与这个小伙子结合。

奇迹诞生了，小伙子的天才迸发了：

这里的怨恨、阴谋和劫掠

变为暴民的狂热与烈火

十足的恶棍做好了埋伏

凶残的律师寻找着猎物

失修的房子在头顶坍塌

女无神论者在口吐白沫……

可悲的真理在各处承认

缓慢起价值，贫穷生堕落。

Here malice, rapine, accident conspire，

And now a rabble rages, now a fire；

their ambush here relentless ruffians lay，

and here the fell attorney prowls for prey；

here falling houses thunder on your head，

and here a female atheist talks you dead……

This mournful truth is everywhere confessed，

Slow rises worth, by poverty depressed.

他在写作上的奇异天赋很快征服了世人，而英国18世纪的后半叶也由他的名字命名。

这个人就是英国杰出的文学家，成就仅次于莎士比亚的塞缪尔·约翰逊（Samuel Johnson），如图19-1所示，他的妻子就是波特夫人。英国18世纪的后半叶称为“约翰逊时代”。

图　19-1　英国文豪塞缪尔·约翰逊（1709—1784）

19.1　输入输出

本书的前3个部分详细阐述了操作系统的3大核心

功能：进程管理、内存管理、外存管理（文件）。但是单有这3个核心功能是不够的。计算机归根结底是为人类服务的，这就要求计算机必须提供某种机制使得人可以向计算机发出命令或操纵计算机。也就说计算机与人之间必须存在某种沟通的机制。这种沟通的机制就是计算机的输入输出机制。

输入提供的是一个“人→计算机”的通道。即人或外部世界通过输入向计算机发出命令或提供数据。输出提供的则是相反方向的通道，即“计算机→人”的通道。计算机通过这个通道向人或外部世界输出自己的计算结果，包括对其他设备的控制操纵命令。

显然，输入输出的存在才使得计算机的存在有了意义。就像一个人，如果没有输入输出，即她或他不能与外部世界打交道，则这个人通常被认为是痴呆或白痴。即使这个人实际上是一个天才情况也是如此。同样，一台没有输入输出的计算机，不管其运算功能多么强大，也是废铁一堆（对于计算机外的世界，或者人类来说）。由此，输入输出也就成为操作系统设计时的一个重要考虑。

对于操作系统设计人员来说，从高层设计来看，关于输入输出我们要问的问题有两个：

●输入输出要达到什么目的？

●操作系统是如何实现输入输出功能的？

19.2　输入输出的目的

输入输出的目的，简单来说，就是提供一个人机交互的通道，使得人与计算机能够进行沟通。但这是抽象的层次。具体来说，输入输出的目的是什么呢？

我们在前面讲述进程、内存和文件时说过，操作系统是一个魔术师和管理者。对于输入输出这部分功能来说，也不例外。操作系统要管理的自然是输入输出设备。而魔术则是提供一个统一的界面来屏蔽输入输出设备的差异，使得数据的表示能够在不同设备之间相互转换而无须用户的操心。那么到这里，我们就可以得出操作系统输入输出的目的是：

●屏蔽输入输出设备的差异。

●在不同设计之间进行数据表示的转换。

达到上述目的需要的机制，仿照我们前面的模式就是设备独立与设备保护。

这里的设备独立指的是输入输出不以设备的不同而转移，即不管输入输出设备如何更好或更新，我们进行输入输出的模式和方法保持不变。而设备保护则是一个输入输出设备的操作不会影响对另一个输入输出设备的操作。

那么要想实现设备独立和设备保护，我们需要从硬件和软件两个方面出发进行考虑。下面我们先来探讨一下硬件层面的输入输出，然后再探讨软件层面的输入输出。

19.3　输入输出硬件

输入输出设备种类繁多，功能各不相同，操控也不尽相同。对于普通人或者电气工程师来说，输入输出设备呈现的首要特征是其物理组件：芯片、布线、能量供应、电机等。而对于软件工程师或程序员来说，输入输出设备呈现的则是程序员或用户界面：可接受的命令、能提供的功能和错误处理机制等。毫无悬念，本书采取的自然是从软件工程师的角度，尤其是操作系统设计人员的视角来对待输入输出。

从程序员或操作系统设计人员的视角来看，所有的输入输出设备可以（大概）划分为两个大类：块设备和字符设备。这种划分是以设备存储和传输数据的方式来决定。块设备，顾名思义，就是以数据块为单位存储和传输数据的输入输出设备，如磁盘、光盘、U盘、磁带等。而字符设备自然是将数据按照字符（字节）为单位来存放和传输的设备，如鼠标、键盘、打印机、网络界面等。

当然，上述分类并不是绝对的。有时，一个设备可以同时作为块设备和字符设备。例如，网络界面通常被认为是一个字符输入输出设备，但在某些时候可以与内存进行DMA，从而看上去更像一个块设备。而另外的设备，如时钟，则不属于这两种中的任何一种。（时钟是输入输出设备吗？）

字符设备和块设备的最大不同表现在寻址上。块设备的数据按数据块为单位进行处理，而每个数据块都有一个唯一的磁盘地址，也就是说数据块是可寻址的。而字符设备里的字符是不可寻址的。当然，由于一个字符占一个字节（对于ASCII码来说），而字节是可以寻址的，很多人会认为字符因此也是可寻址的。但这个理解是不正确的。（为什么？）

19.3.1　输入输出设备的差异性

输入输出设备由于种类不同，制造商不同，技术标准不同，其特性可以有巨大的不同。而这种不同越是明显，对操作系统的设计的挑战就越大。因为屏蔽这些巨大的不同，使得不同的设备相互共存并不是一件容易的事情。

其中最为明显的一种差异是其数据传输的速度。输入输出设备的传输速度涵盖范围从每秒10个字节到几兆个字节。表19-1给出的是较为常见的一些设备的数据传输速率。

19.3.2　设备控制器

输入输出设备本身并不是一个不可分割的整体，而是由不同的部件构成。一般来说，一个输入输出设备至少可以分为两部分：机械部分和电子部分。

机械部分自然是设备的物理硬件部分，而电子部分则是设备的控制器。控制器有时也称为适配器，通常为一块印刷电路板。控制器可以处理多个设备，或者说多个同类的设备可以共用一个控制器。图19-2描述的就是输入输出设备和它们的控制器。

图　19-2　输入输出设备和它们的控制器

设备控制器的任务可以简单地分为如下几项：

●控制设备的物理运行。

●将序列字位流转化为字节块流。

●进行纠错操作。

设备控制器与CPU之间的数据交互通过设备寄存器进行。设备寄存器附着在设备控制器上。通过向这些寄存器进行写入，操作系统可以向设备发出输入输出命令，如把设备关闭或打开。而通过读取这些寄存器的内容，操作系统可以获得设备的状态信息。

为了提高与CPU交互数据的效率，输入输出设备通常还备有数据缓冲区。例如，视频控制器通常带有自己的视频RAM。CPU通过与视频RAM进行数据交互，就可以传输巨大的数据量。

19.3.3　物理I/O模式

计算机在输入输出时可以选择的模式有很多。这些选择的不同体现在CPU和内存的使用上。通常来说，物理输入模式可以按照两种考虑进行分类：

●根据CPU访问I/O设备的方式进行分类。

●根据CPU在I/O过程中的涉入程度进行分类。

根据CPU与设备控制器沟通方式与内存的不同关系，物理I/O模式可以分为如下3种：

●专有通道的I/O

●内存映射的I/O

●混合I/O

1.专有通道I/O

在专有通道I/O模式下，I/O与内存是完全脱离的。每个控制寄存器被赋予一个I/O端口。这个I/O端口就是一个8位或者16位的整数。这个整数与内存地址没有任何关系。而正是由于I/O端口地址与内存地址没有任何关系，或者说I/O端口地址不是内存地址，操作系统必须使用专门的输入输出特殊指令来进行数据的读写。例如，许多指令集结构均使用IN REG, PORT指令来从设备读取数据；而指令OUT PORT, REG则用来将数据写入设备。

专有通道模式的优点是与内存分开，输入输出操作不会影响或干扰内存操作。尤其是输入输出软件的可靠性通常不如内存管理软件，这种分离就显得更加有价值。但俗话说，成也萧何，败也萧何。这个优点恰恰也是其缺点。由于与内存分开，输入输出指令与内存访问指令自然也不相同。因此，进行内存访问与进行输入输出的指令互不相同。事实上，正如上一段所描述的，这种模式下的输入输出需要使用专门的IN/OUT指令来进行。

由于高级语言不支持这种IN/OUT低级指令，如果程序员想要进行I/O，则必须在程序中直接使用这些低级语言指令，而这将增大程序设计的难度，并打破人们不懈追求的封装，导致程序可靠性降低。专有通道I/O还有一个问题，即保护问题。这是因为使用低级语言的保护作用非常有限。例如，低级语言一般不具备访问控制和授权能力，使用这种模式进行访问的主体可以对I/O设备进行任意操作，包括故意制造故障。

早期的计算机使用专有通道I/O模式，这些计算机包括IBM360、IBM370等。

2.内存映射的I/O

内存映射的I/O，顾名思义，就是将I/O映射到内存里面。从而使得I/O和内存管理得到统一。具体来说，就是将I/O设备的每个控制寄存器和设备缓冲区寄存器赋予一个唯一的内存地址。对这些地址的访问就是对I/O设备的访问。对这些地址的访问从逻辑上看，就是内存访问。

读者应该还记得第11章里面讨论操作系统和用户程序在内存里面的相对位置时，有一种模式是操作系统处于内存地址的两端，用户程序处于内存空间的中间。在这种模式下，内存顶端的操作系统部分就是内存映射的输入输出，即设备的控制寄存器的地址在可用物理内存地址的上方。

内存映射的I/O从DEC公司的PDP-11开始引入。

内存映射虽然具有I/O与内存访问统一的优点，但是也存在诸多问题。

第1个问题是使用缓存而产生的问题。由于使用内存映射，I/O控制寄存器被当做内存来看待，而内存里面的内容则有可能缓存起来。对于一般的数据来说，缓存提供的是优势。但对于控制寄存器来说，则是一个大缺点。因为操作系统在读控制寄存器时需要的是最新的内容，而不是缓存的内容。I/O设备的任何变化都将在控制寄存器内容上体现。但这个内容却不一定在缓存中体现。这样，如果读操作在缓存命中，操作系统获得的系统状态将是以前的状态，这将直接影响I/O操作的执行。

内存映射的第2个问题则是总线竞争。在单总线系统里，内存和设备均需要对总线上的数据进行监听，以确认命令是否针对自己。这样将产生总线竞争而降低系统效率，如图19-3a所示。如果是多总线系统，则I/O设备有可能看不到总线上的地址，如图19-3b所示。

图　19-3　单总线和多总线的内存映射问题

对于内存映射的问题，我们也有一定的应对办法。对于第1个问题，我们可以使用缓存禁止位。对于多总线数据迷失问题，解决的办法有多个：

●失败与再试：如果一个请求没有得到内存响应，则将数据发到其他的总线。

●窃听：在总线上安装一个窃听装置，负责对数据进行分发。

●地址过滤：使用一个过滤装置把地址进行自动过滤到合适的地方。

例如，奔腾处理器结构采用的就是多总线地址过滤模式，如图19-4所示。但对于单总线竞争问题，则尚无有效的解决办法。

3.复合I/O模式

当然也可以使用上述两种方式的组合，即复合I/O模式。在这种复合模式下，数据缓冲区为内存映射，但是I/O端口为分开的部分。即设备控制器寄存器需要使用特殊命令来访问。例如，英特尔公司的奔腾处理器将640KB到1MB的地址留给设备数据缓冲区，而0到64KB则留给I/O端口。

图　19-4　奔腾处理器的地址过滤结构

图19-5显示了上述3种模式的结构图。

图　19-5　硬件I/O模式

19.3.4　根据CPU在I/O过程中的涉入程度进行分类

而按照CPU在I/O过程中的涉入程度来分类，物理I/O模式可以分为：

●繁忙等待访问

●直接内存访问

1.繁忙等待访问

不管是否使用内存映射的输入输出，处理器均需要与I/O控制器和数据缓冲区进行数据交换。而这种交换既可以按字节进行，也可以按数据块进行。如果按字节进行，CPU当然需要在整个过程中介入，即CPU在I/O过程中一直处于繁忙状态。

显然，让CPU在I/O过程中一直保持繁忙不是一件好事情。如果要传输的数据是一片连续的内存空间，则我们就可以把CPU从I/O中解脱出来。解脱出来的办法就是直接内存访问（即Direct Memory Access, DMA）。

2.DMA访问

DMA的工作原理是，如果按数据块进行I/O，即需要传输大量数据时，就无须CPU的介入。在这种情况下，我们可以让I/O设备与计算机内存进行直接数据交换。而CPU则可以去忙别的事情。这种将CPU的介入减少的I/O模式称为直接内存访问。

问题是，将CPU从繁忙等待中解脱出来，难道DMA的整个数据读写过程不需要使用处理器的功能吗？当然不是。数据传输当然使用CPU，只不过这里使用的CPU不是计算机里面所有进程共享的CPU，而是由另外一个CPU来负责数据传输。这个另外的CPU就是DMA控制器。

也许读者会问，这有什么意思，还是需要CPU繁忙等待，只不过换成一个不同的CPU来进行繁忙等待。何必这么麻烦呢？还不如就让通用CPU来处理。这里的关键是DMA里面的CPU可以比通用CPU简单，而且价格便宜很多，它只需要能够以不慢于I/O设备的速度进行数据读写即可。其他复杂功能，如算数运算、移位、逻辑运算等功能皆可以不要。

DMA控制器既可以构建在设备控制器里面，也可以作为独立的实体挂在计算机主板上。而以独立形式存在的DMA控制器更为常见。使用DMA进行I/O的工作流程如图19-6所示。

图　19-6　DMA工作流程

DMA输入输出的过程如下所示：

1）CPU对DMA进行设置，告诉其I/O的起始地址和数据长度。

2）启动DMA过程。

3）DMA进行数据传输。

4）DMA结束后发出中断。

5）CPU响应中断并处理结束事宜。

DMA控制器依生产商的不同而有很大的区别。最简单的DMA控制器在一段时间内只能处理一个I/O，即不能并发；而复杂的DMA控制器可以同时处理多个I/O，即它能够提供多个I/O通道，每个通道可以对应一个I/O设备。

3.DMA模式的考虑因素

使用DMA模式进行I/O时有一些因素需要考虑：

●如何访问总线。

●数据存放何处。

●内存寻址模式。

由于DMA需要使用内存总线，而CPU也需要使用内存总线，这样将形成内存总线竞争。那么DMA使用总线的模式将依赖于CPU使用内存的模式。根据这些模式的不同，我们可以选择周期盗用（cycle stealing）或者爆发模式（burst mode）。

由于DMA传输的数据量通常较大，数据存放何处就是一个需要考虑的因素。我们既可以将数据直接存入内存，也可以存入DMA缓冲区，然后通过中断让CPU一次性将DMA缓冲区的数据拷入内存。

另外一个考虑因素就是如何寻址内存。是使用虚拟地址还是物理地址？使用物理地址的好处是速度快，缺点是绕过MMU存在安全和可靠性风险。

4.DMA模式的优缺点

优点就是将主CPU从I/O中解脱出来。而缺点自然是增加了成本和复杂性。而且，由于DMA需要与CPU竞争内存总线，其效率的提高不如理论上的期望。因此，并不是任何I/O都应该使用DMA。

在下述情况下，应该选择使用主CPU：

●设备I/O速度非常高。

●主CPU没有其他事情可做。

●成本考虑。

19.4　I/O软件

前面几节从硬件的角度介绍了输入输出，但是只有硬件是不够的。毕竟，对于用户来说，直接对硬件进行操作十分困难。

首先，硬件的种类和类型实在太多，单单磁盘就有不同类型、不同生产厂商的数十种之多，而每种磁盘的控制界面并不一样。至于网卡、视频卡、键盘、鼠标等也存在五花八门的品牌和系列。

其次，对一种硬件设备来说，其访问过程并不简单。要想正常访问，必须知道该设备的物理结构和特性，其工作原理和机制。而这对于程序设计员来说，显然有点要求过多。

例如，由于对控制寄存器的访问和对内存的访问不同，用户必须明白I/O设备的底层命令，并且对于不同的设备这个命令是不同的。这样用户写的程序必须依设备的不同而有所不同，这显然不是一件令人高兴的事情。例如，用户在读写一个文件时，因为介质是软盘、磁盘或光盘，他们需要写出3个版本的程序。

解决这个问题的答案当然是软件，或者说操作系统软件。我们已经多次强调过，操作系统的角色就是魔术师和管理者。在这里也不例外。I/O软件的目的就是魔术和管理。魔术是将不同I/O设备的差异屏蔽，使它们看上去似乎是一样的东西，都具有令人赏心悦目的界面；而管理自然是对这些设备进行管理，该独享的独享，该共用的共用，需要缓冲的缓冲。并对设备进行实际的驱动（发出读写命令）。

19.4.1　I/O软件的目的

从最高层来说，I/O软件的目的我们已经说过，就是魔术和管理。而具体来说，其目标有如下几项：

●设备独立

●统一命名

●错误处理

●数据传输

●缓冲

●共用与独享

设备独立指的是程序对I/O设备的访问不依赖于设备的物理特征，且在I/O程序的编写时无须事先指定I/O设备。统一命名指的是设备或文件的命名不依赖于具体的计算机，这样使用名字将使程序可以在任何机器上运行；错误处理指的是对输入输出过程中产生的数据错误进行侦测与纠正，而且纠错应该在最靠近硬件的层面上进行。数据传输指的是实际操控数据在主机和外设之间的传递，例如支持同步（阻塞传输）和异步（中断驱动）数据传输。缓冲是为数据传输提供一个临时存放地，然后在方便的时候将数据拷贝到最后目的地。共用与独享指的是将设备尽量变为共享，以增大资源利用率和降低死锁发生的概率。例如，将磁盘、打印机变为共享。

19.4.2　逻辑I/O模式

在从硬件角度讨论I/O时，我们说过可以有不同的I/O模式。那么从软件角度来看I/O，也存在不同的模式，而且这些模式与硬件I/O模式遥相呼应。

从CPU的涉入程度来分，可以分为可编程I/O和中断驱动I/O，这两种分别对应硬件原理的繁忙等待I/O和直接内存访问。

1.可编程I/O原理

在可编程模式下，CPU等待I/O的完成，即CPU涉入程度很深。这种模式也称为轮询或者繁忙等待。图19-7给出的是打印一串字符的可编程I/O程序片段。

显然，在等待打印机状态变为就绪的过程中，CPU不能干任何事情。而每次等待后做的事情只不过是将一个字符发送到打印机的数据寄存器里面。这对于CPU来说，真有点“大材小用”了。而且，我们前面说过，大材小用或者浪费资源并不是繁忙等待的唯一甚至最严重缺陷。最严重的缺陷是还有可能造成优先级倒挂，进而造成死锁。

图　19-7　可编程I/O举例

解决的方法就是不繁忙等待，而是使用中断。

2.中断驱动I/O原理

中断驱动就是将CPU从繁忙等待的繁琐中解脱出来。在发送完一个或一批数据后，CPU就去忙别的事情。I/O设备处理完这批数据后，向CPU发出中断。CPU响应中断后再发送下一批数据。具体来说，中断驱动I/O的过程如下：

1）CPU初始化I/O并启动第一次I/O操作。

2）CPU去忙别的事情。

3）当I/O完成时，CPU将被中断。

4）CPU处理中断。

5）CPU恢复被中断的程序。

图19-8描述的是中断驱动I/O里面的后3个步骤。

图　19-8　中断驱动的I/O工作流程

图19-9描述的是中断驱动I/O的打印程序片段举例。图19-9给出的是打印系统调用时执行的代码，图19-10给出的是CPU中断响应部分。系统调用片段对应上面所列步骤的前两个步骤，中断响应部分对应后3个步骤。在系统调用片段中，我们首先将需要打印的数据从用户空间拷贝到内核空间，然后启用中断（如果中断本来就是启动状态，这个操作也不会产生任何副作用）。在此之后，等待打印机状态变为就绪，然后发生第1个字符。之后在打印机处理这个字符的输出时，操作系统调用调度器来选取另一个程序来执行。

在中断响应片段中，首先检查是否打印完毕，如果是则将用户叫醒。否则，发生下一个要打印的字符，将仍然需要打印字符数减1。然后回应中断，并从中断处理程序返回（到被中断的程序）。

图　19-9　打印系统调用时执行的代码

图　19-10　CPU中断响应部分

这里请注意，在系统调用部分的初始设置时，操作系统采用轮询来等待打印机变为就绪。而不是使用中断。这是因为，操作系统还没有交给打印机任何数据来进行打印。

3.直接内存访问I/O原理

中断驱动I/O由于在I/O设备工作时无须繁忙等待，其效率比可编程I/O要高。但是它毕竟需要CPU周期性地中断来发送或接收后续的数据。这种频繁中断对于系统效率来说并不是什么好事。例如，在图19-9和图19-10的例子中，每一个字符输出均需要中断一次。这样，虽然较可编程I/O效率高，但总体来说还是有很大的改进余地。那么怎么改进呢？

当然是降低CPU响应中断的频率：从一个I/O任务多次中断变为一个I/O只有一次中断，这就是直接内存访问，我们在硬件角度看I/O时已经讲过了DMA的原理。现在从软件的角度看就更加清楚了。由于一个I/O任务不一定可以一次将数据传输完毕，因此中间还需要CPU进行处理，但为了将主CPU从I/O的繁琐事情中解脱出来，我们另外设立一个CPU来响应一个任务中间的中断。而这个CPU就是DMA控制器。

更进一步来看，既然有一个单独的CPU来处理I/O，也就没有必要使用中断。就让这个额外的CPU轮询也不会降低系统效率。因此DMA模式下的I/O通常是轮询。图19-11给出的是使用DMA打印时的程序片段。

图　19-11　使用DMA进行打印时的程序片段

图19-11a是DMA设置部分。该部分首先将需要打印的数据从用户空间拷贝到内核空间，然后设置DMA控制器，包括起始地址和数据量，并启动DMA。然后调用调度器来选择另一个程序执行。

图19-11b是DMA结束部分。DMA结束后会向主CPU发出中断。一旦收到这个中断请求，CPU可以立即回应，而无须检查I/O任务是否完成。因为在DMA模式下，DMA控制器只有在I/O完毕时才会发出中断。回应后叫醒用户，然后从中断响应程序返回。

19.5　I/O软件分层

从前面所述可以看出，I/O是一件颇为繁琐的事情。它涉及用户空间和内核空间的数据交换、I/O设备的设置与启动、中断响应与返回，而且整个I/O需要提供一个与I/O设备无关的统一界面。为了完成一个繁琐的工作，人们通常会将其分为更小的任务来处理。在I/O软件上自然也不例外。

I/O软件通常按照I/O功能进行分层，每一层有提供独特的功能，并与相邻的层面设计有标准界面。当然，在不同的操作系统中这种分层也是不同的。但一般来说都会有下面几层：

●用户层I/O软件

●设备独立的操作系统软件

●设备驱动程序

●中断服务程序

下面我们对每一层的功能进行解说。

19.5.1　中断服务程序

由于大多数I/O均为中断驱动，中断驱动服务程序就成为绝大部分I/O软件的不可分割的部分。中断服务程序由于直接与硬件相关，针对不同的I/O硬件，中断响应的处理也不尽相同。因此，中断服务程序是I/O软件系统分层里面的最底层。为了降低操作系统的复杂性，中断服务程序的暴露窗口应该越小越好。也就是说，知道中断服务程序或者与其打交道的操作系统部分越小越好。

降低暴露窗口的最好办法是让设备驱动程序负责中断响应。即设备驱动程序启动I/O操作后阻塞，然后等待中断。而一个设备驱动程序可以通过操作信号量或睡觉来进行阻塞。当收到中断请求后，中断服务程序先执行，然后将处于阻塞状态的设备驱动程序解锁。这种解锁可以通过发送信号或者对信号量进行操作。下面为中断处理的步骤：

1）保存没有被中断硬件保存的相关寄存器。

2）设置中断服务程序的上下文。

3）设置中断服务程序的栈。

4）回应中断控制器。

5）重开中断。

6）从保存处恢复寄存器。

7）执行服务程序。

8）设置MMU以执行下一个进程。

9）设置新进程的寄存器。

10）开始执行新进程。

19.5.2　设备驱动程序

设备驱动程序，顾名思义，就是直接驱动I/O设备进行输入或输出操作的软件。它属于与设备控制器直接联系的I/O软件部分。它与具体的I/O设备直接相关，并针对每个特定的I/O设备进行优化。设备控制程序通常由设备制造商提供，但归属于操作系统内核。正因为这一属性，设备驱动程序是操作系统安全的一个巨大隐患。

由于需要直接驱动设备的运行，设备驱动程序必须清楚设备的所有细节。例如，磁盘驱动程序必须清楚什么是扇面、磁道、磁柱、磁头、磁臂、电机和电机驱动等信息；鼠标驱动程序需要具备辨认按下哪个键的能力。

设备驱动程序需要完成的任务包括：

●从上层接收抽象的读写请求。

●确保读写操作完成。

●初始化设备，开关设备。

●对设备的功能进行管理。

多数操作系统都定义了一个标准的块设备界面和字符设备界面。每种界面由一组子程序组成。这组子程序可以由操作系统的其他部分调用。

在UNIX系统下，设备驱动程序与整个操作系统编在一个二进制文件里。如果要增加新的设备驱动程序或者修改现有的设备驱动程序，则需要重新编译操作系统。UNIX的这种做法主要是考虑了安全的因素。由于用户或其他人无法动态地加载设备驱动程序，因此操作系统安全性较高。不过，这种不能动态加载的限制让人感觉十分不便，因此在最新的UNIX版本如Solaris 10里，设备驱动程序可以动态加载。

而Windows操作系统从一开始（NT开始）就在运行设备驱动程序时进行动态加载。而这也是Windows不如UNIX安全的一个原因。

19.5.3　设备驱动程序操作

在收到一个I/O请求后，设备驱动程序做的第一件事情是检查输入参数是否合法。如果不合法，则返回错误。如果参数正常，则将I/O请求的抽象表示转换为设备能够认识的具体表示，如将线性数据块号转换为磁头、磁道、扇面等。然后，设备驱动程序需要检查设备状态以确认其处于闲置状态。如果设备繁忙，则将I/O请求送入该设备的等待队列里面以待处理。如果设备没有开机，则驱动设备进行并启动电机。

在做完上述各项工作后，剩下的就是真正的I/O操作了。设备驱动程序通过发出一系列的I/O命令到设备控制寄存器里面来进行真正的数据传输工作。如果需要，设备驱动程序需要阻塞并等待中断。而每次从中断醒过来，则需要检查I/O是否正确完成，再将数据传输到上面调用设备驱动程序的应用。

在所有数据传输完毕后，设备驱动程序将返回到调用者那里。

19.5.4　设备独立的操作系统软件

一般来说，设备驱动程序并不直接从用户处接收I/O请求，而是通过另外一层中介获得用户请求。这层介于设备驱动程序与用户程序之间的中介就是设备独立的操作系统软件。操作系统在设计时之所以有这层软件是因为I/O软件的一部分与设备有关，一部分与设备无关。而如果与设备无关，就可以将这部分共用起来，放置在设备驱动程序之上，为用户提供一个统一的I/O界面。

这种对于所有I/O设备都共同的操作包括诸如缓冲、错误报告、分配与释放独享设备、提供设备独立的数据块尺寸等。设备独立的操作系统软件与设备驱动软件之间的分界自然与每个设备有关。

1.统一界面

设备独立的操作系统软件的一个重要目标是提供一个统一的I/O界面。即让所有的I/O设备看上去一样或者相似，这是操作系统惯常扮演的角色。采用的手段则是为设备驱动界面规定一个标准：在下层规定设备驱动程序必须提供的功能清单，在上层规定内核为设备驱动程序提供的功能清单和界面。

2.缓冲

缓冲是几乎所有I/O设备都需要的一种操作。缓冲的目的有两个：一是桥接速度不同的设备，使之可以沟通同步；二是提供灵活的健壮机制，因为在每个缓冲层都可以进行一些健壮性、可靠性和安全性处理。而第1点又有两层意思：一是提高数据传输速度，因为快速设备不必等待慢速设备；二是防止溢出，因为慢速设备来不及处理的数据可以存放在缓冲区而不会丢失。

当然，缓冲也有缺点，就是降低了数据传输的时效性。因为数据层层缓冲处理是需要时间的。如果一个系统的时效性非常重要，则最好不要使用缓冲，而是在通信双方之间创建一个没有缓冲的直接通道，这样，数据从一方发出后，另一方将马上收到。例如，Mach操作系统的x-kernel就提供这种无缓冲的直接数据通道。自然，这种行为是比较危险的，必须非常小心来避免数据溢出和丢失。

3.错误报告

在I/O操作中，错误是难免的。这是因为I/O需要与计算机外面的世界打交道。而外面的世界自然不如计算机内部的世界井然有序。统计数据表明，计算机壳里面发生数据传输错误的概率很低，而在计算机壳外部进行数据传输发生错误的概率相当高。二者可以相差几百万倍。因此，在I/O中进行错误处理是件十分重要的事情。

如何进行错误处理则取决于错误的类型。一般来说，错误可以分为程序错误和真正的I/O错误。程序错误就是用户要求设备做一件该设备无法做到的事情，例如从输出设备上读数据。真正的I/O错误当然是指数据读写过程中发生的错误，例如数据读错了，或者磁盘盘片损坏了等。

对于程序设计错误来说，I/O软件除了将错误报告给用户外，似乎不能做任何别的事情了。但如果是I/O错误，那I/O软件则需要进行适当的纠错操作，看能否消除错误。如果不能消除错误，可以询问调用者如何处理，或者干脆返回一个错误码给用户。

19.5.5　用户层I/O软件

前面说过，设备驱动程序从设备独立的操作系统软件层接受I/O请求。而设备独立的操作系统软件则从用户或应用软件处接收指令。但是用户也好，应用软件也好，如果需要向I/O软件发出指令，得需要一个发出指令的界面。这个用户层可以直接使用的I/O界面就是用户层I/O软件。这一层既然可由用户直接操控，它自然运行在用户空间。

例如，很多读者都见过的count=write（fd, buffer, nbytes）命令就是用户层I/O软件的一部分。这条指令里面的write被很多人误认为是操作系统的系统调用，而实际上并不是。这是一个由高级语言提供的库函数，它将操作系统的相关（在这个例子里是相同的）系统调用包裹起来。用户与这个库函数打交道，而这个库函数在编译出来后，会变成一系列指令，来完成系统调用过程。

除此之外，放在用户层I/O软件里面的I/O功能包括格式化、假脱机等。

思考题

1.从硬件视角看，有哪些I/O模式？

2.从软件角度看，有哪些I/O模式？

3.哪些I/O模式需要使用轮询？

4.将抽象I/O命令转换为具体I/O指令是什么部分的责任？

5.缓冲在I/O中起的是何种作用？

6.直接内存访问的工作原理是什么？

7.有人认为直接内存访问成本高，有人认为其I/O成本低，你认为如何？为什么？

8.为什么在I/O时进行错误处理非常重要？

9.设备驱动程序通常由设备制造商，而非操作系统设计者提供。这个事实对操作系统设计产生何种影响？为什么？

10.中断是操作系统运转的根本基础，请论述中断在I/O处理中的重要性。

第七篇　多核原理篇

美言不信，信言不美

随着多核技术的流行，操作系统作为计算机的管理者，不得不增加对多核的管理功能。而从单核到多核，并不只是处理器或处理核数量的简单变化，其对操作系统的影响是多方位的。首先，多核的出现对操作系统的进程和线程调度产生了直接影响。因为这个时候可以同时调度多个线程和进程进行执行。其次，在多核环境下，一条指令可以同时在多个核上面执行，就使得单核环境下的同步机制不一定运行正确。另外，多核的出现使得能耗的管理趋于重要。

本篇对新出现的多核技术进行讲解。重点讨论多核环境给操作系统带来的影响。全篇包括第20章和第21章两章。第20章的内容包括多核处理器结构（超线程结构、多核结构、多核超线程结构）、多核内存结构（UMA、NUMA、COMA、NORMA）、对称多核处理器计算机的启动过程、多处理器之间的通信、SMP缓存一致性等。第21章的内容包括多核进程同步、多核环境下的软件同步原语、旋锁及其实现、队列旋锁、多核环境下的进程调度、多核环境下的能耗管理和多核系统性能。

多核技术对操作系统的影响

本篇最重要的概念是多核之间的协调，即如何让多个核的计算能力相得益彰，而不是相互抵消。从某种程度上说，多核之间的协调与前面讲过的进程或线程之间的协调具有相通性。而从另一个角度看，这种协调又是进程之间或线程之间协调的扩展和延续，因此，只要明白了线程之间的协调原理与机制，理解多核技术及其对操作系统的影响就是水到渠成的事情了。

第20章　多核结构与内存

引子：不能承受之热

由于人类的贪婪本性，我们对计算机芯片的速度总是感到不满意。每次芯片速度的提升带来的是更加贪得无厌的需求，从而造成人类对芯片速度提升的不断追求。自从1979年推出频率为5 MHz的8088处理器以来，英特尔公司一直在努力提升CPU的时钟频率，其最新最快的奔腾芯片时钟频率已经达到了令人咋舌的3.8 GHz。

但是与程序不同的是，计算机不能违背物理定律。如果芯片需要快速运算，如每几个纳秒进行一次状态变换，则只能使电子传输一个很短的距离，如最多50cm。这就需要将很多元件集中在很小的空间区域内，而这将产生大量的热量。这些热量必须想办法散去，否则芯片将被烧毁。在过去，工程师们在此方面进行了卓越的努力。

图　20-1　在传统技术下，运行在更高速度下的未来芯片将变得如太阳般炙热

但随着芯片主频的提升，芯片的散热成为了一个不可逾越的障碍。在单位面积上聚集元件的数量也已经饱和。这使得处理器性能的改善遇到了瓶颈。这个瓶颈打破了几十年来一直保持的芯片时钟频率（又或单位芯片上的元件数量）每两年增加一倍（或每3年翻两番）的摩尔定律。芯片工业的技术专家早就预言过：随着芯片速度的提升，工程人员面临的困难将呈指数级增加。事实上，芯片生产商在单个芯片的性能上已经无计可施。英特尔公司多次提到其在攻克4G芯片中遇到了各种难以克服的困难。而难中之难就是芯片产生的热量令人窒息。

借用英特尔技术总监帕特力克·格辛格的话说：“如果芯片设计不出现根本性变革，十年内，运行在更高速度的个人机芯片将变得如太阳般炙热。”

20.1　以量取胜

明知芯片速度的提升已经达到难以为继的境界，但是人类对速度的追求却并没有丝毫停歇的意思。那怎样在不烧毁计算机的情况下满足人类漫无止境的贪婪呢？

质量上不行，数量上补。这是人类几千年来总结出的“金科玉律”。既然无法将单块的CPU速度提升到令人满意的境界，那就使用多个CPU来满足这一要求。也就是说，如果技术上打不过对手，我们就以数量取胜！而以多取胜的核心就是本章将要论述的多核技术。

在今天，芯片厂商已经将他们的将来押宝在多处理器和多核上面。多处理器指的是在一个体系结构上放置多个CPU，而多核则指在同一块芯片（CPU）上放置多个核（core），即执行单元。多核和多CPU的区别是多核结构更加紧凑，成本在同等执行单元数量的情况下更便宜、功耗更低。例如，具有两个执行单元的双核处理器就比使用两个处理器的多处理器结构便宜、紧凑、功耗低。为简单起见，人们现在将多处理器和多核结构统称为多核结构。

多核技术的开端是所谓的双核。该概念最早由IBM、HP、Sun等支持RISC架构的高端服务器厂商提出，主要运用于服务器，如IBM于2001年推出的双核RISC芯片Power 4，HP于2004年2月推出的PA-RISC8800，以及Sun于2004年3月推出的UltraSPARC IV的双内核处理器。但让多核成为家喻户晓的技术名词则是在多核进入IA阵营后。在AMD和英特尔分别将多核引入到个人PC后，多核技术迅速得到普及。随着AMD和英特尔在多核技术上的大力研究和推进，多核技术已经从双核推进到4核、8核甚至更高。而基于多核技术的计算机产品也已经比比皆是。芯片厂商期待这种多核结构能够改善整块芯片的处理器能力（因为有多个处理器），提升芯片的吞吐量，从而达到间接提升芯片性能的目的。

多核计算机的出现，打破了单核环境下的许多操作系统设计的正确性或可靠性。例如，我们在锁的实现时讲过的以中断启用和禁止来实现锁的做法，在多核环境下将不能工作。事实上，本书前面两大部分讲述的进程和内存管理的许多策略和机制针对的均是单CPU或单核的环境。这些策略和机制在多核环境下要么不能正确运行，要么效率太低，当然也有可能二者兼而有之。因此，为了适应多核环境所提出的新要求，也为了更好地利用多核技术提供的新方便，操作系统需要做出相应调整。本章就对多核环境对操作系统的影响进行讨论。

本书先对多核的体系结构做一简单介绍，然后讨论多核环境下的进程和内存管理。由于文件系统基本不受多核环境的影响，我们将不予讨论。

20.2　多核基本概念

要讨论多核环境下的操作系统所做的调整，首先需要知道多核环境和单核环境的不同之处。我们首先来看一下多核的一些基本概念。在x86体系结构下，多处理功能芯片经过了对称多处理器结构（SMP Architecture）、超线程结构（Hyper Threading）、多核结构（Multi-core Architecture）和多核超线程结构（Multi-core Hy-per Threading Architecture）的4个演变阶段。我们下面分别予以介绍。

20.2.1　多处理器结构

除了提升CPU主频和增加一、二级缓存容量外，提升计算机性能的最直截了当的办法就是在一台电脑里面安装多个CPU。由于CPU数量增加，电脑同时处理的工作量就增加，自然提升了系统吞吐量和改善了用户响应时间，从而感觉到计算机的性能得到了提升。

多处理器结构说简单一点就是在一条总线上挂载多个处理器。在传统的体系结构下，一台电脑里面只有一个CPU。而在多处理器系统里，一台电脑里面可以有多个CPU。图20-2给出的就是英特尔公司的有两个CPU的计算机体现结构：

图　20-2　多处理器结构

在多CPU的情况下，以CPU之间的关系不同又可以分为对称和非对称多处理器结构。在对称结构下，多个CPU的角色功能平等，没有主从之分，这种多CPU结构称为对称多处理器结构（Symmetric Multi-Processor Architecture, SMP）。在非对称多处理器结构中，则不同CPU的角色地位不同，有主从CPU之分。这种多CPU结构称为非对称多处理器结构（Asymmetric Multi-Processor Architecture, AMP）。由于SMP结构远比AMP结构普遍，本章讨论的多处理器结构将只对SMP进行。

当然，我们并不需要限制在两个CPU上。我们也可以在一台电脑里面安装多于2个的CPU。图20-3给出的就是一台有着4个CPU的计算机体系结构简化图。

图　20-3　有着4个物理CPU的对称多处理器结构

20.2.2　超线程结构

虽然在一台电脑里安装多个CPU提升了计算机的性能，但是付出的代价是高昂的成本和巨大的功耗。而在实际中，基于很多原因，CPU的执行单元并没有得到充分使用。如果CPU不能正常读取数据（总线/内存的瓶颈），其执行单元利用率会明显下降。另外就是目前大多数执行线程缺乏ILP（Instruction-Level Parallel-ism，指令级并发，即多条指令同时执行）支持。这些都造成了目前CPU的性能没有得到全部的发挥。因此，英特尔公司提出了超线程技术来让一个CPU同时执行多重线程，从而提高CPU效率和用户满意度。

超线程技术是在一个CPU上同时执行多个程序而共享这个CPU内的资源，理论上要像两个CPU一样在同一时间执行两个线程，超线程技术可在同一时间里让应用程序使用芯片的不同部分。而为了支持这种技术，需要在处理器上多加入一个逻辑处理单元指针（Logical CPU Pointer）。因此新一代的P4 HT的模板的面积比以往的P4增大了5%。而其余部分如ALU、浮点运算单元、二级缓存则保持不变。

图20-4描述的是超线程结构。图中的每个CPU并不是物理上的单个CPU，而是两两为一个独立的CPU，即图中只有4个物理CPU，而每个CPU又因超线程技术分解为两个逻辑CPU。每个逻辑CPU可以执行一个线程序列。这样一个物理CPU可以同时执行两个线程。

图　20-4　4个物理CPU、8个逻辑CPU的超线程结构

需要注意的是，含有超线程技术的CPU需要芯片组和软件的支持，才能比较理想地发挥该项技术的优势。操作系统如Windows XP、2003以及Linux 2.4.x以后的版本均支持超线程技术。

虽然采用超线程技术能同时执行两个线程，但它并不像两个真正的CPU那样，每个CPU都具有独立的资源。当两个线程都同时需要某一资源时，其中一个要暂时停止，并让出资源，直到这些资源闲置后才能继续。因此超线程的性能并不等于两个CPU的性能。

20.2.3　多核结构

多CPU成本高、功耗大，而超线程技术又不等于两个CPU的性能，而且时常会碰到两个线程需要同一资源时必须停止一个线程的现象。

那么什么办法可以同时克服多CPU和超线程的缺点呢？即性能像多CPU、功耗像超线程的结构呢？有，就是多核结构。

多核结构就是在一个CPU里面布置两个执行核，即两套执行单元，如ALU、FPU和L2缓存等。而其他部分则两个核共享。这样，由于使用的是一个CPU，其功耗和单CPU一样。由于布置了多个核，其指令级并行将是真正的并行，而不是超线程结构的半并行。

例如，英特尔公司的奔腾D和奔腾EE（见图20-5）即是分别面向主流市场以及高端市场的双核芯片。其每个核采用独立式缓存设计，在处理器内部两个核之间是互相隔绝的，通过处理器外部（主板北桥芯片）的仲裁器负责两个核心之间的任务分配以及缓存数据的同步等协调工作。两个核共享前端总线，并依靠前端总线在两个核之间传输缓存同步数据。

图　20-5　奔腾D和奔腾EE双核芯片（来源：英特尔公司）

当然，我们也可以在一个计算机里面放置多个配置有多个执行核的CPU，而形成更多的核。

例如，如果我们用4个双核CPU则可以构建如图20-6所示的多核、多处理器体系结构。

图　20-6　由4个CPU、8个核组成的计算机体系结构

20.2.4　多核超线程结构

在多核情况下，我们也可以使用超线程技术，从而形成多核超线程技术。即每个物理执行核里面又分解为两个或多个逻辑执行单元，如图20-7所示。

例如，英特尔公司的奔腾EE多核芯片就支持超线程技术，一块奔腾EE芯片在打开超线程技术之后会被操作系统识别为4个逻辑处理器。

图　20-7　由4个CPU、8个核、16个逻辑执行单元组成的多核超线程结构

20.3　多核的内存结构

由于一台计算机里面有多个执行核，而每个执行核均需要对内存进行访问。那么这种访问在多个核之间是如何协调的呢？或者说内存在多个核之间是如何分配的呢？

20.3.1　UMA

最简单的内存共享方式就是将内存作为与执行核独立的单元构建在核之外，所有的核通过同一总线对内存进行访问。由于每个核使用相同的方式访问内存，其到内存的延迟也相同，这种访问模式称为均匀内存访问（Uniform Memory Ac-cess, UMA）。在这种模式下，最重要的是所有核的地位在内存面前平等。其优点是设计简单，实现容易。缺点是大锅饭，难以针对个体的程序进行访问优化，以及扩展困难。随着执行核数量的增加，对共享内存的竞争将变得白热化，从而造成系统效率急剧下降。

当前的对称多处理器共享存储系统基本上采用此种模式。这种模式只能在处理器个数或执行核数量较少时方可使用。

20.3.2　NUMA结构

如果我们想构建CPU数量很多的多处理器系统，或者欲构建执行核多于4个以上的多核系统，则UMA结构因内存共享瓶颈而不能胜任。在这种情况下，一种自然的选择是使用多个分开的独立共享内存。每个执行核或CPU到达不同共享内存的距离不同，访问延迟也不一样。这种访问延迟不一致的内存共享模式称为非均匀内存访问（Non-Uniform Memory Access, NUMA）。在这种模式下，最重要的特点是执行核在不同的内存单元面前地位并不平等：到近的内存具有优势地位，到远的内存则处于劣势。

在NUMA下，原则上应该将程序调度到离本地内存（程序存放的内存单元）近的执行核上，以提升程序的内存访问效率，从而提高程序的执行效率。

NUMA结构的优点是灵活性高、扩展容易。在执行核的数量增加的时候，其访问内存的效率可以保持不下降。不过，这种不下降的前提是优良的调度策略，即在调度时能够将程序就近执行。否则，有可能因内存访问距离远而造成效率下降。因此，NUMA对调度的要求很高。但因为扩展容易，所以NUMA得到了非常广泛的应用。

例如，如果想组建更多的核，我们可以将两个图20-7所示的结构组合成为更多核的、多内存的NUMA结构，如图20-8所示。

例如，英特尔公司就是以NUMA方式构建了80个核的芯片，如图20-9所示。

图　20-8　非均匀内存访问NUMA的　多核多处理器结构

图　20-9　英特尔的80个核多核　芯片（来源：英特尔公司）

20.3.3　COMA

我们前面说过，NUMA具有灵活、易扩展的优点，但对调度的要求高。如果不能或难以将程序调度到就近的执行核上，那又怎么办呢？答案是缓存。即在每个执行核里面配置缓存，其执行需要的数据均缓存在该缓存里面。所有访问由缓存得到满足。这样，不论数据原来是处于哪个内存单元，其对效率的影响均将不复存在。

这种完全由缓存满足数据访问的模式称为全缓存内存访问（Cache Only Memory Access, COMA）。在这种模式下，每个执行核配备的缓存共同组成全局地址空间。

20.3.4　NORMA

如果内存单元为每个执行核所私有，且每个执行核只能访问自己的私有内存，对其他内存单元的访问通过消息传递进行，则就是非远程内存访问模式（Non-Re-mote Memory Access, NORMA）。这种模式的优点是设计比NUMA还要简单，但执行核之间的通信成本高昂。这已经有一点像网络了。因为效率问题，这种模式在多核体现结构下使用甚少。

20.4　对称多处理器计算机的启动过程

对于单处理器或单核计算机来说，其启动过程没有什么悬念。因为只有一个CPU。但对于多核和多处理器来说，问题就不是这么简单了。计算机启动到底意味着什么？是只要有一个CPU启动就算启动，还是所有CPU都启动才叫启动？多个CPU有什么启动顺序没有？

显然，多个CPU不可能同时启动，必定有先后次序之分，因为我们不能让两个CPU同时执行BIOS（或EFI）里面的指令（BIOS不支持多线程）。因此，除一个CPU外，必须让其他CPU均处于中断屏蔽状态。也就是说CPU的启动是有次序的。

问题是，这个次序是固定的还是随机的？

对于对称多处理器结构来说，这个顺序是固定的。所有的CPU里面有一个被定为启动处理器（Bootstrap Processor, BSP），而其他的处理器则作为应用处理器（Application Processor, AP）。到底哪个CPU是BSP则由某一特定寄存器的值来决定。例如，对于英特尔公司的多处理器结构来说，寄存器IA32＿APIC＿BASE＿MSR里面有一个BSP标志位。该位被设置则意味着该处理器是BSP处理器。而在任何时候只能有一个CPU的BSP标志位被设置。这一点可由系统总线裁决（arbitration on the system bus）来实现。

有了BSP和AP的区别后，SMP的启动过程就比较清楚了：

1）BSP首先读取并执行BIOS（或EFI）的初始化（boot-strap）代码（通常处于物理地址FFFF FFF0H）对自己进行初始化，这种初始化包括设置APIC（Ad-vanced Programmable Interrupt Controllers，高级可编程中断控制器）环境，建立全局的数据结构，设置跳转代码（trampoline codes）并准备AP的运行环境。

2）而AP则在上电或重启后进行一个简单的自我设置后进入等待启动状态。

3）然后BSP通过发送进程间中断来叫醒AP，对AP进行启动并令其进行初始化。

4）AP在收到BSP发出的IPI启动信号后，则将执行BIOS（或EFI）里面的AP初始化代码或BSP准备的跳转代码。该跳转代码将初始化AP处理器。此后，AP再次进入等待BSP中断的状态。

5）而BSP则继续执行BIOS或EFI的后续代码，并负责启动操作系统。

值得一提的是，SMP的BIOS与单核或单处理器里的BIOS并不一样。由于有多个处理器，SMP的BIOS里面包括了多个处理器的规格和信息，每个处理器的APIC描述表。而这些信息包括诸如CPU的数量与编号、本地APIC信息、I/O的APIC信息等。由上面的启动过程也可以看出，SMP的BIOS里面不只有BSP的初始化代码，通常还包括AP的初始化代码。

20.5　多处理器之间的通信

既然一个系统里面有多个CPU，这些CPU之间总需要进行某种通信，以进行任务协调。而这种协调既可能是CPU本身需要，也可能是运行在它们上面的进程和线程之间需要。那么CPU之间的通信方式是什么呢？

我们讲进程时讨论过进程间的通信：管道、套接字、信号、信号量、消息队列、共享内存等。这些机制能否用来实现多CPU之间的通信呢？

在多CPU之间通信，自然也可以发送信号。不过这个信号不是内存的一个对象，因为这样的话，无法及时引起另外一个CPU的注意。而要引起其注意，需要发送的是中断。

用来协调这些CPU之间中断的机制就是高级可编程中断控制器（APIC）。这是实现SMP功能必不可少的，且是英特尔多处理规范的核心。在此种规范下，每个CPU内部必须内置APIC单元（成为那个CPU的本地APIC）。CPU通过彼此发送中断（IPI，即处理器间中断）来完成它们之间的通信。通过给中断附加动作，不同的CPU可以在某种程度上彼此进行控制。

除了每个CPU自己本地的APIC外，所有CPU通常还共享一个I/O APIC来处理由I/O设备引起的中断，这个I/O APIC是安装在主板上的。图20-10描述的是英特尔公司的Xeon多处理器结构下的本地APIC和I/O APIC的结构示意图。

图　20-10　英特尔多处理器（Xeon）里的本地APIC和I/O APIC

在目前的建置中，系统的每一个部分都是经由APIC总线连接的。“本机APIC”为系统的一部分，负责传递中断至指定的处理器。例如，当一台机器上有3个处理器，则它必须相对的要有3个本机APIC。自1994年的Pentium P54c开始，英特尔已经将本机APIC建置在其处理器中。实际建置了英特尔处理器的电脑就已经包含了APIC系统的部分。

系统中另外一个重要的部分为I/O APIC。系统中最多可拥有8个I/O APIC。它们会收集来自I/O装置的中断信号且在当那些装置需要中断时传送信息至本机APIC。每个I/O APIC有一个专有的中断输入（或IRQ）号码。英特尔过去与目前的I/O APIC通常有24个输入，其他的可能有多达64个。而且有些机器拥有数个I/O APIC，每一个都有自己的输入号码，加起来一台机器上会有上百个IRQ可供中断使用。

当然，除了处理处理器间及输入输出的中断外，APIC也负责处理本地中断源发出的中断，如本地连接的I/O设备、时序中断、性能监视计数器中断、高温中断、内部错误中断等。

20.6　SMP缓存一致性

由于在对称多处理器结构下，每个处理器都有自己的缓存，因此在一个系统里面存在多个缓存的情况下就有可能出现两个缓存的数据不一致的情况。即两个CPU缓存同样的数据，其中一个或两个CPU对数据进行了修改从而造成两个CPU缓存数据的不同。而这有可能造成严重的后果。因此确保SMP里面的缓存一致性十分重要。

SMP必须确保对内存地址的访问是最新的数据。为此，必须使用一些特定的缓存一致性策略或模型。而缓存一致性策略在学术界得到了广泛的研究，并存在许多现成的模型或方法。例如，英特尔公司的x86体系结构使用MESI模型来实现缓存的一致性。

MESI是英语4个单词的首字母缩写：Modified、Exclusive、Shared、Invalid。这4个字母分别代表缓存的4个状态：修改、独享、共享和无效。这4个状态的意思如表20-1所示。

而MESI的4个状态之间的转换关系如图20-11所示。

图　20-11　MESI模型下状态转换关系

MESI模型下状态转换在分布式系统里面有着详细解释。读者可查阅分布式操作系统教材或书籍。本书在此不再赘述。

20.7　多处理器、超线程和多核的比较

多处理器、超线程和多核的共同点是均为了提升计算机性能而设计，均可以同时执行多个指令序列。但是区别也很明显，主要体现在同时执行的两个线程之间共享物理资源的多少。多处理器的共享物理资源最少，每个线程有自己单独的处理器；超线程共享最多，ALU、FPU、MSR、缓存等均为共享物理资源；而多核则介于两者之间，共享处理器，但不共享ALU、FPU等。具体来说，我们有：

对于HT线程来说，其共享的资源包括ALU、某些MSR和缓存；而其独享的资源有本地APIC、通用寄存器、L1缓存、CPUID等。

对于多核来说，共享资源包括最后一级的缓存（如英特尔的智能缓存（Intel Smart Cache））、一少部分寄存器如MSR，能供管理单元也有可能共享。而独享的有CPUID、APIC、BIOS等。

因为共享资源数量不同，多处理器、超线程、多核的成本自然也不相同。多处理器成本最高、独立性最高、功耗最大；超线程的成本、独立性和功耗最小；而多核则处于中间。

当然，不同的制造商所生产的多核处理器中每个核之间共享的资源不尽相同。上面的划分仅仅是按照一般情况来进行。在实际中请参考具体计算机制造商的说明书。

这里值得一提的是，超线程技术是英特尔公司所独有的，其他公司不一定使用这种技术。例如，AMD公司就直接从多处理器跨越到了多核。

思考题

1.推出多核结构的主要动机是什么？

2.请问超线程结构是什么？

3.多核结构有多少种形式，分别是什么？

4.多核之间是如何进行通信的？

5.多核的内存结构有哪些种类，哪一种最常见？

6.MESI状态有哪些？

7.管道、套接字、信号、信号量、消息队列、共享内存6种单CPU环境下的通信机制中哪些可以用于实现多核之间的通信？为什么？

8.APIC代表什么？它在多核处理器中具有何种地位？

9.多核结构下系统的启动过程是怎样的？

第21章　多核环境下的进程同步与调度

引子：“多核”帝国的陨落

公元前146年，经过120年的胶着，罗马终于在第3次交战中战胜了迦太基，成为地中海的新霸主。这个700多年前由台伯河岸边一座小城发展起来的罗马共和国似乎踌躇满志，意气风发，势不可当地向着世界霸主的地位前进……

但是，随着时间的推移，罗马共和国的前景却日渐暗淡。在繁荣富贵中，传统的纪律和品格逐渐瓦解。曾经帮助罗马成为地中海霸主的两名执政官以及元老院的管理制度已经成为妨碍共和国继续发展的障碍。由于三权分立，谁也不能控制谁。这样，在道德水准江河日下的情况下，内乱频生就无法避免了。

公元前60年，这种内乱达到了巅峰。罗马一位征战高卢的年轻指挥官尤里乌斯·凯撒拒绝了元老院令其解散军队的要求，率部越过卢比孔河（Rubicon），向执政官庞培、克拉苏，向元老院，向罗马共和国公然宣战！并随后击败了庞培和元老院组织的抵抗，占领了罗马。在罗马宏伟的竞技场上，凯撒宣告自己为罗马的终身独裁者。至此，存在了800余年的罗马共和国宣告结束，罗马帝国的时代得以开启……

但罗马帝国的开始并不太平。凯撒执政仅16年就遭到刺杀，致使罗马帝国又一次陷入内乱。在这次内乱中，凯撒的侄子屋大维击败对手马可·安东尼和埃及艳后克莉奥佩特拉，成为罗马帝国的新统治者。这位奥古斯都大帝在公元前27年恢复了共和制，并将自己的地位从“皇帝”降低为“罗马首席公民”，从而开创了罗马帝国的辉煌时代。罗马的版图和影响力空前壮大。正如有着“罗马的荷马”之称的诗人维吉尔所说：

哦，罗马人！

请记住，用你的影响力统治各个国家，这应该是你的艺术；

用法律成就和平，宽恕乞求之人，用战争制服傲慢之徒……

——摘录《埃涅阿斯记》

繁荣虽得，长久却不能。由于罗马国土过于庞大，284年，罗马皇帝戴克里先设立了由两名共治皇帝和两名凯撒助手的4人执政体系。而这种有着多个执政“核心”的体系很快就显示了巨大的破坏力。执政的4人常各怀鬼胎，导致大规模内乱和东都君士但丁堡的建立。395年，皇帝狄奥多西一世将帝国分别送给两个儿子，从此罗马分为东西两个帝国，首都分别为罗马和君士但丁堡。这次分裂加速了罗马的陨落。476年，在盎格鲁、撒克逊、法兰克、伦巴第、匈奴、东西哥特、汪达尔、条顿等凶猛蛮族的多路袭击下，西罗马帝国陷落，罗马时代宣告结束。

而“多核”（多个执政核心）是罗马陨落的一个重要原因。

21.1　多核环境下操作系统的修正

多核的出现对软件的设计产生了巨大的影响。那些原来在单核环境下合理的设计和实践无法适应多核环境。例如，由于多CPU或者多个执行核的存在，我们在单核环境下的原语操作将不能适应，而需要修正。除此之外，进程的调度也是一个大大需要修改的地方，因为现在进程不只有一个可以选择的执行核可以选择了。

内存管理的变化是多核环境带来的另外一个重要变故。此外，能耗优化也是多核环境下必须考虑的问题，如果一个核上没有执行程序，是否需要让这个核停止工作？

本章即对多核环境下的进程同步与调度以及能耗优化进行论述。

21.2　多核环境下的进程同步与调度

多核环境带来的最大变化是进程的同步与调度。由于进程运行在不同的CPU或执行核上，其同步就不仅仅是线程的同步，而有可能是执行核或CPU之间的同步。而进程的调度也将涉及将哪个进程分配到哪个CPU或执行核上。由于不同的核在内存的共享方式上有可能不同，其运行有数据共享的进程和没有数据共享的进程的效率就会有很大不同。这就需要调度策略的合理选择与执行来保证系统的整体运行效率。

21.3　多核进程同步

在单核环境下，一次只能有一个程序在执行。而在多核环境下，由于多个执行核或CPU的存在，多个程序可以真正地同时执行。因此，多核环境下的进程同步与单核环境下将有着很大的不同。

我们先来看多核环境下的一个具体的同步问题。假定我们有两个处理器CPU0和CPU1，而这两个处理器执行的程序均需要对内存地址%eax的内容进行加1的操作，如图21-1所示。

图　21-1　CPU0和CPU1同时进行INC操作

显然，如果CPU0和CPU1对这段程序的执行按照图21-1所示错开，则内存地址%eax里面的内容只被加了一次1。而这与我们预期的结果不符。

要保证上述两端程序执行的正确性，我们需要在CPU0执行其程序时，CPU1不会执行上述程序，或者CPU1执行的时候CPU0不会执行。这就需要采取措施保证一段程序的执行是原子操作。不过这里的原子操作与我们前面单核环境下的原子操作有所不同：它必须保证跨越CPU的原子性。即一个CPU执行时，另一个CPU不执行某段程序。

那么我们如何做出这种保证呢？

正如我们在第8章说过的，所有软件原语操作均构建在硬件原子操作的基础上。没有硬件的原子操作，软件原语操作就是空中楼阁，无法挺立。在这里也不例外，所有的多处理器原语操作也需要硬件的支持。下面我们看一下从处理器的架构上面提供了哪些原子操作。

21.4　硬件原子操作

在单核环境下，我们说过，硬件提供的原子操作有：中断的启用与禁止、加载存入指令、测试与设置。而这3种操作除中断启用与禁止不工作外，其他两种在多核环境下均可使用。

对于加载存入原子操作来说，下面的操作均是原子操作：

●读写一个字节。

●读写一个按16位对齐的16位的字。

●读写一个按32位对齐的32位的双字。

而测试与设置则需要针对共享内存单元进行。

21.5　总线锁

在多核环境下，还有一种硬件原子操作称为总线锁。总线锁就是将总线锁住，只有持有该锁的CPU才能使用总线。这样，由于所有CPU均需要使用共享总线来访问共享内存，而总线的锁住将使得其他CPU没有办法执行任何与共享内存有关的指令，从而使得数据的访问是排他的。

除此之外，硬件提供的另外一种同步原语是交换指令，即xchg（exchange）。

该指令可以以原子操作完成在寄存器和内存单元之间的内容置换。该指令的语义可由下述程序片段表示：

int cmpxchg（addr, v1，v2）｛

int ret=0；

/*停止所有内存活动并忽略所有中断*/

if（*addr==v1）｛*addr=v2；ret=1；｝

/*重启内存活动和回应中断，返回ret；*/

｝

21.6　多核环境下的软件同步原语

在硬件提供的同步原语基础上，我们可以构建软件同步原语。由于多核技术相对比较新，如何实现多CPU同步尚没有统一标准，这样造成不同的操作系统实现的软件同步原语不尽相同。下面我们看一下Linux和Windows内核里提供的一些原子操作。

Linux内核提供的原子操作包括如下几种：

●总线锁：置换、比较与置换、原子递增操作。

●原子算术操作：原子读、设置、加、减、递增、递减、递减与测试。

●原子位操作：位设置、位清除、位测试与设置、位测试与清除、位测试与改变。

Windows内核提供的原子操作包括如下几种：

●互锁操作（interlocked operation）

●执行体互锁操作（executive interlocked operations）

这里需要注意的是，目前操作系统还没有为多核环境提供锁操作，因为这种操作代价比较大。除了一些特殊的类型，我们还可以对位操作保证原子性。不过位运算并不是没有代价的，如果能够不共享的话就不要共享，这样可以提高性能。

21.7　旋锁

旋锁（spin lock）是几乎所有多核操作系统均提供的一种CPU互斥机制，是操作系统内核用于多处理器互斥的机制，即用户程序不能使用旋锁进行互斥。旋锁通常用于保护某个全局的数据结构，如Windows里面的DPC（延迟过程调用）队列。这里的互斥指的是多个处理器或执行核之间的互斥，即两个处理器或核不能（物理上）同时访问同一个数据结构；而不是第8章中讲过的多线程之间的互斥。对于局部数据结构来说，则因为只在一个CPU下而不需要使用旋锁。例如，设备驱动程序需要通过旋锁来保证对设备寄存器和其他全局数据结构访问的排他性，即任何时候只能有设备驱动程序的一个部分，从某一个处理器可以访问这些寄存器和数据结构。

旋锁通过获取和释放两个操作来保证任何时候只有一个拥有者。旋锁的状态有两种：要么是闲置的，要么被某个CPU所拥有。这里再次提醒，旋锁的拥有者是CPU，而不是线程。因此，如果一个CPU获得一个旋锁，那么运行在该CPU上的所有的线程都可以访问该旋锁所保护的寄存器和数据结构。旋锁的作用与Win-dows API里面的mutex作用非常类似。

使用旋锁的过程如下：

1）等待旋锁变为闲置。

2）获得旋锁。

3）访问寄存器和全局数据结构。

4）释放旋锁。

例如，Windows使用旋锁保护对DPC队列的访问过程，如图21-2所示。

图　21-2　在Windows中使用旋锁保护DPC队列的过程

在图21-2中，两个处理器A和B均需要访问全局的DPC队列（DPC即延迟过程调用）。主要用于在中断时将那些不需要高优先级执行的代码放进一个队列，等空闲再执行的机制）。因此我们用旋锁来进行处理器间的互斥。对于处理器A来说，如果要访问全局数据，就要先获得旋锁，直到成功，然后才访问。对于处理器B来说情况也一样。

21.7.1　旋锁的实现

旋锁的实现也必须在硬件提供的原子操作上进行。我们前面说过，多处理器环境下的硬件原子操作有加载与存入、测试与设置。这两种办法皆可以用来实现旋锁，而使用测试与设置更为简单。

在使用测试与设置来实现旋锁时，旋锁是一个特定的内存单元。这个特定的

内存单位必须位于整个系统的共享内存里面。这是旋锁的物理载体。如果一个处理器要使用旋锁，就必须检查这个特定内存单元的值。如果为0，则将其设置为1，表示获得该旋锁。如果为1，则表示该旋锁被其他处理器所占有，则在该旋锁上进行繁忙等待，即不停地循环，这也是叫旋锁的缘故。如图21-3所示。

图　21-3　旋锁中的旋转

获得和释放旋锁的代码是用汇编语言写的。如果使用高级语言，则有的动作就无法执行，即使能够执行，也很可能速度缓慢。而且体系结构的一些优点也只有汇编语言能利用。为了提高速度并且最大限度的利用底层处理器结构提供的各种锁机制，用来获取和释放旋锁的代码通常用汇编语言写成。

21.7.2　旋锁的缺点

旋锁有什么缺点呢？很多读者可能马上注意到旋锁的繁忙等待问题。如果这个CPU没有获得旋锁，就循环往复，繁忙等待。我们说过繁忙等待不好。那CPU在这儿繁忙等待，这是不是一种浪费？本书前面提到过锁的两个缺点：一是浪费资源，二是可能造成优先级倒挂。

虽然我们前面说过，繁忙等待不是什么好事情，但在旋锁的情况下，繁忙等待并不会造成什么不良后果。这是因为繁忙等待的主体在这里是CPU，而不是线程。

我们先来看一下旋锁里的繁忙等待是否浪费时间。如果没有获得旋锁的CPU不进行繁忙等待，那又能干什么呢？CPU要获取旋锁是因为CPU需要使用旋锁才能继续执行，它即使不等在旋锁上，也干不了别的事情。因此，等在上面算不得什么巨大浪费。

这里需要注意的是，竞争旋锁的主体不像线程或进程会阻碍别的线程运转。这里的竞争主体是CPU。而对于CPU来说，你等待你的，我运转我的，并不会造成系统效率大幅度下降。

另外，在旋锁上的繁忙等待时间通常较短。因此持有旋锁的CPU肯定在往前推进（而不像线程持有锁的情况，线程有可能没有推进），会很快释放旋锁。切换到别的线程再回来，反而慢了。因此，这种等待浪费也不是什么大不了的事情。

锁的另外一个缺点在旋锁上也不会发生。因为旋锁由CPU持有，不是线程持有，因线程具有优先级关系而可能出现的优先级倒挂现象在旋锁上自然也不会出现。

那么旋锁有没有缺点呢？

答案是肯定的。我们说过，人造的东西不可能没有缺点，旋锁也不例外。只不过，旋锁的问题与普通锁的问题不一样。旋锁的问题是对总线的竞争，而这是一个严重的问题。

那么总线竞争是怎么回事情呢？

旋锁的物理体现是什么？内存单元。这个内存单元在什么地方？在全局内存里。因为每个CPU在检查旋锁的状态时均需要使用系统总线来访问旋锁所在的共享全局内存单元。如果它要测试并设置这个全局内存单元，就需要使用内存总线。那么它不停地发信号到总线上，会造成对内存总线的竞争。这是效率非常低下的一种机制。这就是旋锁的缺点。

那有什么办法解决旋锁的总线竞争问题吗？有，解决方案就是队列旋锁。

21.7.3　队列旋锁

队列旋锁的中心思想就是，需要旋锁的CPU不要到全局内存去SPIN，而是到自己的局部内存去SPIN。这样就可以排除对总线的竞争。在旋锁上排一个队，就表示哪些CPU要这个旋锁。释放的时候就去检查这个队列，交给队列里的第一个CPU。

那么什么叫“交给对方”？就是把局部变量改成释放状态。你本来是在一个全局单元等着检查，现在你不在全局上等，而在局部单元上等着发生变化。谁来做这个变化呢？不是你自己，而是释放旋锁的CPU来改变。一发生改变，你马上就知道了。

那么旋锁的队列在什么地方？那当然在全局的地方。别的CPU可以访问局部内存，但是延迟时间长。原则上不访问别的内存，除非迫不得已。这里就是迫不得已。那么这也不会有总线竞争了，因为不会把内存信号发到总线上去。另外这还提供了先进先出的语义，从而在某种程度上达到公平的效果，即使用同一旋锁的CPU具有类似的性能。

使用队列旋锁的一个巨大优点是其扩展性。由于每个CPU等在自己的本地内存单元上，此种机制几乎可以无限扩展。

21.8　其他同步原语

在多核环境下使用的其他同步原语包括信号量、内核对象等。这些有的在第8章已经论述，有些属于商业操作系统的实现，本书就不再赘述。

21.9　多核环境下的进程调度

对于进程和线程来说，多核环境与单核环境的最大不同是可以有多个线程或进程真正地同时执行，而在单核情景下，这个同时不是真正的物理同时，而是虚拟的逻辑同时。就是这个从逻辑同时到物理同时的变化，使得多核环境下的调度与单核环境下的调度有所不同。

下面就从调度目标、调度策略、调度算法和调度手段上分别对多核环境下的调度进行讲解。

在调度目标上，单核环境下调度应该达到的目标，多核环境下也应该达到。这些目标包括：

●快速响应时间。

●后台工作的高吞吐率。

●防止进程饥饿。

●协调高低优先级进程。

除此之外，多核调度还需要考虑多核之间的负载平衡问题，即每个核的工作量应该比较均衡。

21.9.1　调度策略

对于多个核来说，每个CPU有着自己的就绪队列（runqueue）。该队列里面又可以按照不同的优先级分解为多个子队列，就像单核环境下的情况一样。一个进程可以排在任何一个CPU的队列上，但也只能排在一个CPU的队列上。图21-4描述的就是Linux实现里面的一个CPU的就绪队列和排在其上按优先级分为不同子队列的情况。图21-4中“活动队列”上排的是可以执行的进程，“过期队列”上排的是不能被执行的进程（受阻进程）。

图　21-4　Linux实现下的CPU就绪队列

显然，对于不同优先级的进程来说，其调度算法为优先级高的优先。在同一优先级里面，通常采用时间片轮转。例如，对于图21-5里面的P0进程来说，其时间片用完后，将被放到其所在队列的末尾，如图21-5和图21-6所示。

图　21-5　Linux实现下的时间片轮转调度，P0的时间片用完

图　21-6　Linux实现下的时间片轮转调度，P0被挪动到队列末尾

当然，与单核情况下类似，一个进程的优先级是可以随着时间的推移而变化的，用以保证进程不会发生饥饿。事实上，对于一个CPU的这些队列里面的进程来说，本书在第4章阐述过的所有进程调度算法均可用上。这里不再赘述。

如果需要，一个线程也可以从一个CPU上移动到另一个CPU上。

21.9.2　调度域

我们前面说过，多核环境下需要考虑不同CPU之间的负载平衡问题。比如有的CPU会很闲，而有些CPU会很忙，这就要平衡。而对于Linux来说，用于负载平衡的机制是调度域。调度域指的是一组CPU。而负载平衡就是在一个调度域里面的CPU之间进行平衡，使得它们执行的进程数相同或类似，或者它们的繁忙程度类似。

一个多核系统里面可以有多个调度域。所有的CPU均被映射到某个调度域。而调度域是一个层次架构，顶级调度域囊括所有的CPU，而子调度域则通常仅包括部分CPU。例如，一个典型的调度域架构可以如下：

●CPU＿domains（HT）

●Core＿domains

●Phys＿domains

●Node＿domains

●Allnodes＿domains

每个调度域设有多个标志，这些标志用来表示该调度域的各种属性，主要的标志如下：

●#define SD＿LOAD＿BALANCE 1/*对本域进行负载平衡*/

●#define SD＿BALANCE＿NEWIDLE 2/*在变为闲置时进行负载平衡*/

●#define SD＿BALANCE＿EXEC 4/*在调用程序时进行负载平衡*/

●#define SD＿BALANCE＿FORK 8/*在创建子进程时进行负载平衡*/

●#define SD＿WAKE＿IDLE 16/*任务叫醒时进入闲置状态*/

●#define SD＿WAKE＿AFFINE 32/*任务叫醒到醒来的CPU上*/

●#define SD＿WAKE＿BALANCE 64/*在叫醒时进行负载平衡*/

●#define SD＿SHARE＿CPUPOWER 128/*域成员共享CPU能供*/

这些标志都是Linux定义的。每个CPU都有一个进程队列，可以通过它计算每个CPU的负载。

21.9.3　负载平衡

负载平衡的目标是将进程均匀分配到每个CPU的就绪队列里面。一个负载均衡的系统的效能通常会优于一个负载不平衡的系统。对于Linux来说，负载平衡在调度域里面进行。负载平衡的方法有主动和被动两种。

主动负载平衡是队列里面进程数多的CPU将某些进程推出去（push）。被动负载平衡则是队列为空的CPU从别的CPU队列里面将进程拉出来（pull）。

由于一个系统里面有不同的调度域，不同的调度域其繁忙程度有可能不同。因此，在调度域里面进行负载平衡的情况下，也可能需要在不同调度域之间进行平衡。

因此，在负载平衡时，我们需要找出最繁忙的调度域，在每个调度域里面找出最繁忙的队列，然后将任务从一个队列移动到另一个队列，或者另一个调度域。

21.9.4　进程迁移

在判断需要进行负载平衡后，就需要将一个进程从一个处理器队列移动到另外一个处理器。而这个移动包括了整个上下文的移动，如页表、TLB、缓存等。

21.9.5　钉子进程

有时候，因为一个进程的特殊性，我们需要让它在一个特定的CPU上执行。这在非对称多处理器的多核环境下非常普遍。在这种情况下，由于不同CPU的功能并不相同，而一个进程可能需要某个特定CPU的功能才能执行。即使这个CPU非常繁忙，我们也不愿意将某个进程移动到另一个CPU上。这时我们就将一个进程钉（pined）在一个CPU上。一个被钉住的进程是不能移动的。

除了因功能不同而钉住一个进程外，还可以因为缓存而钉住一个进程。如果一个进程的许多信息已经缓存在一个CPU的缓存里面，我们可能也不太愿意迁移该进程到别的CPU上。

21.9.6　关联线程的调度

如果一个应用被分解为多个线程，由于多个线程需要共享许多资源，这时需要将这些线程尽量分配到同一个处理器核上执行，以提升缓存命中率。

最后需要指出的是，对于多核处理器系统的调度，目前还没有公认的或明确的标准。因此，各个相关的实体在此方面都是各说各话。因此，本书论述的调度方法只能作为一种参考。

21.10　多核环境下的能耗管理

能耗已经成为信息领域面临的一个重大问题，近年来，如何降低信息系统的能耗已经成为一个热门的研究课题。对于多核计算机来说，降低能耗方面可以比单核时更加重要。

通常情况下，CPU运行在正常状态，其主频时钟频率运行在最高值，此时的能耗也处于最高状态。但如果一个CPU没有任务可执行，我们可以令其执行一条所谓的终结（halt）指令，使其进入到C状态（一个耗能很低的状态）；如果一个CPU的工作量很少，我们可以降低其主频频率，使其运行在较低的速度上，从而降低能耗。这个低频率状态就是所谓的P状态。

不过，在进行状态改变的时候，需要考虑到许多因素。这些因素包括：逻辑CPU之间的依赖性、不同物理CPU之间的相关性。例如，同属于一个物理CPU的逻辑CPU是否可以独立地改变状态？一个CPU状态的改变是否会影响其他物理CPU的运行？这些相关性因多核结构的不同而不同。接下来看一下超线程和多核结构的情况。

21.10.1　超线程结构

对于超线程结构来说，C状态是独立的。即一个逻辑CPU可以变为C状态，而不影响其他逻辑CPU的运行。简单地说，我们可以彻底关闭一个逻辑CPU，而将其所用资源全部移交给其他逻辑CPU。如果只有两个逻辑CPU，则关闭一个逻辑CPU的结果相当于暂停多核环境。

P状态是不独立的。因为一个物理CPU里面的逻辑CPU共享同一个时钟，它们的P状态控制寄存器也是共享的。因此，一旦一个逻辑CPU进入P状态，则另一个逻辑CPU也将进入P状态。事实上，所有共享P状态控制寄存器的逻辑CPU皆进入P状态。

21.10.2　多核结构

对于多核结构来说，C状态是独立的，即一个核可以独立地进入到C状态而不影响另一个核的运转。如果两个核同时进入C状态，则两个核之间的共享部分也可以进入C状态。如果双个核里面只有一个核进入C状态，则共享部分不能进入C状态。

多核之间拥有独立的P状态控制寄存器，但是每个核之间的P状态是相互依赖的，它们之间的依赖关系由软件协调。

21.10.3　调度上的考虑

关闭一部分执行核可以降低系统的能耗。但关闭执行核或降低其执行频率有可能对与其有关联的其他执行核产生影响。因此，为了既可以节能，又不对其他执行核产生影响，我们可以从调度上予以考虑。例如，将任务分解到一个包装里面的逻辑CPU上，使得这一个物理CPU上的所有逻辑CPU都处于忙碌状态，而其他物理CPU保持空闲，从而可以关闭或降低执行等级。只有无法将任务都置于一个包装的逻辑CPU时，才分解到其他物理CPU上。

还有就是多核的电源优化。CPU没有执行任务的时候，会执行一条指令，使CPU进入不工作的状态。但这个状态能够保留寄存器里面的数据。例如，在Linux操作系统下，每个核可以有C1、C2、C3 3种状态。C0为正常状态，C1、C2为电源优化状态。我们可以通过将不同的核设置为不同的状态而达到电源优化的目的。比如一个可以在C3状态，一个可以在C0状态。即使是在C0工作状态，也可以通过调节执行核的频率和电压来省电。

21.11　讨论：多核系统的性能

多核真的能提高计算机的性能吗？答案是不一定。

对于多核来说，每个核可以独立地执行任务，而这些核之间是否可以协调和在何种程度上进行协调就决定了多核系统的性能提升到底有多少。

随着多核技术的普及，越来越多的人拥有了自己的多核计算机。可是很多人并没有觉得多核计算机在速度和性能上有着任何非同凡响的提高。有的人甚至根本就没有觉得有任何效率上的变化，有的人甚至觉得效率反而下降了。

这些人的感觉是对的吗？

我们当然知道，感觉是靠不住的东西。因为没有觉得效率的提升并不等于效率真的没有提升。也许是我们的期望值太高，在期望与现实的反差之下对效率的提高视而不见；也许是在多核计算机上运行的软件更为复杂，从而屏蔽了效率提高的现象；也许我们十多年前就开始使用多核计算机了（例如，Sun的SPARC），因此，对现在英特尔、AMD等推出的多核技术不屑一顾；也许我们对多核还有敌意，故意诋毁多核在此方面的作用。

但是我们也知道，感觉并不是无中生有的，很多人说没有体会到效率的提高也不是空穴来风。事实上，多核真的不一定能提升软件执行效率。对于某些特殊应用，如嵌入式应用、高性能计算，多核非但不提高性能，反而降低效率。例如，对于嵌入式应用来说，一家电信设备制造商将其应用从一个多处理器系统迁移到一个多核系统。而这个迁移所带来的区别只不过是独立内存（多处理器）到共享内存（多核），造成了巨大的性能下降。而且这种下降不以应用本身的并发程度为转移（参看《IEEE Computer》2008年11月刊《Measuring Multicore Performance》，第99～102页）。

2008年，位于新墨西哥州的美国圣地亚国家实验室（Sandia National Laborato-ries）对多核技术进行了大规模的仿真试验，他们对8核、16核和32核计算机系统进行了试验。试验结果令人失望。结果表明，随着核数量的增加，多核计算机的性能很快就不再提升，并在达到一定数量后，性能呈下降趋势。尤其是对于需要进行大量数据处理的应用如海量数据筛选、安全控制系统等来说，这种性能下降十分显著。对于这些应用来说，核的数量增加并没有带来性能的提升。根据圣地亚国家实验室的数据，在核数达到8以后，核数的增加丝毫也不会带来效率的提升（见图21-7）。

当然，多核技术并不是一点也不会带来性能的提升。如果这样的话，多核技术也不会发展到今天的广为人知的地步。事实上，多核技术对于那些可以自然分解为大量并发任务的应用来说是如庖丁解牛，游刃有余。

由此可见，多核技术是否带来益处完全取决于客户的应用。如果是解微分方程，请使用多核计算机，如果是进行海量信息筛选和数据挖掘，则最好不要使用多核计算机。

图　21-7　多核与性能关系（来源：美国圣地亚国家实验室）

即使是可以利用多核技术的应用，其利用程度也有赖于程序的编写方式。只有在程序编写时就充分考虑到多核的结构特点的软件才能最大限度地从多核技术获得益处。充分利用多核结构编写并发度高的程序却不是任何人都能够胜任的一个任务。当然，多核厂家或编译器的制作者可以在编译器这一层上进行操作，将普通程序员编写的非并发程序尽可能编译为某种程度的并发代码，从而对多核技术加以利用。

当然，即使我们写不出并发程序，也并不意味着多核技术就没有好处。至少，我们在不同的核上面运行不同的应用。这些不同应用之间的联系最好别太多。

造成多核技术这个问题的核心是内存墙。虽然CPU的速度不断提升，但其访问数据的速度却没有同步提升。虽然核数不断增加，但从这些核到计算机其余部件的链接并没有同步增加，这样，保持所有CPU获得充足的数据就是一个大问题。

对于多核技术来说，现在的问题是，在信息爆炸的年代，数据挖掘和数据分析的应用越来越占据重要地位，如何将多核技术引导到能够为这些应用进行有意义的性能提升是摆在所有关心多核技术的人的面前的一个紧迫课题。多核技术到底能发展到什么程度，就让我们拭目以待吧。

思考题

1.多核调度时与单核调度最大的区别是什么？

2.简要阐述旋锁的工作原理。

3.旋锁的持有者是谁，进程、线程或其他？

4.CPU在旋锁上的繁忙等待能否造成优先级倒挂？为什么？

5.CPU在竞争旋锁时会通过繁忙等待来等待旋锁的释放。有同学建议，CPU在发现旋锁繁忙时不要繁忙等待，而是去睡觉。而持有旋锁的CPU在释放旋锁时给需要使用旋锁的CPU发消息或中断来叫醒CPU。你觉得这个办法如何？

6.多核系统带来的操作系统主要变化是什么？

7.将程序从单核环境移植到多核环境下是否一定会缩短运行时间？为什么？

8.进程迁移是怎么回事情？

9.多核环境下的能耗管理有哪些措施？你还能想出别的办法吗？

10.在支持超线程的多核结构下，一个进程的两个线程是调度在一个物理执行核上的两个逻辑执行核好，还是分别调度到两个不同的物理执行核好？如果针对两个不同的进程，结果又如何呢？

第八篇　操作系统设计篇

多闻数穷，不若守中

前面七大篇对操作系统的基本概念和各种机制进行了详细论述。虽然这些论述是分篇分章进行的，但它们之间并不是互相割裂的，而是有着内部的逻辑联系的。联系它们的纽带有多条。首先，操作系统作为计算机的管理者，需要对计算机的各个组成部分进行管理，这就导致了CPU管理、内存管理、磁盘管理、I/O设备管理等操作系统功能的出现和相互关联。其次，操作系统作为魔术师，需要对计算机的各种硬件进行抽象和装扮，以使其显得更大、更快、更好、更容易使用。而这些抽象就形成了进程线程、虚拟内存、文件系统、各种I/O模式等操作系统构造的出现。而这些构造之间也因操作系统魔术师的角色而互相联系。

但除了管理和魔术这两条共同的纽带外，操作系统各个部分还有一个联系纽带，那就是其设计上所遵循的哲学原理。虽然CPU、内存、磁盘、I/O等设备听上去相差甚大，但对它们进行管理和抽象时采用的策略却有很多相通之外。本篇即对这些相通之处或哲学原理进行讨论。

本篇从高屋建瓴的角度对操作系统设计的十条哲学原理进行阐述。显然，操作系统的设计原理有很多，本篇选取的只是诸多原理里面非常重要的十条。第22章将从操作系统和人类社会两个层面对这十条原理进行论述与比较，以使读者更加清楚地明白操作系统就是人类社会在计算机里面的反映，明白了人类社会的运转就明白了操作系统的运转。读完本篇后，读者可自行发现挖掘操作系统的其他设计原则和原理。

操作系统的不同设计从根本上说是不同哲学的对决

本篇最为重要的核心是不同的生活哲学将导致不同的操作系统设计与构造。只要真正理解了人生哲学，就真正明白了操作系统。

第22章　操作系统设计之哲学原理

引子：残缺心智的胜利

设计师：你好，尼欧。

尼欧：你是谁？

设计师：我是设计师，我创造了母体（Matrix）。我一直在等你。我知道你有很多问题要问，虽然这个过程改变了你的神志，但你依然是不折不扣的人。因此，我的一些回答你能明白，有些你将不能明白。相应地，你的第一个问题也许是最适当的一个问题，但你没有意识到的是它也是最无关的。

尼欧：为什么我会在这里？

设计师：你的生命是母体设计中一个固有失衡等式的剩余总和。你是一个异常情况的终极体现。尽管我竭尽全力避免它，仍不能将其消除，这并不令人感到意外，因此，它并不是无法控制的。而这就是你为什么会在这里。

尼欧：你还没有回答我的问题。

设计师：你说得对。有意思，这个问题你问得比其他人要快。

尼欧：其他人？什么其他人？有多少个？回答我！

设计师：母体比你想象的要老得多。我喜欢用异常的出现次数来计算母体的年龄。以这种方式计算，这已经是第六个版本了。

尼欧：那只有两种可能的解释：要么没人告诉过我，要么他们也不知道。

设计师：正确。就像你推测的，这个异常是系统范围的，即使最简单的等式也受其影响。

尼欧：选择。问题的关键是选择。

设计师：我设计的第一个母体非常完美，像一件完美无瑕的艺术品。其成功只有它后来史诗般的失效可以媲美。它失败的必然性现在来看非常清楚，就是由每个人的固有缺陷所导致。因此，我根据人类的历史重新设计了母体，以便更准确地反映人类本性中多变的怪诞特质。但我再次失败了。我最后终于明白我得不到正确答案是因为它不需要太多的心智，或者说它需要的是一个无需完美的心智。正因为如此，问题的解答被另一个程序偶然发现，而这个程序原本是为研究人类心理而设计的。如果说我是母体之父，则该程序无疑是母体之母。

尼欧：先知。

设计师：嗯。正如我所说的，她偶然发现了一个解决方案。只要你给他们选择的自由，这个方案被将近99.9%的试验体接受，即使他们只在潜意识上意识到这个选择。虽然这个方案可行，但却在根本上存在缺陷。因此，它产生了自相矛盾的系统范围的异常。如果不加以抑制就有可能威胁到系统本身。对于那些拒绝该方案的试验体，尽管只是少数，如果不加以抑制就会不断增加灾难发生的可能性。

尼欧：你指的是锡安。

设计师：你在这里是因为锡安就快要被摧毁。居住在里面的人全都会被消灭，那里所有的一切都会被抹去。

尼欧：胡说。

设计师：否认是所有人类反应中最容易预测的一种。但请相信，这将是我们第六次摧毁锡安，并且我们干起来越来越得心应手。救世主现在的作用就是返回源极，将你所携带的编码重新植入主程序。然后从母体中选出16个女性、7个男性共23个人类个体来重建锡安。不按照这个过程进行将导致灾难性的系统崩溃，而这将杀死连接在母体上的所有人。加上锡安的毁灭，整个人类将不复存在。

尼欧：你不会让这样的事情发生，你不能的。你们需要人类才能生存。

设计师：我们已经做好了接受多种存活方式的准备。但与此相关的问题是，你是否已经准备好为这个世界所有人类的灭亡承担责任？

（设计师按下笔上的一个按键，母体里各个地方的人的图像显现在监视器里。）

设计师：你的反应很有趣。你的5个前辈都基于一个相同推测：一个用来建立你与你们物种深刻联系的条件判断。而这个判断将使救世主正常运行。其他救世主们对此的体验非常抽象，而你的经历却是相当具体：你正经历着爱。

尼欧：崔妮蒂！

设计师：中肯地说，她进入母体，牺牲她自己来拯救你。

尼欧：不！

设计师：终于到了揭示真相的时刻。当系统的根本缺陷最终显现的时候，所体现的异常既是开始又是终结。这里有两道门，你右边的门通往源极和锡安的拯救，左边的门回到母体，回到崔妮蒂，和你们种类的绝灭。就像你充分表明的，问题的关键是选择。但我们已经知道你将如何选择，不是吗？我已经能够看到你身上的连锁反应，你体内的生化物质表明情感的发作已经开始，而这种情感像是专门为了压制逻辑和理智而设计的。你的情感使你看不到这个简单而明显的事实——她就快要死了，而你却无可奈何。

（尼欧走向他左边的门。）

设计师：哼。希望，人类错觉的精髓。它既是你们最强大的力量又是你们最大的弱点。

尼欧：如果我是你，我希望我们不会再见面。

设计师：我们不会再见面的……

——摘自《黑客帝国2——重装上阵》（见图22-1）中设计者与尼欧的对话

图　22-1　《黑客帝国2》中尼欧与设计师的对话

操作系统的设计就是将方方面面的技术和设计有机结合起来，构建一个掌控整个计算机的巨无霸软件系统。当然这种整合并不是一件容易的事情。各种各样的技术细节并不一定互相兼容或相得益彰。它们有时候甚至是矛盾的，或者相消相损的。如果将进程管理、内存管理、I/O管理、文件管理和安全管理有条不紊地整合到一起，需要花费巨大的心血。

整合所遵循的原则就是操作系统的哲学原理。而这个原理不是别的，就是人类自身特质所推动的人类生活哲学。就像母体设计师所设计的母体因符合人类自身的某种特质而让多数人感到满意一样，只要我们也遵循人类的特质，从人类的生活哲学着眼进行设计，就能够设计出令多数人满意的操作系统。

22.1　操作系统设计的追求

与母体设计师的追求一样，操作系统的设计追求与人类自身的追求相同，有如下几个目标：

●保证操作系统本身运行正确。

●提供尽可能多的功能。

●尽量提高系统的效率。

●在追求效率的基础上尽量顾及公平。

上面第3条的效率有两层意思：一是这个实现的系统本身具有很高的管理和运行效率；二是实现过程本身成本很低。

仔细分析可以看出，上述4个追求与人类社会自身的追求完全合拍。人类社会当然要保证社会本身运转秩序正常（社会秩序良好），在此基础上提供尽可能多的功能（社会功能），这个社会必须以效率为导向（例如“让一部分人先富起来”），在追求效率的基础上尽量顾及公平（例如“创建和谐社会”）。由此可见，在操作系统设计时可以将人类社会发展中得出的原理加以应用，从而导出操作系统设计所要遵循的哲学原理。

显然，人类的生活哲学很多，应用到操作系统里面的原则自然也很多。本书不打算将所有的设计哲学原理均予以讨论，仅挑选十条最重要的设计原则进行论述。其他的原则读者可自行体会。

22.2　操作系统设计的第1条哲学原理：层次架构

纵观世界的任何一个国家或部落，我们都会发现某种层次结构：一个国家元首或部落首领之下的层层管理机制。每一层的功能各不相同，且下面一层只对上面一层负责。上层通常也只对其直属下层进行直接控制。

操作系统也不例外，它的功能分为多个模块，并按层次分解。下面一层向上面一层提供功能，而上面一层也只能对直接下属进行控制，如图22-2所示。

图　22-2　操作系统的层次架构

采用层次结构不仅使得操作系统的构造过程容易，也因为符合人类的习惯而更加易于理解和使用。这样将使操作系统结构的清晰，节省开发操作系统的成本。

22.3　操作系统设计的第2条哲学原理：没有对错

日常生活中经常可以看到一些这样的争吵场景，争吵双方各执一词，互不相让。究其原因，则是因为双方都觉得己方有理，对方无理，正如俗话说“公说公有理，婆说婆有理”。之所以出现这种各说各话的情景，是因为事情本身并没有对错，对错不过是人的主观概念不同而已。

放在操作系统里，这条原则同样有效。操作系统本身并无对错之分，只有好坏之分。就像我们不能说Windows是对的，UNIX是错的。我们只能说，Windows更容易使用，而UNIX不太容易使用而已。而对于另外一些人来说则恰恰相反。因此，在设计操作系统时，只要达到功能、效率、公平、正确的平衡即可，而不用担心你做的系统是错的。例如，操作系统进程调度策略有很多，而每种调度策略有其适用的场景。我们不能说，时间片轮转是对的，而优先级调度是错误的。只能说时间片轮转更接近公平，而优先级调度更接近人类社会的等级制度。这两者都有其存在的合理性。

没有对错的原理在数据结构里面体现得更为充分。例如，平衡搜索树的实现就存在AVL树（高度平衡树）、Splay树（伸展树）、红黑树、费波拉齐亚树和B树。其中的每一种树都有其使用的场景，但却没有对错之分。在使用红黑树的场景下当然可以使用伸展树或费波拉齐亚树，不会造成正确性的改变，只不过效率有可能不一样。

图　22-3　操作系统设计就像跳探戈，一种没有对错的舞蹈

这种没有对错的原理在风靡一时的探戈舞里也有充分的体现（见图22-3）。探戈舞作为所有舞蹈里面最自由的舞，它没有对错之分。无论你做什么动作，在探戈里面都可以，而别的一些舞蹈就不可以

随便做动作。因此，从某种程度上说，操作系统的设计就跟跳探戈一样，你觉得好，你就做。

22.4　操作系统设计的第3条哲学原理：懒人哲学

有人说，这个世界是由懒惰的人推动的。因为懒惰，我们发明洗衣机来洗衣服；因为懒惰，我们发明汽车作为代步工具。当然有人不这么认为，他们会说是为了效率。但不管怎么说，很多人都会有一种习惯，就是一件事情不到迫不得已时是不会去做的，这就是懒人哲学。这点在操作系统设计时被淋漓尽致地体现出来。我们来看一下UNIX操作系统里面的fork系统调用的实现。

本书第3章已经论述过，fork是一个类UNIX操作系统里面创建子进程的系统调用。在fork刚刚出现的时候，fork创建的是一个和父进程一模一样的子进程，它们有着相同的地址空间和资源。而fork的实现就是将父进程地址空间拷贝到子进程地址空间里。这样，在fork后，系统里面将出现两个一模一样的进程，执行一样的功能，只不过其进程ID不同，执行的序列顺序不同。

一切似乎顺理成章，但问题是，应用程序在fork时真的是为了创建两个执行同样任务的进程吗？这种可能性当然存在。但是大部分时候用户fork一个进程是为了执行一个不同的程序，如图22-4所示。

图　22-4　fork系统调用的使用

在图22-4中，fork的子进程（else语句）立即被一个新的程序所覆盖（第8行）。这样，fork的子进程执行的是不同于父进程的程序，这是多数人所希望的。

不过，这样就带来了一个问题，既然子进程被创建后立即就被新的程序覆盖，那么将父进程地址空间的内容拷贝到子进程地址空间显然就是一种浪费。而造成这种浪费是因为我们做事情太积极，还没有搞清楚子进程的用途时就将父进程拷贝到子进程。

为了消除这种浪费，工程师们对fork的语义进行了修改，在fork时只创建一个空的子进程，而不进行父子进程地址空间的拷贝。这样，如果将来要运行新的程序，我们没有做任何无用功。而万一用户创建子进程的目的是运行和父进程同样的程序，则到需要的时候再拷贝也不迟。实际上，fork的修改使得父子进程共享一个地址空间。只要父子进程均不对地址空间内容进行修改，这种共享就是没有任何问题的。而一旦某个进程（父或子）需要对地址空间进行修改（写操作），这个时候我们将需要修改的值进行拷贝，使得父子进程使用不同的拷贝，从而使得写操作可以正常进行。这种不到万不得已不拷贝在计算机术语里面称为懒惰或延迟的拷贝（lazy copy）。

懒人哲学（见图22-5）的合理性在于提前将事情做完也许是一种浪费。因为没有人能够预见到未来会发生什么事情，而情况的变化有可能造成前面所做事情毫无意义。例如，如果某个领导的意见经常变化，则下属就可能不必将领导的命令贯彻执行，而是拖延，也许过一段时间，领导改变观点，造成前面的命令无效。这样懒惰执行的结果是节省了能量。

图　22-5　能懒则懒既是生物界的原则，也是操作系统的一条设计原理

操作系统里面的懒人哲学之所以合理也是基于同样的原因。我们并不知道后面会发生什么事情，也许你急于完成的事情过一会儿就变得不值一提了。

22.5　操作系统设计的第4条哲学原理：让困于人

中国有句古话：“各人自扫门前雪，休管他人瓦上霜。”或者说将方便留给自己，困难让给别人（也有人认为是：将困难留给自己，把方便让给别人）。但把方便留给自己是人的本性。虽然我们鼓励人们大公无私，或者为他人着想，但这永远是一种理想，达到这个境界的人即使有，也是凤毛麟角。而大家司空见惯的是所谓的“踢皮球”。这个人踢过来，那个人踢过去，就是没有人愿意费力气帮助需要得到帮助的人。

作为人设计的操作系统，人的这一本性自然体现无疑。一个简单的例子就是文件系统一致性的保证。操作系统会使用各种原语操作保证文件系统的一致性，但这仅限于文件系统的元数据，而不是平面数据（用户数据）。即操作系统只对目录夹的操作进行原语保护，而对用户数据的操作通常不会采用这种保护措施。对于操作系统来说，它需要保证自己的正确性，而文件夹对于操作系统的文件系统的正常运转至关重要，因此，文件夹始终不能出现问题。而用户文件的一致与否则不影响操作系统本身的运行。虽然用户文件毁坏可能激怒用户，但这不是操作系统有义务管的事情。这种哲学就是让困于人。

当然，如果操作系统有能力也有空闲，适当地进行用户数据的保护也是可以的。但请记住，这是分外活，而不是分内事情。

另外一个例子，对死锁的处理。我们讲过，死锁的应对有4种模式。虽然现代操作系统采取了部分措施来降低死锁出现的概率，但由于死锁的动态避免代价巨大，现代商业操作系统均不支持动态避免，从而造成死锁的不可避免。而这种不愿花费力气进行死锁避免，却把死锁可能留给用户的做法就是典型的让困于人，即让用户来承担困难。

如果我们放眼到操作系统之外，这一点体现得更为清楚。例如，在计算机硬件引入了流水线设计后，出现了分支延迟和加载延迟的概念。这是因为一条分支指令发送到流水线后，需要至少两个时钟周期才能判断是否需要分支。而到第2个时钟周期时，后面的一条指令已经进入流水线。如果这个时候发现需要分支，后面的指令有可能就是不应该执行的指令。这时就需要将后面一条指令从流水线上清除。而清除流水线的代价颇高。

这样，分支指令后面的一条指令无论前面是否分支都会被发送到流水线上。这条指令所占的位置就称为分支延迟位（branch delay slot）。而为了避免流水线清除操作所带来的代价，最好的办法就是在分支延迟位上放一条无论是否分支均需要执行的指令。这样无论分支与否，流水线都不需要进行清除操作。

显然，这是一个不错的想法。但问题是，谁有能力判断一条指令的执行是否与前面的分支条件有关呢？用户？编译器？操作系统？答案是显然的。只有编译器有能力做出这种判断。因为只有编译器看过了这个程序，事实上，它看了两遍。自然有条件选出一条不依赖分支条件的指令放置在分支延迟位上。

这样看的话，分支延迟位的问题解决了。但现实是，这个问题没有解决。原因就是编译器不愿意承担这个选择分支延迟位指令的差事。因为这个实在费力，弄不好还会造成程序错误。编译器为什么要承担这个额外的义务呢？既然研究计算机体系结构提出来流水线，凭什么让编译器来收拾其弄出来的烂摊子呢？编译器的设计人员当然不会答应。

这样，这个问题最后只好由研究体系结构的人自己解决：或清除流水线或插入空白操作。

22.6　操作系统设计的第5条哲学原理：留有余地

对于大部分人来说，在做任何事情的时候都会留有一点余地。俗话说，“不可将事情做绝”。虽然兵法里面有所谓的置之死地而后生的说法，但这毕竟是在特殊情况下的一种战术，而不是绝大多数人信奉的教条。对于芸芸众生来说，还是留有回旋余地比较好。

而留有余地这一点在操作系统的设计中也得到了充分体现。看文件系统目录夹的设计：一个目录夹记录里面通常都有一部分所谓的保留空间（reserved space）。例如，DOS目录夹记录里面就有10个字节的保留空间，如图22-6所示。

图　22-6　DOS目录夹记录里面的保留空间

之所以留下这10个字节的保留字是因为我们不能肯定设计是完美的。这样，如果后来人对此系统进行改善，他们将有余地回旋。而事实上，这10个保留字在Win98文件系统里面就得到了利用。如果没有这些保留字，我们将不得不为了扩展而设计全新的系统。

22.7　操作系统设计的第6条哲学原理：子虚乌有——海市蜃楼之美

操作系统的目的是服务上层的应用程序和用户。而这些上层应用的要求经常是五花八门，吹毛求疵。这些要求和硬件直接能够提供的服务相差十万八千里。架起这十万八千里桥梁的不是别的东西，是操作系统。而为了架起这座桥梁，操作系统是用户要什么就提供什么。提供的这种东西在用户看来虽然实实在在，但实际上都是子虚乌有的东西（见图22-7）。

这正是操作系统的最大角色：魔术师。就像《黑客帝国》电影里面所演绎的，设计师设计了一个巨大的魔术：一个让人感觉十分真实的物质世界。虽然生活在母体里面的人都感觉这个世界是真真切切的，但这个物质世界实际上根本不存在，是子虚乌有的。

图　22-7　操作系统所提供的各种抽象有如现实生活中的海市蜃楼

例如，在操作系统里面，用户看到的内存是一个非常简单、具有线性美的一维数组，这个数组的空间无限大（实际上是与磁盘一样大），速度无限快（实际上是与缓存一样快）。但实际上我们的物理内存只不过512MB或者1GB（也许更大一点，但终归不如磁盘大），速度也只有缓存的十分之一。因此，用户看到的无限大、无限快的内存空间是根本不存在的。

22.8　操作系统设计的第7条哲学原理：时空转换——沧海桑田之变

对于很多人来说，时间和空间是完全不同的两回事情。但对于另外一些人来说，时间就是空间，空间就是时间。按照现在流行的物理学宇宙观，时间和空间都是因为宇宙大爆炸从无到有被创造的。既然它们都有着同样的“无有”的源头，它们之间能够互相转换就不足为怪了。

事实上，时间和空间的转换在历史上并不鲜见。第二次世界大战初期，苏联红军通过放弃西部的大片领土，换来了喘气的时间，也等来了寒冷的冬天，从而转被动为主动而反败为胜。可谓是沧海桑田之变。

这种时空转换在操作系统里面比比皆是。最显著的例子就是页表的实现。由于页表的尺寸通常很大，占用内存过多，我们便将页表分级，只保留一部分页表在内存，而其他部分放置于磁盘上。这样页表所占空间大为减少。但付出的代价就是时间成本：从虚拟地址转换为物理地址需要经过多级转换，从而导致转换时间增加。为了控制这种时间的增加，操作系统又使用了快表来提升转换的速度。而快表付出的代价不是别的，正是空间的增加。同样的转换也出现在文件缓存、非一致性多级索引结构等许多其他的地方。

时空转换原理在算法设计中也常有体现：在散列算法的实现里，为了提高搜索的效率，我们不惜增加空间成本来获得常数时间的查找算法。

22.9　操作系统设计的第8条哲学原理：策机分离与权利分离

众所周知，在运动场上，裁判和运动员不能是同一个人，甚至不能是同一国家的人，为的是使比赛公平。在一个国家机器里，立法机构和执法机构也不能是同一个团体，否则就会出现滥用法律的情况。而立法就是策略，执法就是实现机制。立法和执法分离就是策机分离哲学。美国的三权分立就是人类社会在策机分离上的典型代表。

策机分离的原则在操作系统设计中得到了充分体现。不过，操作系统里面的策机分离主要不是为了公平，而是为了实现的灵活性。比如策略可以由用户指定，操作系统则是执行机制。所以有了调度算法参数化，算法在内核里，参数可以由用户指定。

例如，操作系统提供了优先级调度，但是进程的优先级却可以由用户指定。操作系统提供的是优先级调度的实现机制，而用户则负责制定优先级策略，即每个进程的优先级到底是多少，从而确保所有进程按照用户的希望进行调度。由于策机分离，操作系统在实现优先级调度时可以随时变更实现的方法，而不会影响用户程序的执行。

跳出操作系统的范畴，我们看到，策机分离在计算机领域中也得到了体现。在程序设计领域，人们对界面的设计和对界面的实现是分开的；在计算机安全领域，对安全标准的设计和安全设计的实现是分开的；在通信领域，对接口规范的设计和接口的实现也是分开的。这种策机分离既保证了所有产品的一致性，又留有足够的灵活性。

22.10　操作系统设计的第9条哲学原理：简单为美——求于至简，归于永恒

爱因斯坦说过：“一切事情应该尽可能简单。”在数学领域有个不成文的共识：如果一个问题有多个数学表示，那么最简单的表示通常是正确的。在人类社会里面，也是越简单的架构效率越高。而且，如果简单，其正确性也比较容易确认。而复杂的东西，要证明其正确则通常颇费周折。就是对于个体的人来说，也应追求简单。复杂的生活让人感到疲惫，甚至会把人压垮。

将简单为美的哲学应用到操作系统里面，就是操作系统的设计应该越简单越好。而这种简单的哲学在操作系统里面得到了充分体现。

例如，在文件的存储方式上，我们在网型组织、树型组织、记录流、数据块流和字节流的各种选择当中，现代操作系统选择的都是最简单的字节流，如图22-8所示。

图　22-8　文件的组织形式

22.11　操作系统设计的第10条哲学原理：适可而止

第10条哲学原理是用来修正前面的9条原理的。即在前面所论述的9条原理的贯彻过程中，要保持一个度，适可而止。而不是无限推进，从而达到事情的反面。中国5000年的历史总结出了中庸之道，即凡事不可走极端。走极端带来的结果就是泰极生否。

例如，在简单为美的哲学下，我们不能过于简单。即使是十分注重简单的爱因斯坦，他在其著名的"make everything as simple as possible"的论断后面还留了一个“尾巴”，即"but not simpler"。很多人看到了爱因斯坦这句话的前半部分，却没有或不愿意看到其后面的这个“尾巴”。而这个“尾巴”明明白白地告诉我们：“一切事情应当尽可能简单，但没有更简单。”这就给追求简单设定了限制。而这个限制的标杆就是爱因斯坦本身。如果一个事情比爱因斯坦的东西还简单，那就是过于简单了（从这一点上可以看出爱因斯坦那种不露声色的傲慢自大：温文尔雅，却充满了骄傲）。

例如，在设计文件的组织形式时，如果使用字位流就是过于简单了，在进行页面更换时，使用随机页面替换算法就是过于简单了。

思考题

1.你认为在效率和公平的天平上，操作系统应该将哪个放在第一位？2.从操作系统设计中找出其他时空转换的例子。

3.从操作系统的核心部分中找出层次结构的例子。

4.你能找出更多的“留有余地”的例子吗？

5.从操作系统技术里面找出另一个简单为美的例子。

6.适可而止是普通人难以达到的境界，试论述如何在操作系统设计中实现这种哲学。

7.操作系统的这些设计哲学并不仅仅只适应于操作系统一门学科，也适应于计算机的其他学科，甚至非计算机学科，试举出几个例子加以说明。

8.你觉得本章列出的各种设计哲学对你理解操作系统有何帮助？

9.你能举出操作系统里面的其他哲学原理吗？请列出你能想到的原则，并将其与人类生活联系起来。

10.在操作系统的各种机制里面，你还能找出哪些过于简单的设计？

11.谈谈你对操作系统技术和原理的总体感想，你觉得这些哲学设计原理在未来会改变吗？

结语：失望与希望

美国密歇根大学的一位心理学教授曾经做过一个新奇的实验。他将一些蜜蜂和苍蝇放进一个敞口的大玻璃瓶里面，然后将玻璃瓶横放在桌上，底部朝向阳光。这时有趣的现象出现了：蜜蜂由于有光线感，都向着阳光的一面飞，撞到瓶底掉下来后接着还是往这个方向飞，就这样一而再，再而三，经过无数次折腾后终于力竭而亡，没有一只蜜蜂飞出玻璃瓶。而苍蝇因为没有什么光线感，胡乱飞，最终一大部分苍蝇从瓶口飞了出来……

当一个人向着一个方向或者理想执著前行时，他的行为也许就是瓶中蜜蜂的行为。我们当然应该执著，问题是我们能肯定我们定下的理想或追求是真实，而不是虚幻呢？我们如何断定碰到挫折是因奋力不够，还是前面有一面看不见的透明玻璃墙呢？

其实人们缺乏的往往不是执著，而是因对方向和理想的不确定而无法执著。对理想和追求的把握需要的就不仅是知识，更重要的是智慧。如何才能获得智慧呢？我想起了箴言：

“敬畏神是智慧的开端……”（《箴言》9.10节）

本书到此就结束了。感谢读者的执著，似乎看不完的一本书，也终于读完了。但看完了本书，并不等于操作系统的学习就结束了。实际上，要真正掌握操作系统，对本书知识的掌握只是第一步，或者说本书提供的是学习操作系统的一个起点，而不是终点。

首先，本书只是从哲学原理上对操作系统予以阐述，对具体操作系统的实现细节基本忽略不计。而要完全透彻地理解操作系统，这些具体实现细节则不可或缺。事实上，只有亲手设计过商业操作系统，或者亲手阅读分析过商业操作系统源代码的人，对操作系统的掌握才可能真正到位。正因为如此，本书在这里建议读者：

●设计实现一个全功能的操作系统。

●参加Windows、Linux或Solaris课程的学习。

●阅读分析一个实际的商用操作系统源代码。

如果读者能够在理解本书内容的基础上完成上述3点建议中的任何一点，则对操作系统的理解将大大加深。如果能够完成上述任何两点建议，你就是一个操作系统专家了。如果能全部完成上述3点，则你就是操作系统领域的泰斗！

其次，本书覆盖的内容与有些读者所期待的操作系统内容并不完全一致。毋庸置疑，一本书的内容究竟应该包括哪些是见仁见智的。而操作系统尤其如此。操作系统从无到有，在经历了许多年的发展后形成了许多子学科，而这些子学科又进一步发展成为单独的学科而从操作系统学科中剥离出来，例如，计算机网络、数据库系统、计算机安全、分布式计算等。由于这些领域均已经有了自己的课程，它们自然不应再包含在操作系统的教程里。

而就是在操作系统范围内的内容也不一定需要包括在大学本科的操作系统教程里面。由于操作系统的复杂性，其内容极为丰富，将所有与操作系统关联的内容包括在一本书里将使得整本书的内容臃肿，关键点也将埋没在繁杂的无关紧要的琐细里。因此，为了使本书焦点突出，作者仅选择了对操作系统的核心内容进行讲述，其他内容留给读者自己探索。只要是读者真正理解本书的内容，在操作系统上进行进一步探索就有了坚实的基础。

当然，由于操作系统的复杂性，细节繁多，设计出好的操作系统十分不易，甚至是不可能的。现在市面上的商用操作系统均存在这样或那样的缺点，有些缺陷甚至让人难以忍受。从互联网用户对各种商用操作系统的反映来看，似乎没有什么操作系统令人完全满意。

此时，不由得想起一首英文讽刺歌《所有操作系统都很烂》（every OS sucks）。

所有的操作系统都浪费时间，从桌面到手提机所有算盘以后的一切，只不过是一堆垃圾从微软到苹果机，再到Linux、Linux真寒碜每台计算机都死机，因为每种操作系统都很烂。

Every OS wastes your time, from desktop to the lap Everything since the abacus, is just a bunch of crap From Microsoft to Macintosh, to Linux Linu-nux Every computer crashes, cause every OS sucks.

由此可见，设计一个完美的操作系统是多么困难，甚至是无法达到的。这就意味着我们在操作系统上的探索是永无止境的，虽然不时会有达到完美的感觉，但仔细思量却发现不过是自我幻觉而已。就像真理一样，似乎能感觉到，但似乎又没有把握。而本书所要达到的目标就是为读者探索操作系统提供些许指导，一个指引读者探索方向的路标。

本书只是学生探索操作系统征程上的一个路标

还记得本书前言里面的那首小诗吗？它的结尾是这样的：

不要失望，

失望会使我们衰老，

趁我们还年轻，

抬起头来，让我们数星星；

也许，我们能数得清；

也许，我们能看到真相；

也许，我们能找到希望……

参考文献

[1]Richard McDougall, Jim Mauro. Solaris内核结构[M].Sun中国工程研究院译.北京：机械工业出版社，2007.

[2]Richard McDougall, Jim Mauro. Solaris性能与工具[M].Sun中国工程研究院译.北京：机械工业出版社，2007.

[3]Andrew Tanebaum.现代操作系统（原书第2版）[M].陈向群，马洪兵，等译.北京：机械工业出版社，2005.

[4]Andrew S. Tanenbaum.Distributed Operating Systems[M].US：Prentice Hall.1994.

[5]A. Silberschatz, J.Peterson, P.Galvin.Operating Systems Concepts[M].6th ed.US：Wiley，2008.

[6]George Coulouris, Jean Dollimore, Tim Kindberg，等.分布式系统：概念与设计（英文版·第4版）[M].北京：机械工业出版社，2005.

[7]多核系列教材编写组.多核程序设计[M].北京：清华大学出版社，2007.

[8]Mark E. Russinovich, David A.Solomon.Microsoft Windows Internals：Mi-crosoft Windows Server 2003，Windows XP, and Windows 2000[M].4th ed.US：Microsoft Press，2005.

[9]UreshVahalia. UNIX internalsBy[M].US：Prentice Hall，1996.

[10]Johnson M. Hart.Win32 System Programming：A Windows2000 Application Developers Guide[M].2nd ed.US：Addison-Wesley，2000.

[11]Arnold Robbins. Unix in a Nutshell[M].4th ed.US：OReilly Media，2008.

[12]IEEE Computer Magazine[J]. 2004-2009.

Table of Contents

前言

计算机的心智

操作系统的奥秘

操作系统之哲学原理

本书内容安排

基础原理篇

进程原理篇

线程原理篇

内存原理篇

文件原理篇

I/O原理篇

多核原理篇

操作系统设计篇

本书特点

第一篇 基础原理篇

第1章 操作系统导论

引子：智者的挑战

1.1 人造与神造

1.2 程序是如何运行的

1.3 什么是操作系统

1.4 魔术与管理

1.5 用户程序与操作系统

1.6 操作系统的范畴

1.7 为什么学习操作系统

思考题

第2章 操作系统历史

引子：不能承受之真

2.1 第一阶段：状态机操作系统（1940年以前）

2.2 第二阶段：单一操作员单一控制端操作系统（20世纪40年代）

2.3 第三阶段：批处理操作系统（20世纪50年代）

2.4 第四代：多道批处理操作系统（20世纪60年代）

2.5 第五代之一：分时操作系统（20世纪70年代）

2.6 第五代之二：实时操作系统

2.7 第六代：现代操作系统（1980年以后）

2.8 操作系统的演变过程

2.9 操作系统的未来发展趋势

2.10 讨论：操作系统虚拟化和虚拟化的操作系统

思考题

第3章 操作系统基本概念

引子：软件师的尴尬

3.1 计算机硬件基本知识

3.2 抽象

3.3 内核态和用户态

3.4 操作系统结构

3.5 进程、内存和文件

3.6 系统调用

3.7 壳

思考题

第二篇 进程原理篇

第4章 进程

引子：牛顿的困惑

4.1 进程概论

4.2 进程模型

4.3 多道编程的好处

4.4 进程的产生与消亡

4.5 进程的层次结构

4.6 进程的状态

4.7 进程与地址空间

4.8 进程管理

4.9 进程的缺陷

思考题

第5章 进程调度

引子：恐怖分子的调度

5.1 进程调度的定义

5.2 进程调度的目标

5.3 先来先服务调度算法

5.4 时间片轮转算法

5.5 短任务优先算法

5.6 优先级调度算法

5.7 混合调度算法

5.8 其他调度算法

5.9 实时调度算法

5.10 进程调度的过程

5.11 高级议题：调度异常之优先级倒挂

思考题

第6章 进程通信

引子：孤独爆破手的自白

6.1 为什么要通信

6.2 进程对白：管道、记名管道、套接字

6.3 进程电报：信号

6.4 进程旗语：信号量

6.5 进程拥抱：共享内存

6.6 信件发送：消息队列

6.7 其他通信机制

思考题

第三篇 线程原理篇

第7章 线程

引子：亚历山大的分身术

7.1 进程的分身术——线程

7.2 线程管理

7.3 线程模型的实现

7.4 现代操作系统的线程实现模型

7.5 多线程的关系

7.6 讨论：从用户态进入内核态

7.7 讨论：线程的困惑——确定性与非确定性

思考题

第8章 线程同步

引子：滑铁卢的同步

8.1 为什么要同步

8.2 线程同步的目的

8.3 锁的进化：金鱼生存

8.4 睡觉与叫醒：生产者与消费者问题

8.5 信号量

8.6 锁、睡觉与叫醒、信号量

8.7 管程

8.8 消息传递

8.9 栅栏

思考题

第9章 死锁应对之哲学原理

引子：迷雾笼罩的加拿大

9.1 为什么会发生死锁

9.2 死锁的描述

9.3 死锁的4个必要条件

9.4 哲学家就餐问题

9.5 死锁的应对

9.6 消除死锁的必要条件

9.7 银行家算法：冒险的代价

9.8 哲学家就餐问题之解

9.9 讨论：死锁的思考——综合治理

9.10 讨论：死锁、活锁与饥饿

思考题

第10章 锁的实现

引子：高登绳结

10.1 以中断启用与禁止来实现锁

10.2 以测试与设置指令来实现锁

10.3 以非繁忙等待、中断启用与禁止来实现锁

10.4 以最少繁忙等待、测试与设置来实现锁

10.5 中断禁止、测试与设置

思考题

第四篇 内存原理篇

第11章 基本内存管理

引子：让别人无路可走

11.1 内存管理的环境

11.2 内存管理的目标

11.3 虚拟内存的概念

11.4 操作系统在内存中的位置

11.5 单道编程的内存管理

11.6 多道编程的内存管理

11.7 闲置空间管理

思考题

第12章 页式内存管理

引子：虚拟概念的变现

12.1 基址极限管理模式的问题

12.2 分页内存管理

12.3 分页系统的优缺点

12.4 翻译速度

12.5 缺页中断处理

12.6 锁住页面

12.7 页面尺寸

12.8 内存抖动

思考题

第13章 页面更换算法

引子：黑洞理论的替换

13.1 页面需要更换

13.2 页面更换的目标

13.3 随机更换算法

13.4 先进先出算法

13.5 第二次机会算法

13.6 时钟算法

13.7 最优更换算法

13.8 NRU算法

13.9 LRU算法

13.10 工作集算法

13.11 工作集时钟算法

13.12 页面替换策略

思考题

第14章 段式内存管理

引子：否定之否定

14.1 分页系统的缺点

14.2 分段管理系统

14.3 分段的优缺点

14.4 段页式内存管理

14.5 段号是否占用寻址字位

14.6 讨论：否定之否定的嵌套——纯粹分段与逻辑分段、分页与段页

思考题

第五篇 文件原理篇

第15章 磁盘操作

引子：EMC——从不可能到可能

15.1 磁盘组织与管理

15.2 磁盘的结构

15.3 盘面的结构

15.4 磁盘驱动器的访问速度

15.5 操作系统界面

15.6 磁盘调度算法

15.7 讨论：固态盘

15.8 讨论：智能磁盘系统

思考题

第16章 文件系统

引子：掩饰的极致

16.1 为什么需要文件系统

16.2 文件系统

16.3 文件系统的目标

16.4 文件的基本知识

16.5 从用户角度看文件系统

16.6 地址独立的实现机制：文件夹

16.7 文件系统调用

16.8 内存映射的文件访问

思考题

第17章 文件系统实现

引子：成功中的失败

17.1 文件系统的布局

17.2 文件的实现

17.3 目录实现：地址独立的实现

17.4 闲置空间管理

思考题

第18章 文件系统性能

引子：从不可能到可能

18.1 文件授权管理

18.2 主动控制：访问控制表

18.3 能力表

18.4 访问控制的实施

18.5 其他文件安全措施

18.6 文件系统性能

18.7 文件系统效率性能

18.8 文件系统设计分析：日志结构的文件系统

18.9 海量数据文件系统

思考题

第六篇 1/O原理篇

第19章 输入输出

引子：约翰逊的输出——从没有到爆发

19.1 输入输出

19.2 输入输出的目的

19.3 输入输出硬件

19.4 I/O软件

19.5 I/O软件分层

思考题

第七篇 多核原理篇

第20章 多核结构与内存

引子：不能承受之热

20.1 以量取胜

20.2 多核基本概念

20.3 多核的内存结构

20.4 对称多处理器计算机的启动过程

20.5 多处理器之间的通信

20.6 SMP缓存一致性

20.7 多处理器、超线程和多核的比较

思考题

第21章 多核环境下的进程同步与调度

引子：“多核”帝国的陨落

21.1 多核环境下操作系统的修正

21.2 多核环境下的进程同步与调度

21.3 多核进程同步

21.4 硬件原子操作

21.5 总线锁

21.6 多核环境下的软件同步原语

21.7 旋锁

21.8 其他同步原语

21.9 多核环境下的进程调度

21.10 多核环境下的能耗管理

21.11 讨论：多核系统的性能

思考题

第八篇 操作系统设计篇

第22章 操作系统设计之哲学原理

引子：残缺心智的胜利

22.1 操作系统设计的追求

22.2 操作系统设计的第1条哲学原理：层次架构

22.3 操作系统设计的第2条哲学原理：没有对错

22.4 操作系统设计的第3条哲学原理：懒人哲学

22.5 操作系统设计的第4条哲学原理：让困于人

22.6 操作系统设计的第5条哲学原理：留有余地

22.7 操作系统设计的第6条哲学原理：子虚乌有——海市蜃楼之美

22.8 操作系统设计的第7条哲学原理：时空转换——沧海桑田之变

22.9 操作系统设计的第8条哲学原理：策机分离与权利分离

22.10 操作系统设计的第9条哲学原理：简单为美——求于至简，归于永恒

22.11 操作系统设计的第10条哲学原理：适可而止

思考题

结语：失望与希望

参考文献