UCB-CS162-操作系统笔记-全-

UCB CS162 操作系统笔记(全)

🖥️ 课程 P1:第 1 讲 - 操作系统是什么

在本节课中,我们将要学习操作系统的核心概念。我们将探讨操作系统的定义、它在计算机系统中的角色,以及为什么学习操作系统对于理解现代计算至关重要。课程内容将从宏观的互联网视角开始,逐步深入到操作系统的具体功能和抽象。


概述:什么是操作系统?

操作系统是计算机系统中最为核心的软件。它管理着计算机的硬件和软件资源,并为计算机程序提供公共服务。我们可以将操作系统视为硬件与应用程序之间的桥梁。

上一节我们介绍了计算机系统的宏观图景,本节中我们来看看操作系统的具体定义和角色。


操作系统的多重角色

操作系统并非单一实体,不同的人对其有不同的理解。它集多种角色于一身。

1. 魔术师:提供虚拟化抽象

操作系统将复杂、多样的物理硬件(如CPU、内存、磁盘、网络设备)转化为一系列简洁、统一且易于使用的逻辑抽象。

以下是操作系统提供的关键抽象:

  • 进程与线程:取代单一的物理CPU,为每个运行中的程序提供独立的执行环境(进程)和并发执行流(线程)。
  • 地址空间:取代杂乱的物理内存字节,为每个进程提供受保护的、看似连续且私有的内存空间。
  • 文件:取代磁盘上无结构的存储块,提供具有层次结构和命名机制的持久化数据存储单元。
  • 套接字:取代可能丢失、无序的网络数据包,提供可靠的、面向流的网络通信端点。

这些抽象使得程序员无需关心底层硬件的具体细节,从而能够更高效地开发应用程序。应用程序运行在由操作系统创建的进程这个“容器”中。

进程可以形式化地定义为:
进程 = 地址空间 + 一个或多个执行线程 + 系统状态(如打开的文件、网络连接等)

2. 裁判:管理与保护资源

操作系统负责在多个竞争资源的程序(进程)之间进行协调、分配和保护,确保系统的公平性、安全性和稳定性。

以下是操作系统作为裁判的核心职责:

  • 资源分配:决定哪个进程在何时使用CPU(调度)、获得多少内存等。
  • 隔离与保护:确保一个进程不能随意访问或破坏另一个进程的内存、文件等资源。这是通过硬件和软件机制共同实现的。
  • 权限控制:基于用户身份,控制其对系统资源(如文件、设备)的访问权限。

例如,在一个单核CPU上,操作系统通过时间片轮转等技术,快速地在多个进程间切换,制造出它们“同时”运行的假象。当需要从一个进程(如进程A)切换到另一个进程(进程B)时,操作系统会执行以下操作:

  1. 保存进程A的CPU寄存器状态到内核数据结构中。
  2. 恢复进程B之前保存的寄存器状态。
  3. 切换内存映射,使CPU能访问进程B的地址空间。
  4. 将CPU控制权交给进程B的代码。

如果进程B试图非法访问属于进程A或操作系统内核的内存,硬件会触发异常(如分段错误),操作系统便会介入并终止该进程,从而保护系统的完整性。

3. 粘合剂:提供通用服务

操作系统提供一系列通用的服务和库,简化了应用程序的开发。这些服务增强了系统的功能性和易用性。

以下是操作系统提供的一些常见服务:

  • 设备驱动:统一管理各种硬件设备的操作细节。
  • 网络协议栈:实现TCP/IP等协议,使应用程序能进行可靠的网络通信。
  • 用户界面:提供图形窗口系统、命令行外壳等与用户交互的环境。
  • 系统工具:提供编译器、调试器、文件管理器等实用程序。

关于哪些服务应属于操作系统“内核”,哪些可以作为外部服务存在,存在不同的设计哲学(如宏内核与微内核),这也是操作系统设计中有趣的争论点。


为什么学习操作系统?

学习操作系统不仅因为它本身是一个精妙复杂的系统,更因为它所蕴含的理念和技术是现代软件开发的基石。

以下是学习操作系统的重要意义:

  • 理解系统底层:无论是开发高性能应用、进行系统调试,还是从事安全研究,深入理解操作系统工作原理都是关键。
  • 掌握核心概念:进程、线程、并发、同步、内存管理、文件系统等概念,在分布式系统、数据库、云计算等领域无处不在。
  • 应对技术趋势:面对多核/众核处理器、海量存储、物联网设备激增以及严峻的安全挑战,良好的操作系统知识有助于设计出更高效、更可靠的系统。
  • 实践工程思维:操作系统课程强调如何设计正确、健壮的系统,而不仅仅是能运行的系统。这培养了严谨的工程思维和解决复杂问题的能力。

课程结构与学习建议

CS162 课程将通过理论讲解和动手实践相结合的方式,带你深入操作系统内部。

课程内容将分为几个主要部分:

  1. 操作系统概念与实用技能:从用户视角熟悉系统,并快速切入底层。
  2. 并发:深入讲解进程、线程、锁、死锁以及如何在单核和多核上实现并发。
  3. 内存管理:探讨虚拟内存、地址转换、共享与保护。
  4. 存储与文件系统:从I/O设备开始,讲解如何构建文件系统,并涉及事务等内容。
  5. 分布式系统:将概念扩展到网络,讨论RPC、分布式文件系统、一致性等。
  6. 可靠性与安全:研究容错、日志、安全机制及云计算相关主题。

学习建议:本课程包含具有挑战性的小组项目,需要熟练使用C语言和调试工具。请务必尽早开始课程提供的预备任务(作业0和项目0),熟悉开发环境,并积极组建团队、参与讨论。


总结

本节课我们一起学习了操作系统的核心定义与多重角色。我们了解到,操作系统是:

  1. 魔术师:通过虚拟化技术,将复杂的硬件抽象为进程、地址空间、文件等易于使用的概念。
  2. 裁判:负责资源的分配、调度与保护,确保多个应用程序能安全、公平、高效地共享计算机系统。
  3. 粘合剂:提供设备驱动、网络协议、用户界面等一系列通用服务,简化应用程序开发。

操作系统是计算世界的基石,它让从微型传感器到全球数据中心的庞大生态系统得以协同工作。在接下来的课程中,我们将揭开这些抽象和机制的神秘面纱,亲手探索它们是如何实现的。

🧠 操作系统课程 P10:调度概念与经典策略

在本节课中,我们将要学习操作系统中一个核心概念:调度。我们将探讨为什么需要调度,介绍几种经典的调度策略,并分析它们的优缺点。理解调度是理解操作系统如何高效管理多个任务的关键。


🔄 回顾同步机制

上一节我们详细讨论了信号量和监视器等同步机制。本节中,我们来看看操作系统如何决定哪个线程可以访问CPU资源。

信号量有两种使用方式。信号量有一个初始值,这是在分配时设置的。如果你将这个值设置为 1,实际上你得到了一个互斥锁,或者称为二进制信号量,你可以用它来进行锁定。所以你会像这样使用它,做 P 操作时把它减到 1,做 V 操作时把它加到 1。

如果两个人同时尝试做信号量 P 操作,其中一个会被挂起。这是一个纯粹的原子操作。所以两个线程不可能意外地同时通过这个操作。它会原子性地递减这个值。如果你想递减到零以下,它会让你进入休眠状态。我们还看到了通过将初始值设置为零,我们可以实现调度约束。

这可能让你进行一个联合操作。所以你把它设为零,然后尝试做一个 P 操作来让自己进入休眠状态。其他任何人都可以用一个 V 操作把你唤醒。最后,这就是线程连接的例子,假设原始的“鹦鹉线程”进入休眠,然后结束的子线程执行 V 操作把它唤醒。

然后最后我们给出了可乐机的有界缓冲区解决方案。这是一个很好的三信号量的例子。我们之所以有三个信号量,是因为我们有两个条件和一个锁。这两个条件是关于限制可乐机中最大可乐罐数量的,同时也在说明不能让可乐罐数量变成负数,所以我们在两个方面都做了限制。

以下是生产者和消费者的代码模式:

// 生产者
P(empty_slots);
P(mutex);
// 将物品放入缓冲区
V(mutex);
V(full_slots);

// 消费者
P(full_slots);
P(mutex);
// 从缓冲区取出物品
V(mutex);
V(empty_slots);

这里的关键模式是,我们总是像队列一样保护一些东西,因为这些操作如果多个线程同时进入可能会出现问题,这就是为什么我们在它们周围加上互斥锁。然后我们在空槽上执行一个信号量的 P 操作,以确保生产者进入之前有东西,消费者则是相反的操作。

红色部分是用来保护队列的临界区。然后我们有这个信号,当生产者终于生产了东西,它会唤醒某些可能正在休眠的线程。当消费者最终把罐子倒空时,他们可能会唤醒生产者。

然而,我们说过有更好的东西。一个监视器,它是一个锁和零个或多个条件变量。其实,如果你只有零个条件变量,那就不太有趣了。条件变量专门是一个等待队列,你可以在持有锁的情况下去休眠,这就是关键的概念。和其他任何等待队列不同的是,在条件变量的等待队列中,你先抓住锁,检查条件,然后如果不满足就去休眠。正是这种奇特的差异或者API的不同,使得它们变得非常强大且易于使用。

通常有三种操作,它们根据不同的包有不同的名字,但大致是等待、信号和广播。规则是,进行这三种操作时,始终要持有锁。进行等待、信号或广播操作时,始终要持有锁。

所以我们给了你这个典型的结构,如果你在考虑一个监视器程序,这是一个思考的方式。你抓住锁。你检查条件是否满足。如果不满足,你就去休眠。注意,这个等待操作总是会发生。就像一个信号量P,它可能不会让你休眠,而一个等待操作总是会让你休眠。这是关键的接口方面。然后,当你醒来时,使用典型的Mesa调度,你总是必须重新检查你的条件。然后你可能以某种方式保留你找到的资源,然后你可以解锁,结束时你再锁一次,并发送信号表示你完成了。这是一个模式。我们在读者-写者示例中做到了这一点。

这是读者-写者示例,它可以有一个写者或多个读者,但读者和写者永远不会同时存在。每次只有一个写者。那就是我们这里代码的动机。

这是一个读者代码,你抓住锁。看到了模式吗?你检查并确保没有写者在活动或者休眠。如果有,你就变成一个等待的写者,然后去休眠。当你醒来时,你不再是一个等待的写者。你会一直循环,直到系统中没有任何写者,在这种情况下,你就会执行++操作,表示你是一个活跃的读者,释放锁并访问数据库。然后在签出时,你再次获取锁,减少计数,表示你不再是一个等待中的读者。接着如果没有剩余的读者,就会触发某些条件。那些是活跃的,但如果有等待的写者,你就会通过信号唤醒它们,然后最终释放锁。

那么,为什么我们在这个时候释放锁?记住,这个进入条件并不是阻止访问数据库。它的作用是保护数据库的进入条件。我们需要释放锁,这样其他线程才能进入并自行分类。写者的情况类似,但稍有不同。

那么在这里,我们对写者的进入条件是:不能有任何活跃的写者或活跃的读者。如果有任何一种情况,我们就会进入睡眠状态。否则,我们会醒来并进行访问。这是一种修改样式的访问。然后,当我们再次获取锁时,如果没有其他等待中的写者,我们就不再是一个活跃的写者。如果有等待中的写者,我们会唤醒其中一个。如果有等待中的读者,我们则会唤醒所有读者。然后我们退出。

所以,再次说明,为什么我们在这里使用广播而不是信号。可能有多个读者,唤醒他们所有人。通过执行这组ifelse语句,我们确保了优先给写者而不是读者。这时我们会广泛地发出信号给写者,然后再广播给读者。尚不清楚是否某个读者会在写者之前醒来,若发生这种情况,我们实际上会违反我们政策中的优先级规则。这总是先让写者去执行。就正确性而言,这是没问题的。它不会违反“一个写者或多个读者”的正确性条件,但它会违反政策。似乎这个特定的代码符合的政策是写者优先于读者。

这实际上导致了,为什么要给写者优先权。事实上,这里有几个原因。其一是,如果你查看典型的跟踪,读取操作通常比写入操作多,所以优先考虑写者并不是坏事。另一个原因是,你可以想象,写者使数据更加更新,因此给读者提供了获取更新数据的机会。这不是强制要求。你可以使用任何其他策略。

这里的问题是为什么要再次释放锁,答案是我们必须释放上面的锁,以便其他线程可以进入并进行分类。因为如果我们持有锁,那么进入的其他线程就无法被分类为读者或写者。

现在,问题是这里有几个问题,一个是读者是否会饿死。如果写者持续不断,读者可能永远无法得到机会。如果这是你使用的条件,你可能需要稍微调整代码的设计,但监视器足够强大来处理这种情况。假如我们取消了条件检查,重绘出这个呢?那这样做还会正确吗?依然正确,因为有了 while 循环。入口处就是这样,这也是为什么Mesa比“马语义”更受欢迎的原因之一,因为你可以稍微松懈一些。只要你唤醒的线程比需要的多,你仍然会是正确的。这种情况的危险是你可能唤醒的线程不够,接着系统就会死锁。实际上,我想这算是一个活锁。

在接下来的几节课中,然后我们可以将这个信号转变为广播,唤醒所有的写者。为什么这样做还是正确的呢?只有一个写者会得到它,其他的写者不会。理解这一点的方式是因为代码开始时的那个锁。所有进入系统的线程都会被锁串行化,所以你每次只能查看一个条件。所以下来一个一个地处理,即使有一千个写者,也是一个一个地处理,第一个将获得作为活跃写者的能力,其他的会在稍后等待。

然后最后,如果我们只使用一个条件变量,会怎么样呢?所以不要将读取和写入操作分开,仅仅说“好吧,继续进行就可以了”。现在事实证明,我说的关于循环的内容是正确的,所以我们不会得到不正确的行为,但是如果我们不使用广播而使用信号,可能会发生什么呢?因为如果读者和写者都在同一个队列中,我们只唤醒一个,那么我们可能无法得到我们想要的服务员类型。

例如如果我们将“okay to write”变成“okay to read”,再变成“okay to continue”,这样就变成了不同的代码。然后我们有这样的场景,假设我们的一个到达了,接着w一个要到达。嗯,我们的一个仍在工作。然后当那个读者去检查时,如果我们只发送信号给一个服务员,那么会发生什么呢?我们实际上会发送信号给错误的人,我们会给w一个发送信号。但是w一个醒来后会说:“哦,看,系统中有读者,可能这样不起作用。”所以我们必须确保正确发送信号。所以我们在这里进行广播,只是为了确保有足够的人能唤醒所有的东西。这对于写者来说尤其具有挑战性。它在双方都有挑战性,所以基本上你只想广播。

现在我们的监视器与信号量根本不同。你可能会怀疑答案是否定的。因为我们设法以某种方式做出了一个非常复杂的提示。对于可乐机来说,信号量是非常强大的。所以如果它们真的是等价的,似乎如果我们有了信号量,我们应该能够实现监视器。从同步的角度来看,这不是某种完整性吗?但是技巧是我们得小心。首先,锁是简单的,监视器有锁,信号量你初始化为1,锁很简单。保证从那里开始会变得更难。那么如果我们尝试像这样实现条件变量,等待会获取一个信号量,并执行一个信号量P,信号则执行一个信号量V呢?这能工作吗?问题在于我们获取了信号量锁并执行了等待。在一个条件变量信号量上,你将带着锁去睡觉。一切都坏了。所以这一实现一开始就不行,因为你不能在这个特定的信号量上睡觉。

好吧,这样行吗?所以等待释放锁,然后在条件变量上执行信号量P。然后在退出等待之前重新获取锁。信号只是执行信号量V。这看起来稍微好一点,因为它不会因为持有锁而进入死锁状态,因为你看,当你执行等待时,你先释放了锁。但这有效吗?这仍然是错误的。因为记住,信号与某些操作不相同,因为如果你先发出信号然后再等待,使用这个实现,它们永远不会等待。而条件变量是,你去说等待时,你总是会等待。信号量是对称的,而条件变量信号则不是。

那么我们能做什么呢?假设一个线程发出信号,而没有人等待。那么,如果一个线程稍后等待,线程就会等待。而如果你做一个V操作,而没有人在等待,你会增加信号量,如果线程稍后执行P操作,你就会减少信号量并继续。所以,这将没有这样的特性:每次你调用等待时,你都会休眠。这看起来是错误的。也许我们做不到。之前尝试的一个问题是,P和V是可交换的,不管它们按什么顺序执行,结果是一样的,而条件变量则不是。所以,如果你看看,这个修复解决了问题,等待时释放了锁。是否一个信号量P并获取锁,而信号量说如果信号量队列不为空,那么执行一个信号量V。这样行吗?它有效,但它是非法的。你不允许读取信号量的值。所以,这里有一个竞争条件,信号量操作可能会在错误的时机执行。但实际上,事实证明这是可能的。我会给你们一个提示。如果我们保持一个条件变量整数,所有我们用来在释放锁之前增加的那个变量,那么我们可以跟踪有多少个线程在休眠。信号量操作可以查看它,因为这是合法的。因为他们有锁。所以这里是一个有趣的例子,拥有锁在信号量操作周围实际上对于这个特定实现的工作至关重要。因为如果你没有锁,试图检查条件变量或检查那个整数变量,而你没有锁,那么有人可能会在你不知情的情况下改变它,这就是问题所在。我们不再详细讲这个问题了。那么,重点是,你可以用信号量做出监视器,而你可以想象,用监视器做出信号量更容易。这是一个你们可以做的练习。

但是,随着我们前进,假设你们会越来越擅长于查看同步问题并解决它们。这就是其中之一,我可以给你们举例,但最终还是要靠你们自己去重新训练思维,去思考这些问题。但你知道,我认为,如果最糟糕的情况发生,使用监视器应该是最容易想到的事情。我在我做的一个研究项目中曾建议过某人使用条件变量,因为他们需要让线程休眠。然后稍后唤醒它,结果证明这绝对是最正确的做法。非常简单。顺便说一下,这些在很多情况下都可以使用,包括像 P 线程这样的线程包。

但在我们完全关闭同步之前,我想说一点关于语言同步支持的内容。所以如果你去查看线程,你会看到 P 线程新的 Texas,还有 P 线程条件变量。它们已经被实现了。所以如果你只需要做一下 man 查阅 P 线程包,你会看到它们。只要你在使用 P 线程,你肯定能够与监视器和信号量同步。这些是可以使用的。C 是一种有点疯狂的语言。C 最糟糕的地方就是它允许你做各种坏事。你永远不应该做的事情。最糟糕的是你可能会有悬空的内存指针,或者忘记了释放某个东西,或者释放了两次,这里有很多坏事。但这不是我们今天要讨论的内容。如果你的代码获取了锁,并且在代码内部发生了异常,你需要检查每一个异常路径,并在从该过程返回之前释放锁。因为如果你不这么做,我们会把它留在那里。然后你可以很容易地通过抛出异常返回调用函数,锁依然保持着,没有人会释放它。看到了吗?因为正常的代码是你获取锁,做一些操作,然后释放锁。但是如果发生了异常,你需要确保释放锁。这使得 C 在锁定方面真的很复杂。你必须做这些事。

更加复杂的是 set jump 和 long jump 的概念。set jump 和 long jump 是异常的一种简陋版本。它们是这样的:有一个堆栈,你从程序 A 开始,然后到程序 B,接着调用一个叫做 set jump 的东西。这会给你一个句柄,让你可以丢弃一大堆堆栈并返回到你调用 set jump 的地方。所以它就像一个异常。这里,我们进入 C,获取锁,进入 D。在 E 中出现错误或某些情况时调用 long jump。它会丢弃所有的堆栈并返回上面,而不需要重新获取锁。或者没有释放锁。坏消息。现在,幸运的是,set jump 和 long jump 是你今天经常使用的东西。但我只是警告你,在 C 语言中,这只是乱七八糟。然后也许你可以使用 goto。当你接触到比 C 语言更强大的东西时,结果发现它是好的。例如,在 C 语言中,你仍然需要确保如果食物有异常,它会被抛出。而且你可以捕获它,这意味着你至少需要做类似这样的事情:你说我要尝试做,如果有异常,我会释放很多。注意看,这看起来像是 C 代码,但它要干净得多,因为所有异常都会通过那一条路径。

现在,如果你是 C++ 背景的观众,你可能会说,但等等。这不是做这件事的好方法。还有更好的方法。我鼓励你们谷歌一下锁守卫。这意味着这里我们有一个过程。无论有多少人尝试执行,它都会干净地增加这个全局变量。这里发生的事情是,当你分配锁时。其实这是一个局部变量。在这里,你分配了一个锁守卫,它锁定了全局互斥量。关键在于,无论安全增量退出的条件如何,这个锁都会在任何情况下释放。所以如果你在做锁定操作时,C++ 或它的一些变种是最干净的。因为这种特定类型的锁守卫。基本上,每当它最终超出作用域时,它就会自动释放锁。Python。你可以分配一个锁,然后你可以说用锁来停止,这意味着无论那个 with 语句如何退出。锁会被释放。这几乎与我刚才展示的 C++ 完全相同。Java。你可以说 public synchronized。意思是每个对象都有一个内建的隐式监视器,包括一个锁和一个条件变量。因此,结果是,你可以说这个特定的方法 get balanced。你还记得我们讨论过银行账户是同步的吗?而这个 get balance 只有在获取对象的锁之后才能被线程运行,然后它会进入并让你继续。仅仅通过说出“同步”这个词。我们就可以自动使用这个法则。比起我们几节课前介绍的方式,这样是不是简化了很多?此外,正如我之前所说,Java 只有一个条件变量。你可以为每个对象编写代码,所以你可以进行等待操作。你也可以进行带有超时的等待操作。而且你可以通知并通知所有等待的线程,或者进行广播、信号和广播。这就是你在 Java 中如何做的。所以 Java 实际上将监视器内建到了语言中。这真的挺酷的。


⏱️ 引入调度概念

现在,我们暂时可以放下同步这个话题。接下来我想说的是,操作系统是一种循环。如果有就绪线程,系统就会从中选择一个执行。否则,你就会运行一个空闲线程,并定期执行循环。这就是整个操作系统的工作。

今天我们想要做的,是开始讨论调度,实际上就是“选择什么”的问题。这个图显示了一个就绪队列,它是所有准备好由 CPU 执行的线程集合。真正的问题是,操作系统如何决定就绪队列中的哪个线程将会是下一个执行的线程?调度其实就是决定哪些线程在每一时刻可以访问资源。在接下来的几节课中,我们将讨论如何分配 CPU 时间。但是调度远不止这些。我们可以讨论带宽调度。你可以谈论I/O资源调度,可以谈论内存调度。你可以调度很多东西,但目前我们将只调度CPU。因为我们实际上是在试图弄清楚发生了什么,介于就绪队列和CPU之间。这是接下来几节课的目标。

调度完全是关于提示信号的。关于调度的一些假设,首先,一些假设实际上来自70年代,调度在70年代成为了一个重要的议题。这是一个重要的研究领域。请注意,我们只有三节课讲它,但它确实是一个重要的研究领域。很多关于CPU调度的隐性假设实际上都来源于那个时期。而这些假设类似于:每个用户只有一个程序,每个程序只有一个线程,程序是独立的。这是一个非常老派的思维方式,也算是一种当多个用户共享同一台机器时的情况,所以它是一种大型机的视角。但70年代做的许多工作至今仍然被愉快地使用。显然,这些具体的想法是不现实的,但它们确实简化了问题,以一种可以解决的方式来处理。所以在第一次讲座中,我们将从这种背景开始:每个用户一个程序,每个程序线程独立。然后,我们将逐步为我们的过程增加更多的复杂性,以便使调度变得更加有趣。

高层次的目标,提醒一下,这就是我们最喜欢的图表,再次来自第一天的打印机逆色:品红色、青色和黄色。这些代表着不同的线程,我们将决定如何在一个CPU上调度它们。请注意,现在它们的CPU时间各不相同,因为程序执行的时间长短不同。调度程序如何决定呢?到目前为止,问题还很简单。什么是用户?用户是一个实体或一个拥有资源分配的人,最简单的想法就是登录,你登录了,就成了用户。CPU通过用户ID来处理你。但随着学期的推进,实际上可以更一般化。比如说,如果你有一个公私钥对,在安全的意义上,它也可以是一个“主体”,但是现在就当作登录ID来理解吧。

这里还有一个问题,进程调度是不是更多的是调度器的隐式操作呢?比如调度器切换线程,而这些线程可能属于不同的进程。所以暂时我们不会讨论,或者限制自己去担心哪个线程属于哪个进程。我们有很多线程,它们都需要CPU时间。比如你可以想象,可能会处理完一个进程中的所有线程后,再切换到另一个进程。你可以想象,几乎可以想象任何场景。例如,大规模的拉丁语程序之类的,你几乎可以想象任何事情。但有趣的是,既然已经提到这个问题,如果有一个进程有100个线程,另一个进程有一个线程。什么是公平呢?将相同的时间分配给进程A中的100个线程和进程B中的一个线程,是否公平?也就是说,它们总共有101个线程,每个线程得到1/101的时间。或者,是否更合理地将一半的时间分配给进程A,另一半分配给进程

操作系统调度(二):案例研究、实时与前进进度 🧠

在本节课中,我们将深入学习操作系统的调度策略。我们将从回顾基础调度目标开始,探讨几种经典的调度算法,并深入研究如何预测任务行为以优化调度。接着,我们将分析多级反馈队列和彩票调度等高级策略,并了解Linux O(1)调度器的设计。最后,我们将转向实时调度,讨论其独特的目标和挑战,并分析优先级反转等经典问题。


回顾:调度目标与基础策略

上一节我们介绍了调度的基本概念,本节中我们来看看几种核心的调度目标及其对应的简单策略。

调度程序的核心任务是从就绪队列中选择下一个要运行的任务。我们通常关注以下几个目标:

  • 最小化响应时间:这是指从任务提交到开始执行所需的时间。对于交互式应用(如键盘输入)至关重要。
  • 最大化吞吐量:指单位时间内完成的任务数量。这在批处理或云计算场景中非常重要。
  • 公平性:确保所有任务都能获得一定的CPU时间,避免某些任务被“饿死”。

需要注意的是,这些目标有时是相互冲突的。例如,为了最小化响应时间而频繁进行任务切换(抢占),可能会破坏缓存局部性,从而降低吞吐量。

我们讨论过几种简单的调度策略:

  • 先来先服务(FIFO):按照任务到达的顺序执行。实现简单,但可能导致平均等待时间很长。
  • 轮转法(Round Robin):为每个任务分配一个固定的时间片(Quantum)。当一个任务的时间片用完后,它会被放回就绪队列的末尾,下一个任务开始执行。这种方法提高了响应性,但可能增加上下文切换的开销。
  • 最短作业优先(SJF):假设我们知道每个任务的运行时间(突发时间),则优先调度运行时间最短的任务。这可以最小化平均等待时间。
  • 最短剩余时间优先(SRTF):这是SJF的抢占式版本。当有新任务到达时,调度器会比较新任务和当前运行任务的剩余运行时间,优先调度剩余时间更短的那个。

公式:平均等待时间
平均等待时间 = (所有进程等待时间之和) / 进程数量

公式:平均完成时间
平均完成时间 = (所有进程完成时间之和) / 进程数量


预测未来:如何估计任务执行时间?

上一节我们假设知道任务的执行时间,本节中我们来看看在现实中如何预测它。

SRTF是最优的,但它需要一个“水晶球”来预知每个任务的执行时间,这在实际中是不可能的。因此,我们需要通过历史数据来预测未来。

一个常见的方法是使用指数加权移动平均来估算一个任务的下一次突发时间。

公式:指数加权移动平均
τ_{n+1} = α * t_n + (1 - α) * τ_n
其中:

  • τ_{n+1} 是预测的下一次突发时间。
  • t_n 是最近一次观察到的实际突发时间。
  • τ_n 是之前的预测值。
  • α 是一个介于0和1之间的参数,用于调节最近观测值的影响权重(α越大,最近的历史影响越大)。

通过这种自适应预测,调度器可以近似地实现SRTF的行为,优先调度那些预测为短时间的任务,从而改善系统的响应性。


高级调度策略

在掌握了基础策略和预测方法后,我们来看看两种更精巧的调度策略:彩票调度和多级反馈队列。

彩票调度 🎫

彩票调度的核心思想是将CPU时间作为资源,通过“彩票”进行分配。

工作原理

  1. 系统拥有一定数量的彩票(例如100张)。
  2. 每个任务根据其重要性或期望获得的CPU份额被分配一定数量的彩票。
  3. 调度时,随机抽取一张彩票,持有该彩票的任务获得下一个时间片的运行权。

代码概念:彩票分配

# 假设有三个任务A、B、C,分别持有5、3、2张彩票
tickets = {'A': 5, 'B': 3, 'C': 2}
total_tickets = sum(tickets.values())
# 抽奖过程:生成一个1到total_tickets之间的随机数,根据彩票区间决定运行哪个任务

优点

  • 比例分配:一个持有20%彩票的任务,长期来看将获得约20%的CPU时间。
  • 避免饥饿:通过给每个任务至少一张彩票,可以保证其最终能获得CPU时间。
  • 动态调整:通过增减任务的彩票数,可以平滑地调整其CPU份额,而不会像严格优先级调度那样导致低优先级任务完全饿死。

我们可以通过给短任务分配更多彩票来模拟SRTF的行为,同时保证长任务也能获得进展。

多级反馈队列 🔄

多级反馈队列是一种结合了多种策略的启发式方法,旨在自动识别并优待交互式(短突发)任务。

以下是其工作原理的简化描述:

  1. 系统维护多个优先级不同的队列(例如Q1, Q2, Q3...)。
  2. 新任务首先进入最高优先级的队列(如Q1)。
  3. 每个队列可以采用不同的调度算法(如Q1用RR,Q2用RR但时间片更长,Q3用FIFO)。
  4. 任务在某个队列中如果用完了其时间片仍未完成,它会被降级到下一个更低优先级的队列中。
  5. 任务如果在时间片用完前主动放弃CPU(例如进行I/O操作),它可能会保持升级到更高优先级的队列。

设计目标

  • 交互式任务(短CPU突发+长I/O等待)通常会很快用完时间片并进入I/O等待,因此它们倾向于停留在高优先级队列,获得快速响应。
  • 计算密集型任务(长CPU突发)会逐渐被降级到低优先级队列,从而为交互式任务让路。

这种方法不需要明确知道任务类型,而是通过观察其运行时行为(是否经常用完时间片)来动态调整其优先级,是对SRTF思想的一种有效近似。


案例研究:Linux O(1) 调度器

了解了多级反馈队列的思想后,我们来看一个真实的复杂实现:Linux 2.6 内核早期的 O(1) 调度器。

核心结构

  • 140个优先级队列:优先级0-99用于实时任务,100-139用于普通任务(由nice值映射)。
  • 两个队列数组activeexpired。调度器从active数组中按优先级选择任务执行。当任务用完其时间片后,它被移到expired数组。当active数组为空时,交换两个数组。
  • O(1)操作:通过位图快速查找最高优先级的非空队列,使得选择下一个任务的时间是常数。

启发式策略
O(1)调度器包含复杂的启发式规则来识别交互式任务。例如,它会跟踪任务的睡眠(I/O等待)时间与运行时间的比例。睡眠时间长的任务会被认为更具交互性,从而获得临时的优先级提升(“交互式积分”),以避免其响应性下降。

问题
这些启发式规则虽然有效,但使得调度器代码变得非常复杂、难以理解和维护。不同的工作负载可能需要不同的启发式规则,导致规则集不断膨胀。这正是Linus Torvalds对其不满,并最终推动开发全新调度器(CFS)的原因之一。


实时调度 ⏱️

到目前为止,我们讨论的调度目标都是关于优化平均性能。但对于某些关键系统,可预测性比平均性能更重要。这就是实时调度的领域。

实时任务的特点

  • 有明确的截止时间
  • 有已知的(或可估算的)最坏情况执行时间
  • 通常是周期性到达的。

分类

  • 硬实时:必须在截止时间前完成,否则会导致严重后果(如飞机控制系统、心脏起搏器)。
  • 软实时:希望能在截止时间前完成,但偶尔错过是可以接受的(如视频播放、音频流)。

最早截止时间优先(EDF)调度

EDF是一种动态优先级调度算法。

规则:在任何时刻,调度器总是选择截止时间最早的任务来执行。

可调度性测试:对于一组周期性任务,如果其总CPU利用率小于等于100%,则EDF可以调度它们。
公式:EDF可调度条件
U = Σ (C_i / P_i) <= 1
其中 C_i 是任务i的最坏情况执行时间,P_i 是它的周期。

挑战:EDF的有效性严重依赖于对任务最坏情况执行时间(WCET)的准确估计。这通常需要复杂的静态代码分析和考虑缓存、流水线等硬件行为,在实践中非常困难。


饥饿与优先级反转 ⚠️

在结束调度话题前,我们必须讨论两个重要问题:饥饿和优先级反转。

饥饿

饥饿是指一个任务无限期地无法获得CPU时间从而无法取得进展。它不同于死锁(下节课内容)。饥饿通常由调度策略引起,例如:

  • 在严格优先级调度中,如果持续有高优先级任务到达,低优先级任务可能会永远得不到执行。
  • 在类似“后进先出”的调度策略中,早到达的任务可能被埋没。

优先级反转

这是一个在优先级调度中可能发生的严重问题,它甚至会导致高优先级任务被饿死。

场景

  1. 低优先级任务L持有一个锁。
  2. 高优先级任务H开始运行,并尝试获取同一个锁,因此被阻塞。
  3. 此时,一个中优先级任务M开始运行(因为H被阻塞,L优先级低)。
  4. 任务M会一直运行,阻止了L运行,从而L无法释放锁,导致H永远被阻塞。

解决方案:优先级继承
当高优先级任务H因等待低优先级任务L持有的锁而被阻塞时,L临时继承H的高优先级。这样,L就能尽快执行并释放锁,之后优先级恢复原状,H得以继续执行。这个机制防止了中优先级任务M的干扰。

著名案例:1997年的火星探路者号探测器就曾因优先级反转导致系统不断重启。地面工程师通过远程调试启用优先级继承功能后,问题得以解决。


总结

本节课中我们一起深入探讨了操作系统调度的多个方面:

  1. 我们回顾了调度的核心目标:响应时间、吞吐量和公平性,并指出了它们之间的权衡。
  2. 我们探讨了如何通过历史数据(如指数加权平均)预测任务行为,以近似实现最优的最短剩余时间优先调度。
  3. 我们学习了彩票调度,它通过概率分配的方式实现比例共享和避免饥饿。
  4. 我们分析了多级反馈队列,这是一种通过观察任务行为动态调整优先级以优待交互式任务的启发式方法。
  5. 我们以Linux O(1)调度器为案例,看到了一个复杂、充满启发式规则的真实世界调度器及其面临的挑战。
  6. 我们转向了实时调度,了解了其追求可预测性的独特目标,并介绍了最早截止时间优先算法及其可调度性分析。
  7. 最后,我们讨论了饥饿现象和优先级反转这一经典问题及其解决方案——优先级继承。

调度是操作系统的核心且复杂的组成部分,需要在各种相互竞争的目标和约束之间做出精巧的折衷。理解这些基本策略和挑战,是构建和优化高效计算系统的基石。

🧠 课程 P12:调度算法、饿死与死锁

在本节课中,我们将学习调度算法的其他方面,特别是如何避免任务“饿死”,并深入探讨计算机系统中一个经典且棘手的问题——死锁。我们将了解死锁的成因、如何检测它,以及有哪些策略可以预防或避免死锁的发生。


🔄 调度算法回顾与饿死问题

上一节我们介绍了多种实时调度算法,其核心目标是保证性能的可预测性。无论是硬实时系统(如防抱死制动系统)还是软实时系统(如多媒体流),我们都希望任务能在截止时间前完成。

然而,像最短剩余时间优先多级反馈队列这类基于优先级的算法,存在一个共同问题:低优先级或长时间运行的任务可能永远无法获得CPU时间,这种现象称为“饿死”

例如,在最短剩余时间优先算法中,系统总是优先运行剩余时间最短的任务,这可能导致长时间任务被无限期推迟。同样,在严格的优先级调度中,高优先级任务会持续占用CPU,导致低优先级任务无法执行。

解决饿死:公平分配CPU

为了避免饿死,我们需要确保所有任务都能获得一定的CPU份额,而不是单纯基于优先级。以下是两种旨在实现公平分配的调度策略:

1. 彩票调度法

基本思想是为每个任务分配一定数量的“彩票”,其数量与任务应获得的CPU份额成正比。调度时,系统随机抽取一张彩票,并运行对应的任务。

核心公式

  • 总票数 = 所有任务票数之和
  • 任务运行概率 = 该任务票数 / 总票数

这种方法在长期统计上是公平的,但在短期可能由于伪随机数生成器的偏差导致不公平。

2. 步幅调度法

为了消除随机性带来的短期不公平,步幅调度采用了确定性的方法。

核心计算

  • 步长 = 一个大常数 / 任务持有的票数
  • 每个任务维护一个“通行证”计数器。
  • 调度器总是选择通行证计数器最小的任务运行。
  • 任务运行后,将其步长值累加到自己的通行证计数器中。

代码逻辑示意

# 初始化
stride_A = LARGE_CONSTANT / tickets_A
stride_B = LARGE_CONSTANT / tickets_B
pass_A = 0
pass_B = 0

# 调度决策
if pass_A < pass_B:
    run_task(A)
    pass_A += stride_A
else:
    run_task(B)
    pass_B += stride_B

持有更多票数(即步长更小)的任务,其通行证计数器增长更慢,因此会被更频繁地调度,从而精确地按比例分配CPU时间。


⚖️ Linux 完全公平调度器

在实践中,一个著名的公平调度器实现是Linux的完全公平调度器。它的设计目标是让每个进程获得平等的CPU时间,同时保持良好的响应速度。

CFS的核心思想是维护每个任务的虚拟运行时间。系统跟踪每个任务实际获得的CPU时间,但通过一个与任务优先级(nice值)相关的权重将其转换为虚拟时间。调度器总是选择虚拟运行时间最小的任务来运行,这相当于在追赶进度最慢的任务。

关键机制

  • 目标延迟:保证每个可运行任务都能在该时间段内至少运行一次。
  • 最小粒度:为防止过多的上下文切换开销,时间片有下限。
  • 虚拟运行时间:实际CPU时间根据任务权重进行缩放。高权重任务(低nice值)的虚拟时间增长更慢,从而获得更多实际CPU时间。

权重计算示例
两个任务nice值相差5,则低nice值(高优先级)任务的权重约为高nice值任务的3倍(1.25^5 ≈ 3),因此它获得的CPU时间也约为3倍。


📊 如何选择调度算法?

调度算法的选择取决于应用场景和性能目标:

  • CPU吞吐量:可能选择先来先服务
  • 平均响应时间:可能选择最短剩余时间优先或其近似算法。
  • 公平性:可能选择Linux CFS步幅调度
  • 截止时间保证:使用最早截止时间优先等实时调度算法。
  • 优先级处理:使用严格优先级调度,但需警惕饿死。

一个重要的洞见是:调度算法主要在系统资源利用率未饱和时效果显著。当利用率接近100%时,响应时间会急剧上升,任何调度算法都难以维持良好性能,此时增加资源(如更快的CPU)才是根本解决方案。


☠️ 从饿死到死锁

饿死是指任务因持续无法获得所需资源而无法进展,但理论上这种状况可能结束(例如高优先级任务停止)。而死锁则是一种更严重的、无法自行解除的僵局。

死锁的经典例子
两个线程,线程A持有锁X并申请锁Y,同时线程B持有锁Y并申请锁X。双方都等待对方释放资源,导致程序永久停滞。

死锁发生的四个必要条件(必须同时满足):

  1. 互斥:资源一次只能被一个线程使用。
  2. 占用并等待:线程持有资源的同时,等待其他资源。
  3. 不可抢占:资源只能由持有者自愿释放。
  4. 循环等待:存在一个线程和资源的循环等待链(T1等T2的资源,T2等T3的资源,...,Tn等T1的资源)。

🔍 死锁的检测与表示

我们可以使用资源分配图来检测死锁。图中包含两类节点:进程资源。边表示关系:

  • 请求边:进程 -> 资源(进程申请该资源)
  • 分配边:资源 -> 进程(该资源实例已分配给进程)

死锁检测算法

  1. 初始化一个“可用资源”向量。
  2. 查找一个其所有资源请求都能被当前可用资源满足的进程。
  3. 假设该进程运行完毕,释放其所有资源,将这些资源加入“可用资源”池。
  4. 重复步骤2-3,直到没有进程可被标记为完成。
  5. 如果最终仍有进程未完成,则这些进程处于死锁状态。

🛡️ 死锁的应对策略

主要有四种处理死锁的策略:

  1. 预防:通过破坏死锁四个必要条件中的至少一个来设计系统。
    • 破坏互斥:让资源可共享(但很多资源如打印机无法共享)。
    • 破坏占用并等待:要求线程一次性申请所有所需资源(可能导致资源利用率低)。
    • 破坏不可抢占:允许从线程中抢占资源(实现复杂)。
    • 破坏循环等待:对所有资源类型进行全局排序,要求线程必须按序申请资源(例如,必须先申请锁A,才能申请锁B)。

  1. 避免:系统在分配资源前进行安全性检查,仅当分配后系统仍处于安全状态(即所有进程仍能找到一个完成序列)时才进行分配。银行家算法是经典的死锁避免算法。

  2. 检测与恢复:允许死锁发生,但定期运行检测算法。一旦发现死锁,则采取恢复措施,如:

    • 终止进程:强制终止一个或多个死锁进程。
    • 资源抢占:从一个进程剥夺资源给另一个进程,需处理被抢占进程的恢复。
  3. 忽略:像“鸵鸟算法”一样,假设死锁永远不会发生或极少发生。这对于许多通用操作系统是常见做法,将避免死锁的责任交给了应用程序开发者。


🎯 本节总结

本节课我们一起深入探讨了调度中的公平性问题与死锁现象。

  • 我们首先分析了基于优先级的调度可能导致的任务饿死问题,并介绍了彩票调度步幅调度等旨在公平分配CPU的算法,以及Linux系统中著名的完全公平调度器的实现原理。
  • 随后,我们将话题转向更严重的死锁问题,明确了死锁发生的四个必要条件,学习了如何使用资源分配图进行死锁检测。
  • 最后,我们系统梳理了应对死锁的四大策略:预防、避免、检测与恢复、以及忽略,并了解了各种策略的典型方法与其优缺点。

理解这些概念对于设计和开发稳定、高效的并发系统至关重要。

操作系统课程 P13:第13讲 - 内存管理1:地址转换与虚拟内存 🧠💾

在本节课中,我们将结束对死锁的讨论,并开启一个全新的重要话题:内存管理。我们将学习如何通过地址转换和虚拟内存技术,让多个程序安全、高效地共享有限的物理内存资源。

从死锁到内存管理 🔄

上一节我们介绍了死锁的避免和恢复技术。死锁与饥饿的关键区别在于,死锁涉及资源的循环等待,并且通常需要外部干预才能解决。避免死锁的一种方法是确保系统始终处于安全状态,例如使用银行家算法

银行家算法的核心思想是模拟资源分配,检查是否存在一个让所有线程都能顺利完成的安全序列。其伪代码逻辑如下:

while (有未完成的线程) {
    找到这样一个线程T:其最大未来需求 - 已分配资源 <= 当前可用资源
    if (找不到这样的线程T) {
        return UNSAFE; // 系统处于不安全状态
    }
    // 假设线程T完成
    可用资源 += T已分配的资源;
    将T标记为完成;
}
return SAFE; // 系统处于安全状态

现在,让我们将话题切换到内存管理。与虚拟化CPU类似,我们也需要虚拟化内存,为每个程序提供“独占内存”的假象,同时实现保护和高效共享。

为什么需要内存共享与保护? 🛡️

程序的状态由CPU寄存器内容和内存数据共同定义。为了实现多任务,我们必须让多个程序共享物理内存,但这带来了两个核心需求:

  1. 多路复用:将多个程序的地址空间映射到有限的物理RAM上。
  2. 保护:防止一个程序访问或破坏其他程序(或操作系统)的内存数据。

地址空间是程序视角下的内存视图,它可能使用虚拟地址。操作系统和硬件(MMU)负责将虚拟地址转换为实际的物理地址。这种转换是内存管理的基石。

地址绑定:从编译时到运行时 ⏳

程序中的地址在何时被确定(绑定)到物理地址?计算机的发展史也是绑定时机不断推迟的历史:

  • 早期/嵌入式系统:在编译时绑定。程序被编译为在特定物理地址运行,适用于单一程序环境。
  • 早期PC(如Win 3.1):在加载时绑定。加载器根据程序重定位表,在将程序装入内存时修改其地址。
  • 现代系统:在执行时绑定。每次内存访问时,由硬件动态地将虚拟地址转换为物理地址。这提供了最大的灵活性。

基础地址转换:基址-界限寄存器 📏

最简单的动态地址转换方案是基址-界限寄存器(Base and Bounds)。

  • 基址寄存器:存放程序物理内存的起始地址。
  • 界限寄存器:存放程序的大小(或结束地址)。

以下是其工作流程:

  1. CPU生成一个虚拟地址(通常从0开始)。
  2. 硬件检查:虚拟地址 < 界限寄存器?如果否,触发异常(段错误)。
  3. 硬件计算:物理地址 = 虚拟地址 + 基址寄存器
  4. 使用计算出的物理地址访问内存。

这种方法实现了保护(越界访问被捕获)和重定位(程序可加载到任意物理位置)。但它将整个地址空间视为一个连续块,导致两个问题:

  1. 外部碎片:内存中散布着许多小的空闲区域,但不足以容纳新程序。
  2. 内部浪费/不灵活:程序的栈、堆、代码等段在虚拟空间中是分开的,但基址-界限模型却将它们打包成一个整体,无法高效处理稀疏地址空间,也不易实现段共享。

更灵活的方案:分段 🗂️

分段(Segmentation)解决了上述问题。它将程序的地址空间划分为逻辑段(如代码段、数据段、堆段、栈段),每个段有独立的基址和界限。

以下是分段地址转换过程:

  1. CPU生成的虚拟地址被分为两部分:段号段内偏移
  2. 使用段号作为索引,查询段表,获取该段的基址和界限。
  3. 检查:偏移量 < 界限?如果否,触发异常。
  4. 计算:物理地址 = 该段基址 + 偏移量

分段允许每个段独立地放入物理内存的不同位置,更符合程序的实际结构。它支持更精细的保护(例如,将代码段设为只读/可执行),也更容易实现段共享(多个程序映射到同一个物理段)。

然而,分段仍然管理的是大小可变的连续内存块,因此外部碎片问题依然存在。操作系统需要复杂的“紧缩”操作来合并空闲空间。

关键概念与总结 📚

本节课我们一起学习了内存管理的入门知识:

  • 地址空间:程序所能“看到”的内存地址集合。分为虚拟地址空间和物理地址空间。
  • 地址转换:通过硬件(MMU)将程序使用的虚拟地址映射到实际的物理地址。这是实现内存虚拟化、保护和共享的核心机制。
  • 基址-界限模型:简单的动态重定位与保护方案,但将地址空间视为单一连续块,导致碎片化和不灵活。
  • 分段模型:将地址空间按逻辑划分为多个段,每个段独立映射。它更灵活,支持共享和精细保护,但依然受外部碎片困扰。

地址转换在每次内存访问(取指令、读写数据)时发生,由硬件快速完成。只有在发生异常(如缺页、越界)时,操作系统才会介入处理。在进程上下文切换时,操作系统必须保存和恢复旧进程的地址转换状态(如基址/界限寄存器或段表),并加载新进程的状态。

下一讲,我们将探索一种更强大、能彻底解决外部碎片问题的地址转换方案:分页

🧠 课程 P14:内存管理(二)虚拟内存、缓存与TLB

在本节课中,我们将继续深入探讨虚拟内存的工作原理,并介绍缓存和翻译后备缓冲区(TLB)的概念。我们将从虚拟内存的翻译机制开始,分析不同内存管理模型的优缺点,并最终引出如何通过缓存技术来优化内存访问性能。


📖 虚拟内存与地址翻译

处理器和进程看到的是虚拟地址。内存管理单元(MMU)的任务是将这些虚拟地址翻译成物理地址,即实际访问内存的方式。内存有两种视图:处理器看到的虚拟视图和内存实际的物理视图。翻译过程不仅实现了内存保护,防止进程访问其他进程或操作系统的内存,还使得每个进程都能拥有相同的内存起始视图(例如从地址0开始)。

多段模型

在上一讲介绍的多段模型中,处理器内存储存了一个段映射表,其中包含一组基地址和限制地址对。虚拟地址被分为段号和偏移量两个字段。段号用于索引段映射表,获取基地址,然后与偏移量相加得到物理地址。同时,需要进行边界检查和权限验证(如读写、执行权限)。元数据(如有效位、权限位)也存储在此表中。

核心概念示例(段地址翻译)

物理地址 = 段基址[段号] + 偏移量

分页模型

分段模型存在外部碎片和内部碎片问题,且交换粒度大(整个段)。分页模型将物理内存划分为固定大小的页面(如4KB),并按页面分配内存。这通过位向量管理空闲页面,简化了分配过程。

核心概念示例(页表条目)

// 一个简化的页表条目结构
typedef struct {
    uint32_t physical_page_number;
    bool valid;
    bool readable;
    bool writable;
    bool executable;
} PageTableEntry;

在简单分页中,虚拟地址被分为虚拟页号(VPN)和页内偏移量。页表指针指向物理内存中的页表。通过VPN索引页表,获得物理页号(PPN),再与偏移量组合得到物理地址。权限检查同样必不可少。

核心概念示例(地址翻译)

物理地址 = (页表[虚拟页号].物理页号 << 偏移量位数) | 偏移量

🔄 分页的优势与挑战

分页模型的主要优势在于:

  1. 灵活的映射:虚拟页面可以映射到任何物理页面,无需连续。
  2. 易于共享:多个进程的页表条目可以指向同一个物理页面,实现代码(如libc)和数据的共享。
  3. 简化分配:使用位图管理固定大小的页面,分配速度快,无外部碎片。

然而,简单分页面临巨大挑战:页表过大。对于一个32位地址空间(4GB)和4KB页面,需要2^20个页表条目,每个条目占4字节,总大小达4MB。对于64位地址空间,页表大小更是天文数字,无法实际使用。

多级页表

为了解决页表过大的问题,引入了多级页表(如二级页表)。它将虚拟地址分割为多段,分别用于索引不同层级的页表。

以32位系统为例(10-10-12划分)

  • 偏移量:12位(页面大小4KB)。
  • 一级页表索引:10位(指向二级页表)。
  • 二级页表索引:10位(指向物理页)。

顶级页表(页目录)常驻内存。二级页表可以按需创建、调入内存或换出到磁盘。这种方式使得页表总大小与进程实际使用的虚拟内存量成正比,而非虚拟地址空间的最大值。

核心概念示例(二级页表翻译)

物理地址 = (二级页表[一级索引][二级索引].物理页号 << 12) | 偏移量

段页式结合

另一种方案是结合分段和分页,顶层使用段映射,底层使用页表。虚拟地址包含段号、虚拟页号和偏移量。段映射提供每个段的页表基址。这种方式在上下文切换时需要保存段映射和页表指针,但能更灵活地管理稀疏地址空间。


⚡ 性能问题与缓存引入

多级页表虽然节省了空间,但增加了访问延迟。一次内存访问可能需要进行多次页表查找(每次查找都是一次内存访问),导致性能严重下降。

问题核心:我们不能承受每次内存引用都进行完整的地址转换。

解决方案思路:利用局部性原理。程序倾向于在短时间内重复访问相同的代码和数据(时间局部性),以及访问相邻的内存地址(空间局部性)。我们可以缓存最近使用过的地址翻译结果。


🚀 翻译后备缓冲区(TLB)

翻译后备缓冲区(TLB)是一个位于MMU中的小型、高速缓存,用于存储最近使用过的虚拟页到物理页的映射。

工作原理

  1. CPU发出虚拟地址。
  2. MMU首先在TLB中查找该虚拟页号(VPN)。
  3. 如果找到(TLB命中),则立即获得物理页号(PPN),与偏移量组合成物理地址。整个过程非常快。
  4. 如果未找到(TLB未命中),则需遍历页表(可能多级)进行地址翻译。翻译完成后,将新的映射存入TLB,以备后续使用。

TLB条目示例

typedef struct {
    uint64_t virtual_page_number;
    uint64_t physical_page_number;
    bool valid;
    int asid; // 地址空间标识符,用于多进程上下文
    // ... 其他标志位
} TLBEntry;

TLB极大地减少了地址翻译的平均开销,因为大多数内存访问都能在TLB中命中。

TLB与上下文切换

当发生进程上下文切换时,新进程的虚拟地址映射与旧进程不同。因此,TLB中缓存的旧进程的映射会失效。解决方案包括:

  1. 清空TLB:切换时清空整个TLB(简单但可能影响性能)。
  2. 添加地址空间标识符(ASID):为每个进程分配一个唯一ID,与VPN一起存储在TLB中。这样,TLB可以同时保存多个进程的映射,通过ASID区分。

💎 总结

本节课我们一起学习了虚拟内存管理的核心机制及其优化。

  1. 地址翻译是虚拟内存的基础,它通过MMU将虚拟地址转换为物理地址,实现了内存保护和进程隔离。
  2. 分页模型使用固定大小的页面,解决了分段的外部碎片问题,并通过页表管理映射。
  3. 多级页表段页式结合是管理稀疏大地址空间的有效方法,它们通过层次化结构减少了页表的内存占用。
  4. 多级翻译带来的多次内存访问开销是严重的性能瓶颈。
  5. 翻译后备缓冲区(TLB) 作为地址翻译的高速缓存,利用局部性原理,缓存最近使用的页表条目,极大地加速了地址翻译过程,是构建高效内存系统的关键组件。

理解这些机制是掌握现代操作系统内存管理,以及后续学习缓存一致性、虚拟化等高级主题的重要基础。

操作系统课程 P15:内存 3 - 缓存与TLB(续)及分页 🧠💾

在本节课中,我们将深入学习内存管理的核心机制。我们将探讨如何通过缓存和转换后备缓冲器(TLB)来加速地址转换过程,并理解分页系统的工作原理。这些技术是构建高效、高性能操作系统的基石。


概述:地址转换的挑战与缓存的作用

上一节我们介绍了多级页表的结构。本节中我们来看看,为了将程序使用的虚拟地址转换为物理地址,内存管理单元(MMU)需要进行多次内存访问。这个过程如果每次都直接访问内存,速度会非常慢。因此,我们需要引入缓存机制来加速这一关键路径。


多级页表回顾与地址转换流程

我们使用双级页表结构,采用 10-10-12 的分割方式。这意味着:

  • 虚拟地址的前10位(P1)索引顶级页表。
  • 中间10位(P2)索引二级页表。
  • 最后12位是页内偏移量。

页表每个条目为4字节,一个4KB的页面刚好可以存放1024个条目。页表基址寄存器(如x86的CR3)指向顶级页表的物理地址。

地址转换流程如下:

  1. MMU用P1索引顶级页表,获得二级页表的物理地址。
  2. MMU用P2索引二级页表,获得目标物理页的页表项(PTE)。
  3. 将PTE中的物理帧号(PFN)与12位偏移量结合,得到最终的物理地址。

这个过程每次内存访问(取指令、加载、存储)都需要执行,如果页表项不在缓存中,代价会非常高。


缓存基础:加速的秘诀

缓存是一个存储副本的仓库,其访问速度远快于原始存储位置(如主内存)。其有效性基于局部性原理

  • 时间局部性:最近被访问过的数据很可能再次被访问。
  • 空间局部性:访问某个数据时,其附近的数据也可能很快被访问。

我们使用平均内存访问时间(AMAT) 来衡量缓存性能:
AMAT = 命中率 × 命中时间 + 未命中率 × 未命中时间

示例分析:
假设处理器访问缓存需1纳秒,访问内存需100纳秒。

  • 若缓存命中率为90%,则 AMAT = 0.9×1 + 0.1×(1+100) = 11.1纳秒。这比直接访问内存快,但比处理器速度慢10倍。
  • 若将命中率提升至99%,则 AMAT = 0.99×1 + 0.01×101 = 2.01纳秒,性能大幅改善。

这说明了实现高缓存命中率对性能至关重要。


转换后备缓冲器(TLB):专为地址转换设计的缓存

由于每次地址转换都可能涉及多次内存访问,直接将其放入通用数据缓存并不高效。因此,我们设计了专用的缓存——转换后备缓冲器(TLB)

TLB的工作方式:

  • :虚拟页号(VPN)。
  • :对应的物理帧号(PFN)及页表项中的标志位(如有效位、脏位、访问权限等)。
  • 作用:当CPU发出虚拟地址时,首先查询TLB。若找到对应项(TLB命中),则立即获得物理地址,无需访问内存中的页表。

TLB未命中处理流程:

  1. CPU查询TLB,未命中。
  2. 触发MMU遍历内存中的页表结构,进行地址转换。
  3. MMU获得物理地址后,将该转换关系载入TLB,以备后续使用。
  4. CPU使用获得的物理地址访问数据缓存或内存。

TLB有效利用了程序访问的时空局部性(例如,循环中的指令、连续访问的栈数据),从而能获得极高的命中率。


缓存的组织结构与设计权衡

缓存设计需要考虑多个维度,以下是三种常见组织结构:

以下是三种缓存映射方式:

  1. 直接映射缓存

    • 每个内存块只能映射到缓存中的一个特定位置。
    • 优点:硬件简单,访问速度快。
    • 缺点:容易发生冲突未命中(多个内存块竞争同一个缓存位置)。
  2. 组相联缓存

    • 缓存分为若干组,每个内存块可以映射到某一组内的任意位置。
    • 优点:减少了冲突未命中,是速度与灵活性的折中。
    • 缺点:硬件比直接映射复杂(需要多个比较器和多路选择器)。
  3. 全相联缓存

    • 任何内存块可以放入缓存中的任何位置。
    • 优点:最大限度地减少了冲突未命中。
    • 缺点:硬件实现最复杂,访问速度最慢(需要与所有条目比较)。

替换策略:当缓存已满且需要载入新数据时,必须决定替换哪个旧数据块。常见策略有:

  • 随机替换:实现简单,但可能替换掉重要数据。
  • 最近最少使用(LRU):替换最久未被访问的数据,效果较好,但实现复杂。
  • 在实际硬件(如TLB)中,由于追求速度和简化硬件,有时会采用类LRU或随机策略。

写策略:处理写入缓存的数据如何同步到下一级存储。

  • 写直达:数据同时写入缓存和内存。一致性简单,但写操作慢。
  • 写回:数据只写入缓存,被替换时才写回内存。写操作快,但需要维护“脏位”以跟踪数据一致性,更复杂。

TLB的实际考量与优化

TLB的特殊性

  • TLB位于关键路径上,其速度直接影响处理器流水线。因此,TLB通常采用全相联高组相联的组织结构,以最小化冲突未命中,即使这增加了硬件复杂度。
  • 一些架构(如历史MIPS)采用软件管理TLB。TLB未命中时,由操作系统负责遍历页表并填充TLB。这虽然增加了未命中开销,但允许操作系统实现更智能的替换策略,从而提升整体命中率。

地址空间标识符(ASID)

  • 为避免进程切换时刷新整个TLB(导致性能下降),TLB条目会附带一个ASID。
  • 查询TLB时,需同时匹配VPN和ASID。这样,不同进程的地址转换可以共存于TLB中,上下文切换时无需清空TLB。

虚拟索引物理标记缓存

  • 现代处理器常采用这种优化。利用虚拟地址中的页内偏移量(在翻译前后不变)提前索引数据缓存,同时进行TLB查询。待TLB返回物理页号后,再用其与缓存中的标签进行比较。这重叠了地址转换和缓存访问的时间,提升了性能。

现代处理器内存层次结构示例

一个典型的现代多核处理器(如Intel Skylake)内存层次如下:

  • 每个核心私有
    • L1指令缓存 & L1数据缓存
    • 指令TLB & 数据TLB
  • 核心间共享
    • 统一的L2、L3缓存
    • 更大的二级TLB(STLB)
      这种设计平衡了私有数据的快速访问和共享数据的容量与一致性。

页错误:需求驱动的内存管理

当MMU在地址转换过程中发现页表项无效(如页面不在内存中)或访问权限违规时,会触发一个页错误异常。这是一个同步事件,会中断当前指令。

页错误处理流程(由操作系统完成):

  1. 操作系统页错误处理程序被调用。
  2. 分析错误原因(例如,页面在磁盘上、写只读页、栈增长等)。
  3. 采取相应行动(例如,从磁盘调入页面、分配新页面、复制写时复制页面等)。
  4. 更新页表项,使其有效。
  5. 重新执行引发页错误的指令。

这种按需调页机制使得操作系统能够将物理内存作为磁盘上程序映像的缓存。只有真正被访问的页面才会被加载到内存中,从而高效地支持运行比物理内存更大的程序。


总结

本节课中我们一起学习了:

  1. 缓存的核心原理:利用局部性,通过AMAT模型理解其对性能的关键影响。
  2. TLB的专用角色:作为地址转换的缓存,极大减少了MMU访问内存的次数,是高效虚拟内存实现的基石。
  3. 缓存的组织与权衡:包括映射方式(直接、组相联、全相联)、替换策略(LRU、随机)和写策略(写直达、写回),这些设计决策在速度、命中率和硬件成本间取得平衡。
  4. TLB的实践优化:如使用ASID避免上下文切换刷新,以及虚拟索引物理标记缓存等重叠优化技术。
  5. 页错误的含义:它是连接硬件与操作系统的桥梁,实现了需求驱动的内存管理,使物理内存成为磁盘的高效缓存。

通过缓存、TLB和分页机制的协同工作,操作系统得以创造出“容量极大、速度极快”的内存抽象,为应用程序提供近乎无限的线性地址空间,同时保证了隔离性与安全性。

🧠 课程 P16:内存管理(四)需求分页策略

在本节课中,我们将要学习操作系统中的需求分页机制及其核心的页面替换策略。我们将探讨如何利用磁盘作为后备存储,为进程提供远大于物理内存的虚拟地址空间,并深入研究当物理内存不足时,如何选择最合适的页面进行替换,以最小化性能损失。


📊 概述:内存访问时间与缓存层级

上一节我们介绍了虚拟内存和页表的基本概念。本节中,我们来看看如何计算平均内存访问时间,并理解需求分页如何融入这个模型。

假设一个处理器有 L1 缓存和 DRAM(主内存)。平均内存访问时间(AMAT)可以表示为:

AMAT = L1命中时间 + L1未命中率 × L1未命中惩罚

其中,L1的未命中惩罚就是访问下一级(如L2缓存或主内存)的平均时间。这个模型可以扩展到多级存储层次,包括将主内存视为磁盘(SSD/HDD)的缓存。访问时间的差异巨大:

  • 芯片内缓存:亚纳秒到纳秒级
  • 主内存(DRAM):约100纳秒
  • 硬盘(HDD):约1000万纳秒

我们的核心挑战是:如何利用访问速度为1000万纳秒的磁盘,让程序感觉像是在访问100纳秒的主内存。


⚙️ 需求分页的工作流程

上一节我们介绍了理想情况下的地址转换。本节中我们来看看当所需页面不在内存中时,会发生什么。

理想情况下,指令生成虚拟地址,通过MMU和TLB转换为物理地址并访问数据。但当页表项标记为“无效”时,会触发页面错误(Page Fault)

以下是页面错误的处理流程:

  1. 触发异常:运行中的指令被终止,CPU陷入操作系统内核。
  2. 执行处理程序:操作系统页面错误处理程序被调用。
  3. 定位页面:处理程序检查发现该页面在磁盘上,并确定其在磁盘上的位置。
  4. 分配物理帧:如果需要,选择一个内存中的旧页面进行替换(驱逐)。
    • 如果被选中的页面是“脏的”(内容已被修改),则需先将其写回磁盘。
  5. 加载页面:将所需页面从磁盘加载到空闲的物理内存帧中。
  6. 更新映射:更新页表项,将其标记为有效,并指向新的物理帧。使TLB中该页面的旧条目失效。
  7. 重启指令:将原先被中断的进程放回就绪队列。当其再次被调度时,重新执行触发页面错误的指令,此时该指令将成功执行。

这个过程将主内存视为磁盘的缓存。我们可以用缓存设计的经典问题来审视它:

  • 块大小:页面(通常为4KB)。
  • 组织结构完全关联。任何虚拟页面可以映射到任何物理帧,这提供了最大灵活性,避免了冲突未命中。
  • 查找方式:通过TLB和页表进行查找。
  • 替换策略:当需要腾出空间时,选择哪个页面进行驱逐?这是本节课的重点。
  • 写策略写回(Write-back)。因为写直达(Write-through)会导致每次写操作都直接访问磁盘,速度极慢。脏页需要在被替换时写回磁盘。

需求分页提供了透明的间接层。对程序而言,它拥有完整的、连续的虚拟地址空间,无需关心数据实际存储在物理内存还是磁盘上。


📝 页表项的关键位

页表项(PTE)中的几个关键位支撑了需求分页机制:

  • 存在位(Present Bit):类似有效位。1表示该页面在物理内存中;0表示不在内存中(可能在磁盘上)。
  • 访问位(Accessed Bit):当页面被读或写时,硬件自动将其置1。用于追踪页面是否被近期使用,是页面替换算法的重要依据。
  • 脏位(Dirty Bit):当页面被写入时,硬件自动将其置1。表示该页面内容与磁盘副本不一致,在被替换前必须写回磁盘。

🗂️ 后备存储与页面定位

当页面不在内存中时,操作系统需要知道去哪里找到它。以下是管理后备存储的常见方式:

以下是操作系统定位不在内存中的页面的主要方法:

  • 专用交换分区/文件:在磁盘上划出一块固定或可扩展的区域,专门用于存储被换出的页面。操作系统维护一个映射表,将(进程ID, 虚拟页号)映射到该分区内的具体磁盘块。
  • 内存映射文件:对于程序代码等只读或可读写文件,可以直接将文件本身映射为进程虚拟地址空间的后备存储。当访问这些页面时,直接从文件对应的磁盘位置读取。这允许多个进程共享同一份物理内存和磁盘中的代码。

🔍 工作集与替换策略的重要性

程序在运行中并不会同时访问其全部虚拟地址空间。在任何一个时间窗口内,进程频繁访问的页面集合称为其工作集(Working Set)

为了让进程高效运行,我们需要确保其工作集驻留在物理内存中。如果分配给进程的物理帧数少于其工作集大小,就会发生频繁的页面调入/调出,称为颠簸(Thrashing),系统性能会急剧下降。

平均有效访问时间公式揭示了替换策略的关键性:

有效访问时间 = 内存访问时间 + 缺页率 × 缺页处理时间

假设内存访问时间为200纳秒,缺页处理(访问磁盘)为800万纳秒。即使缺页率低至0.1%(每1000次访问缺页1次),平均访问时间也会从200纳秒恶化到8200纳秒,性能下降超过40倍。因此,一个优秀的页面替换算法对于降低缺页率至关重要。


🧮 页面替换算法

现在我们进入核心环节:当物理内存已满,需要加载新页面时,应该选择替换哪个旧页面?以下是几种经典算法。

先进先出(FIFO)

  • 描述:替换在内存中驻留时间最长的页面。
  • 优点:实现简单。
  • 缺点:可能会淘汰经常被访问的活跃页面,性能不佳。存在Belady异常:增加物理帧数有时反而会导致缺页次数增加。

最优算法(OPT/MIN)

  • 描述:替换在未来最长时间内不会被访问的页面。
  • 优点:这是理论上最优的算法,可产生最少的缺页数。
  • 缺点:无法实现,因为它需要预知未来的页面访问序列。

最近最少使用(LRU)

  • 描述:替换过去最长时间没有被访问的页面。
  • 优点:基于“过去可以预测未来”的局部性原理,是对OPT算法的良好近似。
  • 缺点:精确实现LRU成本很高,需要在每次内存访问时更新所有页面的“最近使用时间”信息。

时钟算法(Clock)

  • 描述:一种对LRU的高效近似。将所有物理帧组织成一个环形链表,并有一个指针循环扫描。每帧有一个硬件或软件维护的使用位
    1. 当需要替换页面时,检查指针指向的帧。
    2. 如果其使用位为1,则将其置0,指针前进一格。
    3. 如果其使用位为0,则选择该帧进行替换。
    4. 指针循环扫描,直到找到使用位为0的帧。
  • 变种(二次机会):考虑脏页写回磁盘的成本更高,可以给脏页多一次机会。例如,干净页在第一次遇到使用位为0时就被替换;而脏页则先安排其写回磁盘,并将使用位置0,等指针下次扫描到它时(此时已变干净)再替换。

改进型时钟算法

  • 描述:综合考虑页面的使用情况和修改情况。为每个页面维护一个(使用位, 脏位)对。替换时按以下优先级选择页面:
    1. (0, 0):最近未使用,且干净。最佳替换对象。
    2. (0, 1):最近未使用,但脏。需要写回磁盘。
    3. (1, 0):最近使用过,但干净。
    4. (1, 1):最近使用过,且脏。
      算法会进行多轮扫描,优先淘汰第一类页面。

🎯 总结

本节课中我们一起学习了操作系统内存管理的核心高级主题——需求分页。

我们首先理解了需求分页如何作为磁盘的缓存来工作,并通过透明的页面错误处理机制,为进程提供了巨大的虚拟地址空间。我们深入分析了页表项中关键位(存在位、访问位、脏位)的作用。

接着,我们探讨了选择被替换页面的各种策略:从简单但性能有缺陷的FIFO,到理论最优但不可实现的OPT,再到基于历史访问的LRU及其高效近似——时钟算法及其变种。这些算法的核心目标都是尽可能准确地预测未来访问模式,避免将即将被访问的页面换出,从而最大限度地减少代价高昂的缺页异常。

理解这些替换策略,是优化程序内存使用性能、防止系统颠簸的关键。在接下来的课程中,我们将继续探讨与内存管理相关的其他主题,例如工作集模型和系统颠簸的应对策略。

操作系统课程 P17:按需分页、通用I/O与存储设备 🖥️💾

在本节课中,我们将深入学习虚拟内存的按需分页机制,探讨页面替换策略的优化,并初步了解计算机系统中复杂多样的输入/输出(I/O)子系统。我们将从计算页面访问的平均时间开始,逐步深入到内存分配策略、抖动现象,最后开启对I/O设备与总线的讨论。

按需分页与有效访问时间 ⏱️

上一节我们介绍了虚拟内存作为磁盘缓存的优势。本节中,我们来看看如何量化页面错误(Page Fault)带来的性能影响。

虚拟内存系统的一个关键性能指标是有效访问时间(Effective Access Time, EAT)。它衡量的是在考虑页面命中与未命中的概率后,平均每次内存访问所需的时间。其计算公式如下:

EAT = (1 - p) × 内存访问时间 + p × 页面错误处理时间

其中:

  • p 是页面未命中(即发生页面错误)的概率。
  • (1 - p) 是页面命中的概率。

让我们代入具体数值来理解其影响。假设内存(DRAM)访问时间为 200纳秒,从磁盘加载一个页面的时间(页面错误处理时间)为 8毫秒。如果每1000次访问发生一次页面错误(即 p = 0.001),那么有效访问时间为:
EAT = (1 - 0.001) × 200 ns + 0.001 × 8,000,000 ns ≈ 200 ns + 8000 ns = 8200 ns

尽管DRAM本身访问很快(200纳秒),但由于极低概率的磁盘访问(8毫秒),平均访问时间激增了40倍。这个计算清晰地表明:必须极力避免页面错误,页面替换算法的效率至关重要。

页面替换算法回顾与优化 🔄

既然页面错误的代价高昂,我们需要精心选择被换出到磁盘的页面。我们之前讨论过时钟算法(Clock Algorithm)作为LRU(最近最少使用)的近似实现。

时钟算法(Clock Algorithm)

其核心思想是将所有物理页面组织成一个环形链表(“钟面”),并有一个“时钟指针”。当需要替换页面时,算法检查指针指向的页面:

  • 如果该页面的“使用位”(由硬件在访问时设置)为1,则将其置0,指针移向下一个页面。
  • 如果使用位为0,则选择该页面进行替换。
    这种方法避免了精确LRU的昂贵开销,通过寻找一个“旧的”(近期未被访问的)页面来达到近似效果。

二次机会算法(Second-Chance Algorithm)

这是一种在缺乏硬件“使用位”支持时的变通方案。操作系统维护两个列表:

  • 绿色列表:已映射到页表、可快速访问的页面。采用FIFO管理。
  • 黄色列表:仍在内存中,但已在页表中取消映射的页面。采用LRU管理。

其工作原理如下:

  1. 访问绿色列表中的页面:快速命中。
  2. 访问黄色列表中的页面:触发软页面错误(Soft Page Fault)。操作系统只需将该页面移回绿色列表,并将绿色列表中的一个页面移到黄色列表,无需磁盘I/O。
  3. 访问不在任何列表中的页面:触发硬页面错误(Hard Page Fault),需要从磁盘加载。此时,将黄色列表中最旧的页面(LRU牺牲者)换出。

这种方法通过“第二次机会”机制,同样实现了近似LRU的行为,将频繁使用的页面保留在快速访问的绿色区域。

优化:空闲页面列表

为了进一步优化,可以将时钟算法与一个空闲页面列表结合。时钟算法定期运行,将一些“旧”页面放入空闲列表(按FIFO管理)。当发生页面错误时,直接从空闲列表头部取用一个页面,从而将耗时的页面选择过程从关键路径中移除。如果空闲页面是“脏页”(被修改过),系统可以后台将其写回磁盘。

多进程内存分配与抖动 🌀

之前我们假设单个进程的内存分配。现实中,多个进程共享物理内存。分配策略主要有两种:

  • 全局替换:所有进程的页面在一个全局池中竞争。简单,但一个进程可能“偷走”另一个进程的页面,影响公平性和性能。
  • 本地替换:为每个进程分配固定数量的页面帧,每个进程在自己的帧集合内进行替换。这保证了公平性,尤其有利于实时任务,但可能造成内存利用率不均衡。

工作集与抖动

工作集(Working Set)是一个进程在最近一段时间Δ内访问过的页面集合。它是该进程正常运行所需的最小物理页面数。

当系统中所有进程的工作集大小之和超过可用物理内存总量时,就会发生抖动(Thrashing)。此时,操作系统忙于在磁盘和内存之间频繁地换入/换出页面,而进程几乎无法获得CPU时间执行实际计算,系统吞吐量急剧下降。

检测和缓解抖动的方法包括:

  • 监控页面错误率:如果所有进程的页面错误率都居高不下,可能发生了抖动。
  • 挂起进程:将某些进程完全换出到磁盘,腾出内存让其他进程顺利运行,之后再换回。
  • 预取:利用空间局部性,在发生一次页面错误时,将其附近的一组页面(集群)一起读入内存。
  • 工作集模型:操作系统尝试跟踪或估算进程的工作集,并在进程被重新调度时预取其工作集页面。

输入/输出(I/O)子系统概述 🔌

没有I/O的处理器是无用的。I/O子系统负责连接CPU与成千上万种速度、特性各异的设备(如磁盘、网络、键盘、显示器),其核心挑战在于如何通过虚拟化提供统一、简单、可靠的抽象接口,隐藏硬件的复杂性和差异性。

设备与总线

设备通常通过I/O控制器(或适配器)与系统连接。控制器是设备上的一个芯片,它有一组寄存器。CPU通过读写这些寄存器来向设备发送命令或查询状态。

总线(Bus)是连接多个设备的共享通信链路,包含数据线、地址线和控制线。总线的优点是灵活,缺点是带宽共享、一次只能进行一次传输,且添加设备会因电容增加而降低速度。现代系统(如PCI Express)采用高速串行点对点链路替代传统并行总线,以提升性能。

系统通常采用层次化的总线结构来管理速度差异巨大的设备:

  • 快速、短距离的总线(如处理器-内存总线)靠近CPU。
  • 通过桥接器连接到速度较慢、距离较长的总线(如PCI总线)。
  • 最终连接到各种外围设备总线(如USB、SATA)。

CPU与设备的通信方式

CPU主要通过两种方式与I/O控制器通信:

  1. 端口映射I/O:CPU使用特殊的指令(如INOUT)访问特定的I/O端口地址。
  2. 内存映射I/O:将设备控制器的寄存器映射到物理内存地址空间中。CPU通过普通的加载/存储指令访问这些“内存”地址,实际上是在与设备通信。这种方式更为常见和灵活。

总结 📚

本节课我们一起学习了:

  1. 按需分页的性能模型:通过有效访问时间公式,我们量化了页面错误的巨大代价,理解了优化页面替换算法的必要性。
  2. 页面替换算法的优化:回顾了时钟算法及其变种(如二次机会算法),并介绍了使用空闲页面列表将替换逻辑移出关键路径的优化技巧。
  3. 多进程内存管理:探讨了全局与本地替换策略,引入了工作集的概念来解释抖动现象及其应对策略。
  4. I/O子系统入门:概述了I/O子系统在操作系统中的核心作用,介绍了设备控制器、总线的基本概念,以及CPU与设备通信的两种主要方式(内存映射I/O和端口映射I/O)。

下节课,我们将更深入地探讨具体的存储设备(如磁盘)的工作原理,以及操作系统如何管理这些设备。

课程 P18:通用 I/O(续)、存储设备与性能 🖥️💾

在本节课中,我们将继续学习通用 I/O 的相关知识,并深入了解存储设备的工作原理及其性能特点。我们将探讨处理器如何与设备通信、内存映射 I/O 与端口映射 I/O 的区别、直接内存访问(DMA)机制,以及磁盘和固态硬盘(SSD)的内部结构与性能考量。


处理器与设备的通信 🔌

上一节我们介绍了处理器通过总线与设备进行通信的基本概念。本节中,我们来看看现代处理器如何具体实现这一过程。

CPU 通过一个高速、短而宽的内存总线与系统其他部分连接。为了集成 I/O 设备,我们使用适配器(通过内存总线与控制器通信)和控制器(与设备本身接口)。CPU 执行操作,通过总线适配器将指令传输到控制器,控制器随后返回响应。由于控制器位于内存总线上,我们可以像访问内存一样对其进行读写操作,从而控制设备。此外,设备能够通过中断机制通知处理器。

典型的控制器内部包含一组控制寄存器和状态寄存器,以及一些可寻址的内存块(如显示器的像素缓冲区或网络设备的数据包缓冲区)。通过读写这些寄存器,可以控制和查询设备状态。

与设备通信主要有两种方式:端口映射 I/O内存映射 I/O


端口映射 I/O 与内存映射 I/O 🗺️

端口映射 I/O 使用特殊的 I/O 指令(如 Intel x86 架构中的 inout 指令)直接访问设备的控制寄存器。每个寄存器对应一个端口号。

例如,从端口 0x20 执行输入操作可能会读取一个特定寄存器,而向端口 0x21 执行输出操作可能会写入另一个寄存器。

代码示例(x86 汇编风格)

// 从端口 0x20 读取一个字节
unsigned char value = inb(0x20);
// 向端口 0x21 写入一个字节
outb(0x21, data);

内存映射 I/O 则更为通用。它将设备的控制寄存器和内存区域映射到处理器的物理地址空间中。通过对这些特定物理地址进行加载(读)和存储(写)操作,即可控制设备。内核通过页表将虚拟地址映射到这些物理地址,并可以设置页面表项(PTE)为“不缓存”,以确保读写操作直接作用于硬件。

关键区别

  • 端口映射 I/O:专用指令,地址空间独立。
  • 内存映射 I/O:使用标准加载/存储指令,地址是物理内存空间的一部分。

现代系统普遍采用内存映射 I/O,因为它提供了统一的基于地址的访问模型。


现代处理器架构与 I/O 集成 🏗️

了解了基本通信方式后,我们来看看这些组件在现代处理器芯片上是如何集成的。

以 Intel Skylake 处理器为例,其芯片上集成了多个 CPU 核心、大型共享缓存、内存控制器以及 I/O 接口(如直接媒体接口 DMI)。所有组件通过一个环形网络互连。当核心进行物理地址的读写时,请求会通过此网络被路由:如果是 DRAM 地址,则路由到内存控制器;如果是映射给 I/O 设备的高位地址,则路由到相应的 I/O 接口。

处理器通常通过 DMI 等高速总线连接到平台控制器集线器(PCH),该集线器再提供 USB、以太网、SATA、PCI Express 等接口,用于连接各类速度相对较慢的 I/O 设备。


程序 I/O 与直接内存访问(DMA) ⚙️🚀

处理器与设备控制器交互后,数据如何在设备与主内存之间传输?主要有两种机制。

程序 I/O(或轮询式 I/O)是指 CPU 通过一个循环,逐个字节或字地从设备控制器读取数据并存储到内存,或反之。这种方法实现简单,但会完全占用 CPU,效率低下。

直接内存访问(DMA) 是一种更高效的机制。CPU 只需初始化传输(告知 DMA 控制器源地址、目标地址和数据量),随后 DMA 控制器会接管,直接在设备内存和主内存之间搬运数据,而无需 CPU 干预。传输完成后,DMA 控制器会触发一个中断通知 CPU。

DMA 的优势:释放 CPU 去执行其他任务,大大提高了系统整体效率,尤其适用于大块数据传输。


设备完成通知:中断与轮询 🔔

当 I/O 操作(如 DMA 传输)完成时,系统如何知晓?主要有两种通知机制。

中断:设备操作完成后,硬件会触发一个中断信号。CPU 暂停当前工作,保存上下文,跳转到中断服务程序(ISR)进行处理(例如,唤醒正在等待该 I/O 的线程),然后恢复之前的工作。中断响应及时,但处理开销较大。

轮询:CPU 定期主动检查设备状态寄存器,查看操作是否完成。轮询开销较低,但如果检查过于频繁而设备未就绪,则会浪费 CPU 周期;如果检查间隔太长,则会增加响应延迟。

在实际系统中,常结合使用两者。例如,高性能网络处理中,第一个数据包到达时触发中断,ISR 在服务过程中改为轮询模式以快速处理后续成批到达的数据包,清空队列后再退出。


设备驱动程序结构 🧩

操作系统通过设备驱动程序与硬件设备交互。驱动程序通常分为两部分:

  1. 上半部分:与操作系统高层(如文件系统、网络协议栈)接口,接收请求,可能将调用线程阻塞并放入等待队列,然后启动设备操作。
  2. 下半部分:作为中断服务程序运行。当设备操作完成并触发中断时,下半部分被调用,它处理完成状态,唤醒等待中的线程,并可能进行一些数据搬运工作。

这种结构使得操作系统核心部分(如文件系统)能够通过统一的接口与各种不同的硬件设备交互,实现了“一切皆文件”的 Unix 设计哲学。


存储设备:磁盘 💿

现在,让我们将目光转向重要的存储设备。首先介绍传统的旋转磁盘。

磁盘数据存储在以恒定角速度旋转的磁性盘片上。数据被组织在同心圆的磁道上,多个盘片同一半径的磁道组成柱面。磁道又被划分为固定大小的扇区(通常为 512 字节或 4KB),这是读写的最小单位。

访问磁盘上一个扇区涉及三个主要时间:

  1. 寻道时间:移动磁头到目标磁道所需的时间。
  2. 旋转延迟:盘片旋转,使目标扇区到达磁头下方所需的时间。
  3. 传输时间:实际读取或写入数据所需的时间。

磁盘访问延迟公式
总延迟 = 排队延迟 + 控制器时间 + 寻道时间 + 旋转延迟 + 传输时间

性能示例:对于一个典型磁盘(平均寻道 5ms,转速 7200 RPM,传输速率 50 MB/s),随机读取一个 4KB 块的总延迟约为 9ms,吞吐量约 451 KB/s。而顺序读取(无需寻道)的吞吐量则可接近传输速率,快得多。

因此,减少寻道操作是优化磁盘性能的关键,文件系统设计会致力于提高数据的局部性


存储设备:固态硬盘(SSD) ⚡

固态硬盘(SSD)使用闪存芯片而非旋转盘片来存储数据,没有机械运动部件。

SSD 的基本操作单位:

  • :读写的最小单位,通常为 4KB。
  • :擦除的最小单位,通常由许多页(如 256KB)组成。

闪存的一个关键特性是:页可以编程(写入)多次,但只能擦除有限次数,且擦除必须以块为单位。因此,直接覆盖已写入的页是不可能的。

为了解决这个问题,SSD 内部有一个 闪存转换层(FTL)。FTL 维护一个映射表,将操作系统看到的逻辑块地址(LBA)映射到闪存芯片上的物理页地址。当需要“覆盖”写入一个逻辑页时,FTL 会将其写入一个新的空闲物理页,并更新映射表,旧页则被标记为无效。后台的垃圾回收进程会收集无效页,擦除整个块,并将有效页搬移到新块,从而回收空间。FTL 还负责磨损均衡,将写操作均匀分布到所有闪存块上,以延长 SSD 寿命。

SSD 的访问延迟公式简化为:总延迟 = 排队延迟 + 控制器时间 + 传输时间。由于没有寻道和旋转延迟,其随机访问性能远优于传统磁盘。


总结 📚

本节课中我们一起学习了:

  1. 处理器与 I/O 设备的通信机制,重点区分了端口映射 I/O 和更通用的内存映射 I/O。
  2. 数据搬运机制,对比了低效的程序 I/O 和高效的 DMA 方式。
  3. 设备完成通知,了解了中断和轮询两种方式及其适用场景。
  4. 设备驱动程序的基本结构及其在操作系统中的作用。
  5. 两种主要存储设备磁盘的机械结构、访问时序和性能特点(强调寻道开销);SSD 的闪存特性、FTL 层的关键作用(映射、垃圾回收、磨损均衡)及其性能优势。

理解这些底层 I/O 和存储原理,对于后续学习文件系统如何高效组织和管理数据至关重要。

📚 课程 P19:文件系统 1 - 性能(续)、排队理论与文件系统概述

在本节课中,我们将继续探讨存储设备的性能模型,并引入排队理论来分析I/O系统的行为。我们还将为后续深入讲解文件系统奠定基础。

🧠 性能模型回顾

上一节我们介绍了硬盘(HDD)和固态硬盘(SSD)的基本性能模型。本节中,我们来看看如何更精确地测量和建模性能。

性能通常可以从两个维度衡量:延迟吞吐量

  • 延迟:完成单个任务所需的时间,单位是秒(s)、毫秒(ms)等。
  • 吞吐量/带宽:单位时间内可以完成的任务数量或传输的数据量,例如每秒操作数(ops/s)或每秒字节数(B/s)。

一个简单的I/O操作延迟模型可以表示为:
延迟 = 开销 + 数据量 / 峰值带宽

其中,开销是启动操作(如向控制器发送请求)所需的固定时间。

以下是一个网络链路的性能分析示例:

  • 假设有一个1 Gbps(千兆比特每秒)的链路,其峰值带宽为 125 MB/s。
  • 假设每次传输的启动开销为 1 ms。
  • 我们可以绘制延迟有效带宽随数据包大小变化的曲线。
    • 延迟曲线是一条直线,其截距由开销决定。
    • 有效带宽曲线在数据包很小时很低,随着数据包增大而逐渐接近峰值带宽。
  • 半功率点是一个关键概念,指达到一半峰值带宽所需的数据包大小。它帮助我们理解需要多大的数据量才能有效利用带宽。

核心要点:高开销意味着需要传输更大的数据块才能获得良好的有效带宽。在磁盘系统中,这意味着应尽量减少寻道等开销操作。

⚙️ 并行与流水线

为了提升系统整体吞吐量,我们常采用流水线并行技术。

  • 流水线:将一个任务拆分为多个可重叠执行的阶段。例如,一个I/O请求可能经历“用户程序 -> 系统调用 -> 文件系统 -> 设备驱动 -> 硬件”等多个阶段。如果这些阶段能像流水线一样工作,系统的整体处理速率就能提升。
  • 并行:使用多个相同的资源同时处理多个任务。例如,多个用户进程同时提交I/O请求,或者系统使用多个磁盘。

需要注意的问题:引入并行性会带来同步的挑战。当多个请求同时访问共享资源(如文件系统的数据结构)时,必须使用锁等机制来保证正确性。

🎢 排队理论简介

当请求到达速率超过瞬时处理能力时,就会形成队列。排队行为对系统响应时间有巨大影响。

一个请求的响应时间可以分解为:
响应时间 = 队列等待时间 + 服务时间

服务时间相对固定,但队列等待时间会随着系统利用率升高而急剧增长

利特尔法则

利特尔法则是一个普遍适用的排队系统基本定律:
系统中的平均任务数 (N) = 平均到达率 (λ) × 平均响应时间 (L)

直观理解:如果你以每秒2人的速率进入商店(λ),平均每人停留5分钟(L),那么店里平均有10人(N)。

M/G/1 队列模型

我们常用M/G/1模型来近似分析I/O队列:

  • M:请求到达时间间隔服从无记忆的指数分布(马尔可夫过程)。这模拟了请求的“突发性”。
  • G:服务时间服从一个一般分布,其方差用平方系数 C 描述。
    • C = 0 表示服务时间是确定的。
    • C = 1 表示服务时间是无记忆的(如指数分布)。
    • 对于磁盘,C 通常约为 1.5,因为好的文件系统利用局部性,使得多数寻道时间短于平均值。
  • 1:表示只有一个服务器。

在该模型下,队列中的平均等待时间 T_q 为:
T_q = T_s × [u / (1 - u)] × [(1 + C) / 2]
其中:

  • T_s 是平均服务时间。
  • u 是系统利用率(u = λ × T_s,且必须小于1)。
  • C 是服务时间分布的平方系数。

关键结论:当利用率 u 接近 1 时,分母 (1 - u) 趋近于 0,导致队列等待时间 T_q 趋向于无穷大。这就是为什么系统在高负载下响应会变得极慢。

实例分析

假设一个磁盘系统:

  • 请求到达率 λ = 10 次 I/O/秒。
  • 平均服务时间 T_s = 20 ms = 0.02 秒。
  • 假设为 M/M/1 队列(C=1)。

计算过程:

  1. 利用率 u = λ × T_s = 10 × 0.02 = 0.2
  2. 队列平均等待时间 T_q = T_s × [u / (1 - u)] = 0.02 × [0.2 / 0.8] = 0.005 秒 = 5 ms。
  3. 平均队列长度 N_q = λ × T_q = 10 × 0.005 = 0.05 个请求。
  4. 平均总响应时间 L = T_q + T_s = 5 ms + 20 ms = 25 ms

可以看到,即使利用率只有20%,排队也增加了5ms的延迟。如果利用率升至0.8或0.9,排队延迟将成为主导因素。

🚀 性能优化思路

了解了排队理论后,我们可以从以下几个方向优化I/O性能:

以下是几种优化思路:

  1. 降低服务时间:使用更快的硬件(如SSD)、优化驱动程序或控制器。
  2. 提高并行度:使用多个磁盘(RAID)或通过网络分布请求,将负载分散到多个队列。
  3. 利用队列吸收突发:队列本身是有益的,它能平滑突发流量,避免请求被立即拒绝。
  4. 在等待时做其他工作:当进程因I/O阻塞时,操作系统可以调度其他进程运行,隐藏I/O延迟。
  5. 实施准入控制:对于队列长度有限的系统,在过载时拒绝新请求,防止系统崩溃。
  6. 智能调度请求:对于磁盘,可以对队列中的请求进行重新排序(如电梯算法),以减少平均寻道时间。

重要原则:作为工程师,应避免让任何系统长期运行在接近100%利用率的状态。通常,将利用率保持在50%-80%是更稳健的选择,能在吞吐量和延迟之间取得较好平衡。

📝 总结

本节课中我们一起学习了:

  1. 性能度量:明确了延迟、吞吐量和开销的概念,并分析了它们之间的关系。
  2. 并行技术:回顾了流水线和并行处理如何提升系统吞吐量,并指出其带来的同步挑战。
  3. 排队理论核心
    • 引入利特尔法则 N = λ × L
    • 学习了M/G/1队列模型,理解了利用率 u 对队列延迟 T_q 的巨大影响(公式 T_q ∝ u/(1-u))。
    • 认识到高利用率下,排队延迟是系统响应时间的主要瓶颈。
  4. 优化方向:总结了通过降低服务时间、提高并行度、利用队列和智能调度等方法来优化I/O性能。

最终,一个硬盘I/O请求的总延迟可归纳为五个部分:排队延迟控制器延迟寻道时间旋转延迟传输时间。而对于SSD,则主要是控制器延迟传输时间。理解这些组成部分及其相互作用,是设计和优化高效文件系统的基础。

操作系统基础:第二讲 - 四个核心概念 🧠

在本节课中,我们将学习操作系统的四个基本概念:线程、地址空间、进程和双模式操作。这些概念是理解操作系统如何管理资源和提供抽象服务的基础。


概述 📋

上一节我们介绍了操作系统的三种常见角色:裁判、魔术师和粘合剂。本节中,我们将深入探讨操作系统为实现这些角色而构建的四个核心抽象概念。这些概念共同构成了现代操作系统的基础框架。


1. 线程 🧵

线程是第一个核心概念。它是一个虚拟的执行上下文,完整描述了正在执行的程序状态。线程包含程序计数器、寄存器、执行标志和栈。然而,线程是一个虚拟实体,它不一定始终在物理CPU上运行,也不一定运行在同一个CPU上。

线程是独立的实体,我们可以从线程的角度,而非物理CPU的角度来思考程序的执行。当线程在处理器上运行时,我们称其为“常驻”线程,其状态(如程序计数器、寄存器)被加载到物理硬件中。当线程被挂起时,其状态被保存到内存中的一个特定区域,称为线程控制块

核心概念:线程是一个虚拟CPU,其状态可以用一个数据结构表示,例如在C语言中可能类似于:

struct thread_control_block {
    void *program_counter;
    void *stack_pointer;
    int registers[32];
    // ... 其他状态
};

操作系统通过多路复用技术,在单个物理CPU上快速切换多个线程,从而制造出所有线程都在同时运行的假象。这种切换可以由计时器中断触发,也可以由线程自愿放弃CPU(例如进行I/O操作时)触发。


2. 地址空间 🗺️

地址空间是第二个核心概念。它是程序在读写时所看到的内存地址集合。对于一个32位处理器,地址空间大约有40亿个地址。关键在于,这个地址空间是虚拟的,可能与实际的物理内存布局完全不同。

当一个线程尝试访问某个地址时,可能发生多种情况:

  • 正常读写内存。
  • 触发I/O操作(如内存映射I/O)。
  • 引发异常或故障(如段错误)。
  • 与其他程序通信(如共享内存)。

一个典型的进程地址空间布局如下:低地址部分存放指令和静态数据,高地址部分是操作系统空间(受保护),中间是堆(向上增长)和栈(向下增长)。地址空间中间可能存在空洞。

为了实现保护和隔离,硬件需要提供地址转换机制。一个简单的早期方法是基址-界限寄存器。CPU输出的每个地址都会与基址寄存器相加,并检查是否超出界限。这可以防止程序访问其分配区域之外的内存(包括操作系统和其他程序的内存)。

更现代和通用的方法是分页。内存被划分为固定大小的页。硬件通过一个称为页表的数据结构,将虚拟地址(由页号和页内偏移组成)转换为物理地址。页表由操作系统管理,其基地址存储在一个特殊的硬件寄存器中。切换进程时,只需更改这个页表基地址寄存器,即可切换到完全不同的地址空间视图。

核心概念:地址转换公式可以简化为:
物理地址 = 页表[虚拟页号] + 页内偏移
其中,页表是一个由操作系统维护的映射表。


3. 进程 ⚙️

进程是第三个核心概念,它结合了前两者。一个进程是一个受保护的地址空间与一个或多个线程的组合。它是程序的一个执行实例,运行在自己的隔离环境中。

进程是资源分配和保护的基本单位。一个进程中的多个线程共享该进程的地址空间(代码、数据、堆、文件等),因此它们之间没有内存保护,可以高效协作。但每个线程拥有自己独立的寄存器和栈。

不同的进程则通过各自独立的地址空间实现严格隔离。一个进程无法直接访问另一个进程的内存,从而提供了可靠性、安全性和公平性。

核心概念:进程 = 受保护的地址空间 + 一个或多个线程。它是一个封装了执行环境和资源的“盒子”。


4. 双模式操作 🛡️

双模式操作是第四个核心概念,也是实现上述保护机制的硬件基础。硬件必须提供至少两种运行模式:内核模式(或系统模式、监管模式)和用户模式

  • 用户模式:普通应用程序在此模式下运行。在此模式下,某些特权操作(如直接修改页表寄存器、执行某些指令)是被禁止的。
  • 内核模式:操作系统内核在此模式下运行。它可以执行所有指令,访问所有硬件资源。

从用户模式到内核模式的转换必须受到严格、明确的控制,不能随意进行。这种转换主要通过三种途径发生:

  1. 系统调用:用户程序主动调用操作系统服务(如读写文件)。
  2. 中断:由外部设备(如定时器、网卡)触发。
  3. 异常:由程序运行时的错误(如除零、页错误)触发。

当发生上述事件时,硬件会自动切换到内核模式,并将控制权交给操作系统中预先定义好的处理代码。处理完毕后,操作系统再通过特殊的指令(如“从中断返回”)安全地切换回用户模式,并恢复用户程序的执行。

核心概念:处理器中有一个模式位。当该位为0时,表示内核模式;为1时,表示用户模式。硬件根据此位来决定是否允许执行特权操作。


总结 🎯

本节课我们一起学习了操作系统的四个基本概念:

  1. 线程:虚拟化的CPU执行上下文,是并发的基本单位。
  2. 地址空间:程序可见的虚拟内存地址集合,通过基址-界限或页表机制与物理内存隔离。
  3. 进程:受保护的地址空间与线程的组合,是资源分配和保护的基本单位。
  4. 双模式操作:硬件提供的用户模式和内核模式,是实现隔离与保护的基石。

这些概念层层递进:硬件提供双模式支持;在此之上,操作系统为每个进程创建受保护的地址空间;在每个地址空间内,可以运行一个或多个线程来执行任务。理解这些抽象是深入探索文件系统、虚拟内存、调度等高级主题的关键。

操作系统课程 P20:第20讲 - 文件系统设计(续)与案例研究 📂

在本节课中,我们将继续深入探讨文件系统的设计,并研究几个具体的文件系统案例。我们将学习如何将底层的块设备抽象为用户友好的文件和目录,理解不同文件系统的设计权衡,并分析它们如何优化性能。


概述

上一节我们介绍了文件系统的基本概念和磁盘性能模型。本节中,我们将首先回顾排队理论在I/O中的应用,然后探讨如何通过调度和接口设计来隐藏I/O延迟。接着,我们将深入文件系统的核心设计,包括其组成部分、数据结构以及两种经典的文件系统实现:FAT和Unix快速文件系统(FFS)。


回顾:排队理论与磁盘性能

排队理论帮助我们理解当多个I/O请求到达一个资源(如磁盘)时,系统行为会如何变化。我们使用一个简单的模型,其中到达率(λ)和服务时间(T_s)是关键参数。

公式:平均队列时间 T_q = T_s * (1 + C) / (2 * (1 - ρ)),其中 ρ = λ / μ 是利用率,C 是服务时间分布的变异系数。

当利用率接近100%时,队列延迟会急剧增加。这对于理解磁盘在重负载下的行为至关重要。


优化磁盘访问:调度与接口

为了最大化磁盘性能,我们需要减少耗时的寻道操作。大规模的顺序读取是优化的主要目标。

磁盘调度算法

以下是几种常见的磁盘调度算法,它们通过重新排序请求来减少磁头移动。

  • FIFO(先进先出):按请求到达顺序服务。公平但效率低下,因为磁头可能频繁来回移动。
  • SSTF(最短寻道时间优先):总是服务离当前磁头位置最近的请求。提高了效率,但可能导致边缘请求“饥饿”。
  • SCAN(电梯算法):磁头沿一个方向移动,服务途中的所有请求,到达一端后反向移动。减少了饥饿现象。
  • C-SCAN(循环扫描):磁头只朝一个方向移动服务请求,到达末端后立即返回起点重新开始。提供了更均匀的等待时间。

现代磁盘控制器通常内置了智能调度算法,操作系统只需提交请求即可。

隐藏I/O延迟的接口

为了在I/O操作进行时允许CPU执行其他任务,操作系统提供了不同的编程接口。

  • 阻塞接口:发起I/O调用的进程会睡眠,直到操作完成。简单但无法重叠计算与I/O。
  • 非阻塞接口:I/O调用立即返回。如果数据未就绪,则返回一个错误码。进程可以通过轮询来检查状态。
  • 异步接口:进程提交一个I/O操作描述后立即返回。操作系统在后台执行I/O,并在完成后通过回调或信号通知进程。这是重叠计算与I/O最高效的方式。

文件系统设计核心

文件系统是操作系统中的一层,它将块设备(如磁盘)的块接口,转换为用户和应用程序所看到的文件、目录接口。

用户视图 vs. 系统视图

  • 用户视图:文件是可变大小的字节序列(字节流),支持随机访问。
  • 系统视图:磁盘以固定大小的(如4KB)为单位进行读写。文件系统负责在字节流和磁盘块之间进行转换。

关键操作:当用户读取12个字节时,文件系统需要:

  1. 确定这12个字节位于哪个(或哪些)4KB块中。
  2. 将整个块读入内存中的缓冲区缓存
  3. 从缓存中提取所需的12字节返回给用户。
    写入操作同样可能需要“读取-修改-写入”整个块。

文件系统的组成部分

一个完整的文件系统需要管理以下信息:

  1. 目录结构:将人类可读的路径名映射到文件标识符(如i节点号)。
  2. 索引结构:记录每个文件的数据块在磁盘上的位置(如FAT表、i节点)。
  3. 存储块:实际存放文件数据的磁盘块。
  4. 空闲空间管理:跟踪磁盘上哪些块是空闲的,可用于分配。

目录与文件标识

目录是一种特殊的文件,其内容是一张表,记录了(文件名,文件标识符)的映射关系。
在类Unix系统中,文件标识符通常是i节点号。i节点是文件的“元数据”结构,存储了文件属性(大小、权限等)和指向其数据块的指针。

路径解析示例:打开文件 /home/user/file.txt 需要:

  1. 读取根目录/,找到home的i节点号。
  2. 读取home目录,找到user的i节点号。
  3. 读取user目录,找到file.txt的i节点号。
  4. 访问file.txt的i节点,进而访问其数据块。

文件系统案例研究

案例一:FAT(文件分配表)文件系统

FAT是一个古老但至今仍广泛用于U盘、SD卡的文件系统,因其设计简单,易于在嵌入式设备中实现。

核心数据结构

  • 文件分配表(FAT):一个贯穿整个磁盘的大数组,每个条目对应一个磁盘块。条目内容是指向下一个块的指针,或标记为空闲、坏块等。
  • 目录项:存储文件名和文件的起始块号

工作原理

  1. 目录项中存储的起始块号,是文件第一个数据块在磁盘上的位置,也是进入FAT表的索引。
  2. 要查找文件的第N块,需要从起始块开始,在FAT表中依次遍历链表指针,直到找到第N块。
  3. 文件的所有块通过FAT表中的链表连接在一起。

代码/公式描述文件块查找

当前块号 = 目录项.起始块号
for i in 范围(1, 目标块索引):
    当前块号 = FAT[当前块号]
返回 当前块号

优点与缺点

  • 优点:结构极其简单,实现容易。
  • 缺点
    • 随机访问性能差(需要遍历链表)。
    • 文件易产生碎片,破坏局部性。
    • 文件元数据(如权限)存储在目录中,而非与文件绑定,安全性较差。

案例二:Unix FFS(快速文件系统)与 i节点结构

FFS(及其后继者如ext2/ext3)是类Unix系统的经典设计,其核心是i节点结构。

i节点结构
i节点包含了文件的元数据(权限、大小、时间戳等)和一组指向数据块的指针。这些指针经过精心设计,以同时优化小文件和大文件的访问。

指针布局

  • 直接指针:i节点中有多个(如12个)指针直接指向数据块。这使得小文件可以快速访问,无需额外磁盘读取。
  • 间接指针
    • 一级间接指针:指向一个块,该块本身不存数据,而是存满了指向数据块的指针。
    • 二级间接指针:指向一个块,该块存满了一级间接指针。
    • 三级间接指针:以此类推。

这种多级索引结构使得文件系统可以支持从几个字节到数TB大小的文件。

FFS的性能优化

  1. 柱面组:将磁盘划分为多个柱面组,每个组都有自己的i节点区和数据区。目标是让一个目录下的文件和它们的i节点、数据块尽量位于同一个柱面组内,减少寻道时间。
  2. i节点分布:将i节点分散到磁盘各处,而不是集中存放,提高了可靠性和局部性。
  3. 预留空间:默认保留一部分磁盘空间(如10%),这为文件系统分配连续空间提供了灵活性,有助于减少碎片。

总结

本节课我们一起深入学习了文件系统的设计。我们回顾了排队理论对I/O性能的分析,探讨了通过磁盘调度和异步接口来优化性能的策略。我们剖析了文件系统的核心职责,即在字节流和磁盘块之间进行转换,并管理目录、文件索引和空闲空间。

通过研究FATUnix FFS两个典型案例,我们看到了不同的设计哲学:FAT追求极致的简单,而FFS通过精巧的i节点结构和布局优化来追求高性能与可靠性。理解这些基础设计,是后续学习现代文件系统高级特性(如日志、快照、压缩)的基石。

📁 课程 P21:文件系统案例研究(续)、缓冲与可靠性

在本节课中,我们将继续深入探讨文件系统的设计。我们将回顾几种经典文件系统的结构,理解它们如何管理磁盘块、优化性能,并初步接触文件系统可靠性的核心挑战。我们还将学习内存映射文件和缓冲区缓存的工作原理。


回顾:文件系统的基本组成

上一节我们介绍了文件系统的主要组成部分。文件系统提供了一种将名称(文件名)映射到实际数据的方法。目录结构提供了名称到 i 节点(inode)的索引,而 i 节点则代表了文件本身,并指向存储文件内容的数据块。

存储介质被划分为一系列固定大小的数据块(例如 512 字节或 4KB)。因此,我们需要一种方法来标记哪些块属于哪个文件,以及它们的顺序。i 节点正是为了解决这个问题而设计的,而目录结构则提供了方便的名称查找功能。


🗂️ 案例研究:从 FAT 到快速文件系统

FAT 文件系统

我们首先讨论了来自 MS-DOS 的古老文件系统 FAT。它的结构非常简单,这也是它至今仍被使用的原因。FAT 的核心是一个与每个磁盘块一一对应的大型数组,称为文件分配表(File Allocation Table)。这个数组的唯一作用就是按顺序将属于同一个文件的块链接在一起。

在这种简化的文件系统中,甚至没有独立的 i 节点概念。寻址文件的方式是:从根目录(通常位于块号 2)开始,找到文件的起始块号,然后顺着 FAT 中的链表找到其余块。

然而,这种方式存在明显问题:它总是需要进行线性搜索,因此在顺序访问和随机访问上效率都很低。虽然简单到可以放入固件,但从性能角度看并不理想。

4.1 BSD 文件系统

接下来,我们介绍了来自 4.1 BSD 的原始 Unix 文件系统。它引入了包含直接块指针和间接块指针的 i 节点结构。

i 节点结构示例(概念性):

struct inode {
    // 元数据(权限、所有者、时间戳等)
    metadata_t meta;
    // 直接块指针(例如 10 个)
    block_ptr_t direct_blocks[10];
    // 一级间接块指针
    block_ptr_t indirect;
    // 二级间接块指针
    block_ptr_t double_indirect;
    // 三级间接块指针
    block_ptr_t triple_indirect;
};

这种结构优化了不同大小文件的存储:小文件可以通过直接指针快速访问,而大文件则通过多级间接指针高效表示。一个 i 节点可以完全不填写某些指针,因此最短的文件可能只占用一个数据块。

这种布局关注的是正确性——它定义了哪些块属于文件以及它们的顺序,但并未涉及性能优化。块可能被随机分配,导致访问效率低下。在早期的 BSD 系统中,随着文件的不断创建和删除,磁盘碎片化会越来越严重,性能逐渐下降。

4.2 BSD 快速文件系统(FFS)

为了解决性能问题,4.2 BSD 引入了快速文件系统(Fast File System, FFS)。它保留了相同的 i 节点结构,但彻底改进了磁盘块的分配策略。

FFS 的核心优化思想是 局部性(Locality)。以下是它采取的主要措施:

  1. 将磁盘划分为块组(Block Groups/Cylinder Groups):每个块组包含一组连续的磁道,并拥有自己的 i 节点、数据块和位图。这样,一个目录及其下的文件很可能被分配在同一个块组内,减少了磁头寻道距离。
  2. 使用位图管理空闲空间:取代了低效的链表,可以快速找到连续的空闲块。
  3. 预留空间:默认保留约 10% 的磁盘空间。这提高了在磁盘上找到连续空闲块序列的概率,有助于减少碎片。
  4. 优化块大小:将块大小从 1KB 增加到 4KB,减少了管理开销,并更好地利用了磁盘传输带宽。
  5. 旋转优化(Skip Sector):考虑到磁盘旋转延迟,FFS 会有意地将连续文件的块分散放置,确保在读取完一个块并处理时,磁盘恰好旋转到下一个块的位置。不过,现代磁盘控制器通常内置了轨道缓冲区,已经透明地解决了这个问题。

通过这些启发式方法,FFS 在保持简单性的同时,大幅提升了文件访问的性能,特别是顺序访问的性能。它不需要像 FAT 那样进行定期的磁盘碎片整理。


🔗 目录、硬链接与符号链接

文件系统的命名层由目录实现。目录本质上是一种特殊的文件,其内容是一系列“文件名 -> i 节点编号”的映射对。

硬链接(Hard Link) 是目录中一个直接指向文件 i 节点编号的条目。一个 i 节点可以有多个硬链接(即多个名称)。只有当指向一个 i 节点的所有硬链接都被删除,且没有进程打开该文件时,该 i 节点及其数据块才会被真正释放。这通过引用计数机制管理。

符号链接(Symbolic Link / Soft Link) 则不同,它是一个包含目标文件路径名的特殊文件。当访问符号链接时,系统会去解析这个路径名。符号链接可以跨文件系统,甚至可以指向不存在的目标(形成“悬空引用”)。


🗺️ 路径名解析与缓存

当我们打开一个像 /home/cs162/stuff.txt 这样的文件时,系统会逐级解析路径:

  1. 从固定的根目录 i 节点(通常编号为 2)开始。
  2. 读取根目录的数据块,找到条目 home 及其 i 节点号。
  3. 读取 home 目录的 i 节点和数据块,找到条目 cs162 及其 i 节点号。
  4. 读取 cs162 目录的 i 节点和数据块,找到条目 stuff.txt 及其 i 节点号。
  5. 最后,访问 stuff.txt 的 i 节点,进而访问文件数据。

这个过程涉及多次磁盘 I/O。为了加速,操作系统使用了多种缓存:

  • 目录项缓存(dentry cache):缓存最近解析过的路径名到 i 节点的映射。
  • i 节点缓存:缓存最近访问过的 i 节点信息。
  • 缓冲区缓存:缓存最近访问过的磁盘数据块(包括目录内容块)。

一旦文件被打开,其 i 节点信息就会缓存在内核的文件描述符结构中,后续的读写操作无需重复进行路径解析和权限检查。


💾 内存映射文件(mmap)

传统的文件 I/O(read/write)涉及数据在用户缓冲区、内核缓冲区、磁盘之间的多次拷贝。内存映射文件提供了一种更高效的替代方式。

系统调用 mmap 可以将一个文件的一部分直接映射到进程的虚拟地址空间。之后,进程可以通过普通的内存加载/存储指令来访问文件数据,就像访问内存数组一样。

mmap 使用示例(简化):

#include <sys/mman.h>
int fd = open("file.txt", O_RDWR);
char *mapped_addr = mmap(NULL, file_size, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
// 现在可以直接通过 mapped_addr 指针读写文件内容
strcpy(mapped_addr + 10, "New Data"); // 修改文件内容
munmap(mapped_addr, file_size); // 解除映射
close(fd);

其工作原理与虚拟内存的需求分页紧密结合:当进程访问映射区域的某个地址时,如果对应的文件页尚未加载,会触发一个页面错误(page fault)。页面错误处理程序会从磁盘读取相应的文件块到物理内存,并更新页表。对映射区域的写入会先标记为“脏页”,最终由系统刷回磁盘。

mmap 的优势在于:

  • 减少数据拷贝:避免了 read/write 系统调用中的内核缓冲区与用户缓冲区之间的拷贝。
  • 简化编程:可以像操作内存一样操作大文件。
  • 支持进程间共享:多个进程可以映射同一个文件,实现共享内存通信。

🚀 缓冲区缓存(Buffer Cache)

磁盘访问速度远慢于内存。为了弥补这个差距,内核在内存中维护了一个 缓冲区缓存(Buffer Cache),用于缓存最近访问过的磁盘块。

缓冲区缓存的作用:

  1. 缓存磁盘块:包括文件数据块、i 节点块、目录块、位图块等。
  2. 统一访问点:所有文件的读写请求都先经过缓冲区缓存。如果数据在缓存中(缓存命中),则直接返回,无需磁盘 I/O。
  3. 延迟写入(Delayed Write):当进程执行 write 系统调用时,数据只是被复制到缓冲区缓存中的“脏”块,系统调用便立即返回。实际的磁盘写入会在之后异步进行。
  4. 预读(Read-ahead):当系统检测到顺序读取模式时,会提前将后续的磁盘块读入缓冲区缓存,以备后续访问,从而减少缓存未命中。

缓冲区缓存的管理挑战:

  • 替换策略:当缓存满时,需要选择哪些块被换出。通常采用类似 LRU(最近最少使用)的算法。
  • 写回时机:脏块需要被写回磁盘以保持持久化。系统会定期(例如每 30 秒)或根据特定规则(缓存满、文件关闭、调用 sync)触发写回。
  • 内存分配权衡:内核需要在缓冲区缓存和虚拟内存的页缓存之间动态分配物理内存,以平衡文件 I/O 性能和应用程序运行需求。

延迟写入虽然提高了性能(允许合并写入、电梯排序),但也带来了风险:如果系统在脏块写回前崩溃,最近的数据修改将会丢失,甚至可能导致文件系统元数据不一致(例如目录条目丢失)。这引出了我们对文件系统可靠性一致性的需求。


📝 本节总结

本节课我们一起深入学习了文件系统的设计与优化:

  1. 我们回顾了从 FATBSD 快速文件系统 的演进,理解了如何通过块组、位图、预留空间等设计来优化文件访问的局部性和性能。
  2. 我们厘清了目录、硬链接与符号链接的区别与实现。
  3. 我们探讨了路径名解析过程,以及操作系统如何利用目录项缓存、i节点缓存来加速这一过程。
  4. 我们学习了内存映射文件(mmap) 这一高效的文件 I/O 机制,它利用虚拟内存系统将文件直接映射到进程地址空间。
  5. 我们分析了缓冲区缓存的核心作用,它作为磁盘的缓存,通过延迟写入预读策略极大地提升了文件系统性能,但也引入了数据丢失和一致性的风险。

下一节课,我们将重点探讨如何解决这个风险,即文件系统的可靠性机制,特别是日志(Journaling) 技术,它能在系统崩溃后快速恢复文件系统的一致性。

课程 P22:事务(续)、端到端论证与分布式决策 🧩

在本节课中,我们将深入探讨系统可靠性的核心概念。我们将从文件系统缓存的作用开始,逐步理解可用性、持久性与可靠性的区别。接着,我们会详细分析如何通过复制(如RAID技术)和事务处理(如日志文件系统)来构建可靠的存储系统。最后,我们会简要介绍端到端论证和分布式决策的基本思想。

可靠性基础:缓存、可用性与持久性 💾

上一节我们介绍了文件系统的基本结构,本节中我们来看看系统可靠性的几个关键维度。

文件系统使用缓冲缓存来提升性能。因为访问内存(约100纳秒)比访问磁盘(数百万纳秒)快得多,所以系统将磁盘块缓存在内存中。我们缓存数据块、i节点、目录块以及空闲映射表等元数据结构。

现在,我们来区分几个重要的可靠性概念:

  • 可用性:指系统能够接受和处理请求的概率,通常用“几个9”来衡量(如99.9%被称为“三个9的可用性”)。其核心思想是故障独立性,即一个组件的故障不应导致整个系统宕机。
  • 持久性:指系统在故障后恢复数据的能力,即将容错应用于数据。但数据“被保存”不等于“可访问”。例如,古老的象形文字数据存在了数千年(持久),但直到罗塞塔石碑被发现才变得可读(可用)。
  • 可靠性:比可用性要求更高。它指系统在指定条件下,于规定时间内正确执行所需功能的能力。一个存在Bug、会损坏数据的服务可能是“可用”的,但绝不是“可靠”的。太空任务和飞行计算机对可靠性的要求极高。

实现持久性:从硬件到架构 🛡️

理解了可靠性的目标后,我们来看看如何在多个层次上实现数据的持久性。

我们通过多层防护来增强像文件系统这样的系统的耐用性:

  1. 物理层:磁盘块包含ECC(错误校正码),用于处理制造缺陷或物理撞击导致的数据错误。
  2. 写入策略:为确保写入持久化,应用程序可采用写穿透策略,即数据必须写入硬盘后才返回。但这会牺牲缓存带来的性能优势。
  3. 非易失存储:替代方案是使用电池备份RAM非易失性RAM来暂存写入操作。这样即使系统崩溃,数据也不会丢失,待系统恢复后再写入磁盘。
  4. 长期存活:硬盘本身会故障(平均故障间隔时间约5万小时)。为确保数据长期存活,需要进行复制,并保证副本间的故障独立性(例如,将副本存放在不同数据中心甚至不同大陆)。

一个警示故事:某服务器使用电池备份内存存储关键元数据(如空闲位图、i节点表)。电池失效后未被察觉,一次短暂断电导致该内存数据丢失。由于备份策略不完整,部分数据永久丢失。这说明实现完全的故障独立性非常困难。

数据复制技术:RAID 🎯

为了实现故障独立性,工业界发展出了成熟的复制技术,RAID便是其中之一。

RAID(冗余阵列独立磁盘)的核心思想是:使用多个普通(相对廉价)的磁盘,通过组织成阵列并复制数据,来获得比单个昂贵的高可靠性磁盘更高的性价比和可靠性。

以下是几种常见的RAID级别:

  • RAID 1(镜像)
    • 原理:每个数据块都完整复制到另一个磁盘(镜像盘)。
    • 优点:读取性能高(可从任一磁盘读),写入需要两次操作。容错能力强,需两个磁盘同时故障才会丢失数据。
    • 缺点:存储成本高(有效容量仅为总容量的一半)。可采用热备盘来缩短故障恢复窗口,但成本更高。
  • RAID 5(带分布式奇偶校验的条带化)
    • 原理:将数据条带化分布在多个磁盘上,并计算一个奇偶校验块(通过对数据块进行XOR运算得到),校验块也分布在不同磁盘上。
    • 优点:兼顾存储利用率、读取性能和容错(允许单个磁盘故障)。故障后可通过其他盘的数据和校验信息重建。
    • 挑战:现代磁盘容量巨大(如10TB),重建时间很长。在此期间若发生第二块磁盘故障,数据将无法恢复。此外,同批次磁盘可能存在关联故障。
  • RAID 6:允许阵列中两个磁盘同时故障,容错能力更强。
  • RAID 0(条带化):仅将多个磁盘合并为一个大的逻辑盘,无任何冗余。任一磁盘故障即导致全部数据丢失,可靠性极低,不推荐用于需要数据保护的场景。

更通用的方法是擦除编码:将数据编码成n个片段,只需其中任意m个(m < n)即可重建原始数据。这能以更低的空间开销实现高耐久性,并易于跨地理区域分布。但写入时需要更新多个片段,复杂度较高。

文件系统可靠性:排序恢复与写时复制 🔄

数据块的持久性得到保障后,我们需要确保文件系统结构本身的可靠性。

文件系统面临的威胁包括:更新过程中发生崩溃导致状态不一致,以及存储介质故障。主要有两种方法来保证文件系统元数据的可靠性:

1. 谨慎排序与恢复(如FFS,FAT)
这种方法强调以特定顺序执行更新操作(例如:先写数据块 -> 分配i节点 -> 更新位图 -> 更新目录),并假设故障可能中断此过程。系统启动时,运行恢复工具(如fsck)扫描磁盘,根据规则修复不一致状态(如将已分配但无目录项的文件放入lost+found目录)。这种方法主要保护元数据,不保护文件内容,且恢复时间与磁盘容量成正比。

2. 写时复制(如ZFS,WAFL)
该方法不覆盖现有数据块,而是通过更新指针结构来创建新版本。例如,修改文件时,只复制并更改受影响的数据块及其上游的索引块,最终更新根指针。这带来了两个好处:

  • 原子性:更新要么全部生效(新指针被提交),要么全部不生效(仍使用旧指针)。
  • 高效快照:旧版本的数据结构可以轻松保留,实现低开销的快照功能。
    这种方法同样侧重于保护文件系统结构的完整性

事务:实现原子更新的通用机制 ⚙️

无论是文件系统还是应用程序,都需要一种通用机制来保证一系列操作的原子性,事务正是为此而生。

事务将内存中临界区的原子性概念扩展到了持久存储。它确保一系列读写操作作为一个不可分割的单元执行(全有或全无),即使发生崩溃,系统也能恢复到一致状态。

一个典型的事务示例是银行转账:

BEGIN TRANSACTION;
UPDATE accounts SET balance = balance - 100 WHERE user = ‘Alice‘;
UPDATE branches SET total = total - 100 WHERE branch_id = Alice‘s_branch;
UPDATE accounts SET balance = balance + 100 WHERE user = ‘Bob‘;
UPDATE branches SET total = total + 100 WHERE branch_id = Bob‘s_branch;
COMMIT;

BEGINCOMMIT之间的所有操作被视为一个原子单元。系统通过锁等机制处理并发事务间的冲突。

日志文件系统:事务在存储中的实践 📝

事务思想在文件系统中的直接应用就是日志文件系统,这也是现代操作系统的标准配置。

日志文件系统(如ext3/4, NTFS, APFS, XFS)的核心思想是:先将更改的意图(元数据操作)作为事务顺序写入一个专门的日志区域,然后再实际修改文件系统结构

其工作流程如下:

  1. 日志写入:将创建文件所需的所有元数据操作(分配块、初始化i节点、更新目录)作为一条记录写入日志。
  2. 提交事务:向日志写入一个提交记录,标志此事务日志已完整持久化。
  3. 检查点/回写:后台将日志中已提交的事务真正应用到文件系统的实际位置。
  4. 垃圾回收:应用完成后,可清理日志中的旧记录。

崩溃恢复

  • 若日志中有BEGIN但无COMMIT,则丢弃该不完整事务。
  • 若日志中有BEGINCOMMIT,则重做该事务。由于日志记录的操作是幂等的(如“将i节点X的指针设为块Y”),重复执行不会导致错误。

优势与权衡

  • 优势:保证了元数据操作的原子性,大幅缩短了崩溃后的恢复时间(只需扫描和重放日志,而非全盘扫描)。
  • 权衡:所有元数据需写入两次(先日志,后实际位置),带来额外开销。因此,通常只对元数据进行日志记录,文件数据内容的原子性仍需由应用程序通过其他方式(如临时文件重命名)保证。

端到端论证与分布式决策 🌐

最后,我们简要展望两个更宏观的可靠性主题。

端到端论证的核心观点是:某些功能(如可靠传输)最好由通信链路两端的应用程序来实现,而非由中间系统(如网络层)单独保证。因为只有应用程序能真正了解“正确完成”的语义。这提醒我们在设计系统时,需仔细考虑将可靠性机制放在哪一层最合适。

分布式决策涉及在多个独立节点间达成一致,是构建可靠分布式系统的基石。经典的共识算法(如Paxos, Raft)用于解决在网络不可靠、节点可能故障的情况下,如何就一个值或序列达成一致的问题,这是实现分布式事务、复制状态机等高级功能的基础。


本节课中我们一起学习了系统可靠性的多层次构建。我们从缓存与可靠性概念出发,探讨了通过RAID和擦除编码实现数据持久性,分析了文件系统通过排序恢复和写时复制来保护元数据。接着,我们深入探讨了事务这一通用原子性机制,及其在日志文件系统中的具体实践。最后,我们概要介绍了端到端论证和分布式决策这两个构建大规模可靠系统的关键思想。理解这些技术及其权衡,是设计健壮软件系统的重要基础。

课程23:分布式系统、网络与TCP协议(上) 🌐

在本节课中,我们将学习分布式系统的基本概念、面临的挑战以及构建分布式应用的基础。我们将探讨分布式决策制定的难题,并介绍网络通信的核心模型与协议分层思想。


分布式系统概述

过去,计算模式经历了从大型主机到客户端-服务器,再到如今对等网络与分布式系统的演变。分布式系统由众多互联的计算机组成,它们协同工作以完成共同的任务。

我们身边充满了微处理器,从手机、笔记本电脑到房间内的无线接入点、照明控制器等。这些设备相互连接,运行着各种分布式应用程序。

分布式系统的发展有重要的经济原因。与购买昂贵的大型主机相比,使用许多廉价的小型计算机进行扩展,成本效益更高,也更具弹性。

然而,分布式系统也带来了新的挑战。系统可靠性可能不升反降,因为任何一台未知远程计算机的故障都可能导致整个服务不可用。安全性也变得更加复杂,系统中最薄弱的环节可能成为攻击的入口。


网络协议与分层模型

为了实现不同设备间的通信,我们需要定义协议。协议规定了通信的语法(消息格式与顺序)和语义(消息的含义与触发的动作)。

现实世界充满了各种网络技术(如光纤、Wi-Fi)和应用程序需求。如果每个应用都需要直接支持每种网络技术,或者每种新技术都需要重写所有应用,这将无法扩展。

互联网通过引入一个关键的中间抽象层解决了这个问题:互联网协议。这形成了所谓的“互联网沙漏模型”。

互联网沙漏模型 🌐

在沙漏模型中,狭窄的腰部是单一的网络层协议。其下层是各种链路层技术,上层是各种传输层协议应用程序

公式表示:

[ 多种应用 (HTTP, FTP, Skype...) ]
            |
[ 传输层 (TCP, UDP...) ]
            |
[ 网络层 (IP) ]  <-- 狭窄的“腰部”
            |
[ 链路层 (以太网, Wi-Fi, 光纤...) ]
            |
[ 物理层 (电缆, 无线电波...) ]

这种分层架构的优势在于:

  • 互操作性:任何支持IP的网络技术都可以相互交换数据。
  • 创新促进:应用开发者只需针对IP编程,网络工程师只需让新技术支持IP,双方可以独立创新。

其缺点在于:

  • 更改困难:对核心层(如从IPv4升级到IPv6)的修改非常缓慢且昂贵。
  • 性能开销:分层可能导致头部信息冗余和功能重复。

端到端原则

关于网络功能应该放在哪一层,有一个重要的设计哲学:端到端原则

该原则的核心论点是:某些功能(如可靠性、安全性)最终只能由通信的端点来正确、完整地实现。因此,在底层网络中间节点中实现这些功能可能是不必要的,甚至是有害的,因为它会增加系统复杂性,并对不需要该功能的应用造成性能负担。

一个例子:可靠文件传输
假设我们要从主机A可靠地传输一个文件到主机B。

  • 方案一(网络提供可靠性):确保每一步(读盘、发送、接收、写盘)都可靠。但即使如此,数据仍可能在端点内存中被宇宙射线等因素破坏,因此最终仍需在端点进行完整性校验
  • 方案二(仅端点保证):网络只提供尽力而为的传输。传输完成后,接收方计算整个文件的校验和,发送回发送方比对。如果不匹配,则重传整个文件。

端到端原则倾向于方案二的思路。它建议,除非在底层实现某功能能带来显著的性能提升,并且不会对不需要它的应用造成负担,否则应避免在底层实现该功能。

这也解释了为什么我们有TCP(在传输层提供可靠性)和UDP(不提供可靠性)两种协议,供应用根据自身需求选择。


构建分布式应用:进程间通信

编写分布式应用需要协调运行在不同机器上的进程。由于没有共享内存,我们依赖消息传递作为同步和通信的基本原语。

一个基础的抽象是邮箱。进程可以向目标邮箱发送消息,也可以从自己的邮箱接收消息。

代码描述发送与接收:

send(mailbox, message_buffer); // 向指定邮箱发送消息
receive(mailbox, message_buffer); // 从指定邮箱接收消息(可能阻塞)

这类似于单机环境中的生产者-消费者模型,但生产者和消费者可以位于世界任何地方。操作系统和网络协议会处理缓冲、流量控制等复杂问题。

基于此,我们可以构建客户端-服务器应用:

  1. 客户端将请求消息发送到服务器的邮箱。
  2. 服务器从邮箱接收请求,处理它。
  3. 服务器将响应消息发送回客户端的邮箱。
  4. 客户端接收响应。

分布式共识与两阶段提交

在分布式系统中,让所有节点就某个值达成一致(共识)是一个核心且困难的问题。一个经典的难题是两将军问题,它说明了在不可靠信道上,仅通过消息传递无法确保双方同时发起某个动作。

然而,如果我们把要求从“同时”放宽到“最终一致”,就可以解决一类重要的分布式共识问题,例如分布式事务提交。这通过两阶段提交协议实现。

两阶段提交协议 ⚙️

该协议涉及一个协调者和多个参与者(工作节点)。目标是让所有节点原子化地决定是提交还是中止一个事务。

协议分为两个阶段:

第一阶段:投票阶段

  1. 协调者向所有参与者发送“准备提交”请求。
  2. 参与者执行事务直到最后一步,然后决定是否能够提交。
    • 如果可以,它将“同意提交”的投票和事务日志持久化到稳定存储,然后发送“投票提交”给协调者。之后进入“就绪”状态,锁定资源,等待最终指令
    • 如果不可以,它直接将“投票中止”发送给协调者,并可在本地中止事务。

第二阶段:决策阶段

  1. 协调者收集所有投票。
    • 如果所有参与者都投票“提交”,协调者将“全局提交”的决定持久化到日志,然后向所有参与者发送“全局提交”命令。
    • 如果有任何参与者投票“中止”,协调者将“全局中止”的决定持久化,然后发送“全局中止”命令。
  2. 参与者收到最终命令后:
    • 若收到“全局提交”,则完成事务提交,释放资源。
    • 若收到“全局中止”,则中止事务,回滚更改,释放资源。

关键点:

  • 持久化日志:用于故障恢复。节点崩溃重启后,通过日志知道自己之前的状态和决定。
  • 协调者的提交点:当协调者将“全局提交”写入稳定存储时,事务的结果就已确定,必须最终提交。
  • 阻塞问题:参与者在“就绪”状态会一直阻塞,直到收到协调者的命令。这是该协议的一个缺点。

故障处理示例:

  • 参与者崩溃:若在投票前崩溃,协调者超时后视为其“投票中止”。若在“就绪”状态崩溃,恢复后会查询协调者以获知最终决定。
  • 协调者崩溃:若在写入决策日志前崩溃,恢复后将中止事务。若在写入“提交”日志后崩溃,恢复后将重新发送“全局提交”命令。

总结

本节课我们一起学习了:

  1. 分布式系统的演变、优势与挑战,特别是其复杂性带来的可靠性、安全性问题。
  2. 网络通信的分层模型,尤其是互联网沙漏结构如何通过IP层促进创新。
  3. 端到端原则这一核心设计哲学,它指导我们将功能放置在合适的层次。
  4. 构建分布式应用的基础——基于消息的进程间通信模型。
  5. 分布式共识的难度,以及通过两阶段提交协议实现分布式事务原子提交的经典方法,包括其步骤和故障处理机制。

这些概念是理解现代计算系统和网络如何工作的基石。在下节课中,我们将继续深入网络协议栈,详细探讨TCP/IP协议族的具体工作机制。

📚 课程 P24:网络与TCP/IP(续),RPC,分布式文件系统

在本节课中,我们将继续学习分布式系统中的核心概念。我们将回顾分布式共识问题,并深入探讨网络协议栈,特别是TCP/IP协议的工作原理。此外,我们还将介绍远程过程调用(RPC)和分布式文件系统的基础知识。

🔄 分布式共识回顾

上一节我们介绍了分布式共识问题,即多个节点需要就一个值达成一致,即使部分节点可能发生故障。本节中,我们来看看实现共识的具体协议及其挑战。

两阶段提交协议

两阶段提交协议是确保分布式事务原子性的经典算法。它分为两个阶段,并使用日志来保证决策的持久性。

以下是该协议的两个阶段:

  1. 准备阶段:全局协调者要求所有参与者承诺提交或决定回滚事务。每个参与者将决定记录在稳定的日志中,并将确认发送给协调者。如果有任何参与者投票中止,协调者将记录“中止”并通知所有参与者中止。
  2. 提交阶段:如果所有参与者都同意提交,协调者在其日志中写入“提交”。此时事务被视为已提交。协调者随后要求所有节点提交,并在收到所有确认后完成事务。

两阶段提交的挑战

尽管两阶段提交能保证一致性,但它存在一个主要问题:阻塞

以下是一个阻塞场景的示例:

  • 参与者B投票“是”并记录在日志中,然后协调者A崩溃。
  • 参与者B恢复后,发现日志中记录了“准备提交”,但不知道最终结果。
  • 此时,B不能单方面决定中止,因为它可能已经履行了提交承诺。因此,B会被阻塞,持有相关资源(如锁),直到协调者A恢复并告知最终决定。

共识协议的替代方案

为了解决阻塞等问题,业界提出了其他共识算法:

  • 三阶段提交:增加一个阶段来减少阻塞,但算法更复杂,不常用。
  • Paxos算法:由Leslie Lamport提出,Google使用,没有固定的领导者,能更好地处理故障,但算法本身非常复杂。
  • Raft算法:由斯坦福大学开发,比Paxos更易于理解和实现,正逐渐成为流行的替代方案。

拜占庭将军问题

到目前为止,我们假设的故障都是非恶意的(如机器崩溃)。但当节点可能恶意行动,试图破坏系统时,就需要更强大的协议。

拜占庭将军问题描述了在有叛徒(恶意节点)的情况下,如何让忠诚的将军们达成一致行动命令。其核心约束是:

  1. 所有忠诚的副将必须遵守相同的命令。
  2. 如果发出命令的将军是忠诚的,那么所有忠诚的副将都必须遵守他发出的命令。

解决拜占庭故障需要满足 N > 3F 的条件,其中N是总节点数,F是故障(恶意)节点数。这意味着系统需要超过三分之二的节点是忠诚的。

拜占庭容错算法不仅适用于恶意环境,也能处理节点以怪异方式故障的情况。

🌐 网络协议基础

在了解了分布式共识后,我们将视角转向支撑分布式系统的网络基础。网络协议是分层构建的,每一层为上层提供服务。

网络类型与设备

网络主要分为广播网络和点对点网络。

以下是常见的网络设备及其功能:

  • 交换机:工作在数据链路层,根据MAC地址在局域网内转发数据帧。它学习每个端口连接的设备MAC地址,形成转发表。
  • 路由器:工作在网络层,根据IP地址在不同网络(如不同局域网)之间路由数据包。它连接多个子网,构成广域网。

MAC地址是设备网卡的唯一硬件标识,而IP地址是逻辑地址,用于网络层路由。使用IP地址的优势在于其可聚合性,例如,伯克利大学的所有IP地址可能都以 169.229 开头,这大大简化了全球路由表。

互联网协议(IP)

IP是互联网的网络层协议,提供“尽力而为”的数据包传递服务。这意味着数据包可能丢失、损坏、重复或乱序到达,就像邮寄明信片一样。

一个IPv4数据包头部包含关键信息,如:

  • 版本:例如IPv4。
  • 源IP地址:发送方的32位地址。
  • 目的IP地址:接收方的32位地址。
  • 协议:指示上层使用哪种传输协议(如TCP或UDP)。
  • 生存时间:防止数据包在网络中无限循环。
  • 头部校验和:用于检测头部在传输中是否出错。

域名系统(DNS)

由于IP地址难以记忆且可能变化,我们使用域名系统将人类可读的域名(如 www.berkeley.edu)映射为IP地址。

DNS是一个分层、分布式的数据库:

  • 根域名服务器:管理顶级域(如 .com, .edu)。
  • 顶级域名服务器:管理下一级域(如 berkeley.edu)。
  • 权威域名服务器:管理具体主机的记录(如 www.eecs.berkeley.edu)。

查询域名时,客户端通常先查询本地缓存,若没有则从根域名服务器开始递归或迭代查询。DNS缓存提高了效率,但也带来了安全挑战(如DNS缓存投毒攻击)。

📦 传输层协议:UDP与TCP

网络层实现了主机到主机的通信,而传输层实现了进程到进程的通信。这是通过端口号来实现的。

用户数据报协议(UDP)

UDP在IP协议之上增加了简单的进程复用功能。它非常轻量,头部开销小,但不提供可靠性保证。

UDP数据包格式在IP头部基础上增加了:

  • 源端口:16位,发送方进程端口。
  • 目的端口:16位,接收方进程端口。
  • 长度:UDP数据报的总长度。
  • 校验和:覆盖整个UDP数据报,用于检错。

UDP适用于那些可以容忍少量丢包但要求低延迟的应用,如音视频流、DNS查询。

传输控制协议(TCP)

TCP提供了可靠的、面向连接的字节流服务。它在不可靠的IP网络上构建了一个可靠的信道。

TCP通过以下机制实现可靠性:

  1. 序列号与确认:每个字节都有序列号。接收方通过返回确认号,告知发送方已成功接收到的连续字节序列。
  2. 超时与重传:发送方为每个发出的数据段启动计时器。如果在规定时间内未收到确认,则重传该数据段。
  3. 流量控制:接收方通过通告窗口大小,告诉发送方自己还有多少缓冲区空间,防止发送方发送过快导致接收方溢出。
  4. 拥塞控制:发送方通过感知网络拥塞程度,动态调整发送速率,成为“良好的网络公民”。

滑动窗口协议

TCP使用滑动窗口协议来高效管理数据传输。窗口大小决定了在收到确认前,可以发送多少数据。

发送方维护三个区域:

  • 已发送并已确认:可以安全丢弃。
  • 已发送但未确认:需要保留,以备重传。
  • 未发送但可发送:在接收方通告窗口范围内的数据。

接收方则维护:

  • 已接收并已交付应用:已处理。
  • 已接收但未交付:在接收缓冲区中,等待应用读取。
  • 未接收:期望接收的序列号之后的数据。

通过滑动窗口,TCP实现了全双工、流水线化的数据传输,充分利用了网络带宽。

🎯 本节课总结

在本节课中,我们一起学习了分布式系统和网络的核心知识。

我们首先回顾了分布式共识问题,探讨了两阶段提交协议的优缺点以及阻塞问题,并介绍了Paxos、Raft等替代方案。我们还深入了解了更具挑战性的拜占庭将军问题及其容错条件。

接着,我们转向网络基础,理解了交换机与路由器的区别,以及IP协议如何提供尽力而为的服务。我们学习了DNS如何将域名映射为IP地址。

最后,我们重点剖析了传输层协议。UDP提供了简单的无连接服务,而TCP则通过序列号、确认、重传、流量控制和滑动窗口等复杂机制,在不可靠的IP网络上构建了可靠的字节流通道,这是大多数互联网应用(如HTTP、SSH、电子邮件)的基石。

这些概念是构建和理解复杂分布式系统与网络应用的关键。

课程 P25:RPC、NFS 和 AFS 🖥️🌐

在本节课中,我们将要学习传输控制协议(TCP)的拥塞控制机制,然后深入探讨远程过程调用(RPC)的原理,最后介绍分布式文件系统,特别是网络文件系统(NFS)和安德鲁文件系统(AFS)的基本概念。


TCP 拥塞控制 🚦

上一节我们介绍了 TCP 如何通过滑动窗口协议实现可靠传输。本节中我们来看看 TCP 如何避免网络拥塞,成为一个良好的互联网公民。

拥塞是指网络中某个部分(如链路或路由器)尝试传输的数据过多,超出了其处理能力。这可能导致数据包被丢弃。TCP 的拥塞控制目标是在不压垮网络的前提下,尽可能高效地利用带宽。

TCP 通过动态调整发送窗口的大小来实现拥塞控制。其核心算法是“慢启动”和“拥塞避免”,遵循“加性增,乘性减”的原则。

以下是拥塞控制的基本流程:

  1. 慢启动:连接开始时,窗口从一个很小的值(如1个MSS)开始。每收到一个确认(ACK),窗口大小就增加一个MSS。这使得发送速率呈指数增长,快速探测可用带宽。
  2. 拥塞避免:当窗口大小达到一个阈值(ssthresh)后,进入拥塞避免阶段。此时,每经过一个往返时间(RTT),窗口大小只增加一个MSS,变为线性增长。
  3. 拥塞检测:当检测到数据包丢失(通过超时或收到三个重复ACK)时,TCP 认为发生了拥塞。
    • ssthresh 设置为当前拥塞窗口大小的一半。
    • 将拥塞窗口重置为一个很小的值(慢启动起点),然后重新开始慢启动过程(如果是由超时触发),或者直接将窗口减半并进入拥塞避免阶段(如果是由重复ACK触发)。

通过这种方式,TCP 的发送窗口大小会围绕网络的实际承载能力动态振荡,在避免拥塞的同时尽力利用带宽。


远程过程调用(RPC)📞

在了解了底层网络传输机制后,我们来看如何构建分布式应用。直接处理消息和套接字非常复杂,远程过程调用(RPC)旨在简化这一过程。

RPC 的目标是让调用远程机器上的服务,看起来就像调用本地函数一样简单。它隐藏了网络通信、数据序列化等细节。

RPC 的工作原理

以下是 RPC 调用的基本步骤:

  1. 客户端调用:客户端程序调用一个本地函数(客户端存根)。
  2. 参数封送:客户端存根将函数参数从本地格式序列化为网络标准格式(如XDR),并打包成网络消息。
  3. 网络传输:消息通过网络发送到服务器。
  4. 服务器解包:服务器端的存根接收消息,将参数反序列化为服务器本地格式。
  5. 本地调用:服务器存根调用实际的服务器函数。
  6. 结果返回:服务器函数执行完毕,将结果返回给服务器存根。
  7. 结果封送:服务器存根将结果序列化,打包成响应消息发回客户端。
  8. 客户端接收:客户端存根接收响应,反序列化结果,并将其返回给最初的客户端调用者。

从程序员视角看,整个过程就像一个普通的函数调用 result = remote_function(arguments)

RPC 的关键问题与挑战

实现 RPC 需要解决几个核心问题:

  • 数据表示(序列化/反序列化):不同机器可能有不同的字节序(大端/小端)、整数大小等。RPC 使用一种标准的网络格式(如大端字节序)进行数据交换。函数 htonl(), ntohl() 等用于主机字节序和网络字节序之间的转换。
    // 示例:将32位主机字节序整数转换为网络字节序
    uint32_t host_value = 123456;
    uint32_t network_value = htonl(host_value);
    
  • 接口定义:客户端和服务器必须就函数名、参数类型和返回类型达成一致。通常使用接口定义语言(IDL)来定义接口,然后由工具自动生成客户端和服务器存根代码。
  • 绑定:客户端如何找到服务器?这可以通过静态配置、动态查询名称服务(如LDAP、DNS)或目录服务来实现。
  • 故障处理:RPC 可能面临本地调用不会遇到的故障:网络中断、服务器崩溃、客户端崩溃等。RPC 系统需要处理超时、重试和错误报告。
  • 性能:RPC 比本地调用慢得多,涉及网络延迟和数据拷贝开销。设计时需要权衡透明性和性能。

分布式文件系统 📂

RPC 的一个重要应用场景是构建分布式文件系统。它允许客户端像访问本地文件一样,透明地访问存储在远程服务器上的文件。

虚拟文件系统(VFS)层

现代操作系统通过虚拟文件系统(VFS)层来支持多种文件系统。VFS 定义了一组通用操作接口(如 open, read, write, close)。当应用程序发起系统调用时,VFS 将其路由到具体的文件系统实现(如 ext4, NTFS, NFS 客户端)。

网络文件系统(NFS)

NFS 是一个经典的分布式文件系统协议。其核心设计思想是 无状态服务器幂等操作

  • 无状态:服务器不保存客户端会话状态(如打开文件表、文件偏移量)。每个请求(如“从文件X的偏移量Y读取Z字节”)都包含完成操作所需的全部信息。
  • 幂等性:操作可以安全地重复执行多次而效果相同。例如,重复发送“删除文件A”的请求,最终结果都是文件A被删除(如果存在)。

这种设计简化了服务器的实现,并易于处理客户端或服务器崩溃后的恢复。客户端只需重试未完成的请求即可。

缓存与一致性问题

为了提高性能,客户端会在本地缓存经常访问的文件数据。但这引入了 缓存一致性问题:当某个客户端修改了文件,其他客户端缓存中的旧数据就过时了。

NFS 采用 弱一致性模型 来解决这个问题:

  • 客户端定期(例如每3到30秒)向服务器轮询,检查缓存的文件属性(如修改时间)是否已改变。
  • 如果发现改变,客户端会使对应的缓存数据失效,并在下次访问时从服务器获取新数据。
  • 这种模型简单,但存在“更新可见延迟”,在轮询间隔内,客户端可能读到旧数据。

安德鲁文件系统(AFS)简介

AFS 是另一个著名的分布式文件系统,它在设计上更侧重于可扩展性和广域网性能。

  • 回调机制:与 NFS 的轮询不同,AFS 服务器会在文件被修改时,主动通知持有该文件缓存的客户端(回调),使其缓存失效。这减少了网络轮询流量。
  • 会话语义:AFS 对文件修改提供了更清晰的语义。文件通常在关闭时才将修改写回服务器,这减少了网络写操作,但可能增加不同客户端看到不一致状态的时间窗口。


总结 🎯

本节课中我们一起学习了构建分布式系统的几个核心概念:

  1. TCP 拥塞控制:TCP 通过“慢启动”和“加性增,乘性减”的窗口调整算法,在保证可靠传输的同时,避免网络过载,公平地共享带宽。
  2. 远程过程调用(RPC):RPC 抽象了网络通信细节,使远程服务调用如同本地函数调用。它涉及存根生成、数据序列化、绑定和复杂的故障处理。
  3. 分布式文件系统:以 NFS 为例,我们了解了如何通过 VFS 层和 RPC 透明地访问远程文件。其无状态和幂等性的设计简化了容错,而客户端缓存与弱一致性模型的权衡则体现了分布式系统设计的经典挑战。

理解这些基础机制,是进一步学习现代分布式计算、云存储和微服务架构的重要基石。

CS162 第三讲:进程、系统调用与Fork 🧠

在本节课中,我们将要学习操作系统中的核心概念:进程、系统调用以及创建新进程的 fork 机制。我们将从进程的基本定义开始,探讨操作系统如何通过系统调用和中断与硬件交互,并最终理解如何创建和管理多个进程。


进程与地址空间回顾

上一节我们介绍了线程和地址空间的概念。本节中,我们来看看如何将它们组合成一个完整的进程。

一个进程是一个正在运行的程序实例。它包含一个或多个线程,并拥有一个受保护的地址空间。地址空间是程序可以访问的内存地址集合,它通过硬件翻译机制(如基址-界限或分页)与物理内存隔离,从而保护进程之间以及进程与内核之间互不干扰。

进程的核心数据结构是进程控制块,它存储了进程的所有状态信息,例如进程ID、寄存器值、内存界限等。


进程切换与保护机制

我们已经知道操作系统需要在多个进程之间切换。这种切换是如何在保护机制下安全进行的呢?

关键在于双模式操作。硬件支持至少两种模式:用户模式系统模式(内核模式)。用户模式下的进程权限受限,无法直接访问硬件或内核内存。当需要操作系统服务或发生外部事件时,会通过受控的路径切换到系统模式。

以下是触发从用户模式切换到内核模式的三种主要方式:

  1. 系统调用:用户程序主动请求内核服务(如读写文件)。
  2. 中断:由外部硬件设备触发的异步事件(如定时器到时、网络数据到达)。
  3. 异常:由正在执行的指令引发的同步事件(如除零错误、页错误)。

当中断或系统调用发生时,硬件会原子性地完成以下操作:

  • 保存当前用户程序的程序计数器(PC)和栈指针(SP)。
  • 切换到系统模式。
  • 将程序计数器设置为预定义的中断向量或系统调用处理程序的地址。
  • 切换到内核栈。

这个过程确保了进入内核的入口是唯一且受控的,防止了恶意用户代码破坏内核。


系统调用工作流程

系统调用是用户程序使用操作系统服务的主要方式。它的执行流程如下:

  1. 用户程序将系统调用编号和参数放入约定的寄存器或栈中。
  2. 执行特殊的指令(如 intsyscall),触发从用户模式到系统模式的硬件切换。
  3. 内核根据系统调用编号,跳转到对应的处理函数。
  4. 内核在验证所有参数安全后,执行服务(例如,检查用户提供的指针是否指向其合法的地址空间)。
  5. 内核将结果复制回用户空间。
  6. 执行返回指令,恢复用户寄存器并切换回用户模式,用户程序从调用点之后继续执行。

关键点:内核绝不信任用户输入。所有来自用户空间的参数都必须经过严格验证。


创建新进程:Fork

现在,我们来看一个创建新进程的核心系统调用:fork

fork 是一个特殊的函数,调用一次,却会“返回”两次。它的作用是复制当前进程,创建一个几乎完全相同的子进程。

以下是 fork 的基本行为:

  • 在父进程中fork 返回新创建的子进程的进程ID(PID),这是一个大于0的值。
  • 在子进程中fork 返回0。
  • 如果创建失败,则在父进程中返回-1。

子进程是父进程的副本,它拥有:

  • 相同的代码段、数据段和堆栈内容。
  • 相同的文件描述符表(指向相同的打开文件)。
  • 独立的进程ID和地址空间。

以下是一个简单的 fork 使用示例:

#include <stdio.h>
#include <unistd.h>

int main() {
    pid_t cpid = fork(); // 在这里,进程一分为二

    if (cpid > 0) {
        // 这段代码只在父进程中执行
        printf("I am the parent. My child's PID is %d.\n", cpid);
    } else if (cpid == 0) {
        // 这段代码只在子进程中执行
        printf("I am the child. My PID is %d.\n", getpid());
    } else {
        // fork 失败
        perror("fork failed");
        return 1;
    }
    return 0;
}

理解 fork:可以想象在 fork() 调用执行完毕的瞬间,操作系统复制了整个进程,并让两个进程都从 fork() 返回处开始继续执行。唯一的区别就是返回值,程序通过判断这个返回值来决定父进程和子进程后续执行不同的逻辑。

fork 常与另一个系统调用 exec 配合使用,后者用于将当前进程的地址空间替换为一个全新的程序。这是 Shell 运行新命令的基础:先 fork 出一个子 Shell 进程,然后子进程调用 exec 来执行目标命令。


总结

本节课中我们一起学习了:

  1. 进程是拥有独立地址空间和执行资源的程序实例,是操作系统资源分配和保护的基本单位。
  2. 通过双模式操作中断向量独立内核栈等机制,操作系统实现了从用户模式到内核模式的安全、受控切换。
  3. 系统调用是用户程序请求内核服务的标准接口,内核在执行前会严格验证所有参数。
  4. fork 系统调用通过复制自身来创建新的进程,这是 Unix/Linux 系统中创建进程的主要方式,它创造了一个与原进程几乎完全相同的子进程,并通过返回值区分父子进程的后续执行路径。

理解进程的创建、切换和保护机制,是掌握操作系统如何管理并发和资源的基础。在接下来的课程中,我们将深入探讨进程调度、线程以及进程间通信等高级主题。

课程 P4:fork(续)与 I/O 介绍(万物皆文件!)🚀

在本节课中,我们将继续深入探讨 fork 系统调用,并介绍 Unix/Linux 中一个核心的哲学思想——“万物皆文件”。我们将学习进程创建、管理以及如何通过统一的文件接口进行输入输出操作。


1. fork 系统调用详解 🔄

上一节我们介绍了进程的基本概念,本节中我们来看看如何使用 fork 创建新进程。

fork 是一个特殊的系统调用。当一个进程(父进程)调用 fork 时,它会创建一个几乎完全相同的副本(子进程)。调用完成后,两个进程都会从 fork 调用处继续执行。

以下是 fork 的关键行为:

  • 返回值fork 在父进程中返回子进程的进程ID(PID,一个大于0的整数),在子进程中返回0。如果返回负数,则表示 fork 失败。
  • 内存复制:子进程会获得父进程地址空间的副本。在实现上,操作系统使用写时复制(Copy-On-Write)技术来高效处理,即初始时共享内存,只有当任一进程尝试写入时,才会复制被修改的部分。
  • 执行分流fork 调用后,父进程和子进程成为两个独立的实体,可以执行完全不同的代码。
#include <unistd.h>
#include <stdio.h>

int main() {
    pid_t pid = fork(); // 创建子进程

    if (pid > 0) {
        // 父进程代码
        printf("I am the parent, child PID is %d\n", pid);
    } else if (pid == 0) {
        // 子进程代码
        printf("I am the child\n");
    } else {
        // fork 失败
        perror("fork failed");
    }
    return 0;
}

2. 进程管理:execwait ⚙️

仅仅复制自身通常不够,我们经常需要让子进程执行全新的程序。这时就需要 exec 系列系统调用。同时,父进程可能需要等待子进程结束并获取其状态,这通过 wait 系统调用来实现。

2.1 exec:执行新程序

exec 会用指定的新程序完全替换当前进程的内存映像(代码、数据、堆栈等),然后开始运行新程序。如果成功,exec 不会返回(因为原进程已被替换);只有失败时才会返回。

以下是 exec 的一个常见用法模式:

#include <unistd.h>
#include <stdio.h>

int main() {
    char *args[] = {"/bin/ls", "-l", NULL}; // 参数列表,以NULL结束
    execvp(args[0], args); // 执行 ls -l 命令
    // 如果 execvp 成功,下面的代码不会执行
    perror("execvp failed");
    return 1;
}

2.2 wait:等待子进程

父进程可以调用 wait 来暂停自己的执行,直到一个子进程结束。wait 还能获取子进程的退出状态。

#include <sys/wait.h>
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>

int main() {
    pid_t pid = fork();

    if (pid == 0) {
        // 子进程:执行一个简单命令后退出
        printf("Child process exiting with code 42\n");
        exit(42); // 子进程退出,返回状态码42
    } else if (pid > 0) {
        // 父进程:等待子进程结束
        int status;
        wait(&status); // 等待子进程,并将退出状态存入status
        if (WIFEXITED(status)) {
            printf("Child exited with status: %d\n", WEXITSTATUS(status));
        }
    }
    return 0;
}

典型模式(Shell模式):Shell 运行程序的标准模式就是 fork -> (子进程)exec -> (父进程)wait


3. 信号:进程间的基本通知机制 📡

除了通过文件或管道通信,进程间另一种简单的通信方式是信号。信号是异步发送给进程的通知,通常用于告知进程发生了某个事件(如用户按下了 Ctrl+C)。

进程可以为大多数信号注册自己的处理函数,以覆盖默认行为(如终止进程)。

#include <stdio.h>
#include <signal.h>
#include <unistd.h>

void signal_handler(int sig) {
    printf("Caught signal %d. Ignoring.\n", sig);
    // 不退出,只是打印信息
}

int main() {
    // 注册信号处理函数,处理 SIGINT (通常由 Ctrl+C 触发)
    signal(SIGINT, signal_handler);

    while(1) {
        printf("Running...\n");
        sleep(1);
    }
    return 0;
}

注意SIGKILLSIGSTOP 信号无法被捕获或忽略。


4. 系统架构回顾:用户模式 vs 内核模式 🏗️

在深入 I/O 之前,我们先回顾一下操作系统的分层架构。

  • 内核模式:操作系统核心代码运行的特权模式,可以直接访问硬件和所有内存。
  • 用户模式:普通应用程序运行的模式,权限受限,无法直接访问硬件或其他进程的内存。
  • 系统调用:用户模式程序请求内核服务的唯一接口,是一个狭窄而明确的函数集合(如 fork, read, write)。
  • 标准库(如 libc):对原始系统调用的封装,提供更友好、更高效的高级 API(如 fopen, printf)。库代码本身运行在用户模式。

应用程序 -> 标准库 -> 系统调用 -> 内核 -> 硬件


5. Unix 哲学:万物皆文件 📁

这是 Unix/Linux 系统设计的核心思想之一。许多资源(普通文件、目录、设备、管道、网络套接字等)都被抽象成“文件”,通过一套统一的接口进行操作。

这套接口的核心系统调用是:

  • open / close:打开/关闭文件。
  • read / write:读写数据。
  • lseek:移动文件指针(随机访问)。

5.1 高级 I/O(流式 I/O,带缓冲)

标准库(如 stdio.h)提供了更易用的流(FILE*)接口。这些函数通常以 f 开头(如 fopen, fread)。

关键特性

  • 缓冲:在用户空间维护缓冲区,减少系统调用次数,提升效率。例如,fgetc 可能一次从内核读入一大块数据到缓冲区,后续读取直接从缓冲区获取。
  • 自动处理:简化了错误处理和格式转换。
#include <stdio.h>

int main() {
    FILE *in_file = fopen("input.txt", "r"); // 高级打开,返回 FILE*
    FILE *out_file = fopen("output.txt", "w");

    if (!in_file || !out_file) {
        perror("File opening failed");
        return 1;
    }

    int c;
    while ((c = fgetc(in_file)) != EOF) { // 逐字符读取(有缓冲)
        fputc(c, out_file); // 逐字符写入
    }

    fclose(in_file);
    fclose(out_file);
    return 0;
}

5.2 低级 I/O(文件描述符 I/O,无缓冲)

这是更接近内核的系统调用接口,操作对象是整数类型的文件描述符

关键特性

  • 文件描述符:一个小的非负整数,是进程打开文件表中某个条目的索引。标准输入、输出、错误对应的描述符分别是 012(宏定义为 STDIN_FILENO, STDOUT_FILENO, STDERR_FILENO)。
  • 无用户缓冲:每次 read/write 都直接引发系统调用。但内核内部仍有缓存以提高磁盘I/O效率。
  • 更直接的控制:提供更底层的操作。
#include <unistd.h>
#include <fcntl.h>
#include <stdio.h>

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/ucb-cs162-os/img/b59c0e1f5f8864879bc09f46bc086ef0_16.png)

int main() {
    int in_fd = open("input.txt", O_RDONLY); // 低级打开,返回文件描述符(int)
    int out_fd = open("output.txt", O_WRONLY | O_CREAT, 0644);

    if (in_fd < 0 || out_fd < 0) {
        perror("File opening failed");
        return 1;
    }

    char buffer[1024];
    ssize_t bytes_read;
    while ((bytes_read = read(in_fd, buffer, sizeof(buffer))) > 0) { // 块读取
        write(out_fd, buffer, bytes_read); // 块写入
    }

    close(in_fd);
    close(out_fd);
    return 0;
}

重要区别:高级 I/O 的缓冲在用户空间的库中,低级 I/O 的缓冲在内核中。混合使用时需小心,可能需要 fflush 来同步状态。


6. 设备驱动与“一切皆文件”的底层实现 ⚙️

“一切皆文件”是如何实现的?关键在于虚拟文件系统设备驱动

  1. 文件结构体:内核为每个打开的文件维护一个结构体,其中包含一个文件操作函数表的指针。
  2. 驱动注册:每种资源类型(磁盘文件、键盘、鼠标、网络卡)的设备驱动程序都会向内核注册自己的文件操作函数表(包含 read, write, open, close 等函数的实现)。
  3. 统一接口:当用户程序对某个文件描述符调用 read 时,内核通过描述符找到文件结构体,再通过其中的函数表调用到相应设备驱动提供的 read 函数。对于用户来说,接口完全一致。

I/O 控制:有些设备需要特殊配置,无法完全通过 read/write 完成。ioctl 系统调用提供了这个额外的控制通道。


7. 超越本地文件:套接字(Sockets)🌐

“万物皆文件”的思想甚至延伸到了网络通信。套接字是网络通信端点的抽象,它同样使用文件描述符,支持 readwrite 操作。

核心概念

  • 套接字是双向通信队列的端点。
  • 通信双方可以是同一台机器上的进程(本地套接字),也可以是不同机器上的进程(网络套接字)。
  • 使用 IP 地址和端口号来命名和定位套接字。

一个简单的客户端-服务器回显模型如下:

  1. 服务器创建套接字,绑定到知名端口并监听。
  2. 客户端创建套接字,连接到服务器的地址和端口。
  3. 连接建立后,双方获得用于通信的新套接字描述符。
  4. 客户端向它的套接字 write 数据,服务器从它的套接字 read 数据,处理后再 write 回去。
// 伪代码示例:连接已建立后的数据交换
// 客户端
write(socket_fd, “Hello”, 5);
read(socket_fd, buffer, sizeof(buffer)); // 等待回显

// 服务器
read(socket_fd, buffer, sizeof(buffer)); // 等待客户端消息
printf(“Received: %s\n”, buffer);
write(socket_fd, buffer, strlen(buffer)); // 发回回显

注意:套接字是面向字节流的,readwrite 的数据量可能不匹配,应用程序需要自己处理消息边界。此外,套接字不支持 lseek 操作。


总结 📚

本节课中我们一起学习了:

  1. fork 的完整机制:包括其行为、写时复制优化,以及如何通过返回值区分父子进程。
  2. 进程管理:使用 exec 让子进程执行新程序,使用 wait 让父进程同步子进程状态。
  3. 信号:作为进程间异步通知的基本机制。
  4. 系统架构:理解了用户模式、内核模式、系统调用和标准库的分工。
  5. “万物皆文件”哲学:这是 Unix/Linux 系统的基石。
  6. 高级与低级 I/O:掌握了带缓冲的流式 I/O(FILE*, fread/fwrite)和无缓冲的文件描述符 I/O(int fd, read/write)两套接口及其区别。
  7. 底层实现:了解了设备驱动和虚拟文件系统如何支撑“一切皆文件”的抽象。
  8. 网络扩展:看到了套接字如何将文件抽象应用于网络通信,实现了跨机器的进程间通信。

通过本课,你应该对进程的创建、管理和通信,以及 Unix 系统统一而强大的 I/O 模型有了扎实的理解。这些概念是后续学习文件系统、并发编程和网络编程的重要基础。

操作系统课程 P5:套接字与进程间通信 🖧

在本节课中,我们将学习操作系统中的两个核心概念:套接字进程间通信。我们将从“一切皆文件”的Unix哲学出发,理解套接字如何作为通信端点,并探讨如何通过进程和线程来实现并发服务。


概述:一切皆文件 📁

在Unix系统中,一个核心的设计哲学是“一切皆文件”。这意味着无论是普通文件、设备、还是网络连接,都可以通过相同的接口(如 openreadwriteclose)来操作。这个接口允许我们将不同程序的数据流连接起来,为进程间通信奠定了基础。

上一节我们介绍了文件操作的基本接口,本节中我们来看看如何将这个接口应用于网络通信。


套接字:网络通信的端点 🔌

套接字是网络通信的端点,它看起来像一个文件,但实际上代表了网络连接两端的队列。通过套接字,不同机器上的进程可以进行数据交换。

套接字的工作原理

套接字通信通常涉及服务器客户端两种角色:

  • 服务器:监听特定端口,等待连接。
  • 客户端:向服务器的IP地址和端口发起连接请求。

每个成功的连接都由一个五元组唯一标识,这使得多个连接可以同时存在:

(客户端IP, 客户端端口, 服务器IP, 服务器端口, 协议)

以下是建立连接的基本步骤:

  1. 服务器创建套接字,绑定到地址和端口,并开始监听。
  2. 客户端创建套接字,并向服务器地址发起连接。
  3. 服务器接受连接,为这个连接创建一个新的套接字用于后续通信。
  4. 双方通过新套接字进行读写操作。

一个简单的服务器模型

一个最简单的服务器模型是顺序处理连接:接受一个连接,处理完毕,关闭连接,再接受下一个。这种模型效率很低。

// 简化示例:顺序服务器
while(1) {
    int conn_sock = accept(server_sock, ...); // 阻塞等待连接
    serve_client(conn_sock); // 处理这个客户端
    close(conn_sock); // 关闭连接
}

使用进程实现并发服务 👨‍👦

为了让服务器能同时处理多个客户端,我们可以为每个新连接创建一个独立的子进程。这是通过 fork() 系统调用实现的。

进程模型的工作流程

以下是使用进程改进后的服务器模型:

  1. 主进程(父进程)监听并接受连接。
  2. 每当有新连接时,父进程调用 fork() 创建一个子进程。
  3. 子进程继承父进程的文件描述符,负责处理该客户端的请求。
  4. 父进程关闭不再需要的连接套接字,并继续监听新连接。
// 简化示例:多进程服务器
while(1) {
    int conn_sock = accept(server_sock, ...);
    pid_t pid = fork();
    if (pid == 0) { // 子进程
        close(server_sock); // 子进程不需要监听套接字
        serve_client(conn_sock);
        close(conn_sock);
        exit(0); // 处理完毕,退出
    } else { // 父进程
        close(conn_sock); // 父进程不需要连接套接字
        // 可以选择 wait() 回收子进程,或继续循环
    }
}

优点:子进程拥有独立的地址空间,提供了良好的隔离性和安全性。
缺点:创建进程(fork)的开销较大,当连接数巨大时,系统资源可能被耗尽。


使用线程实现并发服务 🧵

为了降低创建并发单元的开销,现代服务器更常使用线程。线程被称为“轻量级进程”,它们共享同一个进程的地址空间(代码、堆、全局变量),但拥有独立的栈和寄存器状态。

线程模型的工作流程

使用线程的服务器模型与进程类似,但创建的是线程而非进程:

  1. 主线程监听并接受连接。
  2. 为新连接创建一个新线程。
  3. 新线程处理客户端请求。
  4. 主线程继续监听。

// 伪代码示例:多线程服务器
while(1) {
    int conn_sock = accept(server_sock, ...);
    pthread_create(&thread_id, NULL, serve_client_thread, (void*)conn_sock);
    // 主线程继续循环
}

优点:创建线程的开销远小于创建进程,共享内存使得数据交换非常高效。
缺点:线程间缺乏内存保护,一个线程的错误可能影响整个进程。同时,大量线程同样可能导致资源耗尽(线程池是常见的解决方案)。


进程与线程的状态与调度 ⏱️

无论是进程还是线程,操作系统都需要管理它们的生命周期和CPU时间的分配。

进程/线程的状态

一个进程或线程在其生命周期中可能处于以下几种状态:

  • 就绪:已准备好运行,正在等待CPU。
  • 运行:正在CPU上执行。
  • 阻塞/等待:因等待某事件(如I/O完成)而暂停执行。
  • 终止:已执行完毕,等待被清理。

调度器与上下文切换

操作系统内核的调度器负责决定哪个就绪状态的线程/进程获得CPU使用权。当一个运行中的线程因时间片用完、主动放弃(如调用 yield)或等待I/O而被剥夺CPU时,就会发生上下文切换

上下文切换的过程如下:

  1. 保存当前线程的寄存器状态到其线程控制块。
  2. 根据调度策略,从就绪队列中选择下一个要运行的线程。
  3. 恢复新线程的寄存器状态。
  4. 跳转到新线程的程序计数器,开始执行。

这个过程必须非常高效,因为它是纯粹的“开销”,会减少程序实际执行的时间。


总结 🎯

本节课中我们一起学习了:

  1. 套接字作为网络通信端点的抽象,它遵循“一切皆文件”的接口,通过五元组唯一标识连接。
  2. 如何通过创建子进程来实现并发服务器,提供了隔离性但开销较大。
  3. 如何通过创建线程来实现更高效的并发服务器,共享内存但需注意线程安全。
  4. 操作系统通过调度器上下文切换在单个CPU上实现多个线程/进程的并发执行,管理其就绪、运行、阻塞等状态。

理解这些概念是构建高效、可靠网络服务和应用的基础。在接下来的课程中,我们将深入探讨调度策略、线程同步以及更复杂的进程间通信机制。

操作系统课程 P6:第六讲:同步 1 并发与互斥 🧵

在本节课中,我们将要学习操作系统中的核心概念——并发与互斥。我们将探讨线程如何实现、如何切换,以及当多个线程共享数据时会引发哪些问题。理解这些是构建正确、高效并发程序的基础。


概述:从进程到线程

上一节我们介绍了进程的基本概念和通信方式。本节中我们来看看如何在一个进程内部实现更轻量级的并发执行单元——线程。

进程是包含一个或多个线程的容器地址空间。传统的单线程进程被称为“重型进程”。现代操作系统支持在一个进程中有多个线程。每个线程拥有独立的寄存器和栈存储,但共享相同的代码、数据和文件。


线程的实现与切换 🔄

操作系统的核心工作之一是在多个线程之间进行切换,制造出它们“同时”运行的假象。这通过一个核心循环实现:

while (1) {
    run_thread_for_a_while();
    choose_next_thread();
    save_state_of_current_thread();
    load_state_of_new_thread();
}

这个循环不断保存当前线程状态,并加载下一个线程的状态。

如何获得控制权:内部事件与外部事件

操作系统如何确保能定期从用户线程收回控制权,以运行调度器?主要有两种方式:

以下是内部事件的例子:

  • 显式让出 (Yield):线程主动调用 yield() 系统调用,将CPU交还给操作系统。
  • 执行I/O操作:当线程进行如读取文件等I/O操作时,会进入内核并可能被阻塞,操作系统可借此机会调度其他线程。

以下是外部事件的例子:

  • 定时器中断:硬件定时器定期产生中断,强制CPU进入内核模式。内核的中断处理程序可以调用调度器。
  • 其他硬件中断:如网络数据包到达、磁盘I/O完成等。

早期系统(如旧版Macintosh)依赖内部事件(协作式多任务),一个错误或恶意的无限循环就可能导致整个系统冻结。现代系统主要依赖外部事件(特别是定时器中断)来实现抢占式多任务,确保操作系统始终能掌握控制权。


上下文切换的堆栈视角 🥞

理解线程切换的关键在于理解堆栈的变化。每个线程都拥有一个用户栈和一个关联的内核栈

当发生从用户模式到内核模式的转换(如系统调用或中断)时,CPU会从当前线程的用户栈切换到其内核栈。线程的状态(寄存器等)被保存在内核栈上。

上下文切换例程 switch() 的核心工作是:

  1. 将当前线程的寄存器保存到其线程控制块 (TCB) 中。
  2. 从新线程的TCB中加载寄存器到物理CPU。
  3. 其中被加载的栈指针寄存器会指向新线程的内核栈。
  4. 执行返回指令,此时程序将基于新加载的栈和返回地址继续执行,从而“跳转”到了新线程的上下文中。

这个过程就像改变了执行的“时空位置”,从一个线程的上下文无缝切换到另一个。


创建新线程 🐣

我们如何启动一个全新的线程,并让它能融入上述切换机制?

关键在于初始化新线程的TCB,特别是设置其栈指针和程序计数器(返回地址),使其看起来“仿佛”已经运行过并刚刚调用了 switch()

以下是创建新线程的步骤:

  1. 分配新的栈空间和TCB。
  2. 初始化TCB:将栈指针设置为新栈的顶部,将程序计数器设置为线程启动例程(如 thread_start())的地址。
  3. 将新TCB放入就绪队列。
  4. 当调度器切换到该新线程时,硬件会加载我们预设的栈指针和程序计数器。
  5. 线程开始执行 thread_start(),进行一些初始化工作后,调用用户提供的线程函数。
  6. 从此,该线程便能像普通线程一样参与正常的切换和调度。


并发带来的挑战与概念 🔀

当我们有多个线程(或进程)时,就引入了并发。并发执行可能带来非确定性,因为调度器可以以任意顺序交错执行各线程的指令。

以下是几个关键术语的定义:

  • 多处理 (Multiprocessing):指系统有多个物理CPU或核心,可以真正同时执行指令。
  • 多道程序设计 (Multiprogramming):指系统能同时管理多个作业或进程(可能是在单个CPU上交替运行)。
  • 多线程 (Multithreading):指单个进程内包含多个执行线程。

如果线程之间完全不共享状态,那么无论调度顺序如何,结果都是确定且可重现的。然而,为了实现协作、资源共享或性能加速,线程间通常需要共享数据(如内存、文件),这就引入了复杂性。

竞争条件 (Race Condition) 是指多个线程并发访问和操作共享数据,且执行结果依赖于线程执行的精确时序。由此产生的错误称为并发错误,它们通常难以重现和调试。


总结

本节课中我们一起学习了操作系统实现并发的核心机制。我们探讨了线程的概念、上下文切换的详细过程(包括堆栈操作)、以及如何创建新线程。我们还引入了并发的基本概念,并指出了当线程共享数据时将面临的正确性挑战——竞争条件。

理解线程如何切换是理解并发如何工作的基础。下一讲,我们将深入探讨如何通过同步互斥机制来解决共享数据带来的问题,确保并发程序的正确性。

操作系统课程 P7:同步、并发(续)、锁的实现与原子操作 🔒

在本节课中,我们将继续深入探讨并发编程的核心问题。我们将从线程调度的底层机制出发,理解线程切换是如何在硬件和操作系统层面实现的。接着,我们将直面并发编程中最核心的挑战——同步问题,并学习如何使用锁等机制来确保多线程程序的正确性。最后,我们会探讨原子操作的概念及其重要性。


线程切换的底层机制回顾 🧵

上一节我们介绍了线程调度的基本概念。本节中,我们来看看线程切换在底层是如何具体实现的。

线程切换的核心在于保存当前线程的上下文(主要是寄存器状态),并恢复另一个线程的上下文。这个过程通常由操作系统内核中的调度器(scheduler)和上下文切换函数(switch)协作完成。

每个线程都有自己的栈。当一个线程(例如线程S)调用 yield() 主动让出CPU时,控制流会进入内核。内核的调度器(例如 run_new_thread)会选择一个新线程(例如线程T)来运行,并调用 switch() 函数。

switch() 函数执行以下关键操作:

  1. 将当前线程(S)的寄存器状态保存到其线程控制块(TCB)中。
  2. 从目标线程(T)的TCB中加载其寄存器状态到CPU。
  3. 其中一个关键的寄存器是栈指针(SP)。切换SP意味着CPU的执行栈从线程S的栈瞬间跳转到了线程T的栈。

因此,当从 switch() 函数返回时,程序计数器(PC)和栈指针(SP)都已指向线程T的上下文,从而实现了线程的切换。被换出的线程S的状态被完整保存,仿佛被“冻结”,直到下次被调度器选中并切换回来时,再从当初保存的断点处继续执行。


用户态与内核态的栈切换 🏗️

线程切换常常伴随着处理器模式从用户态到内核态的转换。现代处理器硬件(如x86)为此提供了直接支持。

当用户线程发起系统调用或触发中断时,硬件会自动执行以下操作:

  1. 将当前用户态的指令指针(PC)和栈指针(SP)等关键信息保存到预设的内核栈中。
  2. 将处理器模式切换到内核态,并使用预设的内核栈指针和异常处理程序的入口地址。

这个过程是原子且由硬件保障的。在内核中处理完请求后,通过执行 iret(中断返回)等指令,硬件会逆向操作,恢复用户态的PC和SP,从而返回到用户空间继续执行。

每个用户线程都有一个对应的内核栈,用于它在内核态执行时使用。调度器在进行线程切换时,不仅需要切换用户态的上下文,也需要切换对应的内核栈。


并发编程的挑战与同步的必要性 ⚠️

我们有了在多个线程之间切换的机制。然而,如果不加控制地使用并发,可能会从功能性和正确性上“摧毁”你的程序。

考虑一个简化的银行存款服务器示例。多个线程可能同时处理不同客户的存款请求,它们都执行以下代码:

balance = get_balance(account); // 1. 读取余额
balance = balance + amount;     // 2. 增加金额
put_balance(account, balance);  // 3. 写回余额

如果两个线程(一个存$10,一个存$500)几乎同时操作同一个账户,它们的指令可能会以如下方式交错执行:

  1. 线程A读取余额(假设为$100)。
  2. 线程B读取余额(同样为$100)。
  3. 线程A计算新余额($110)并写回。
  4. 线程B计算新余额($100 + $500 = $600)并写回。

最终账户余额是$600,而不是正确的$610。线程A的存款“丢失”了。这是因为“读取-修改-写回”这三个步骤组成的操作不是原子的,它可能被其他线程打断。

原子操作是指一个操作要么完全执行,要么完全不执行,在执行过程中不可被中断。单个内存的读或写操作在大多数现代处理器上是原子的,但像“递增”这样的复合操作不是。

因此,我们需要一种机制来保护像“更新账户余额”这样的临界区代码,确保在任一时刻,最多只有一个线程在执行它。这就是同步


锁:同步的基本工具 🔐

实现同步最直接的工具是。锁提供了互斥访问的能力。

锁的基本操作有两个:

  • acquire(lock):获取锁。如果锁已被其他线程持有,则调用线程会等待(通常进入休眠状态),直到锁被释放。
  • release(lock):释放锁。允许其他正在等待的线程之一获取该锁。

使用锁修正后的银行存款逻辑如下:

acquire(account_lock);          // 进入临界区前加锁
balance = get_balance(account);
balance = balance + amount;
put_balance(account, balance);
release(account_lock);          // 离开临界区后解锁

现在,即使有多个存款线程,acquirerelease 之间的临界区也保证了同一时间只有一个线程能执行余额更新操作,从而避免了数据竞争。

正确使用锁的要求是:所有访问共享资源(如同一个银行账户)的线程,必须使用同一个锁来保护对该资源的所有访问。


实现锁的挑战与思路 💡

如何实现一个锁呢?一个理想的锁应该具备:

  1. 互斥性:保证最多一个线程持有锁。
  2. 公平性(可选):等待的线程最终能获得锁,不会饿死。
  3. 性能:尤其是当锁被占用时,其他线程不应忙等待(busy-waiting)而空耗CPU周期,应进入休眠状态。

仅使用原子的读/写操作来实现一个正确的锁非常复杂。我们通过一个“买牛奶”的类比来探索几种方案:

  • 方案一(先检查后留便条):可能导致两人都看到没牛奶且没便条,于是都去买,造成“牛奶过多”。
  • 方案二(先留便条后检查):可能导致两人都留下便条,然后都看到对方的便条,于是都不去买,造成“缺奶”。
  • 方案三(Peterson算法思想):通过更复杂的标志和轮流机制,可以在两个线程间正确工作,但代码复杂、不对称,且涉及忙等待。
  • 方案四(理想的锁):这正是我们需要的——acquire() 操作在锁被占用时让线程休眠,release() 操作唤醒一个等待线程。这需要操作系统底层原语的支持。

为了实现方案四,操作系统需要提供比原子读/写更强大的硬件原子操作原语,例如:

  • 禁用中断(在单处理器上可行)。
  • 特殊的原子指令,如 Test-and-Set (TAS)Compare-and-Swap (CAS) 等。

这些硬件原语允许我们以不可分割的方式“检查并修改”一个内存值,从而可以作为构建更高级同步机制(如锁、信号量)的基石。


总结 📚

本节课中我们一起学习了:

  1. 线程切换的底层细节:理解了 switch() 函数如何通过保存和恢复寄存器(尤其是栈指针)来实现线程上下文切换,以及硬件如何辅助完成用户态/内核态的栈切换。
  2. 并发编程的核心挑战:多个线程不加控制地访问共享数据会导致数据竞争,产生不可预测且难以调试的错误。
  3. 同步与原子操作同步是协调线程活动以确保正确性的机制。原子操作是不可分割的基本执行单元,是构建同步机制的基础。
  4. 锁的概念与使用是实现互斥、保护临界区的基本工具。正确使用锁可以防止数据竞争。
  5. 锁实现的思路:实现一个高效、正确的锁需要硬件提供原子操作原语的支持,以避免复杂的软件算法和忙等待。

并发编程要求开发者必须仔细设计,预先考虑所有可能的线程交错执行情况。在接下来的课程中,我们将基于锁的概念,继续学习信号量、条件变量等更高级的同步机制。

操作系统课程 P8:第8讲 - 锁、信号量和监视器 🔒

在本节课中,我们将学习操作系统中的核心同步机制:锁、信号量和监视器。我们将探讨如何利用硬件原语构建高效的锁,并理解更高级的同步抽象如何解决复杂的并发问题。


概述 📋

上一节我们讨论了仅使用加载和存储指令实现同步的复杂性。本节中,我们将看看如何利用更强大的硬件支持来构建更实用、更高效的锁,并引入信号量和监视器这两种更高级的同步原语。


从简单锁到硬件支持

我们之前尝试仅用加载和存储实现锁,但代码复杂且线程间逻辑不同,难以推广。一个理想的锁应具备统一的获取(acquire)和释放(release)接口,并能让等待的线程进入睡眠而非忙等。

禁用中断的尝试(单处理器)

一种思路是利用禁用中断来防止线程切换,从而保护临界区。

// 天真的方法:直接通过禁用/启用中断实现锁
acquire() {
    disable_interrupts();
}
release() {
    enable_interrupts();
}

问题

  • 用户程序不应拥有禁用中断的权限。
  • 长时间禁用中断会阻碍系统响应关键事件(如硬件中断)。
  • 不适用于多处理器系统。

改进:使用中断保护实现锁

我们可以在内核中,利用短暂的禁用中断来实现一个真正的锁数据结构。

int lock_value = 0; // 0=空闲,1=忙碌
queue_t wait_queue;

acquire() {
    disable_interrupts(); // 进入元临界区
    if (lock_value == 1) {
        // 锁已被占用
        enqueue(current_thread, wait_queue);
        sleep(); // 将自己挂起
    } else {
        lock_value = 1; // 获取锁
    }
    enable_interrupts(); // 退出元临界区
}

release() {
    disable_interrupts();
    if (!is_empty(wait_queue)) {
        thread = dequeue(wait_queue);
        wakeup(thread); // 唤醒一个等待线程
    } else {
        lock_value = 0; // 无等待者,释放锁
    }
    enable_interrupts();
}

关键点

  • 锁本身是一个内存变量(lock_value)。
  • acquirerelease 中的核心操作被一个元临界区(通过禁用中断实现)保护,确保其原子性。
  • 当线程无法获取锁时,它会将自己放入与该锁关联的等待队列并进入睡眠,从而避免忙等。
  • sleep() 和上下文切换的细节由内核调度器处理,它遵循“进入调度器时中断总是禁用”的规则。

局限性:此实现依赖于内核特权(禁用中断),且不适用于多处理器。


原子指令与多处理器锁

为了在多处理器上实现用户态的锁,我们需要硬件的原子读-修改-写指令支持。

常见的原子指令

  • 测试并设置 (Test-and-Set): TAS(addr):原子地读取地址addr处的值,并写入1,返回读取到的旧值。
  • 交换 (Swap): SWAP(reg, addr):原子地交换寄存器reg和内存地址addr处的值。
  • 比较并交换 (Compare-and-Swap): CAS(addr, expected, new):如果addr处的值等于expected,则原子地将其设置为new并返回成功;否则返回失败。
  • 加载链接/条件存储 (Load-Linked/Store-Conditional): 一对指令,允许构建更复杂的原子操作。

基于“测试并设置”的忙等锁

利用原子指令,可以构建一个简单的锁,但线程在等待时会“忙等”(自旋)。

int lock = 0;

acquire() {
    while (test_and_set(&lock) == 1) {
        // 锁为忙碌状态,循环等待(忙等)
    }
}
release() {
    lock = 0;
}

问题:忙等浪费CPU周期,尤其在单处理器上,等待线程会阻碍持有锁的线程运行。

优化:测试并测试-设置锁 (Test-and-Test-and-Set)

acquire() {
    while (1) {
        while (lock == 1) {} // 先普通读取(可能在缓存中)
        if (test_and_set(&lock) == 0) { // 再尝试原子获取
            break;
        }
    }
}

优点:减少了多处理器环境下对内存总线的争用,但仍是忙等。

迈向无忙等:混合锁(如Linux Futex)

理想锁的目标是:无竞争时快速(无需进入内核),有竞争时让等待者睡眠。

以下是利用原子指令和系统调用(如信号量)实现混合锁的概念模型:

int lock = 0;
bool maybe_sleep = false;

acquire() {
    while (test_and_set(&lock) == 1) {
        // 获取失败
        maybe_sleep = true;
        semaphore_wait(&lock); // 系统调用,进入内核休眠
        maybe_sleep = false;
    }
}

release() {
    lock = 0;
    if (maybe_sleep) { // 可能需要唤醒
        semaphore_wake(&lock); // 系统调用,唤醒等待者
    }
}

实际实现(如Linux的Futex)更为精巧,锁可能有三种状态:UNLOCKEDLOCKED(无竞争)、LOCKED(有竞争,需内核介入)。仅在检测到竞争时才进行系统调用。


生产者-消费者问题与锁的局限

为了协调更复杂的交互(如生产者-消费者),仅使用锁会带来问题。

考虑一个有界缓冲区,使用锁保护:

// 生产者伪代码
producer() {
    acquire(&buffer_lock);
    while (buffer_is_full()) { // 缓冲区满,等待
        // 问题:持有锁时循环等待!
    }
    enqueue(item);
    release(&buffer_lock);
}

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/ucb-cs162-os/img/22a440ab5259f377d19dc5be83e46a76_5.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/ucb-cs162-os/img/22a440ab5259f377d19dc5be83e46a76_6.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/ucb-cs162-os/img/22a440ab5259f377d19dc5be83e46a76_7.png)

// 消费者伪代码
consumer() {
    acquire(&buffer_lock);
    while (buffer_is_empty()) { // 缓冲区空,等待
        // 同样的问题!
    }
    item = dequeue();
    release(&buffer_lock);
    return item;
}

问题:如果生产者持有锁时发现缓冲区满,它会循环等待。但此时消费者无法获取锁来消费物品,导致死锁

一种低效的解决方法是不断获取和释放锁来检查条件,但这会导致忙等。我们需要更合适的同步原语。


信号量:更通用的同步原语 🚦

信号量由Dijkstra提出,是一种比锁更通用的同步工具。

信号量定义

信号量 S 是一个非负整数,支持两种原子操作:

  • P (或 down/wait):如果 S > 0,则 S = S - 1;否则,调用线程阻塞,直到 S > 0
  • V (或 up/post/signal)S = S + 1,并唤醒一个(或多个)正在 P 操作上阻塞的线程。

初始化时设定 S 的值。

铁路信号灯类比

将信号量视为铁路信号灯:

  • 初始值 S = 2 表示允许两列火车同时进入路段。
  • 火车通过前执行 P(S):若 S>0 则减1通过;若 S=0 则等待。
  • 火车离开后执行 V(S)S 加1,并可能唤醒等待的火车。

用信号量解决生产者-消费者问题

以下是使用两个信号量的解决方案核心思路:

semaphore empty_slots = BUFFER_SIZE; // 初始为空槽数量
semaphore filled_slots = 0;          // 初始为已填充槽数量
mutex buffer_mutex;                  // 保护缓冲区内部分配的互斥锁

producer() {
    while (1) {
        item = produce_item();
        P(empty_slots); // 等待有空槽
        acquire(&buffer_mutex);
        enqueue(item);
        release(&buffer_mutex);
        V(filled_slots); // 通知消费者有新物品
    }
}

consumer() {
    while (1) {
        P(filled_slots); // 等待有物品
        acquire(&buffer_mutex);
        item = dequeue();
        release(&buffer_mutex);
        V(empty_slots); // 通知生产者有空槽
        consume_item(item);
    }
}

优势:生产者会在缓冲区满时自动阻塞(P(empty_slots)),消费者会在缓冲区空时自动阻塞(P(filled_slots)),无需忙等,且不会死锁。


总结 🎯

本节课中我们一起学习了:

  1. 锁的演进:从复杂的软件实现,到利用禁用中断(单处理器内核),再到基于原子指令(如测试并设置、比较并交换)构建用户态锁。我们探讨了忙等锁的问题以及混合锁(如Futex)的设计目标。
  2. 锁的局限性:在解决如生产者-消费者等多线程协调问题时,仅使用锁容易导致死锁或忙等。
  3. 信号量:作为一种更高级的同步原语,信号量通过 P (down) 和 V (up) 操作,优雅地解决了资源计数和线程等待/唤醒的问题,是构建复杂同步模式(如生产者-消费者)的基础。

在接下来的课程中,我们将继续探讨另一种高级同步抽象——监视器,并深入更多同步实践与模式。


(感谢收看)

操作系统同步原语教程:信号量、监视器与读写者问题 🧠

在本教程中,我们将学习操作系统中的高级同步概念。我们将从原子指令开始,逐步深入到信号量(Semaphore)和监视器(Monitor),并最终探讨经典的读写者问题。这些知识对于理解多线程编程和并发控制至关重要。


1. 原子指令序列 ⚙️

上一节我们介绍了同步的基本需求。本节中,我们来看看实现同步的基础——原子指令序列。

原子操作意味着一个不可中断的操作序列。如果没有原子性,实现同步通常需要禁用中断或使用非理想的加载/存储指令。现代架构提供了原子指令来简化同步。

以下是几种常见的原子指令:

  • 测试与设置 (Test and Set):这是一个原子操作,它将一个内存位置设置为1,并返回该位置之前的值。其C语言伪代码形式如下:
    int test_and_set(int *lock) {
        int old = *lock;
        *lock = 1;
        return old;
    }
    
  • 交换 (Swap):原子地交换一个寄存器和一个内存位置的值。
  • 比较与交换 (Compare and Swap, CAS):原子地比较一个内存位置的值与一个期望值,如果匹配,则用新值替换它。其逻辑如下:
    int compare_and_swap(int *value, int expected, int new_value) {
        if (*value == expected) {
            *value = new_value;
            return 1; // 成功
        }
        return 0; // 失败
    }
    

这些指令是构建更复杂同步原语(如锁)的基石。


2. 从原子指令到锁 🔒

理解了原子指令后,我们可以利用它们来构建锁。锁是保证互斥(Mutual Exclusion)的基本工具。

一个简单的锁可以使用“测试与设置”指令来实现。线程通过循环执行test_and_set来尝试获取锁(忙等待)。然而,忙等待会浪费CPU资源。更高效的锁实现(如Linux中的Futex)会结合用户空间的快速路径和需要时的内核介入,从而在无竞争时避免系统调用开销。


3. 有界缓冲区问题与信号量 🥤

锁解决了互斥问题,但对于更复杂的同步约束(如“生产者-消费者”模型中的有界缓冲区)则显得力不从心。本节我们引入一个更强大的工具——信号量。

有界缓冲区问题描述如下:一个固定大小的缓冲区,生产者向其中放入数据,消费者从中取出数据。生产者不能在缓冲区满时放入,消费者不能在缓冲区空时取出。

最初尝试仅用锁来实现会导致死锁或忙等待。例如,生产者在持有锁时发现缓冲区满而进入睡眠,消费者因无法获取锁而不能消费,从而导致死锁。

信号量是一个非负整数,提供两个原子操作:

  • P() 或 down():等待信号量值变为正数,然后将其减1。如果值为0,则调用线程休眠。
  • V() 或 up():将信号量值加1。如果有线程在该信号量上休眠,则唤醒其中一个。

信号量可以用于多种同步场景:

  • 初始值为1的信号量:相当于一个互斥锁(Mutex)。
  • 初始值为0的信号量:可用于线程间等待/通知(类似join)。
  • 初始值为N的信号量:可以控制对N个资源的访问。

3.1 用信号量实现有界缓冲区

以下是使用三个信号量实现有界缓冲区的方案:

  1. mutex:初始值为1的二进制信号量,用于保护对缓冲队列本身的访问(互斥)。
  2. empty:初始值为缓冲区大小N,表示当前空槽位的数量。
  3. full:初始值为0,表示当前已填充的槽位数量。

生产者伪代码:

producer() {
    while (true) {
        item = produce_item();
        P(empty);  // 等待空槽位
        P(mutex);  // 获取缓冲区锁
        enqueue(item);
        V(mutex);  // 释放缓冲区锁
        V(full);   // 增加已填充计数,可能唤醒消费者
    }
}

消费者伪代码:

consumer() {
    while (true) {
        P(full);   // 等待有数据
        P(mutex);  // 获取缓冲区锁
        item = dequeue();
        V(mutex);  // 释放缓冲区锁
        V(empty);  // 增加空槽位计数,可能唤醒生产者
        consume_item(item);
    }
}

注意P(empty)P(mutex)的顺序不能交换,否则可能再次引发持有锁入睡的死锁问题。


4. 监视器与条件变量 🏢

信号量功能强大但语义双重(既用于互斥又用于调度),代码可读性较差。本节我们介绍一种更结构化的同步范式——监视器。

监视器是一种编程语言构件,它封装了:

  • 一个锁:用于提供互斥访问。
  • 一个或多个条件变量:用于管理线程的等待与唤醒。

条件变量总是与一个锁关联,提供三个操作:

  • wait(lock):原子地释放锁并使当前线程在该条件变量上休眠。线程被唤醒后,会重新获取锁再返回。
  • signal():唤醒在该条件变量上等待的一个线程(如果有)。
  • broadcast():唤醒在该条件变量上等待的所有线程。

使用监视器的关键模式是:总是在持有锁的情况下检查条件、调用waitsignalbroadcast


4.1 用监视器实现有界缓冲区

使用一个锁(lock)和两个条件变量(notFull, notEmpty)可以更清晰地实现有界缓冲区。

生产者伪代码:

producer() {
    lock.acquire();
    while (buffer.isFull()) {
        wait(notFull, lock); // 等待“不满”信号,释放锁入睡
    }
    buffer.enqueue(item);
    signal(notEmpty); // 通知消费者“不空”
    lock.release();
}

消费者伪代码:

consumer() {
    lock.acquire();
    while (buffer.isEmpty()) {
        wait(notEmpty, lock); // 等待“不空”信号,释放锁入睡
    }
    item = buffer.dequeue();
    signal(notFull); // 通知生产者“不满”
    lock.release();
    return item;
}

为什么是while循环而不是if 这涉及到Mesa语义Hoare语义。大多数系统(如Pthreads, Java)采用Mesa语义:signal()只是将等待线程标记为可运行,并不保证它立即执行。在被调度执行前,可能有其他线程改变了条件。因此,被唤醒的线程必须重新检查条件,使用while循环是安全且必要的。


5. 读写者问题 📚✍️

最后,我们探讨一个经典的同步问题——读写者问题,它展示了监视器处理复杂策略的能力。

问题描述:一个共享数据资源(如数据库)可以被两类线程访问:

  • 读者:只读取数据,不修改。
  • 写者:读取并修改数据。

要求

  1. 允许多个读者同时读。
  2. 任一时刻最多只能有一个写者。
  3. 读者和写者不能同时访问资源。

我们需要在满足上述要求的同时,避免读者或写者饥饿。


5.1 使用监视器的解决方案

我们可以设计一个偏向写者的策略(写者优先):一旦有写者等待,新到达的读者必须等待,直到所有等待的写者完成。

我们需要以下状态变量和条件变量:

  • activeReaders:正在读的读者数。
  • waitingReaders:等待读的读者数。
  • activeWriters:正在写的写者数(0或1)。
  • waitingWriters:等待写的写者数。
  • okToRead:读者可开始读的条件变量。
  • okToWrite:写者可开始写的条件变量。

读者入口协议:

lock.acquire();
// 如果有活跃的写者或有写者在等待,则读者等待
while (activeWriters > 0 || waitingWriters > 0) {
    waitingReaders++;
    wait(okToRead, lock);
    waitingReaders--;
}
activeReaders++;
lock.release();
// ... 执行读操作 ...

读者出口协议:

lock.acquire();
activeReaders--;
// 如果我是最后一个离开的读者,并且有写者在等待,则唤醒一个写者
if (activeReaders == 0 && waitingWriters > 0) {
    signal(okToWrite);
}
lock.release();

写者入口协议:

lock.acquire();
// 如果有活跃的读者或写者,则写者等待
while (activeReaders > 0 || activeWriters > 0) {
    waitingWriters++;
    wait(okToWrite, lock);
    waitingWriters--;
}
activeWriters = 1;
lock.release();
// ... 执行写操作 ...

写者出口协议:

lock.acquire();
activeWriters = 0;
// 优先唤醒等待的写者
if (waitingWriters > 0) {
    signal(okToWrite);
} else if (waitingReaders > 0) {
    // 没有写者等待,则唤醒所有等待的读者
    broadcast(okToRead);
}
lock.release();

这个方案确保了写者优先,并且使用while循环和状态变量清晰地管理了复杂的等待与唤醒逻辑。


总结 🎯

本节课中我们一起学习了操作系统同步的核心概念:

  1. 原子指令(如Test-and-Set, CAS)是硬件提供的同步基础。
  2. 利用原子指令实现互斥访问,但无法处理复杂的调度约束。
  3. 信号量是一个通用的同步原语,通过P/V操作管理一个计数器,可用于实现互斥、资源计数和线程间信令。
  4. 监视器提供了更结构化的同步方式,结合条件变量,使同步代码更易编写和理解。关键模式是“获取锁 -> 检查条件(循环)-> 等待/操作 -> 发信号 -> 释放锁”。
  5. 读写者问题是一个经典的同步案例,展示了如何使用监视器实现带有特定策略(如写者优先)的复杂访问控制。

掌握这些同步原语及其应用场景,是编写正确、高效并发程序的关键。

posted @ 2026-02-04 18:20  绝不原创的飞龙  阅读(6)  评论(0)    收藏  举报