多伦多大学-ECE353-系统软件设计编程笔记-全-

多伦多大学 ECE353 系统软件设计编程笔记(全)

001:导论

在本节课中,我们将学习操作系统的基本概念,包括其核心功能、关键抽象以及系统调用如何作为用户程序与硬件之间的桥梁。我们将通过简单的示例和工具来探索这些概念。

课程概述与资源

我是你们的讲师 John。本课程是 ECE353 系统软件设计。课程的所有资源都集中在 compens.ch.gg 上。讲座内容可以在我的网站 elpson.com 或课程页面 courses/ECE353 上找到。实验内容托管在 GitHub 上,因此你需要一个 GitHub 账户。课程讨论使用 Discord 平台,你可以通过发送 /anon 加消息来匿名提问。所有讲座都会被录制并直播。

课堂参与非常重要,它能让我快速获得反馈并澄清任何不清楚的地方。课程评估包括六个实验(各占4%),两个在课堂时间内进行的测试(各占12.5%)以及一个期末考试(占50%)。请务必遵守学术诚信准则。

本课程主要使用 C 语言。如果你需要复习,可以参考《C程序设计语言》和《操作系统:三篇简单文章》等书籍。你需要掌握 C 编程、调试、二进制/十六进制/十进制转换以及理解内存是字节寻址的这一核心概念。

什么是操作系统?

操作系统位于应用程序和硬件之间,其核心功能是管理资源。应用程序(如用 Python 或 C 编写的程序)向操作系统请求资源(如内存、CPU、磁盘),操作系统则作为“黑箱”来处理这些请求。在硬件层面,我们通过内存映射 I/O 和中断等方式直接与硬件交互。操作系统则连接了这两个世界。

本课程将围绕三个核心概念展开:

  1. 虚拟化:通过模拟多个独立的副本,让多个程序共享一个物理资源(如 CPU、内存)。
  2. 并发:处理多个同时发生的事件,这是“真正编程”的开始。
  3. 持久性:确保数据在断电后依然保持一致性。

计算机科学中有一句名言:“计算机科学中的所有问题都可以通过增加一个间接层来解决”。操作系统正是这一思想的绝佳体现。

进程:运行中的程序

第一个重要的抽象是进程。一个程序是存储在磁盘上的指令和数据文件,而一个进程则是这个程序正在运行的一个实例。操作系统负责管理进程。

一个进程需要哪些资源?它需要自己的栈和堆内存。此外,由于一个 CPU 核心可能同时运行多个进程,每个进程还需要一组虚拟寄存器,让它感觉自己独占着 CPU 寄存器。

那么,操作系统如何同时运行两个程序呢?一个直观的想法是为每个程序分配不同的物理内存区域用于栈和堆。但这会带来严重问题:一个程序可以读取甚至修改另一个程序的内存,这破坏了安全性和隔离性。此外,对于全局变量,如果编译器为它们分配固定的物理地址,那么运行同一个程序的两个实例就会发生冲突,因为它们会使用相同的地址。

为了验证这一点,我们来看一个 C 程序示例。

上一节我们介绍了进程的概念和直接使用物理内存可能带来的问题。本节中,我们通过一个实验来揭示操作系统是如何解决这些问题的。

以下是一个简单的 C 程序,它在一个无限循环中递增一个局部变量和一个静态全局变量,并打印它们的值。

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

static int global = 0;

int main() {
    int local = 0;
    while (true) {
        ++local;
        ++global;
        printf("local = %d, global = %d\n", local, global);
        sleep(1);
    }
    return 0;
}

当我们同时运行这个程序的两个实例时,每个实例的 localglobal 变量都是从0开始独立递增的。这表明它们的值是隔离的。

如果我们打印变量的地址,会发现有趣的现象:两个进程中,局部变量 local 的地址是不同的,但静态全局变量 global 的地址却是相同的。然而,它们的值却互不影响。

这个现象揭示了虚拟内存的存在。每个进程都拥有自己独立的虚拟地址空间。虽然两个进程中的 global 变量虚拟地址相同,但操作系统通过内存管理单元(MMU)将它们映射到了不同的物理内存地址上。这样既保证了进程间的隔离性(一个进程无法访问另一个进程的内存),又为编译器分配全局变量地址提供了便利(可以使用固定的虚拟地址)。

系统调用:与操作系统对话

进程是独立的,但它们需要与外界通信(例如,打印“Hello World”)。这需要通过进程间通信来实现。一个关键的抽象是文件描述符,它代表了可以读取或写入字节流的资源,如文件、终端或网络连接。

然而,用户程序不能直接操作硬件。与操作系统通信的唯一方式是通过系统调用。系统调用看起来像普通的 C 函数,但其底层实现是特殊的。

例如,一个最小的“Hello World”程序需要两个系统调用:

  1. write(int fd, const void *buf, size_t count):向文件描述符 fd 写入 count 个字节,数据来自 buf
  2. exit_group(int status):退出当前进程,并返回状态码 status

按照惯例,文件描述符 0 是标准输入,1 是标准输出,2 是标准错误。因此,向终端打印“Hello World”就是向文件描述符 1 写入数据。

用“伪代码”表示的最简 Hello World 如下:

// 假设执行从 _start 开始
void _start() {
    write(1, "Hello World\n", 12); // 向标准输出写入12个字节
    exit_group(0); // 正常退出
}

系统调用与普通函数调用的区别在于其应用二进制接口。例如,在 ARM64 架构的 Linux 上,通过将系统调用号放入寄存器 x8,参数放入寄存器 x0x5,然后执行 svc 指令来触发系统调用。这限制了参数的数量和大小。相比之下,C 语言的调用约定(参数通过栈传递)则灵活得多。

内核模式与系统调用追踪

操作系统核心部分称为内核。CPU 有不同的特权模式,用户模式是普通应用程序运行的模式,权限最低。内核模式(或监管者模式)权限更高,允许执行直接与硬件交互的指令。内核就是运行在内核模式下的那部分操作系统软件。

系统调用是用户模式切换到内核模式的唯一途径。在 Linux 上,我们可以使用 strace 命令追踪任何程序执行的所有系统调用。

例如,追踪我们手写的 Hello World 程序,只会看到 writeexit_group 调用。而追踪 C 语言编写的 Hello World 程序,则会看到大量额外的系统调用,如打开 C 标准库文件 (openat)、读取库内容 (read)、管理堆内存 (brk) 等。这是因为 C 程序需要动态链接库并初始化运行环境。

更有趣的是,我们可以用 strace 追踪其他语言(如 Python、JavaScript)写的 Hello World。虽然它们最终都会调用 writeexit_group,但在此之前进行的系统调用数量差异巨大(C: ~38次,Python: ~472次,JavaScript: ~952次),这揭示了不同语言运行时环境的复杂性。strace 是一个强大的工具,可以让我们“看到”任何程序在底层究竟做了什么。

内核架构

内核有不同的设计架构,主要区别在于有多少功能运行在内核模式中:

  • 宏内核:如 Linux。将文件系统、设备驱动、网络协议栈等大量功能都放入内核空间。优点是性能高,缺点是内核体积大,潜在的安全漏洞影响也大。
  • 微内核:仅将最核心的功能(如进程调度、虚拟内存管理、基本IPC)放在内核空间。其他服务(如文件系统、驱动)作为用户态进程运行。优点是内核小巧,安全性、可靠性更高,缺点是进程间通信开销可能影响性能。
  • 混合内核:如 Windows 和 macOS。折中方案,将部分服务放在用户态,但内核仍比微内核包含更多功能。

课程总结

本节课中我们一起学习了操作系统导论。我们了解到操作系统是资源的管理者,并通过虚拟化(如虚拟内存、虚拟CPU)来实现资源的共享与隔离。进程是运行中程序的抽象,是操作系统管理的基本单位。用户程序通过系统调用这一唯一接口与内核交互,请求服务。内核运行在特权更高的内核模式下,直接管理硬件。最后,我们探讨了不同的内核架构(宏内核、微内核)及其设计权衡。

通过本课,你应当理解为什么学习操作系统能使你成为一个更好的程序员——因为所有软件要么通过操作系统与硬件交互,要么本身就在编写操作系统。在接下来的课程中,我们将深入探索虚拟化、并发和持久性这三大主题。

002:程序库 📚

在本节课中,我们将要学习操作系统中的程序库。我们将探讨什么是程序库,它们与内核的关系,以及静态库和动态库的区别。我们还将通过一个具体的例子,了解动态库更新时可能遇到的问题。

什么是操作系统?🤔

上一节我们介绍了内核是操作系统的一部分,它运行在内核模式。那么,操作系统的其他部分是什么呢?例如,苹果公司的Mac OS、iOS、iPadOS、watchOS都使用相同的Darwin内核,但它们通常被认为是不同的操作系统。这是因为我们通常根据操作系统能运行什么样的应用程序来定义它。一个iPhone应用、一个Mac应用和一个TV应用是不同的,因此,即使它们共享90%的代码,技术上也可以说它们是不同的操作系统。

应用程序在运行时可能会经过多层库的调用。我们昨天提到,内核位于内核空间,负责与硬件交互等重要任务。但你的应用程序通常并不直接使用系统调用接口,而是依赖于像C标准库这样的库来为你调用和使用它。

以下是应用程序可能依赖的库的层次结构:

  • C标准库:提供基本的系统调用封装和标准函数。
  • 系统守护进程库:抽象硬件细节,例如网络管理器使用它来识别以太网或Wi-Fi设备。
  • GUI工具包:提供用户界面,例如GTK,它本身会使用另一个库(如Wayland)来将像素显示到屏幕上。
  • 应用程序:如Firefox,可能会混合使用上述所有库。

因此,什么构成操作系统的一部分,取决于你的应用程序。Android和Debian都使用Linux内核,但应用程序不同。如果你只关心一个简单的控制台“Hello World”程序,你甚至可以将这个可执行文件传输到Android手机上运行,在这种情况下,你可能会说Debian和Android是同一个操作系统。

所以,操作系统的定义是:内核加上你的应用程序所需的所有库。唯一确定的是,内核绝对是操作系统的一部分,而一个库是否属于操作系统,则取决于你的应用程序。

编译过程回顾 🔄

在深入库之前,我们先快速回顾一下正常的C语言编译过程。希望这对大家是复习内容。

你有一个 .c 源文件,通过 gcc 或使用 makeMesonCMake 等工具进行编译。每个 .c 文件都会被编译成一个包含所有机器码和函数定义的 .o 目标文件。然后,在一个单独的链接步骤中,所有这些 .o 文件被“糅合”在一起,形成一个单一的可执行文件。这些 .o 文件本质上就是包含函数代码的ELF文件。

静态库与动态库 ⚖️

有两种类型的库:静态库和动态库。

静态库

静态库在链接时被包含到可执行文件中。假设你有多个 .c 文件,并且希望在不同的应用程序中重用它们。复制粘贴代码是非常糟糕的做法。为了创建可重用的代码,你可以将它们编译成一个库,然后多个应用程序都可以使用这个库。

静态库的创建过程如下:将多个 .o 文件通过归档工具打包成一个 .a 文件(即静态库)。然后,你的应用程序在编译时链接这个 .a 文件,而不是直接链接原始的 .c.o 文件。最终生成的可执行文件包含了库中所有代码的副本。这意味着,如果你更新了库中的代码,你必须重新编译链接了该库的所有程序,才能获得更新。

动态库

动态库包含可重用的代码,但其具体使用在运行时才确定。C标准库就是一个动态库。在Linux上,它通常是一个 .so 文件。

动态库的创建过程与静态库类似,也是将 .o 文件集合起来,但使用“共享链接”的方式生成 .so 文件。这个文件包含了一些额外信息,帮助操作系统在运行时找到函数(例如 printf)的位置。

对于使用动态库的程序,在编译链接时,编译器只需要知道使用了哪个库,并在可执行文件中记录这个依赖关系。当程序运行时,操作系统才会动态加载所需的 .so 文件。

动态库的主要优势在于代码共享。几乎每个你写过的程序都使用C标准库。如果每个程序都静态链接自己的 libc 副本,将会浪费大量内存。使用动态库,操作系统可以将 libc 只加载到内存一次,然后让所有使用它的程序共享这份内存。此外,更新动态库(例如修复 printf 的bug)后,所有使用它的程序在下次运行时都会自动使用新版本,无需重新编译。

检查一个可执行文件使用了哪些动态库,可以使用 ldd 命令,例如 ldd a.out

静态与动态库的权衡 ⚔️

上一节我们介绍了两种库的基本概念,本节我们来深入看看它们各自的优缺点。

静态链接的缺点

静态链接基本上就是将库的所有 .o 文件直接复制粘贴到你的可执行文件中。

  • 无法复用库:许多常用库(如C标准库)会在每个使用它的程序中产生重复副本,浪费磁盘和内存空间。
  • 更新困难:如果库中发现bug并修复,你必须重新编译所有使用该库的程序,才能将修复应用到这些程序中。

动态链接的风险

动态库虽然节省资源且便于更新,但也带来了风险。

  • 可能破坏程序:如果动态库的新版本引入了不兼容的更改,可能会直接导致依赖它的程序崩溃或行为异常,而你并没有修改自己的程序代码。这给调试带来了巨大挑战。

问题的核心在于 ABI(应用程序二进制接口) 的变更。即使库的 API(应用程序编程接口) 看起来没变(例如,一个结构体仍然有 xy 两个字段),但如果库的内部实现改变了这个结构体在内存中的布局(例如调换了 xy 的顺序),就破坏了ABI。编译时使用旧结构体布局的程序,在运行时链接到新布局的库,访问字段时就会得到错误的数据。

示例:ABI破坏演示 💥

让我们通过一个具体的例子来理解ABI破坏。假设我们有一个名为 libpoint 的库,它定义了一个表示点的结构体。

版本1的头文件 (point.h) 定义如下:

struct point {
    int y;
    int x;
};

在内存中,y 位于偏移量0字节处,x 位于偏移量4字节处。

版本2的头文件 (point.h) 仅仅调换了字段顺序:

struct point {
    int x;
    int y;
};

现在,x 位于偏移量0字节处,y 位于偏移量4字节处。API没变(都有xy),但ABI变了。

库提供创建点、获取x、获取y、销毁点的函数。

现在,我们写一个程序 (example.c):

  1. 使用 point_create(1, 2) 创建一个点。
  2. 通过库函数 point_get_x(p)point_get_y(p) 打印坐标。
  3. 直接通过结构体指针 p->xp->y 打印坐标。

场景分析:

  • 场景A:程序用 V1头文件 编译,并链接 V1库。运行结果:(1, 2)(1, 2)。一切正常。
  • 场景B:程序用 V1头文件 编译,但运行时加载了 V2库(模拟库更新)。运行结果:库函数打印 (1, 2),但直接访问结构体打印 (2, 1)

原因:
程序在编译时认为结构体是 {y, x},所以 p->x 会去访问内存中第二个整数(偏移量4)。而V2库在创建点和获取值时,是按照 {x, y} 布局来操作的。因此,库函数内部协商一致,能正确工作,但程序自己的直接内存访问就错位了。

这个例子展示了:如果你在头文件中公开了一个结构体的定义,那么它的内存布局就成为你API/ABI的一部分,以后就再也不能更改它了,否则会破坏所有使用它的客户端程序。

一个保持ABI稳定的正确做法是:在头文件中只声明不透明的指针类型(如 typedef struct point point_t;),而将结构体的具体定义隐藏在 .c 源文件中。这样,库的内部实现可以自由修改,只要公共函数接口不变即可。

你可以使用环境变量 LD_LIBRARY_PATH 来指定运行时优先搜索库的路径,从而模拟不同版本库的加载。

语义化版本控制 🏷️

为了管理库的变更并明确告知开发者兼容性,社区采用了语义化版本控制 (SemVer)

版本号格式为:主版本号.次版本号.修订号 (例如 2.1.3)。

  • 主版本号:当你做了不兼容的 API 或 ABI 更改时递增。从 1.x.x 升级到 2.x.x 意味着很可能需要重新编译客户端程序。
  • 次版本号:当你以向后兼容的方式添加新功能时递增。从 1.1.x 升级到 1.2.x 是安全的,新程序可以依赖 1.2 的新特性,而旧程序依然能工作。
  • 修订号:当你做了向后兼容的问题修复时递增。从 1.1.1 升级到 1.1.2 是安全的,通常建议用户更新。

遵循此规则,使用动态库的用户就能通过版本号判断升级是否安全。

动态加载器的其他用途 🛠️

动态库加载机制还有一些有趣的用途,例如调试和性能分析。

LD_PRELOAD

环境变量 LD_PRELOAD 可以强制在程序运行前先加载指定的库。这常被用于:

  • 调试:包装(Wrap)系统函数如 mallocfree,在自定义版本中打印日志,以追踪内存分配和释放情况,检查内存泄漏。
  • 性能分析:替换关键函数以进行性能计数。
  • 安全研究:拦截函数调用。

例如,你可以写一个 libmalloc_wrapper.so,在里面定义自己的 mallocfree,它们会先打印分配/释放的大小和地址,然后再调用真正的 malloc/free。通过 LD_PRELOAD=./libmalloc_wrapper.so ./my_program 运行你的程序,就能看到所有内存操作。

关于内存泄漏的一个说明

一个有趣的现象是,C标准库(如 printf)内部也可能会调用 malloc 来分配临时缓冲区,并且通常不会释放这些内存。这是因为这些内存在进程生命周期内会被重复使用,并且在进程退出时,操作系统内核会回收其所有资源,因此显式释放它们只会降低性能且无必要。

Valgrind 这样的内存检查工具知道这一点,它会将来自 libc 的这类分配标记为“已释放”,从而避免误报。但如果你在自己的库或程序中出于类似原因不释放某些内存,Valgrind 会报告泄漏,你需要有充分的理由来解释其合理性。

除了 Valgrind,你也可以使用编译器内置的地址消毒剂 (AddressSanitizer) 来检测内存错误,它通过编译时插桩实现,运行速度比 Valgrind 更快。例如,在 clanggcc 中使用 -fsanitize=address 选项。

C库函数与系统调用 🔗

你可能注意到,在C语言中直接进行系统调用很罕见,通常都是使用C标准库函数。这是因为C库函数在系统调用之上提供了更友好、更强大的抽象:

  • 错误处理:系统调用通过返回值指示错误,而C库函数会设置全局变量 errno
  • 缓冲:如 printf 会对输出进行缓冲,可能积累多次写入后才进行一次实际的 write 系统调用,以提高效率。
  • 简化接口:一个C库函数可能组合多个系统调用,或添加新功能。例如,exit 系统调用会立即终止进程。而C库的 exit() 函数则允许你通过 atexit() 注册一些清理函数,在进程真正终止前运行。

总结 📝

本节课中我们一起学习了程序库的核心概念。

  • 操作系统由内核和应用程序所需的库共同构成。
  • 静态库 (.a) 在编译链接时被整合进可执行文件,导致代码重复但部署简单。
  • 动态库 (.so) 在运行时加载,允许多个程序共享代码,节省内存,便于更新,但存在因ABI不兼容而破坏现有程序的风险。
  • ABI破坏可能由看似无害的更改(如结构体字段重排)引起。保持ABI稳定的一个关键方法是隐藏实现细节(如使用不透明指针)。
  • 语义化版本控制 (SemVer) 为库的版本管理提供了清晰的规则,帮助用户判断升级的兼容性。
  • 动态链接器提供了 LD_PRELOAD 等强大工具,可用于调试、性能分析和函数拦截。
  • C标准库函数在底层系统调用之上提供了缓冲、错误处理和额外功能(如 atexit)等便利。

理解这些库的工作原理,对于构建稳定、高效且可维护的软件系统至关重要。

003:进程 🖥️

在本节课中,我们将要学习进程的核心概念,包括进程是什么、如何创建和管理进程,以及进程的生命周期状态。我们将通过简单的示例和清晰的解释,帮助初学者理解这些复杂的概念。

概述

进程是运行中程序的一个实例。操作系统通过进程来管理和执行程序。每个进程都有自己独立的内存空间、寄存器和文件描述符。理解进程是理解操作系统如何工作的基础。

进程的构成

上一节我们介绍了进程的基本概念,本节中我们来看看一个进程具体包含哪些组成部分。

一个进程需要跟踪以下信息才能运行:

  • 栈 (Stack):用于存储函数调用和局部变量。
  • 堆 (Heap):用于动态内存分配。
  • 寄存器 (Registers):存储CPU的当前状态。
  • 虚拟内存 (Virtual Memory):进程拥有的独立地址空间,栈和堆都位于其中。
  • 打开的文件描述符 (Open File Descriptors):指向内核中资源的指针数组(如文件、终端)。文件描述符是这个数组的索引。

进程由内核管理,运行在用户空间 (User Space)。当应用程序需要内核提供服务(如读写文件)时,需要通过系统调用 (System Call) 跨越边界进入内核空间 (Kernel Space)

应用程序通常通过标准C库等包装函数来使用系统调用,这些库可能添加额外功能或组合多个系统调用。

进程控制块与生命周期

我们已经了解了进程的组成部分,现在来看看操作系统是如何在内部表示和管理一个进程的。

所有关于进程的信息都存储在一个称为进程控制块 (Process Control Block, PCB) 的数据结构中。在Linux中,它被称为 task_struct。PCB包含:

  • 进程状态
  • CPU寄存器的保存副本
  • 调度信息
  • 内存管理信息
  • I/O状态信息
  • 各种统计信息

每个进程都有一个唯一的进程ID (Process ID, PID),用于在运行时标识该进程。

进程有其生命周期,可以用状态图表示:

  1. 创建 (Created):内核为新进程分配资源,准备执行。
  2. 就绪/等待 (Ready/Waiting):进程已准备好运行,等待内核调度。
  3. 运行 (Running):进程正在CPU上实际执行。
  4. 阻塞 (Blocked):进程因等待系统调用完成(如I/O操作)而暂停执行。
  5. 终止 (Terminated):进程执行完毕,通过 exit 系统调用结束。

进程可以在运行、就绪和阻塞状态之间转换,最终进入终止状态。

在Linux上,可以通过 /proc 文件系统查看进程状态。每个PID对应一个目录,其中的 status 文件包含了该进程的详细信息。

创建进程:fork 系统调用

了解了进程的生命周期后,本节我们来看看如何实际创建一个新的进程。

在Unix/Linux系统中,创建新进程的主要方式是使用 fork() 系统调用。与Windows一次性创建新进程不同,fork 的工作方式是复制当前正在运行的进程

调用 fork() 后,会创建一个新的子进程 (Child Process),它是调用者父进程 (Parent Process)完全克隆。两个进程拥有相同的变量、内存状态,从 fork() 调用之后的位置开始独立运行。

fork() 的API非常简单:

pid_t fork(void);

它返回一个整数:

  • 父进程中,返回子进程的PID(一个大于0的数)。
  • 子进程中,返回 0
  • 如果创建失败,返回 -1

因此,程序通过检查 fork() 的返回值来区分当前是在父进程还是子进程中运行。

fork 示例与特性

现在我们已经知道了 fork 的基本原理,让我们通过一个具体的例子来观察它的行为,并理解一些重要特性。

考虑以下简单程序:

#include <stdio.h>
#include <unistd.h>
int main() {
    printf("Start (PID: %d)\n", getpid());
    pid_t pid = fork(); // 从这里开始“分裂”
    if (pid > 0) {
        // 父进程代码
        printf("Parent: My PID is %d, Child‘s PID is %d\n", getpid(), pid);
    } else if (pid == 0) {
        // 子进程代码
        printf("Child: My PID is %d, My Parent‘s PID is %d\n", getpid(), getppid());
    }
    printf("End (PID: %d)\n", getpid());
    return 0;
}

运行此程序,输出可能类似于:

Start (PID: 100)
Parent: My PID is 100, Child‘s PID is 101
End (PID: 100)
Child: My PID is 101, My Parent‘s PID is 100
End (PID: 101)

或者父进程和子进程的输出可能交错出现。

关于 fork 需要理解的关键点:

  • 两个进程返回:一个进程调用 fork,两个进程从 fork 返回。
  • 独立与并发:父进程和子进程是独立的,它们并发执行。操作系统决定它们的运行顺序,程序员无法预设。
  • 写时复制:出于效率考虑,现代操作系统使用写时复制 (Copy-On-Write) 技术。子进程与父进程共享内存页,只有当任一进程试图修改某个内存页时,才会复制该页。

执行新程序:exec 系列系统调用

fork 创建的是当前程序的副本,但通常我们想运行一个全新的程序。这时就需要 exec 系列系统调用。

exec 函数会用指定的新程序替换当前进程的内存空间(代码、数据、堆栈),然后从新程序的 main 函数开始执行。进程的PID保持不变

一个常用的 exec 函数是 execve

int execve(const char *pathname, char *const argv[], char *const envp[]);
  • pathname:要执行程序的全路径。
  • argv:传递给新程序的参数数组(类似于 main 函数的 argv)。按照惯例,argv[0] 通常是程序名。
  • envp:环境变量数组。
  • 如果成功,该函数不返回(因为原程序已被替换)。如果失败,返回 -1。

一个简单的例子:

#include <stdio.h>
#include <unistd.h>
int main() {
    printf("I‘m about to run ls...\n");
    char *args[] = {"ls", "-l", NULL}; // 参数列表,以NULL结束
    execve("/bin/ls", args, NULL);
    // 如果execve成功,下面的代码永远不会执行
    printf("This line will never print if execve succeeds.\n");
    return 0;
}

组合使用 forkexec:Shell 的工作原理

单独使用 forkexec 都有局限,将它们组合起来才是创建和运行新程序的完整模式。这也是Shell(命令行解释器)运行命令的基本原理。

典型模式如下:

  1. 父进程调用 fork() 创建子进程
  2. 子进程调用 exec() 系列函数,将自己替换成想要运行的新程序。
  3. 父进程可以继续执行其他任务,或者调用 wait()(见下一节)等待子进程结束。

示例代码:

#include <stdio.h>
#include <unistd.h>
#include <sys/wait.h>
int main() {
    pid_t pid = fork();
    if (pid == 0) {
        // 子进程:执行 ls
        char *args[] = {"ls", "-l", NULL};
        execve("/bin/ls", args, NULL);
        // 如果execve失败
        perror("execve failed");
        return 1;
    } else if (pid > 0) {
        // 父进程:等待子进程结束
        printf("Parent waiting for child (PID: %d) to finish...\n", pid);
        wait(NULL); // 等待任意子进程结束
        printf("Child finished.\n");
    } else {
        // fork失败
        perror("fork failed");
        return 1;
    }
    return 0;
}

这就是Shell的工作方式:你输入一个命令(如 ls),Shell进程 fork 出一个子进程,子进程 execls 程序,Shell父进程则 wait 子进程结束,然后重新显示提示符。

进程同步:wait 系统调用

在并发环境中,父进程通常需要知道子进程何时结束以及它的退出状态。wait() 系统调用就是用于此目的。

wait() 使父进程阻塞,直到它的一个子进程终止。然后它回收该子进程的资源,并获取其退出状态。

基本用法:

pid_t wait(int *wstatus);
  • wstatus:一个指向整数的指针,用于存储子进程的终止状态信息。
  • 返回值:成功时返回被终止子进程的PID,失败时返回 -1。

可以使用宏来检查 wstatus

  • WIFEXITED(wstatus):如果子进程正常终止(通过 returnexit),则为真。
  • WEXITSTATUS(wstatus):如果 WIFEXITED 为真,此宏提取子进程的退出码(main 函数的返回值或 exit 的参数)。

示例:

#include <stdio.h>
#include <unistd.h>
#include <sys/wait.h>
int main() {
    pid_t pid = fork();
    if (pid == 0) {
        // 子进程
        sleep(2);
        printf("Child exiting with code 42\n");
        return 42; // 或 exit(42);
    } else if (pid > 0) {
        // 父进程
        int status;
        printf("Parent waiting for child...\n");
        pid_t child_pid = wait(&status); // 阻塞等待
        if (WIFEXITED(status)) {
            printf("Child (PID: %d) exited with status: %d\n", child_pid, WEXITSTATUS(status));
        }
    }
    return 0;
}

还有 waitpid() 函数,可以等待特定的子进程,并设置非阻塞选项。

特殊进程状态:僵尸进程与孤儿进程

进程的生命周期并非总是完美的父等子。当进程间关系处理不当时,会产生两种特殊状态:僵尸进程和孤儿进程。

僵尸进程 (Zombie Process)

当一个子进程已经终止,但其父进程尚未调用 wait() 读取其退出状态时,该子进程就成为一个僵尸进程。它已经不再运行,消耗极少资源,但仍在进程表中占有一个条目(PID)。其目的是保留退出信息供父进程查询。

如果父进程一直不 wait,僵尸进程会一直存在。直到父进程也结束时,它会被“收养”(见下文)并最终被清理。

孤儿进程 (Orphan Process)

当一个父进程先于其子进程终止时,剩下的子进程就成为了孤儿进程。

Unix/Linux 系统为了解决孤儿进程的问题,设定了一个规则:孤儿进程会被进程ID为1的 init 进程(或 systemd 等现代替代品)“收养”init 进程会负责为这些孤儿进程调用 wait(),从而清理它们,防止它们变成永久的僵尸进程。

任何进程都可以通过设置使自己成为一个“子收割者 (subreaper)”,来收养其子孙进程变成的孤儿,而不是交给 init

总结

本节课中我们一起学习了进程的核心知识:

  1. 进程是程序的运行实例,拥有独立的内存、寄存器、文件描述符等资源,由内核通过进程控制块管理。
  2. 使用 fork() 系统调用可以创建新进程,新进程是原进程的克隆。
  3. 使用 exec() 系列系统调用可以让一个进程“变身”去执行一个全新的程序。
  4. forkexec 的组合是Unix/Linux系统创建和运行新程序的标准模式,也是Shell的工作原理。
  5. 父进程使用 wait() 系统调用来等待子进程结束,并获取其退出状态,这是负责任的进程管理方式。
  6. 不正确的进程管理会导致僵尸进程(已终止但未被回收)和孤儿进程(父进程先终止),系统有相应的机制(init 收养)来处理这些问题。

理解进程的创建、执行、同步和清理,是进行系统级编程和理解操作系统行为的基础。

004:基础进程间通信 🧩

在本节课中,我们将要学习进程间通信的基础知识。进程是独立的,因此需要一种机制让它们能够相互通信,这就是进程间通信。我们将从最简单的文件读写开始,逐步深入到管道、信号等更复杂的通信方式。

进程间通信概述

进程间通信是在两个或多个进程之间传输字节的过程。从技术上讲,读写文件就是一种进程间通信的形式。进程不需要同时存活,一个进程可以写入文件后结束,另一个进程随后可以读取该文件。

读写系统调用允许传输任何字节序列,只要写入的是字节即可。

一个简单的程序:模仿 cat 命令

上一节我们介绍了IPC的基本概念,本节中我们来看看一个具体的例子。以下是一个简单的程序,它从标准输入读取数据,并写入标准输出。

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

int main(void) {
    char buffer[4096];
    ssize_t bytes_read;
    while ((bytes_read = read(STDIN_FILENO, buffer, sizeof(buffer))) > 0) {
        ssize_t bytes_written = write(STDOUT_FILENO, buffer, bytes_read);
        if (bytes_written == -1) {
            perror("write");
            return 1;
        }
        assert(bytes_written == bytes_read);
    }
    if (bytes_read == -1) {
        perror("read");
        return 1;
    }
    return 0;
}

这个程序的行为类似于 cat 命令。如果不带参数运行,它会从终端读取输入,并回显到终端。输入 Ctrl+D 可以发送文件结束符,使 read 返回0,程序正常退出。

使用命令行参数打开文件

我们可以修改上面的程序,使其能够通过命令行参数指定要读取的文件。

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

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/trt-ece353-sys-sw-dsn-prog/img/4412766116f36f74ac92cbbcd362df42_7.png)

int main(int argc, char *argv[]) {
    if (argc != 2) {
        fprintf(stderr, "Usage: %s <filename>\n", argv[0]);
        return 1;
    }

    close(STDIN_FILENO);
    int fd = open(argv[1], O_RDONLY);
    if (fd == -1) {
        perror("open");
        return 1;
    }

    char buffer[4096];
    ssize_t bytes_read;
    while ((bytes_read = read(STDIN_FILENO, buffer, sizeof(buffer))) > 0) {
        ssize_t bytes_written = write(STDOUT_FILENO, buffer, bytes_read);
        if (bytes_written == -1) {
            perror("write");
            return 1;
        }
        assert(bytes_written == bytes_read);
    }
    if (bytes_read == -1) {
        perror("read");
        return 1;
    }
    return 0;
}

程序通过关闭标准输入文件描述符(0),然后打开指定文件。由于系统总是分配最小的可用文件描述符,新打开的文件会获得描述符0,从而成为新的标准输入。

Shell 重定向

实际上,利用Shell的重定向功能,即使程序本身没有文件处理代码,也能处理文件。只要程序使用标准文件描述符,Shell就可以在进程启动前重定向它们。

例如,使用 < 可以将文件内容作为标准输入:

./read_write_example < open_example.c

使用 > 可以将标准输出重定向到文件:

./read_write_example < open_example.c > copy.c

这相当于用 cat 命令复制了一个文件。

管道连接进程

我们可以使用管道符 | 将一个进程的标准输出连接到另一个进程的标准输入。

./read_write_example < open_example.c | cat

这个命令会将第一个进程的输出(文件内容)作为第二个 cat 进程的输入,最终结果还是输出文件内容。可以连接多个进程,尽管效率不高。

信号处理

当我们在终端按下 Ctrl+C 时,会向当前前台进程发送一个 SIGINT(信号2)信号。默认情况下,这个信号的处理方式是终止进程。

进程可以自定义信号处理函数来改变对信号的反应。以下是相关的信号编号(在Linux x86-64上):

  • 2: SIGINT(键盘中断)
  • 9: SIGKILL(强制终止,无法被捕获或忽略)
  • 11: SIGSEGV(段错误,非法内存访问)
  • 15: SIGTERM(请求终止,可以被处理)

以下是一个忽略 SIGINTSIGTERM 信号的程序示例:

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

void handle_signal(int sig) {
    printf("Ignoring signal %d\n", sig);
}

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/trt-ece353-sys-sw-dsn-prog/img/4412766116f36f74ac92cbbcd362df42_17.png)

int main(void) {
    struct sigaction sa = {.sa_handler = handle_signal};
    sigaction(SIGINT, &sa, NULL);
    sigaction(SIGTERM, &sa, NULL);

    char buf[4096];
    ssize_t bytes_read;
    while ((bytes_read = read(STDIN_FILENO, buf, sizeof(buf))) > 0) {
        write(STDOUT_FILENO, buf, bytes_read);
    }
    return 0;
}

运行此程序后,Ctrl+C 将不再退出程序,而是打印提示信息。要终止它,可以在另一个终端使用 kill 命令。

kill 命令默认发送 SIGTERM(15)信号:

kill <PID>

要强制终止进程,需要发送 SIGKILL(9)信号,该信号无法被进程捕获或忽略:

kill -9 <PID>

非阻塞等待与信号

大多数系统调用是阻塞的,但也可以使用非阻塞版本。例如,waitpid 可以使用 WNOHANG 选项变为非阻塞,它立即返回,如果子进程尚未结束则返回0。

轮询(不断检查)的方式效率较低。更好的方式是使用中断机制:当子进程终止时,内核会向父进程发送 SIGCHLD 信号。父进程可以注册一个处理函数来立即清理子进程,避免产生僵尸进程。

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

void handle_sigchld(int sig) {
    (void)sig;
    pid_t pid;
    int status;
    while ((pid = waitpid(-1, &status, WNOHANG)) > 0) {
        printf("Child %d exited with status %d\n", pid, WEXITSTATUS(status));
    }
}

int main(void) {
    signal(SIGCHLD, handle_sigchld);

    pid_t pid = fork();
    if (pid == 0) {
        sleep(2);
        return 42;
    }

    printf("Parent sleeping...\n");
    sleep(9999);
    return 0;
}

在这个例子中,父进程可以长时间睡眠或做其他工作。当子进程在2秒后退出时,SIGCHLD 信号会中断父进程,触发处理函数来清理子进程。

信号处理的复杂性

信号处理函数需要是可重入的,因为一个信号处理函数可能在执行时被另一个信号中断,从而再次被调用。在信号处理函数中访问全局变量或调用非异步信号安全的函数(如 printf)可能导致不可预知的行为。

例如,在一个信号处理函数中关闭文件描述符时,如果另一个相同的信号到达,处理函数再次被调用,可能会尝试关闭一个已经关闭的描述符,导致错误。

总结

本节课中我们一起学习了基础进程间通信的几种方式:

  1. 文件描述符与简单读写:通过读写标准输入输出来实现进程间通信,模仿了 cat 命令。
  2. Shell重定向与管道:利用Shell特性连接进程的输入输出,无需修改程序代码。
  3. 信号:理解了如何发送、捕获和处理信号,包括 SIGINTSIGTERMSIGKILLSIGCHLD
  4. 进程等待:比较了阻塞等待、非阻塞轮询和基于信号的异步等待子进程终止的方法。
  5. 注意事项:认识到编写信号处理代码的复杂性,特别是可重入性的要求。

这些是构建更复杂并发和通信系统的基础组件。

005:进程实践 🚀

在本节课中,我们将深入学习进程管理的实践操作,包括进程间通信(IPC)的核心机制——管道(pipe)。我们将通过阅读简单的操作系统内核代码来理解进程的创建与孤儿进程管理,并通过编程示例掌握管道的创建、使用以及与forkexecdup2等系统调用的配合。

操作系统内核中的进程管理 🔍

上一节我们介绍了进程的基本概念和状态。本节中,我们来看看一个真实但简化的操作系统内核(如XV6)是如何管理进程的。

内核的第一个进程(init进程)负责两个核心任务:启动用户交互程序(如shell)和作为“孤儿院”收养并清理已终止但未被父进程回收的进程(僵尸进程)。

以下是XV6内核中init进程的简化逻辑:

int main(void) {
    int pid;
    // 打开控制台(终端),设置标准输入、输出、错误
    if(open("console", O_RDWR) < 0) {
        // 处理错误...
    }
    dup(0); // 复制文件描述符0到1(标准输出)
    dup(0); // 复制文件描述符0到2(标准错误)

    for(;;) {
        printf("init: starting sh\n");
        pid = fork();
        if(pid < 0) {
            printf("init: fork failed\n");
            exit(1);
        }
        if(pid == 0) {
            // 子进程:执行shell程序
            exec("sh", argv);
            printf("init: exec sh failed\n");
            exit(1);
        }
        // 父进程(init):无限循环等待子进程结束
        while((pid = wait(NULL)) >= 0) {
            if(pid == /* shell的PID */) {
                // 如果结束的是shell,则跳出循环重新启动一个
                printf("init: shell exited, restarting\n");
                break;
            }
            // 否则,只是清理了一个孤儿进程,继续等待
        }
    }
}

这段代码清晰地展示了init进程的双重职责:启动初始shell并持续调用wait来回收终止的子进程。

从单道到多道程序设计 🔄

早期的操作系统(如DOS)采用单道程序设计,即同一时间只能运行一个进程,这极大地限制了系统资源的利用率。

现代操作系统都采用多道程序设计,可以同时管理成百上千个进程。这些进程可以并行(在多核CPU上真正同时运行)或并发(通过快速切换,在单核上模拟同时运行)地执行。

操作系统内核中负责在多个就绪进程之间决定切换时机的组件称为调度器

进程切换与上下文切换 ⚙️

调度器的核心是一个无限循环,它控制着CPU在进程间的切换。这个过程称为上下文切换,其核心步骤如下:

  1. 暂停当前运行进程:停止正在CPU上执行的进程。
  2. 保存进程状态:将该进程的当前状态(主要是所有CPU寄存器的值)保存到内核内存中。
  3. 选择下一个进程:调度器根据算法决定接下来运行哪个就绪进程。
  4. 恢复进程状态:将选中进程之前保存的状态(寄存器值)加载回CPU。
  5. 恢复运行:CPU开始执行该进程的代码。

上下文切换是必要的系统开销。高效的操作系统会通过软硬件结合的方式(例如,仅保存程序实际用到的寄存器)将此开销控制在总运行时间的1%以下,使其几乎可以忽略不计。

进程间通信:管道(Pipe) 📞

在Shell中,我们使用竖线 | 来连接两个命令,将一个命令的输出作为另一个命令的输入。这个功能在程序内部是通过管道系统调用实现的。

pipe系统调用创建一个单向的通信通道。你可以将它想象成一个由内核管理的先进先出(FIFO)字节缓冲区。

以下是pipe系统调用的基本用法:

#include <unistd.h>
int pipe(int pipefd[2]);
  • pipefd:一个包含两个整数的数组。
  • 返回值:成功返回0,失败返回-1并设置errno

调用成功后,pipefd数组会被赋值两个新的文件描述符:

  • pipefd[0]:管道的读端。只能从这个文件描述符读取数据。
  • pipefd[1]:管道的写端。只能向这个文件描述符写入数据。

通过fork创建子进程后,父子进程将共享这些打开的文件描述符,从而可以通过管道进行通信。

管道编程实践 💻

下面我们通过一个例子来演示如何使用管道在父子进程间传递消息。

以下是创建管道、写入和读取数据的基本步骤:

  1. fork之前调用pipe创建管道。
  2. 在父进程中,向pipefd[1]写入数据。
  3. 在子进程中,从pipefd[0]读取数据。

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

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/trt-ece353-sys-sw-dsn-prog/img/cd9b9944f21d6e9de91d952f21d62664_39.png)

void check(int ret, const char* msg) {
    if(ret < 0) { perror(msg); exit(EXIT_FAILURE); }
}

int main() {
    int fds[2];
    char buffer[128];

    // 1. 创建管道
    check(pipe(fds), "pipe failed");

    pid_t pid = fork();
    check(pid, "fork failed");

    if (pid > 0) { // 父进程
        close(fds[0]); // 父进程不读,关闭读端
        const char* msg = "Hello from parent\n";
        ssize_t written = write(fds[1], msg, strlen(msg));
        check(written, "write failed");
        close(fds[1]); // 写入完毕,关闭写端
        wait(NULL); // 等待子进程结束
    } else { // 子进程
        close(fds[1]); // 子进程不写,关闭写端
        ssize_t bytes_read = read(fds[0], buffer, sizeof(buffer)-1);
        check(bytes_read, "read failed");
        buffer[bytes_read] = '\0'; // 确保字符串终止
        printf("Child received: %s", buffer);
        close(fds[0]); // 读取完毕,关闭读端
        exit(EXIT_SUCCESS);
    }
    return 0;
}

关键点与常见陷阱

  • 文件描述符管理:进程应立即关闭它不需要的管道端。例如,父进程只写不读,就应关闭读端(fds[0])。这不仅是良好的资源管理习惯,更是避免程序阻塞的关键(例如,读端未全部关闭会导致read无法返回0)。
  • 阻塞行为read系统调用在管道为空时会阻塞,直到有数据可读或所有写端都被关闭(此时read返回0,表示EOF)。
  • 数据一致性:内核保证写入管道的数据是顺序的,且每个字节只会被读取一次。即使多个进程同时读取同一管道,数据也不会重复。

综合示例:与子进程进行双向通信 🔄

我们常常需要与创建的子进程进行双向通信:既向子进程发送数据(模拟其标准输入),又从子进程接收数据(捕获其标准输出)。这需要用到两个管道以及dup2系统调用。

dup2系统调用的作用是复制一个文件描述符:

int dup2(int oldfd, int newfd);

它使newfd成为oldfd的副本(指向同一个资源)。如果newfd原本已经打开,则会先将其关闭。我们可以利用它将子进程的标准输入(文件描述符0)和标准输出(文件描述符1)重定向到我们创建的管道。

以下是实现双向通信子进程的框架:

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

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/trt-ece353-sys-sw-dsn-prog/img/cd9b9944f21d6e9de91d952f21d62664_45.png)

void check(int ret, const char* msg) { if(ret < 0) { perror(msg); exit(EXIT_FAILURE); } }

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/trt-ece353-sys-sw-dsn-prog/img/cd9b9944f21d6e9de91d952f21d62664_47.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/trt-ece353-sys-sw-dsn-prog/img/cd9b9944f21d6e9de91d952f21d62664_49.png)

void child_proc(int in_pipe[2], int out_pipe[2], const char* program) {
    // 关闭父进程使用的管道端
    close(in_pipe[1]);  // 子进程不从in_pipe写
    close(out_pipe[0]); // 子进程不从out_pipe读

    // 重定向标准输入到in_pipe的读端
    check(dup2(in_pipe[0], STDIN_FILENO), "dup2 stdin failed");
    close(in_pipe[0]); // 重定向后,关闭原文件描述符

    // 重定向标准输出到out_pipe的写端
    check(dup2(out_pipe[1], STDOUT_FILENO), "dup2 stdout failed");
    close(out_pipe[1]); // 重定向后,关闭原文件描述符

    // 执行目标程序(例如 "cat")
    execlp(program, program, (char *)NULL);
    // 如果exec成功,不会执行到这里
    perror("execlp failed");
    exit(EXIT_FAILURE);
}

void parent_proc(pid_t child_pid, int in_pipe[2], int out_pipe[2]) {
    // 关闭子进程使用的管道端
    close(in_pipe[0]);  // 父进程不从in_pipe读
    close(out_pipe[1]); // 父进程不向out_pipe写

    // 1. 向子进程的标准输入发送数据
    const char* input = "testing\n";
    ssize_t written = write(in_pipe[1], input, strlen(input));
    check(written, "write to child failed");
    close(in_pipe[1]); // 发送完毕,关闭写端

    // 2. 从子进程的标准输出读取数据
    char buffer[1024];
    ssize_t bytes_read = read(out_pipe[0], buffer, sizeof(buffer)-1);
    check(bytes_read, "read from child failed");
    buffer[bytes_read] = '\0';
    printf("Parent received from child:\n%s", buffer);
    close(out_pipe[0]); // 读取完毕,关闭读端

    // 3. 等待子进程结束
    int status;
    waitpid(child_pid, &status, 0);
    assert(WIFEXITED(status));
    assert(WEXITSTATUS(status) == 0);
}

int main(int argc, char* argv[]) {
    if(argc != 2) { fprintf(stderr, "Usage: %s <program>\n", argv[0]); exit(1); }

    int in_pipe[2], out_pipe[2]; // in_pipe: 父写 -> 子读, out_pipe: 子写 -> 父读
    check(pipe(in_pipe), "pipe in failed");
    check(pipe(out_pipe), "pipe out failed");

    pid_t pid = fork();
    check(pid, "fork failed");

    if (pid == 0) {
        child_proc(in_pipe, out_pipe, argv[1]);
    } else {
        parent_proc(pid, in_pipe, out_pipe);
    }
    return 0;
}

程序逻辑流程

  1. 创建两个管道:in_pipe(用于父进程向子进程写数据)和out_pipe(用于子进程向父进程写数据)。
  2. fork创建子进程。
  3. 子进程中:
    • 关闭不需要的管道端。
    • 使用dup2将标准输入(0)重定向到in_pipe[0](读端)。
    • 使用dup2将标准输出(1)重定向到out_pipe[1](写端)。
    • 调用execlp执行目标程序(如cat)。该程序将从重定向后的标准输入读取,并向重定向后的标准输出写入。
  4. 父进程中:
    • 关闭不需要的管道端。
    • in_pipe[1]写入数据(这些数据将成为子进程的输入)。
    • out_pipe[0]读取数据(这些数据是子进程的输出)。
    • 调用waitpid等待子进程结束。

重要调试技巧

  • 始终保持标准错误(文件描述符2)不被重定向,以便接收exec失败或断言错误等调试信息。
  • 严谨地检查每个系统调用的返回值。
  • 使用strace工具跟踪程序执行的系统调用,是诊断文件描述符问题的利器。

总结 📝

本节课中我们一起深入探讨了进程管理的实践:

  • 我们通过阅读XV6的init代码,理解了操作系统如何启动和管理进程生命周期。
  • 我们学习了管道作为进程间通信的基本机制,掌握了pipereadwrite的用法。
  • 我们通过编程实践,熟悉了forkexec系列函数以及强大的dup2函数。
  • 我们完成了一个综合示例,实现了与子进程的双向通信,这是许多高级工具(如Shell本身)的基础。
  • 我们强调了文件描述符管理的重要性,不当的管理会导致程序阻塞、资源泄漏或数据混乱。

掌握这些概念和技能,是理解复杂软件系统交互和进行系统级编程的基石。

006:基础调度算法

概述

在本节课中,我们将学习操作系统中的基础调度算法。调度是操作系统分配CPU时间给多个进程的核心机制。我们将从最简单的算法开始,逐步探讨更复杂的策略,并分析它们各自的优缺点。


资源类型

操作系统的核心任务之一是分配资源。资源主要分为两种类型:

  • 可抢占资源:这类资源可以在任何时候被操作系统收回并分配给其他进程。最典型的例子是CPU。多个进程可以共享一个CPU,通过调度轮流使用它。
  • 不可抢占资源:这类资源一旦分配给某个进程,就不能随意收回,除非进程主动释放。例如磁盘空间。如果磁盘已满,操作系统不能随意删除其他进程的文件来腾出空间,必须等待文件被显式删除。

在大多数通用系统中,CPU被视为可抢占资源。调度器就是负责管理CPU时间分配的组件。本节课我们假设系统是单核的。


调度指标

在设计和评估调度算法时,我们主要关心四个核心指标,它们之间有时会相互冲突:

  1. 最小化等待时间:指进程从就绪到最终完成所花费的总时间。
  2. 最小化响应时间:指进程从就绪到第一次获得CPU开始运行所花费的时间。这对于交互式应用(如移动鼠标)的流畅性至关重要。
  3. 最大化CPU利用率:我们希望CPU尽可能保持忙碌,避免空闲。
  4. 最大化吞吐量:指在单位时间内完成尽可能多的进程。
  5. 保证公平性:确保每个进程都能获得公平份额的CPU时间。这个目标常与其他指标(如最小化等待时间)相冲突。

先来先服务调度

上一节我们介绍了调度的基本概念和指标,本节中我们来看看最简单的调度算法:先来先服务。

先来先服务是最基础的调度算法,类似于生活中的排队。进程按照到达就绪队列的顺序依次执行。该算法是非抢占式的,意味着一旦一个进程开始运行,它就会一直运行直到完成或主动放弃CPU。

以下是评估调度算法时常用的进程信息表示:

进程名 到达时间 执行时间
P1 0 7
P2 0 4
P3 0 1
P4 0 4

假设到达顺序为 P1, P2, P3, P4。我们可以用甘特图表示调度顺序:

时间:  0    1    2    3    4    5    6    7    8    9   10   11   12   13   14   15   16
      [P1  P1   P1   P1   P1   P1   P1][P2  P2   P2   P2][P3][P4  P4   P4   P4]

以下是各进程的等待时间计算:

  • P1:到达即运行,等待时间 = 0
  • P2:需要等P1运行7个单位,等待时间 = 7
  • P3:需要等P1和P2运行完,等待时间 = 7 + 4 = 11
  • P4:需要等P1、P2、P3运行完,等待时间 = 7 + 4 + 1 = 12

平均等待时间 = (0 + 7 + 11 + 12) / 4 = 7.5

问题:如果到达顺序变为 P3, P2, P4, P1,平均等待时间会降至3.75。这说明仅仅依赖到达顺序的调度效率可能很低,促使我们寻找更优的算法。


最短作业优先调度

上一节我们看到先来先服务算法的效率受进程到达顺序影响很大。本节我们引入一个能优化平均等待时间的算法:最短作业优先。

最短作业优先算法总是优先调度预计执行时间最短的进程。它同样是非抢占式的。

我们调整进程的到达时间,使其更符合实际情况:

进程名 到达时间 执行时间
P1 0 7
P2 2 4
P3 4 1
P4 5 4

调度过程如下:

  1. 时间0:只有P1就绪,运行P1。
  2. 时间2:P2到达。此时队列中有P2(执行时间4)。
  3. 时间4:P3到达(执行时间1)。由于1<4,P3被插到队列最前面。
  4. 时间5:P4到达(执行时间4)。队列顺序为 P3(1), P2(4), P4(4)。
  5. 时间7:P1结束。调度器选择队列头部的P3运行。
  6. 时间8:P3结束。选择P2运行(与P4执行时间相同,按到达顺序优先)。
  7. 时间12:P2结束。选择P4运行。

平均等待时间 = ( (7-0-7) + (8-2-4) + (8-4-1) + (12-5-4) ) / 4 = (0+2+3+3)/4 = 2.0

优点:理论上可证明,该算法能获得最小的平均等待时间。
缺点

  1. 饥饿:长作业可能因为不断有短作业到达而永远得不到执行。
  2. 不可预测:操作系统通常无法预知一个进程需要运行多久。


最短剩余时间优先调度

为了解决最短作业优先的饥饿问题并进一步提高响应性,我们引入抢占机制,得到最短剩余时间优先算法。

该算法是抢占式的最短作业优先。调度器总是选择剩余执行时间最短的进程来运行。当一个新进程到达时,如果它的剩余时间比当前正在运行的进程剩余时间更短,当前进程就会被抢占。

使用与上一节相同的进程数据:

进程名 到达时间 执行时间
P1 0 7
P2 2 4
P3 4 1
P4 5 4

调度过程分析:

  1. 时间0-2:运行P1(剩余7 -> 5)。
  2. 时间2:P2到达(剩余4)。4 < 5,抢占P1,运行P2。
  3. 时间4:P2已运行2个单位(剩余2)。P3到达(剩余1)。1 < 2,抢占P2,运行P3。
  4. 时间5:P3结束。P4到达(剩余4)。当前剩余时间:P2(2), P4(4), P1(5)。运行P2。
  5. 时间7:P2结束。剩余时间:P4(4), P1(5)。运行P4。
  6. 时间11:P4结束。运行P1。
  7. 时间16:P1结束。

计算平均等待时间(使用完成时间-到达时间-执行时间的公式):

  • P1: 16 - 0 - 7 = 9
  • P2: 7 - 2 - 4 = 1
  • P3: 5 - 4 - 1 = 0
  • P4: 11 - 5 - 4 = 2
    平均等待时间 = (9+1+0+2)/4 = 3.0

优点:进一步优化了平均等待时间和响应时间。
缺点:仍然存在饥饿问题,且同样需要预知进程的运行时间,不切实际。


时间片轮转调度

前面介绍的算法都侧重于优化等待时间,但牺牲了公平性。本节我们学习第一个实用且注重公平的调度算法:时间片轮转。

时间片轮转算法维护一个先来先服务的就绪队列。每个进程被分配一个固定的CPU时间片(称为量子)。进程运行完一个时间片后,如果还未结束,它会被放回队列的末尾,等待下一轮调度。

以下是该算法的关键参数和影响:

  • 时间片长度
    • 过短:上下文切换频繁,系统开销大,大量时间浪费在进程切换上。
    • 过长:退化为先来先服务,响应性变差,失去公平性的意义。

让我们通过一个例子来理解。假设时间片长度 = 3。

进程名 到达时间 执行时间
P1 0 7
P2 2 4
P3 4 1
P4 5 4

调度过程甘特图如下:

时间:  0    1    2    3    4    5    6    7    8    9   10   11   12   13   14   15
      [P1  P1   P1][P2  P2   P2][P1  P1   P1][P1][P3][P4  P4   P4][P2][P4]
队列演变:
t=0: [P1] -> 运行P1
t=2: P2到达,队列[P2]
t=3: P1用完时间片,重新入队,队列[P2, P1] -> 运行P2
t=4: P3到达,加入队尾,队列[P2, P1, P3]
t=5: P4到达,加入队尾,队列[P2, P1, P3, P4]
t=6: P2用完时间片,重新入队,队列[P1, P3, P4, P2] -> 运行P1
... (后续过程略)

计算关键指标:

  • 平均等待时间
    • P1: 15 - 0 - 7 = 8
    • P2: 14 - 2 - 4 = 8
    • P3: 9 - 4 - 1 = 4
    • P4: 15 - 5 - 4 = 6
    • 平均 = (8+8+4+6)/4 = 6.5
  • 平均响应时间(从就绪到首次运行):
    • P1: 0
    • P2: 3 - 2 = 1
    • P3: 9 - 4 = 5
    • P4: 10 - 5 = 5
    • 平均 = (0+1+5+5)/4 = 2.75
  • 上下文切换次数:在甘特图中每次进程变更计为一次,本例中为7次。

时间片长度的影响

  • 若时间片=1:上下文切换次数激增(本例中为16次),平均响应时间会更好(~2.0),但系统开销巨大。
  • 若时间片=10(大于任何进程执行时间):退化为先来先服务,上下文切换少(3次),但平均响应时间变差(~3.25)。

优点:公平性好,响应时间佳,无饥饿问题。
缺点:平均等待时间可能较差(尤其当所有作业长度相当时),性能严重依赖于时间片长度的选择。


总结

本节课我们一起学习了操作系统中的几种基础调度算法。

  1. 先来先服务:最简单,但效率低下,平均等待时间差。
  2. 最短作业优先及其抢占变体最短剩余时间优先:能提供最优的平均等待时间,但会导致长作业饥饿,且需要预知运行时间,不切实际。
  3. 时间片轮转:第一个实用的算法,通过在公平性、响应时间和系统开销之间进行权衡,通过调整时间片长度来适应不同场景。

调度本质上是在多种竞争性指标(等待时间、响应时间、公平性、吞吐量、开销)之间进行权衡的艺术,没有一种算法能在所有场景下都是最优的。

007:虚拟内存 🧠💾

在本节课中,我们将要学习虚拟内存的核心概念。虚拟内存是现代操作系统的基石,它允许多个进程安全、高效地共享物理内存,同时为每个进程提供独立、连续的地址空间假象。我们将从基本概念开始,逐步深入到其实现机制。

概述

虚拟内存的核心问题是:如何为每个进程创建独立的地址空间,使得它们可以使用相同的虚拟地址,但这些地址最终映射到物理内存中不同的位置?本节我们将探讨解决这个问题的思路,并介绍页式内存管理的基本原理。

虚拟内存的挑战与思路

上一节我们介绍了进程间内存隔离的需求。本节中我们来看看如何实现虚拟地址到物理地址的映射。

两个进程可以使用完全相同的内存地址,但它们是独立的,因此这些地址必须映射到我们机器上不同的物理内存。我们如何创造这种假象?

以下是实现虚拟映射的一些初步想法:

  • 分块管理:将内存划分为大块(例如4096字节),并跟踪这些块之间的映射。
  • 地址预留:在虚拟地址中预留一部分位作为索引,指向物理内存中的大块。
  • 偏移量:为每个进程存储一个固定的偏移量,将其虚拟地址加上偏移量得到物理地址。但这种方法在进程内存大小变化时难以处理。
  • 哈希表:使用哈希表进行映射。但本质上,哈希表是为了高效使用数组而设计的。在虚拟内存映射中,我们可能需要映射每一个地址,因此直接使用数组(即页表)是更直接的思路。

最简单的想法是为每个进程维护一个巨大的查找表(数组),虚拟地址作为索引,表项存储对应的物理地址。然而,这种方法极其浪费空间:为了映射每一个字节,我们需要花费8个字节来记录映射关系,导致只能使用九分之一的物理内存。

页式内存管理:核心思想 💡

为了使映射更高效并提高内存利用率,一个关键思路是将内存划分为更大的固定大小的块,然后只跟踪这些块之间的映射。

我们给这种固定大小的块起一个名字,叫做。我们将所有物理内存划分为固定大小的页。然后,我们只为每个进程跟踪其虚拟页到物理页的映射。

核心公式

  • 物理地址 = 物理页号 (PPN) × 页大小 + 页内偏移 (Offset)
  • 虚拟地址 = 虚拟页号 (VPN) × 页大小 + 页内偏移 (Offset)

通过这种方式,如果一个进程的虚拟地址1000映射到某个物理页,另一个进程使用相同的虚拟地址1000可以映射到另一个不同的物理页。这样我们获得了灵活性,如果所有页大小相同,我们可以移动它们,做各种操作。

页式内存管理的优势 ✅

我们的虚拟内存系统需要满足以下要求,而页式管理很好地支持了这些特性:

  • 进程共存:多个进程必须能够共存,且不应意识到它们在共享物理内存。
  • 内存透明:进程不应关心机器有多少物理内存或其他进程的存在。
  • 安全隔离:默认情况下,进程不能访问其他进程的数据。
  • 性能接近物理内存:虚拟内存访问性能应接近直接使用物理内存。
  • 减少碎片:需要限制内存浪费(碎片)。由于我们使用大块,程序可能不会一次使用整个块,因此我们需要选择一个“恰到好处”的页大小(不能太大也不能太小)。

地址与指针的本质

内存总是按字节寻址的,你可以寻址的最小内存单位是一个字节(八位)。地址和指针本质上并不复杂:你可以将内存视为一个巨大的字节数组,而地址只是这个数组的索引。

段式管理:历史背景 🕰️

在深入页式管理前,了解其前身——段式管理——是有帮助的。段式管理是一种粗粒度的内存管理方式。

它将虚拟地址空间划分为具有不同用途的段(例如代码段、数据段、栈段、堆段)。每个段具有可变大小,可以动态调整,并带有权限(可读、可写、可执行)。

段式地址转换
虚拟地址被分为两部分:段选择符和段内偏移。内存管理单元 (MMU) 通过检查偏移是否在段大小限制内,并将段基址加上偏移来计算物理地址。如果访问违规(如越界或权限不足),就会触发段错误。这就是“段错误”名称的由来,尽管现代系统已不再使用段式管理。

段式管理的主要缺点是段可能非常大,重新定位(移动)的成本很高。

现代方案:页表 📄

我们真正的解决方案是使用页表。页表本质上是一个数组。

核心代码/结构描述

// 页表项 (Page Table Entry, PTE) 的简化表示
typedef struct {
    uint64_t ppn : 44;  // 物理页号
    uint64_t flags : 10; // 标志位(有效位、读写执行权限等)
    // ... 其他保留位
} pte_t;

// 页表本身就是一个 PTE 数组
pte_t page_table[NUM_ENTRIES];
  • 索引:数组的索引是虚拟页号
  • :数组的值是物理页号
  • 独立性:每个进程都有自己的页表。

地址转换过程

假设我们使用39位虚拟地址和4096字节(2^12)的页大小。

  1. 划分地址:将虚拟地址划分为两部分:
    • 页内偏移:低12位(因为2^12 = 4096)。这部分在转换中保持不变。
    • 虚拟页号:高27位(39 - 12 = 27)。这用作页表的索引。
  2. 查表:以 VPN 为索引,在进程的页表中查找对应的 PTE。
  3. 组合地址:从 PTE 中取出物理页号,与原始的页内偏移组合,得到最终的物理地址。

转换示例
假设虚拟地址为 0x1FA0,页表显示 VPN 1 映射到 PPN 4。

  • 偏移 0xFA0 保持不变。
  • VPN 0x1 被替换为 PPN 0x4
  • 最终物理地址为 0x4FA0

页表项详解

一个页表项不仅包含物理页号,还包含重要的标志位:

  • 有效位:该翻译是否对此进程有效。访问无效地址会触发段错误。
  • 权限位:可读、可写、可执行权限。
  • 其他位:如访问位、脏位(用于后续优化),以及供内核使用的保留位。

页表项的大小设计(例如64位/8字节)考虑了未来扩展性,以便支持更大的物理内存地址空间。

单级页表的问题:空间开销过大 ⚠️

我们遇到了第一个大问题:页表本身太大了。

在我们的例子中(39位虚拟地址,4KB页),每个进程需要 2^27 个页表项。每个项8字节,那么每个页表大小就是 1GB。如果一台机器有16GB内存,仅存储16个进程的页表就用完了所有内存,这显然不可行。

多级页表:用时间换空间 🔄

为了解决单级页表空间开销过大的问题,我们引入了多级页表。核心思想是:大多数程序不会使用整个虚拟地址空间,因此我们可以只为实际使用的地址区域创建页表。

我们让每一级页表的大小恰好等于一个物理页(4KB)。由于每个页表项是8字节,一个页可以容纳 512(2^9)个项。因此,每一级页表需要用9位来索引。

对于39位虚拟地址,我们将27位的虚拟页号(VPN)平均分成3组,每组9位,分别用于索引三级页表:L2(根页表)、L1、L0。

转换过程

  1. CPU的 SATP 寄存器指向当前进程的 L2 根页表的物理地址。
  2. 用 VPN 的最高9位作为索引,在 L2 页表中查找。找到的 PTE 包含一个 L1 页表的物理页号。
  3. 用 VPN 的中间9位作为索引,在 L1 页表中查找。找到的 PTE 包含一个 L0 页表的物理页号。
  4. 用 VPN 的最低9位作为索引,在 L0 页表中查找。找到的 PTE 才包含最终的目标物理页号。
  5. 将此物理页号与原始的12位页内偏移组合,得到物理地址。

优势与权衡

  • 空间节省:现在,为了映射一个地址,我们只需要3个页(L2, L1, L0各一个),即12KB,而不是之前的1GB。只有当一个进程使用了大量分散的地址空间时,多级页表的总开销才会超过单级页表。
  • 时间开销:每次内存访问现在需要先进行3次页表查找(3次内存访问),最后才是目标数据访问,总共4次内存访问。这比直接物理访问(1次)或单级页表(2次)要慢。我们是在用时间换取空间
  • 管理简化:内核只需以页为单位管理内存,无论是用于存储进程数据还是存储页表本身,机制统一。

内核的内存管理

内核如何管理物理内存?非常简单:它将物理内存视为一个个页的集合,并维护一个空闲页链表。分配一个页就是从链表中取出第一项;释放一个页就是将其加回链表。这种仅以页为单位的分配方式极大地简化了内核的内存管理。

总结

本节课中我们一起学习了虚拟内存的核心机制。我们从虚拟地址映射的挑战出发,探讨了页式管理的基本思想,即通过页表将虚拟页号映射到物理页号。我们分析了单级页表空间开销过大的问题,并引入了多级页表这一“以时间换空间”的经典解决方案。多级页表通过树状结构,只为进程实际使用的地址区域分配页表,显著减少了内存开销,尽管增加了地址转换的时间。理解页表的结构和转换过程是掌握现代操作系统内存管理的关键。

008:页表

在本节课中,我们将要学习虚拟内存的核心机制——页表。我们将回顾其基本结构,探讨多级页表如何节省空间,并了解地址转换的实际过程。最后,我们将讨论页表带来的性能挑战及其解决方案。

页表回顾

上一节我们介绍了虚拟地址到物理地址的映射。每个进程都有自己独立的页表,由内核管理。虚拟地址被划分为页号(VPN)和页内偏移量(Offset)。

公式虚拟地址 = VPN + Offset

之前我们设想使用一个巨大的页表,将所有VPN到物理页号(PPN)的映射都存储在其中。但这会占用过多内存(例如1GB)。因此,我们引入了多级页表的概念。

多级页表结构

为了解决单一大页表占用空间过大的问题,我们将其拆分为多个层级,每个层级的大小恰好为一页。

核心思想:将VPN进一步拆分,用不同层级的页表索引来逐级查找最终的PPN。

以下是构建多级页表的关键步骤:

  • 首先,确定单个页面上能存放多少个页表项(PTE)。这决定了每一级索引的位数。
  • 接着,将VPN按索引位数拆分成多个部分,每一部分对应一级页表的索引。
  • 内核设置根页表(例如L2),其项指向L1页表。
  • L1页表的项指向L0页表。
  • L0页表的项包含最终的物理页号(PPN)。

地址转换过程

  1. 从根页表寄存器获取L2页表的物理地址。
  2. 使用VPN的第一部分(L2索引)在L2页表中找到对应项,该项指向一个L1页表。
  3. 使用VPN的第二部分(L1索引)在L1页表中找到对应项,该项指向一个L0页表。
  4. 使用VPN的第三部分(L0索引)在L0页表中找到对应项,该项包含最终的PPN。
  5. 将PPN与原始的偏移量(Offset)组合,得到最终的物理地址。

这种树状结构允许我们“剪枝”。如果某个高层页表项无效,其下的整个子树都不需要分配,从而为稀疏的内存使用节省了大量空间。

地址对齐

本节中我们来看看一个重要的硬件优化概念:地址对齐。对齐意味着内存地址是某个值的整数倍。

核心概念:页面是页面对齐的,即页面起始地址的低12位(对于4KB页)总是0。这意味着我们无需在页表项中存储页面的起始偏移量。

判断对齐的简单方法:对于一个以2的幂次方(如4、8、16字节)对齐的地址,只需检查其二进制表示的低若干位是否为0。例如,8字节对齐的地址,其十六进制表示的最后一位必须是0或8。

对齐简化了硬件设计,因为给定一个PPN,我们可以直接计算出其对应的物理页起始地址(PPN << 12)。

地址转换示例

让我们通过一个具体例子来巩固多级页表的转换过程。假设我们有一个虚拟地址 0xABCDEF,采用SV39三级页表结构。

转换步骤

  1. 将地址 0xABCDEF 转换为二进制,并划分为三部分9位的索引(L2, L1, L0)和12位的偏移量。
  2. 假设根页表(L2)位于PPN 7。使用L2索引(值为0)找到L2页表中的第0项,该项指向一个L1页表(假设位于PPN 8)。
  3. 使用L1索引(值为5)找到L1页表中的第5项,该项指向一个L0页表。
  4. 使用L0索引(值为188)找到L0页表中的第188项,该项包含最终的PPN(假设为 0xCAFE)。
  5. 最终的物理地址为 PPN 连接 Offset:0xCAFE + 0xDEF = 0xCAFEDEF

如果在此链中的任何一级页表项无效,访问将触发页错误(Page Fault)。

页表数量分析

了解一个进程需要多少页表很重要。考虑一个使用512个页面的进程。

最少页表数量:3个。需要一个L2页表、一个L1页表和一个L0页表。L0页表可以容纳512个项,刚好映射512个页面。

最多页表数量:1025个。这是最坏情况,假设内存访问极度分散:

  • 1个L2页表(必须存在)。
  • L2页表有512个有效项,每个指向一个不同的L1页表,共512个L1页表。
  • 每个L1页表只有一个有效项,指向一个L0页表,共512个L0页表。
  • 总计:1 + 512 + 512 = 1025个页表。

转换后备缓冲器(TLB)

上一节我们介绍了多级页表,虽然节省了空间,但导致每次内存访问都需要多次访问内存(走页表),速度很慢。本节中我们来看看如何解决这个性能问题。

解决方案:增加一个缓存,即转换后备缓冲器(TLB)。TLB缓存最近使用过的VPN到PPN的映射。

工作流程

  1. CPU需要转换虚拟地址时,首先用VPN查询TLB。
  2. 如果命中(TLB Hit),则直接获得PPN,无需访问页表,速度很快。
  3. 如果未命中(TLB Miss),则必须通过多级页表进行完整的地址转换。转换完成后,会将这个新的映射存入TLB,以备后续使用。

有效访问时间计算
我们可以用以下公式估算平均内存访问时间:
EAT = (命中率 * TLB命中时间) + (未命中率 * TLB未命中时间)

其中,TLB命中时间 = TLB查找时间 + 一次内存访问时间。TLB未命中时间 = TLB查找时间 + (页表层级数 * 内存访问时间) + 一次内存访问时间。

例如,假设命中率80%,TLB查找10ns,内存访问100ns,采用二级页表(访问需2次额外内存访问):
EAT = 0.8*(10+100) + 0.2*(10+2*100+100) = 0.8*110 + 0.2*310 = 88 + 62 = 150ns

如果没有TLB,每次访问都需要310ns。TLB将平均时间降低到150ns,显著提升了性能。

TLB与上下文切换:当CPU切换到另一个进程时,TLB中缓存的映射对新进程无效。常见的处理方式是清空(刷新)整个TLB。有些系统会在TLB项中附加进程ID(ASID),从而避免每次切换都刷新TLB。

为什么“顺序访问”更快

一个常见的编程建议是“顺序访问内存以提高性能”。这与RAM(随机存取存储器)的名称似乎矛盾。TLB揭示了其原因。

根本原因:速度差异主要源于TLB,而非RAM本身。RAM的随机访问速度确实是均匀的。

示例分析

  • 顺序访问一个页面:首次访问该页面会触发TLB未命中,但随后的访问(只要在同一页面内)都会是TLB命中,速度极快。
  • 以页面大小为步长跳跃访问:每次访问都位于不同的页面,几乎每次都是TLB未命中,需要走完整的页表查询流程,速度非常慢。

因此,“顺序访问更快”的本质是提高空间局部性,使得更多的内存访问能命中TLB(以及CPU的数据缓存),从而避免昂贵的页表遍历。

进程地址空间与内核交互

最后,我们简要了解内核如何利用页表管理进程的地址空间。

进程地址空间布局:一个典型的进程地址空间从低地址到高地址包括:

  • 代码段:存放可执行指令,标记为可读、可执行。
  • 数据段:存放全局变量等。
  • :动态分配的内存区域,通过 sbrkmmap 系统调用增长。
  • :用于函数调用,通常向低地址增长。内核会在栈末尾设置一个守护页(Guard Page),用于检测栈溢出。
  • 内核区域:包含一些进程不可直接访问但内核使用的页面,如陷入帧(Trap Frame,用于保存寄存器状态)、蹦床页(Trampoline,用于安全进入内核态)。

内核的页表技巧

  • 惰性分配:当进程请求内存(如malloc)时,内核可以先仅标记页表项有效,但不分配实际物理页。当进程首次访问该页面触发页错误时,内核再分配物理页并建立映射。这提高了效率。
  • 内存映射文件:通过页错误机制,将文件内容动态加载到内存。
  • 写时复制:用于fork等操作,初始共享页面,仅在写入时复制。
  • 固定虚拟地址:内核可以将一些只读数据(如系统时钟)映射到进程地址空间的固定位置。用户程序通过普通内存读取即可访问,无需系统调用,极大提升了性能(例如读取时间戳)。

总结

本节课中我们一起学习了虚拟内存的核心——页表系统。我们从单一大页表的问题出发,引入了多级页表结构来节省内存空间。我们详细分析了地址转换的过程、地址对齐的概念,并计算了进程所需页表的数量。接着,我们探讨了多级页表带来的性能挑战,并介绍了通过TLB缓存来加速地址转换的解决方案,这也解释了为何顺序访问内存效率更高。最后,我们概述了进程地址空间的布局以及内核如何利用页表机制实现惰性分配、写时复制等高级功能。理解页表是理解现代操作系统内存管理的基础。

009:高级调度与虚拟内存实验 🚀

概述

在本节课中,我们将要学习操作系统中的高级调度策略,并深入探讨虚拟内存实验的实现细节。课程分为两部分:第一部分回顾并扩展了调度算法的知识,介绍了优先级调度、多处理器调度和实时调度等高级概念;第二部分则详细讲解了虚拟内存实验(Lab 3)的实现,特别是进程分叉(fork)时的内存复制以及写时复制(Copy-on-Write)技术。


高级调度策略 🔄

上一节我们介绍了基本的轮转调度(Round Robin)。本节中我们来看看更复杂的调度策略。

优先级调度

我们可以为进程分配优先级,让调度器优先运行高优先级的进程。优先级通常用一个整数表示。

  • Linux中的优先级:在Linux中,数值越小表示优先级越高。例如,-20是最高优先级,19是最低优先级。
  • 公式priority = integer_value
  • 潜在问题:如果一直有高优先级进程,低优先级进程可能会“饿死”(Starvation)。一个解决方案是“老化”(Aging):如果一个低优先级进程长时间未运行,临时提升其优先级,运行后再恢复。

优先级反转

当进程间存在依赖关系时,可能会出现优先级反转问题。例如,一个高优先级进程等待一个低优先级进程释放资源(如写入数据),高优先级进程实际上被低优先级进程阻塞。

  • 解决方案:优先级继承(Priority Inheritance)。当内核检测到高优先级进程被低优先级进程阻塞时,临时将低优先级进程的优先级提升到与高优先级进程相同,以打破僵局。

前台与后台进程

根据交互性,进程可分为前台进程和后台进程。

  • 前台进程:需要与用户交互,要求良好的响应时间。可采用轮转调度。
  • 后台进程:在后台运行,注重吞吐量。可采用先来先服务(FCFS)或使用更大时间片的轮转调度。

以下是调度策略的权衡:

  • 可以使用不同的队列管理前台和后台进程。
  • 需要在队列间进行调度,例如使用优先级或轮转。
  • 调度没有唯一正确答案,只有权衡取舍。

多处理器调度

现代计算机通常有多个CPU核心。我们假设系统是对称多处理(SMP)架构,即所有CPU连接到同一内存。

  • 全局单一调度器:所有CPU共用一个调度队列。
    • 优点:CPU利用率高,对所有进程公平。
    • 缺点:可扩展性差(所有CPU阻塞在全局调度器上),缓存局部性差(进程可能在CPU间跳跃,导致缓存失效)。
  • 每CPU调度器:每个CPU核心有自己的调度器。
    • 优点:易于实现,可扩展性好,缓存局部性好(进程倾向于留在同一核心)。
    • 缺点:可能导致负载不均衡。解决方案是“工作窃取”(Work Stealing):当某个CPU空闲时,从其他CPU的队列中“偷取”进程来运行。
  • 处理器亲和性:进程对在特定CPU核心上运行的偏好。可以设置为高、中、低,以减少因切换核心导致的性能波动。
  • 组调度:有时需要一组进程同时被调度(例如,相互依赖的进程)。组调度(或协同调度)允许同时上下文切换一组进程,通常用于高性能计算。

实时调度

某些任务有严格的时间约束(截止时间或频率),例如音频处理。

  • 硬实时系统:必须保证任务在特定时间内完成(如导弹制导、列车控制系统)。需要对系统有完全控制权。
  • 软实时系统:关键进程有更高优先级,在实践中通常能满足截止时间,但无法严格保证。Linux就是一个软实时系统。

Linux调度策略

Linux实现了多种调度策略。

  • 实时进程:使用简单的调度算法(SCHED_FIFOSCHED_RR),优先级为0-99(数值越大优先级越高)。它们总是优先于普通进程被调度。
  • 普通进程:使用默认调度器,优先级(称为“友好值”,niceness)范围为-20到19(数值越小优先级越高)。系统会动态调整优先级以防止饿死。
  • Linux统一优先级:为了统一,Linux内部使用一个优先级数值,其中负数表示实时进程,0及以上表示普通进程。实时进程的优先级被映射为负数。

我们可以使用 top 命令查看进程的优先级(PRI)和友好值(NI)。

完全公平调度器(CFS)

现代Linux内核使用完全公平调度器(Completely Fair Scheduler, CFS)。

  • 理想公平模型:如果有N个进程,每个进程应获得1/N的CPU时间。
  • 虚拟运行时间:CFS为每个可运行进程维护一个虚拟运行时间(virtual runtime)。当一个进程运行了时间T后,其虚拟运行时间增加 T * weight。权重基于进程的优先级(优先级越高,权重越小,虚拟运行时间增长越慢)。
  • 调度决策:CFS总是选择虚拟运行时间最小的进程来运行,并为其计算一个动态时间片,使其能“追赶”其他进程。
  • 实现:使用红黑树(Red-Black Tree)来高效管理进程的虚拟运行时间,实现O(log n)的插入、删除和查找最小值操作。
  • 优势:自然地偏向I/O密集型进程(这类进程运行时间短,虚拟运行时间增长慢,更容易被再次调度),从而获得良好的交互性。


虚拟内存实验 🧠

上一节我们介绍了高级调度策略。本节中我们来看看虚拟内存实验的具体实现。

实验概述

在本实验中,你将实现一个虚拟内存系统库。主要任务包括:

  1. 进程分叉时的内存复制:实现 vms_fork_copy 函数,为子进程创建独立但内容相同的地址空间。
  2. 写时复制:实现 vms_page_fault_handler 函数,支持写时复制(Copy-on-Write)优化。

提供的代码框架包括初始化内存、创建页表、翻译地址等辅助函数。

分叉时内存复制

当父进程调用 fork() 时,需要为子进程创建一份独立的地址空间副本。

  • 页表结构:假设使用三级页表(L2, L1, L0)。L2是根页表。
  • 复制过程
    1. 为子进程创建新的L2页表。
    2. 遍历父进程L2页表中的每个有效条目。
    3. 对于每个有效条目,为子进程创建新的L1页表,并设置子进程L2条目指向它。
    4. 递归地复制L1和L0页表结构。
    5. 对于最终指向数据页的L0条目,需要复制数据页的内容(使用 memcpy),使父子进程拥有独立的物理数据页。
  • 代码提示:可以使用递归函数来遍历和复制多级页表结构。

// 示例:遍历父页表条目
for (int i = 0; i < NUM_PTE_ENTRIES; i++) {
    uint64_t *parent_pte = vms_page_table_from_index(parent_pt, i);
    if (!vms_pte_valid(parent_pte)) {
        continue; // 跳过无效条目
    }
    // 处理有效条目:创建子页表并复制
}

写时复制(Copy-on-Write)

为了提升效率,我们不会在分叉时立即复制所有数据页,而是采用写时复制。

  • 核心思想:父子进程最初共享只读的数据页。只有当某个进程试图写入共享页时,内核才为该进程创建该页的独立副本。
  • 实现机制
    1. 在分叉复制时,对于共享的数据页,清除页表条目中的写权限位(W=0),并设置自定义位(C=1) 作为“共享且原可写”的标记。
    2. 当进程尝试写入该页时,由于无写权限,会触发页错误(page fault)。
    3. 在页错误处理程序(vms_page_fault_handler)中:
      • 检查自定义位(C)。如果C=1,说明这是一个写时复制页。
      • 检查该物理页的引用计数(有多少个页表条目指向它)。你需要维护一个全局数组来跟踪每个物理页的引用计数。
      • 如果引用计数为1(只有当前进程引用),则只需恢复写权限位(W=1)并清除自定义位(C=0),然后返回。
      • 如果引用计数大于1(多个进程共享),则需要:
        • 分配一个新物理页。
        • 将原共享页的内容复制到新页。
        • 修改当前进程的页表条目,使其指向新页,并设置正确的权限(W=1, C=0)。
        • 减少原共享页的引用计数。
    4. 页错误处理返回后,硬件会重试导致错误的写入指令,此时将成功写入新副本。
  • 关键点
    • 只对数据页实施写时复制,页表页必须始终独立。
    • 引用计数是判断是否需要真正复制的关键。
// 页错误处理程序逻辑示例
if (fault_type == WRITE_FAULT) {
    if (vms_pte_custom(fault_pte)) {
        // 这是一个写时复制页
        page_index = get_page_index_from_pte(fault_pte);
        if (ref_count[page_index] == 1) {
            // 唯一引用者,直接恢复权限
            vms_pte_set_write(fault_pte);
            vms_pte_clear_custom(fault_pte);
        } else {
            // 需要复制
            new_page = allocate_new_page();
            copy_page_content(old_page, new_page);
            update_pte_to_new_page(fault_pte, new_page);
            ref_count[page_index]--;
            set_ref_count_for_new_page(new_page_index, 1);
        }
        return; // 处理成功,重试指令
    }
}
// 其他情况(如真正的访问违规)导致致命错误

测试与调试

  • 使用提供的 main.c 进行测试,它包含了多种测试用例。
  • 如果实现有误,可能会遇到“致命页错误”(fatal page fault),调试信息会显示失败的虚拟地址、失败级别和页表条目内容。
  • 重要:在本地开发时,未初始化的内存可能默认为0,使程序偶然正常工作。但在服务器上内存是随机初始化的,可能暴露问题。务必确保代码逻辑正确,不依赖未定义行为。

总结 🎯

本节课中我们一起学习了操作系统中高级调度策略的权衡与实现,包括优先级调度、多处理器调度、实时调度以及Linux完全公平调度器的工作原理。随后,我们深入探讨了虚拟内存实验,详细讲解了如何实现进程分叉时的内存完全复制以及更高效的写时复制技术。理解这些概念对于构建高效、可靠的操作系统至关重要。

010:优先级调度与内存映射 🧠

在本节课中,我们将学习两种重要的系统软件概念:动态优先级调度(也称为反馈调度)和内存映射(mmap)。我们将了解调度算法如何根据进程行为动态调整优先级,以及如何使用 mmap 系统调用高效地将文件内容映射到进程的虚拟内存中,从而避免频繁的读写系统调用。

动态优先级调度 🔄

上一节我们讨论了基本的调度算法。本节中,我们来看看一种更动态的方法——反馈调度。这种算法的核心思想是让系统根据进程的实际CPU使用情况来动态管理其优先级,而不是使用固定的优先级。

核心算法

在反馈调度中,优先级数字越低,表示优先级越高。每个进程在启动时被赋予一个初始优先级 P(N)。调度器总是选择优先级数字最低(即优先级最高)的进程来运行。如果出现平局,则按进程到达的顺序(FIFO)来打破。

算法会记录每个进程在当前“优先级间隔”内的执行时间 C(N)。在每一个优先级间隔结束时,会使用以下公式重新计算所有就绪进程的优先级:

P(N) = P(N) / 2 + C(N)

计算完成后,C(N) 会被重置为0。这个公式的含义是:

  • P(N) / 2:将当前优先级减半,这相当于奖励那些近期没有占用太多CPU的进程(使其数字变小,优先级变高)。
  • + C(N):加上进程在本间隔内的运行时间,这会惩罚那些占用了大量CPU的进程(使其数字变大,优先级变低)。

调度示例分析

为了更好地理解,我们来看一个具体的例子。假设有四个进程 X, Y, A, B,它们都在时间0到达。

  • A 和 B 是 CPU 密集型进程,一旦获得 CPU,就会一直执行直到时间片用完或被抢占。
  • X 和 Y 是 I/O 密集型进程,它们每次只运行1个时间单位,然后会阻塞(例如等待I/O)5个时间单位。

此外,我们设定:

  • 计时器中断每1个时间单位发生一次。
  • 优先级重新计算的间隔是每10个时间单位一次。

场景一:所有进程初始优先级为0

  1. 时间 0-1:所有进程优先级相同(0),按到达顺序选择 X 运行。X 运行1个单位后阻塞。
  2. 时间 1-2:选择 Y 运行。Y 运行1个单位后阻塞。
  3. 时间 2-10:只剩下 A 和 B(优先级仍为0)。选择 A 运行。即使 X 在时间6、Y在时间7恢复就绪,但它们的优先级(0)并不比正在运行的 A(0) 高,因此不会发生抢占,A 一直运行到时间10。
  4. 时间 10:重新计算优先级。
    • X: 0/2 + 1 = 1
    • Y: 0/2 + 1 = 1
    • A: 0/2 + 8 = 8
    • B: 0/2 + 0 = 0
  5. 时间 10+:B 的优先级(0)最低(最高),因此 B 被调度执行。

这个例子展示了算法如何开始“惩罚”长时间运行的进程A,并“奖励”等待的进程B。

场景二:初始优先级不同(X,Y为0,A,B为6)

进程行为不变,但初始优先级不同,这会导致更频繁的抢占,因为 I/O 密集型进程 X 和 Y 的初始优先级更高。

这个动态调整的过程,使得 I/O 密集型(交互式)进程能够获得更好的响应性,而 CPU 密集型进程则不会完全独占资源,这是一种公平性的体现。


内存映射与 mmap 系统调用 🗺️

之前我们学习了虚拟内存和页表。本节中我们来看看一个强大的系统调用 mmap,它允许我们将文件直接映射到进程的虚拟地址空间,从而像访问内存一样访问文件。

mmap 的核心概念

mmap 的基本思想是:让进程虚拟内存中的某些页,直接对应到磁盘上文件的某些块

当调用 mmap 时,内核并不会立即将整个文件读入物理内存。它只是惰性(lazy)地设置好页表项(Page Table Entries, PTEs),并将这些PTEs标记为无效,同时记录下它们应该映射到文件的哪个部分。

当进程第一次尝试访问(读或写)这个内存区域时,会发生缺页异常(Page Fault)。此时,内核的缺页处理程序会:

  1. 分配一个物理页帧(PPN)。
  2. 从磁盘上对应的文件块中读取数据,填充到这个物理页中。
  3. 更新PTE,使其指向这个物理页帧并标记为有效。
  4. 恢复进程的执行。

这样,只有实际被访问到的文件部分才会被加载到物理内存中,非常高效。

使用 mmap 读取文件

以下是使用 mmap 读取整个文件内容并打印的简化示例,与传统的 read 循环方式对比:

// 传统方式:使用 read 系统调用
int fd = open(“file.txt”, O_RDONLY);
char buffer[4096];
ssize_t bytes_read;
while ((bytes_read = read(fd, buffer, sizeof(buffer))) > 0) {
    write(STDOUT_FILENO, buffer, bytes_read);
}
close(fd);
// 使用 mmap 方式
#include <sys/mman.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <stdio.h>
#include <unistd.h>

int main() {
    int fd = open(“file.txt”, O_RDONLY);
    struct stat sb;
    fstat(fd, &sb); // 获取文件大小

    // 核心 mmap 调用
    char *data = mmap(NULL, sb.st_size, PROT_READ, MAP_PRIVATE, fd, 0);
    if (data == MAP_FAILED) {
        perror(“mmap”);
        return 1;
    }
    close(fd); // 映射建立后,可以关闭文件描述符

    // 像访问数组一样访问文件内容
    for (off_t i = 0; i < sb.st_size; i++) {
        putchar(data[i]);
    }

    // 解除映射
    munmap(data, sb.st_size);
    return 0;
}

mmap 参数详解

mmap 的函数原型和关键参数如下:

void *mmap(void *addr, size_t length, int prot, int flags, int fd, off_t offset);

以下是主要参数的含义:

  • addr: 建议的映射起始虚拟地址。通常设为 NULL,让内核自动选择。
  • length: 要映射的字节数。
  • prot: 内存保护标志,决定如何访问这块内存(如 PROT_READPROT_WRITE)。
  • flags: 控制映射行为的标志。两个重要的标志是:
    • MAP_PRIVATE: 写操作不会修改底层文件,而是创建一份私有副本(写时复制)。
    • MAP_SHARED: 对内存的修改会同步到底层文件(最终由内核决定何时写回磁盘)。
  • fd: 要映射的文件的描述符。
  • offset: 文件中的起始偏移量,必须是页大小的倍数。

实际应用:大型模型加载的启示

这解释了课程开头提到的“魔法”:如何用更少的内存运行大型语言模型(LLM)。一个20GB的模型文件,如果使用传统的 read 将其全部加载到内存,显然需要至少20GB的RAM。

但使用 mmap 后:

  1. 程序只需 mmap 这个20GB的文件。
  2. 在实际进行模型推理(计算)时,很可能不会用到模型的全部参数(访问所有页)。
  3. 只有被实际访问到的模型参数所在的文件块,才会被惰性地加载到物理内存中。
  4. 因此,物理内存的使用量可能远小于文件总大小(例如仅需6GB),从而实现了巨大的内存节省。

关于页表开销的计算

使用 mmap 映射大文件时,需要关注虚拟内存页表本身的开销。对于一个20GB的文件:

  • 假设页大小为4KB(2^12字节)。
  • 需要的虚拟页数(也是页表项PTE数)为:20GB / 4KB = 20 * 2^30 / 2^12 = 20 * 2^18 个。
  • 每个PTE在64位系统上通常占8字节。
  • 因此,仅存储这些PTE就需要大约 20 * 2^18 * 8 = 40 MB 的空间
  • 这还不包括多级页表中中间各级页表(L1, L2)所占用的少量额外页面。在最佳连续映射情况下,额外开销很小(约0.07MB),但这是必须由内核管理的元数据开销。

总结 📚

本节课中我们一起学习了两个核心的系统软件概念:

  1. 动态优先级调度(反馈调度):算法通过公式 P(N) = P(N) / 2 + C(N) 动态调整进程优先级,奖励I/O密集型或等待的进程,惩罚CPU密集型进程,从而改善系统整体响应性和公平性。

  2. 内存映射(mmap:这是一个强大的系统调用,允许将文件直接映射到进程的虚拟地址空间。它通过惰性加载机制,让进程可以像访问内存一样高效访问文件数据,并且只将实际使用的部分载入物理内存。这种方法在处理大文件(如大型机器学习模型)时能显著节省物理内存消耗,其原理是内核延迟分配物理页,并在首次访问时通过缺页异常处理程序从磁盘加载数据。

通过结合调度策略和高级内存管理技术,我们可以构建出更高效、更灵活的系统软件。

011:线程 🧵

概述

在本节课中,我们将要学习操作系统中的核心概念——线程。我们将探讨线程与进程的区别,学习如何使用POSIX线程(Pthreads)库来创建和管理线程,并初步了解并发编程中可能遇到的问题。


并发与并行

上一节我们回顾了进程,本节中我们来看看线程。但在深入线程之前,我们需要区分两个常被混淆的概念:并发与并行。

  • 并发:意味着在两个或多个任务之间切换。虽然同一时间只能在一个任务上取得进展,但可以在任务间快速切换,从而在宏观上同时推进多个任务。其目标是同时推进多项任务
  • 并行:意味着两个或多个任务在完全相同的时刻同时发生。这要求任务相互独立,其目标是尽可能快地运行

现代系统通常利用并行性(多核),但并发性同样重要,尤其是在处理大量I/O操作的场景(如Web服务器)。

为了更清晰地理解,我们来看一个现实生活中的例子。假设你在餐桌上,可以执行说话打手势这几个任务,并且有一个奇怪的设定:一旦开始吃,就必须吃完才能停下。

以下是任务组合的分析:

  • 说话:可以并发(交替进行),但不能并行(不能同时进行)。
  • :不能并行(只有一个嘴),并且由于“开始吃就不能停”的设定,也不能并发。
  • 打手势:可以并行(用另一只手),但不能并发(不能停下吃)。
  • 打手势说话:既可以并发,也可以并行。

这个例子说明,并发和并行并不相互蕴含。在计算机中,如果并发切换的速度极快,从宏观上看就像并行一样。


线程简介

线程与进程原理相似,都能实现并行。但关键区别在于:线程默认共享内存

  • 进程:创建时拥有全新的、独立的虚拟地址空间。
  • 线程:属于同一进程的线程共享相同的地址空间。每个线程独立的部分仅包括其当前的寄存器、程序计数器(也是一个寄存器)和栈。

线程与进程的关系是:一个进程可以包含多个线程。默认情况下,一个进程只有一个执行main函数的线程(主线程)。我们可以创建更多线程,实现同一地址空间内的多个执行流。

线程的优势在于:

  • 更轻量:创建和上下文切换开销更小,因为不需要创建新的地址空间或刷新TLB。
  • 通信开销低:共享内存使得线程间数据交换无需系统调用,速度更快。

线程的劣势在于:

  • 缺乏隔离性:一个线程崩溃(如非法内存访问)可能导致整个进程崩溃,影响所有其他线程。
  • 编程复杂:共享内存带来了数据同步和一致性的挑战。

一个典型的应用场景是Web服务器:

while (true) {
    int client_fd = accept(request); // 获取新连接
    create_thread_to_process(client_fd); // 创建线程处理请求
}

服务器主线程快速接受连接,然后为每个连接创建一个工作线程进行处理。由于网络I/O较慢,CPU可以快速在众多线程间切换,实现高并发。

进程与线程对比总结

  • 独立性:进程完全独立;线程共享代码、数据和堆,仅栈和寄存器独立。
  • 创建与切换开销:进程开销大(需新地址空间、页表等);线程开销小。
  • 通信:进程间通信需通过IPC机制(如管道);线程间可直接通过共享内存通信。
  • 生命周期:进程终止时,其所有线程也随之终止。


Pthreads 基础

在本课程中,我们将使用POSIX线程(Pthreads)。在源代码中包含头文件#include <pthread.h>,编译时需添加-pthread标志链接库。

创建线程:pthread_create

创建线程使用pthread_create函数,其原型如下:

int pthread_create(pthread_t *thread, const pthread_attr_t *attr,
                   void *(*start_routine) (void *), void *arg);
  • thread: 指向pthread_t类型变量的指针,用于存储新线程的ID。
  • attr: 指向线程属性对象的指针,通常设为NULL使用默认属性。
  • start_routine: 线程启动后执行的函数指针。该函数必须具有void* func(void* arg)的形式。
  • arg: 传递给start_routine函数的参数。
  • 返回值:成功返回0;失败直接返回错误号(不设置errno)。

以下是一个简单的创建线程示例:

#include <stdio.h>
#include <pthread.h>

void *run(void *arg) {
    (void)arg; // 避免未使用参数的警告
    printf("In run\n");
    return NULL;
}

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/trt-ece353-sys-sw-dsn-prog/img/c3d642df22f411186a1f9ef99c1bf1c0_13.png)

int main() {
    pthread_t thread;
    pthread_create(&thread, NULL, run, NULL);
    printf("In main\n");
    return 0;
}

运行此程序,输出顺序是不确定的,可能是“In main”然后“In run”,也可能是相反。更常见的情况是,主线程打印后立即退出,导致整个进程终止,新创建的线程可能根本没机会运行就被“杀死”了。

等待线程:pthread_join

为了确保主线程等待新线程完成,我们需要使用pthread_join,它类似于进程的wait

int pthread_join(pthread_t thread, void **retval);
  • thread: 要等待的线程ID。
  • retval: 指向指针的指针,用于接收目标线程的返回值(即start_routine的返回值或pthread_exit的参数)。
  • 返回值:成功返回0;失败返回错误号。

修改上面的例子:

int main() {
    pthread_t thread;
    pthread_create(&thread, NULL, run, NULL);
    pthread_join(thread, NULL); // 等待新线程结束
    printf("In main\n");
    return 0;
}

现在,程序总会先打印“In run”,然后打印“In main”(因为主线程在join处阻塞,等待新线程完成)。但两个线程内部的执行顺序仍由调度器决定。

线程终止:pthread_exit

线程可以通过以下方式终止:

  1. start_routine函数返回。
  2. 调用pthread_exit
  3. 被其他线程取消(pthread_cancel)。

pthread_exit函数原型:

void pthread_exit(void *retval);

它终止调用线程,并将retval作为返回值传递给任何等待该线程的pthread_join调用。

一个重要的技巧是:如果主线程希望在所有其他线程结束后再终止进程,它不应该从main返回(那会调用exit终止整个进程),而应调用pthread_exit。这样,主线程终止,但只要进程中还有非守护线程在运行,进程就不会结束。

分离线程:pthread_detach

默认创建的线程是“可连接的”(joinable),必须有人对其调用pthread_join来回收资源,否则会变成“僵尸线程”。

我们可以创建“分离的”(detached)线程,它们会在终止时自动释放所有资源,无需被连接。但这也意味着无法获取其返回值。

int pthread_detach(pthread_t thread);

分离线程后,不能再对其调用pthread_join


向线程传递参数

向线程函数传递参数需要小心,因为参数是通过指针传递的,必须确保指针所指向的内存在线程使用时依然有效。

错误示例:传递栈变量的地址。

void *run(void *arg) {
    int id = *(int *)arg;
    printf("Thread %d\n", id);
    return NULL;
}

int main() {
    pthread_t threads[5];
    for (int i = 0; i < 5; i++) {
        // 错误!传递了局部变量 i 的地址
        pthread_create(&threads[i], NULL, run, &i);
    }
    // ... 主线程可能很快结束,栈被回收
    for (int i = 0; i < 5; i++) {
        pthread_join(threads[i], NULL);
    }
    return 0;
}

这里,所有线程收到的都是局部变量i的地址。主线程循环很快,i的值不断变化,并且主线程可能在线程运行前就结束了,导致线程访问无效的栈内存,得到随机值。

正确示例:为每个线程动态分配内存。

void *run(void *arg) {
    int id = *(int *)arg;
    free(arg); // 使用完后释放内存
    printf("Thread %d\n", id);
    return NULL;
}

int main() {
    pthread_t threads[5];
    for (int i = 0; i < 5; i++) {
        int *arg = malloc(sizeof(int));
        *arg = i;
        pthread_create(&threads[i], NULL, run, arg);
    }
    for (int i = 0; i < 5; i++) {
        pthread_join(threads[i], NULL);
    }
    return 0;
}

每个线程获得自己独有的堆内存块,从而安全地传递参数。


线程实现模型

线程的实现主要有两种模型:

  1. 用户线程:完全在用户空间实现,内核对此一无所知。优点是创建、切换极快(无需系统调用);缺点是一个线程阻塞(如I/O)会导致整个进程阻塞,且无法利用多核实现真正的并行。
  2. 内核线程:由操作系统内核直接管理。优点是可以实现真正的并行,一个线程阻塞不会影响其他线程;缺点是创建、切换涉及系统调用,开销较大。

常见的映射关系:

  • 多对一:多个用户线程映射到一个内核线程。即纯用户线程模型。
  • 一对一:一个用户线程映射到一个内核线程。Pthreads通常采用此模型。
  • 多对多:多个用户线程映射到多个内核线程。试图结合两者优点,但实现复杂,调试困难。

在接下来的实验(Lab 4)中,你将实现一个协作式的用户线程库(Many-to-One)。线程必须主动调用yield让出CPU,而不是被强制抢占。


同步问题初探

线程共享内存带来了强大的能力,也引入了复杂的同步问题。考虑以下程序:

#include <stdio.h>
#include <pthread.h>

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/trt-ece353-sys-sw-dsn-prog/img/c3d642df22f411186a1f9ef99c1bf1c0_38.png)

static int counter = 0;
#define NUM_THREADS 8
#define ITERATIONS 10000

void *run(void *arg) {
    (void)arg;
    for (int i = 0; i < ITERATIONS; i++) {
        counter++; // 隐患所在!
    }
    return NULL;
}

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/trt-ece353-sys-sw-dsn-prog/img/c3d642df22f411186a1f9ef99c1bf1c0_40.png)

int main() {
    pthread_t threads[NUM_THREADS];
    for (int i = 0; i < NUM_THREADS; i++) {
        pthread_create(&threads[i], NULL, run, NULL);
    }
    for (int i = 0; i < NUM_THREADS; i++) {
        pthread_join(threads[i], NULL);
    }
    printf("Final counter value: %d (expected: %d)\n", counter, NUM_THREADS * ITERATIONS);
    return 0;
}

我们创建了8个线程,每个线程对全局变量counter递增10000次,期望结果是80000。但实际运行结果往往小于80000,且每次运行结果可能不同。

原因分析counter++这个操作并非原子操作。它通常对应三条机器指令:

  1. LOAD:将counter的值从内存读入寄存器。
  2. ADD:将寄存器的值加1。
  3. STORE:将寄存器的值存回内存。

如果两个线程交错执行这些指令,就可能发生以下情况:

  1. 线程A加载counter(值为0)。
  2. 线程B加载counter(值仍为0)。
  3. 线程B递增其寄存器(值为1),并写回内存(counter变为1)。
  4. 线程A递增其寄存器(值从0变为1),并写回内存(counter仍为1)。

这样,两次递增操作只使counter增加了1,导致结果错误。这种现象称为竞态条件

临时解决方案

  1. 串行化:创建线程后立即连接它,这实际上失去了并行的意义。
  2. 避免共享:每个线程操作自己的局部变量,最后汇总结果。这类似于MapReduce模式。

在后续课程中,我们将学习使用互斥锁信号量等同步原语来正确、高效地解决这类问题。


总结

本节课中我们一起学习了线程的核心概念。我们首先区分了并发与并行,然后深入探讨了线程作为轻量级执行单元的特性,及其与进程的根本区别。我们掌握了使用Pthreads库创建、等待、终止和分离线程的方法,并特别强调了向线程安全传递参数的注意事项。我们还了解了线程的用户级与内核级实现模型及其利弊。最后,我们通过一个计数器递增的例子,直观地认识了多线程编程中最大的挑战——竞态条件,为后续学习同步机制奠定了基础。线程是强大但危险的工具,正确的同步是编写可靠并发程序的关键。

012:用户线程实验入门

在本节课中,我们将学习如何实现用户级线程库。我们将回顾线程的基本概念,然后深入探讨如何使用 ucontext 库来管理线程的上下文切换、栈分配和调度。此外,我们还将简要介绍套接字编程的基础知识。

线程与进程回顾

上一节我们介绍了进程和线程的基本概念。本节中,我们来看看如何具体实现用户级线程。

一个进程代表一个正在运行的程序,它包含以下部分:

  • 一个打开的文件描述符列表。
  • 一个虚拟内存空间,用于存放程序的代码、全局变量和堆。
  • 一个或多个线程。每个线程拥有自己独立的寄存器和栈空间。

对于用户线程库的实现,我们需要跟踪每个线程的寄存器和栈,并进行调度。

使用 tailq 实现链表

在实现线程调度时,我们需要一个队列来管理准备运行的线程。以下是使用 tailq 宏实现双向链表的方法,这比手动实现链表更简单且不易出错。

首先,我们定义一个结构体作为链表的元素,并使用 TAILQ_ENTRY 宏为其添加链表指针。

struct list_entry {
    int id; // 线程ID
    TAILQ_ENTRY(list_entry) pointers; // 链表指针
};

接着,我们定义链表头结构。

TAILQ_HEAD(list_head, list_entry);

然后,我们可以声明一个全局的链表头并初始化它。

static struct list_head head;
TAILQ_INIT(&head);

现在,我们可以向链表尾部插入元素。

struct list_entry e1;
e1.id = 1;
TAILQ_INSERT_TAIL(&head, &e1, pointers);

要遍历链表中的所有元素,可以使用 TAILQ_FOREACH 宏。

struct list_entry *e;
TAILQ_FOREACH(e, &head, pointers) {
    printf("ID: %d\n", e->id);
}

要移除链表中的元素,可以使用 TAILQ_REMOVE 宏。

TAILQ_REMOVE(&head, &e1, pointers);

线程栈管理

每个线程都需要自己的栈。我们将使用提供的 new_stackdelete_stack 函数来分配和释放栈内存。这些函数内部使用 mmap 并处理了与 Valgrind 调试工具的兼容性问题。

void* stack = new_stack(); // 分配新栈
// ... 使用栈 ...
delete_stack(stack); // 释放栈

使用 ucontext 进行上下文管理

ucontext_t 结构体用于保存和恢复线程的上下文(即寄存器状态)。这是我们实现用户线程的核心。

首先,我们需要为每个线程声明一个 ucontext_t 变量。

ucontext_t t0_ctx, t1_ctx, t2_ctx;

使用 getcontext 初始化上下文结构。注意:直接调用 getcontext 后立即调用 setcontext 会导致无限循环。

getcontext(&t0_ctx); // 保存当前上下文到 t0_ctx
setcontext(&t0_ctx); // 恢复 t0_ctx -> 跳回上一行,无限循环!

要创建一个新线程,我们需要:

  1. 为新线程分配栈。
  2. 初始化其 ucontext_t
  3. 设置其栈指针。
  4. 使用 makecontext 指定线程要运行的函数。

以下是创建并切换到新线程 t1 的示例。

// 1. 分配栈
void* t1_stack = new_stack();

// 2. 初始化上下文
getcontext(&t1_ctx);

// 3. 设置栈信息
t1_ctx.uc_stack.ss_sp = t1_stack;
t1_ctx.uc_stack.ss_size = DEFAULT_STACK_SIZE;

// 4. 指定运行函数并传递参数(仅限int类型)
makecontext(&t1_ctx, (void (*)(void)) t1_run, 1, 42);

// 切换到线程 t1
setcontext(&t1_ctx);

线程函数 t1_run 的定义如下。

void t1_run(int arg) {
    printf("Hooray, got arg: %d\n", arg);
}

默认情况下,当线程函数返回时,整个进程会退出。为了实现线程库,我们需要在线程结束时切换到其他线程。

线程切换与 swapcontext

为了实现从主线程到新线程的切换,并在新线程结束后返回主线程,我们需要使用 swapcontext 函数。它会保存当前上下文到第一个参数,并立即切换到第二个参数指定的上下文。

// 主线程中:保存当前上下文到 t0_ctx,并切换到 t1_ctx
swapcontext(&t0_ctx, &t1_ctx);
printf("Main is back in town.\n"); // t1 结束后,会回到这里执行

在线程 t1 中,我们可以切换到线程 t2,并在 t2 结束后清理 t1 的栈并切换回主线程。

// 在 t1_run 函数中
setcontext(&t2_ctx); // 切换到 t2

// 在 t2_run 函数中
printf("T2 should be done, switch back to T0.\n");
delete_stack(t1_stack); // 清理 t1 的栈(由其他线程执行是安全的)
setcontext(&t0_ctx); // 切换回主线程

另一种管理线程结束的方法是使用 ucontext_tuc_link 字段。如果设置了 uc_link,当线程函数返回时,会自动切换到 uc_link 指向的上下文,而不是退出进程。

// 设置 t1 结束后自动切换到 t2
t1_ctx.uc_link = &t2_ctx;

套接字编程简介

套接字是另一种进程间通信机制,它允许不同机器上的进程进行通信,是网络通信的基础。

创建服务器端套接字需要四个步骤:

  1. socket(): 创建套接字。
  2. bind(): 将套接字绑定到一个地址(文件路径或 IP 地址+端口)。
  3. listen(): 开始监听连接请求。
  4. accept(): 接受客户端连接。

以下是使用 Unix 域套接字(本地通信)的简单服务器示例。

int server_fd = socket(AF_UNIX, SOCK_STREAM, 0);
struct sockaddr_un addr;
addr.sun_family = AF_UNIX;
strncpy(addr.sun_path, "example.sock", sizeof(addr.sun_path) - 1);

bind(server_fd, (struct sockaddr*)&addr, sizeof(addr));
listen(server_fd, 0);

while (1) {
    int client_fd = accept(server_fd, NULL, NULL);
    write(client_fd, "Hello there\n", 12);
    close(client_fd);
}
close(server_fd);
unlink("example.sock");

客户端只需要两个步骤:

  1. socket(): 创建套接字。
  2. connect(): 连接到服务器地址。

客户端示例代码如下。

int sock_fd = socket(AF_UNIX, SOCK_STREAM, 0);
struct sockaddr_un addr;
// ... 设置地址(与服务器相同)...
connect(sock_fd, (struct sockaddr*)&addr, sizeof(addr));

char buffer[128];
read(sock_fd, buffer, sizeof(buffer));
printf("Received: %s", buffer);
close(sock_fd);

总结

本节课中我们一起学习了用户级线程库的实现基础。我们了解了如何使用 tailq 宏方便地管理数据结构,如何使用 new_stack/delete_stack 分配线程栈,以及如何利用 ucontext 系列函数(getcontext, setcontext, makecontext, swapcontext)来保存、恢复和切换线程上下文。我们还简要介绍了套接字编程的基本步骤,这是实现网络通信的基石。掌握这些概念对于完成接下来的实验至关重要。

013:锁 🔒

在本节课中,我们将要学习并发编程中的一个核心概念:锁。我们将探讨为什么在多线程程序中会出现数据竞争,以及如何使用锁来确保数据的一致性和程序的正确性。


数据竞争与原子操作

上一节我们回顾了多线程程序可能产生错误结果的问题。本节中,我们来看看导致这个问题的根本原因:数据竞争。

数据竞争发生在两个并发操作访问同一个变量,并且其中至少有一个是写操作时。即使是在单核CPU上,由于线程的切换(并发),也可能发生数据竞争。为了正确分析程序,我们需要理解原子操作的概念。

原子操作是指不可分割的操作,它们要么完全执行,要么完全不执行。在原子操作之间,线程可能会被抢占(即切换到另一个线程)。然而,高级语言(如C语言)中的一行代码通常会被编译成多个原子指令,这使得分析变得复杂。

编译器在将代码转换为机器指令时,会使用一种称为中间表示(如GCC的Gimple)的格式进行分析和优化。这种表示将复杂的操作分解为一系列简单的、类似汇编的三地址码指令,便于我们理解数据竞争。

例如,对于C语言中的 count++ 操作,其对应的三地址码可能如下:

// 假设 p_count 是指向 count 的指针
d1 = *p_count;   // 原子操作:从内存加载值到临时变量 d1
d2 = d1 + 1;     // 原子操作:执行加法
*p_count = d2;   // 原子操作:将结果写回内存

每个三地址码指令(如加载、加法、存储)都被视为原子的。


为什么会出现错误结果?

现在,我们分析两个线程并发执行 count++ 时可能出现的问题。每个线程都会按顺序执行自己的三地址码指令,但两个线程之间的指令执行顺序是无法预测的。

考虑以下错误场景:

  1. 线程1执行 d1 = *p_count,加载 count 的初始值0。
  2. 在写回之前,系统切换到线程2。
  3. 线程2也执行 d1 = *p_count,同样加载到值0。
  4. 线程2继续执行 d2 = d1 + 1*p_count = d2,将 count 更新为1。
  5. 系统切换回线程1。
  6. 线程1使用它之前加载的旧值0,执行 d2 = d1 + 1 得到1,然后执行 *p_count = d2,将 count 从1覆盖为1。

最终,count 的值是1,而不是正确的2。这是因为线程1使用了过时的数据(脏读),并且两个线程的写操作相互覆盖。

为了彻底分析数据竞争,我们需要考虑所有可能的线程指令交错顺序。只要存在一种顺序导致错误结果,程序就存在Bug。


互斥锁:解决方案

手动分析所有线程交错顺序极其困难。因此,我们使用互斥锁来简化问题。互斥锁确保同一时间只有一个线程可以执行被保护的代码段(称为临界区)。

互斥锁就像一把只有一个钥匙的挂锁。线程调用 lock 相当于拿走钥匙并锁上门,获得独占访问权。其他线程尝试 lock 时,会发现锁已被占用,从而被阻塞,直到持有锁的线程调用 unlock 释放锁。

在Pthreads库中,使用互斥锁的步骤如下:

以下是创建和使用互斥锁的基本步骤:

  1. 声明与初始化互斥锁
    • 静态初始化:pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
    • 动态初始化:使用 pthread_mutex_init(&mutex, NULL);,使用完毕后需调用 pthread_mutex_destroy
  2. 保护临界区
    • 在进入临界区前调用 pthread_mutex_lock(&mutex);
    • 在离开临界区后调用 pthread_mutex_unlock(&mutex);
  3. 尝试加锁
    • 使用 pthread_mutex_trylock 可以尝试非阻塞地获取锁。

将锁应用于之前的计数器示例,我们只需要保护 counter++ 这一行代码:

pthread_mutex_lock(&mutex);
counter++;
pthread_mutex_unlock(&mutex);

这样,无论线程如何切换,同一时刻只有一个线程能执行 counter++,从而保证了结果的正确性(总是80000)。需要注意的是,这会使临界区内的操作串行化,可能影响性能,但正确性是首要目标。

锁并不能阻止线程被抢占。它的作用是:如果一个持有锁的线程被抢占,其他线程在尝试获取同一把锁时会被阻塞,从而无法进入临界区,保证了临界区内的操作不会被打断。


如何实现一个锁?

理解了锁的用途后,我们来看看实现一个正确的锁需要满足哪些属性,并尝试自己设计一个简单的锁。

一个正确的锁实现需要满足三个核心属性:

  1. 安全性(互斥)mutual exclusion。确保任何时候最多只有一个线程处于临界区内。
  2. 活性(进展)progress。如果有多个线程试图进入临界区,至少有一个能成功进入。不能出现所有线程都在临界区外等待却无人能进入的死锁情况。
  3. 有限等待(无饥饿)bounded waiting / starvation-free。任何等待锁的线程最终都能获得锁,不会无限期等待。

此外,一个好的锁实现还应追求高效(开销小)、公平(等待时间近似)和简单易用。

同步原语是分层构建的:

  • 底层:硬件提供的原子操作(如特定的读、写、比较并交换指令)。
  • 中间层:利用原子操作构建的高级同步原语,如互斥锁。
  • 应用层:开发者使用这些同步原语编写正确、高效的无数据竞争程序。


一个天真的锁实现及其缺陷

假设我们用一个整数变量 lock 来表示锁的状态:0 表示未上锁,1 表示已上锁。我们尝试实现 lockunlock 函数。

以下是这个简单锁的实现逻辑:

// 初始化
lock = 0;

void lock(int *lock) {
    while (*lock == 1) { /* 忙等待 */ }
    *lock = 1; // 获取锁
}

void unlock(int *lock) {
    *lock = 0; // 释放锁
}

逻辑:线程调用 lock 时,如果 lock 为1,就循环等待(忙等待);如果为0,就将其设为1并进入临界区。调用 unlock 时将其设回0。

然而,这个实现存在严重问题。考虑两个线程几乎同时调用 lock

  1. 线程1读取 lock,得到0。
  2. 在线程1将 lock 设置为1之前,系统切换到线程2。
  3. 线程2也读取 lock,得到的仍然是0。
  4. 现在,两个线程都认为自己可以获取锁。线程2可能先将 lock 设为1并进入临界区。随后线程1恢复执行,也将 lock 设为1(覆盖了线程2的设置),也进入了临界区。

这就违反了互斥属性,两个线程同时进入了临界区。此外,这个实现还使用了忙等待,即线程在等待锁时会持续消耗CPU周期进行检查,效率低下。


本节课中我们一起学习了数据竞争的成因、互斥锁的概念与使用方法,并分析了一个不正确的锁实现所存在的问题。锁是解决并发访问共享资源问题的关键工具,但实现一个正确、高效的锁本身就是一个挑战。下一讲,我们将探讨如何利用硬件提供的原子操作来实现一个真正可用的锁。

014:更多锁与信号量

在本节课中,我们将继续探讨并发编程中的核心同步原语。我们将深入理解如何利用硬件原子指令实现更高效的锁,并学习如何使用信号量来解决线程间的顺序协调问题,例如经典的“生产者-消费者”问题。


锁的硬件实现

上一节我们尝试用纯软件实现锁,但遇到了困难。本节中我们来看看如何借助硬件提供的原子指令来构建一个可用的锁。

实现锁所需的最低硬件要求是:加载(load)和存储(store)操作必须是原子的,并且指令必须按顺序执行。然而,现代CPU的指令经常乱序执行,因此我们需要硬件提供更多帮助。

我们需要一些有趣的原子指令。让我们假设一个名为 compare_and_swap 的原子函数,其参数如下:

int compare_and_swap(int *ptr, int old, int new);

这个函数总是返回 *ptr 的原始值。它会原子性地检查 *ptr 的当前值是否等于 old。如果相等,则将 *ptr 的值设置为 new。这个操作要么全部完成,要么完全不发生。

基于此,我们可以重新尝试实现锁。我们假设用一个整数来表示锁的状态:0 表示未锁定,1 表示已锁定。目标是确保同一时间只有一个线程能成功获取锁。

以下是我们的新尝试:

void lock(int *lock) {
    while (compare_and_swap(lock, 0, 1) != 0) {
        // 忙等待
    }
}
void unlock(int *lock) {
    *lock = 0;
}

让我们分析两个线程(线程1和线程2)调用此锁函数时的情况。

  • 假设锁的初始值为 0
  • 线程1先执行 compare_and_swap(lock, 0, 1)。由于当前值为 0,函数原子性地将其改为 1 并返回 0。循环条件为假,线程1成功通过锁。
  • 现在线程2执行 compare_and_swap(lock, 0, 1)。由于当前值已是 1,函数返回 1,循环条件为真,线程2进入忙等待。
  • 最终,线程1执行 unlock,将锁的值设回 0
  • 此时,线程2的 compare_and_swap 可以成功执行,将值从 0 改为 1,线程2成功获取锁。

这个实现是安全的,但存在一个问题:忙等待(Busy Waiting)。如果线程无法立即获得锁,它会持续循环检查,浪费CPU资源。


自旋锁与改进

我们刚才实现的本质上是一个自旋锁(Spin Lock)compare_and_swap 指令在几乎所有现代CPU(如x86上的 cmpxchg)上都存在。

自旋锁在临界区非常短,且运行在多核处理器上时可能是可以接受的。但在单核系统上,忙等待毫无意义,因为持有锁的线程无法运行。

一个简单的改进是在循环中添加 yield 调用,让出CPU时间:

while (compare_and_swap(lock, 0, 1) != 0) {
    thread_yield(); // 让出CPU
}

但这引入了公平性问题。当锁被释放时,所有等待的线程都会被唤醒并竞争(“惊群效应”),我们无法控制下一个获得锁的线程是谁。

更好的解决方案是为锁添加一个等待队列

以下是改进思路:

  • lock 函数中,如果线程无法立即获得锁,则将自己加入等待队列,然后阻塞(睡眠)。
  • unlock 函数中,持有锁的线程释放锁后,检查等待队列,并只唤醒队列中的第一个线程。

然而,这个朴素的实现存在两个关键问题:

  1. 丢失唤醒(Lost Wake-up):一个线程可能将自己放入等待队列并进入睡眠后,却再也没有被唤醒。
  2. 错误线程获得锁(Wrong Thread Gets Lock):即使有线程在队列中等待,新来的线程也可能“插队”获得锁。

让我们思考一下导致“丢失唤醒”的线程交错执行顺序。

假设线程1持有锁(锁值为1),正在执行 unlock。线程2没有锁,正在执行 lock

  • 线程2先执行:compare_and_swap 返回1(因为锁值为1),于是它即将执行“加入等待队列”的操作。
  • 此时切换到线程1:线程1执行 unlock,将锁值从1改为0。然后它检查等待队列(目前为空),于是结束。
  • 切换回线程2:线程2现在执行“加入等待队列”并阻塞。
  • 结果:线程2永远睡眠,因为唯一能唤醒它的 unlock 操作已经发生过了。

接下来,思考导致“错误线程获得锁”的线程交错。

假设线程1持有锁,线程2已在等待队列中,线程3也来尝试获取锁。

  • 线程1先执行 unlock:将锁值从1改为0。检查队列,发现线程2,于是唤醒线程2。
  • 但在线程2被调度运行之前,线程3执行了:compare_and_swap(lock, 0, 1) 成功,因为锁值为0。线程3立即获得了锁。
  • 线程2被唤醒后,重新检查锁,发现锁值又为1(被线程3持有),只好再次将自己加入队列并睡眠。
  • 结果:虽然顺序错误,但程序没有崩溃,只是线程2被“插队”了。


正确的锁实现

为了解决上述问题,我们需要在锁内部使用一个自旋锁(作为保护锁) 来保护对锁状态和等待队列的修改操作,确保这些操作是原子的。

以下是修正后的锁数据结构示意:

typedef struct {
    int lock;       // 锁状态:0=空闲,1=占用
    int guard;      // 保护内部状态的自旋锁
    queue_t wait_q; // 等待队列
} mutex_t;

对应的 lockunlock 操作逻辑如下:

lock函数:

  1. 获取内部保护锁 guard(自旋锁)。
  2. 检查 lock 状态。
    • 如果 lock 为0(空闲),则将其设为1(占用),然后释放 guard,成功返回。
    • 如果 lock 为1(占用),则将当前线程加入 wait_q,释放 guard,然后阻塞(睡眠)。
  3. 当线程从睡眠中被唤醒后,它已经获得了锁(由唤醒者设置),直接返回。

unlock函数:

  1. 获取内部保护锁 guard
  2. 检查 wait_q 是否为空。
    • 如果队列为空,则将 lock 设为0(空闲)。
    • 如果队列不为空,则从队列中取出一个线程并唤醒它(注意:此时 lock 状态仍为1,因为锁被转移给了被唤醒的线程)。
  3. 释放内部保护锁 guard

这个实现解决了丢失唤醒和错误线程获得锁的问题,因为它将“检查状态/队列”和“修改状态/队列”的操作在 guard 的保护下原子地完成了。


信号量:协调线程顺序

锁确保了互斥访问,但不帮助协调线程间的执行顺序。接下来我们看看如何使用信号量(Semaphore) 来解决顺序问题。

考虑一个场景:我们有两个线程,线程1执行 print_first 函数打印“this is first”,线程2执行 print_second 函数打印“I‘m going second”。我们必须确保线程1的打印总是发生在线程2之前。

信号量是一个整数值(>=0),它提供两个原子操作:

  • wait() (或 P()): 将信号量的值减1。如果值将变为负数,则调用线程阻塞,直到值大于0。
  • post() (或 V()): 将信号量的值加1,并可能唤醒一个等待的线程。

如何使用信号量来保证顺序呢?

  1. 创建一个信号量 sem,并将其初始值设为0
  2. 在线程2 (print_second) 的一开始,调用 wait(sem)
  3. 在线程1 (print_first) 打印完成后,调用 post(sem)

执行流程:

  • 如果线程2先运行,它在 wait(sem) 处阻塞,因为初始值为0。
  • 线程1运行,打印“this is first”,然后调用 post(sem),将值从0增加到1。
  • 这唤醒了线程2(或允许其通过 wait),线程2然后打印“I‘m going second”。
  • 如果线程1先运行,它直接打印并 post,线程2随后运行时 wait 会立即通过(因为值已为1)。

这样,顺序就得到了保证。

注意:不能简单地用互斥锁(mutex)来实现这种顺序。例如,让线程2连续调用两次 lock() 是不可靠且可能导致未定义行为的。互斥锁应用于互斥,信号量应用于同步。


信号量与互斥锁的关系

有趣的是,互斥锁可以被看作信号量的一种特例

一个初始值为 1 的信号量,可以模拟一个互斥锁:

  • wait() 对应 lock()
  • post() 对应 unlock()

初始值1表示一开始有一个“通行证”。wait() 获取通行证,post() 归还通行证。这实现了互斥。

然而,尽管可以这样做,但为了代码清晰和意图明确,应该使用互斥锁来实现互斥,使用信号量来实现线程间同步


经典问题:生产者-消费者

现在,让我们应用信号量来解决一个经典的并发问题:生产者-消费者

问题描述:

  • 有一个固定大小的环形缓冲区
  • 生产者线程向缓冲区写入数据。它只能在缓冲区有空槽时写入。
  • 消费者线程从缓冲区读取数据。它只能在缓冲区有已填充槽时读取。
  • 必须防止对缓冲区的并发访问导致数据不一致。

我们需要用信号量来协调生产者和消费者。

我们需要两个信号量:

  1. empty_slots: 表示缓冲区中空槽的数量。初始值 = 缓冲区总大小
  2. filled_slots: 表示缓冲区中已填充槽的数量。初始值 = 0

生产者逻辑:

while (有数据要生产) {
    // 等待有空槽
    wait(empty_slots);
    // 向缓冲区写入数据
    fill_slot(data);
    // 通知消费者已填充一个槽
    post(filled_slots);
}

消费者逻辑:

while (要继续消费) {
    // 等待有已填充的槽
    wait(filled_slots);
    // 从缓冲区读取数据
    data = empty_slot();
    // 通知生产者空出一个槽
    post(empty_slots);
    // 处理数据
    process(data);
}

为什么这样可行?

  • empty_slotsfilled_slots 的和始终等于缓冲区大小。
  • 生产者通过 wait(empty_slots) 确保不会在满缓冲区上写。
  • 消费者通过 wait(filled_slots) 确保不会在空缓冲区上读。
  • post 操作配对地通知另一方。
  • 注意:对缓冲区本身的读写(fill_slotempty_slot)可能仍需要额外的互斥锁来保护,如果它们涉及多个内存操作。但在这个简化模型中,我们假设每个槽的读写是原子的。

通过这个设计,生产者和消费者可以高效、安全地并行工作。


总结

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

  1. 锁的硬件实现基础:利用如 compare_and_swap 的原子指令可以实现基本的自旋锁。
  2. 锁的改进:通过添加等待队列和睡眠/唤醒机制,可以构建更高效、更公平的锁,但需小心处理“丢失唤醒”和“错误获取”问题。
  3. 信号量:一个用于线程间同步的强大工具,通过 wait()post() 操作协调线程执行顺序。
  4. 信号量的应用
    • 可以用于保证特定执行顺序(如先打印A后打印B)。
    • 初始值为1的信号量可模拟互斥锁,但建议各司其职。
    • 完美解决了生产者-消费者这一经典并发问题,通过 empty_slotsfilled_slots 两个信号量优雅地控制流程。

记住解决同步问题的通用思路:明确需要保证的约束(互斥或顺序),选择正确的工具(锁或信号量),然后仔细分析所有可能的线程交错执行情况以确保正确性。

015:加锁机制

在本节课中,我们将学习多线程编程中的核心同步机制——锁。我们将从高级语言(如Java)内置的锁机制开始,深入探讨操作系统级别的条件变量,并分析使用锁时可能遇到的性能问题和死锁风险。


🧱 监视器:Java的内置锁机制

上一节我们介绍了互斥锁的基本概念。本节中,我们来看看一些编程语言如何通过内置机制来简化锁的使用。

在Java等面向对象语言中,开发者希望有比原始互斥锁更易用的工具。因此,它们引入了“监视器”的概念。开发者可以将一个方法标记为 synchronized(同步的),编译器会自动处理加锁和解锁。

这意味着,一个对象的所有同步方法中,同一时间只能有一个线程处于活动状态。本质上,编译器会为每个对象创建一个互斥锁,并在每个同步方法的开头插入加锁调用,在返回前插入解锁调用。

以下是Java中的一个示例,我们创建一个表示银行账户的类:

class Account {
    private int balance;

    public synchronized void deposit(int amount) {
        balance += amount;
    }

    public synchronized void withdraw(int amount) {
        balance -= amount;
    }
}

在这个例子中,balance 变量可能被多个线程同时读写,导致数据竞争。通过添加 synchronized 关键字,编译器会将其转换为类似以下伪代码的形式,确保互斥访问:

// 编译器生成的伪代码
public void deposit(int amount) {
    lock(monitor);
    balance += amount;
    unlock(monitor);
}

然而,这种机制粒度较粗:每个对象只有一个锁。如果你需要保护对象内的多个独立字段,这种方法就显得力不从心,因为它会序列化对所有字段的访问。


🚦 条件变量:更灵活的等待队列

上一节我们看到了Java的监视器。本节中,我们来学习一个更基础、更灵活的同步原语——条件变量。

条件变量这个名字可能有些误导,它本身并不存储条件状态。更好的理解方式是将其视为一个线程等待队列。它的类型通常是 pthread_cond_t,并配有初始化 (init) 和销毁 (destroy) 函数。

条件变量有三个核心操作:

  • wait:将当前线程放入队列并阻塞。
  • signal:唤醒队列中的一个线程(通常是队首)。
  • broadcast:唤醒队列中的所有线程。

关键点:条件变量必须与一个互斥锁配对使用。调用 wait 时,必须已经持有该互斥锁。这是因为:

  1. 线程需要安全地(无数据竞争地)将自己加入队列。
  2. wait 会在阻塞前自动释放该互斥锁,以允许其他线程执行。
  3. 当线程被唤醒并从 wait 返回时,它会自动重新获取该互斥锁。

因此,在调用 wait 前后,你都可以假定自己持有这个互斥锁。


🔄 生产者-消费者模式:条件变量实现

还记得我们用信号量实现的生产者-消费者问题吗?使用条件变量同样可以清晰实现。

我们使用一个互斥锁 (mutex) 保护共享的“已填充槽位”计数 (filled),并使用两个条件变量分别表示“有空位” (cond_empty) 和“有数据” (cond_filled)。

以下是生产者的逻辑:

// 生产者
lock(mutex);
while (filled == BUFFER_SIZE) { // 缓冲区满,等待空位
    wait(cond_empty, mutex);
}
// 生产数据并放入缓冲区
filled++;
signal(cond_filled); // 通知消费者有数据了
unlock(mutex);

以下是消费者的逻辑:

// 消费者
lock(mutex);
while (filled == 0) { // 缓冲区空,等待数据
    wait(cond_filled, mutex);
}
// 从缓冲区取出数据
filled--;
signal(cond_empty); // 通知生产者有空位了
unlock(mutex);
// 消费数据

与信号量版本相比,条件变量版本更清晰地表达了等待的条件(如 filled == BUFFER_SIZE),而信号量的正确性更依赖于初始计数值。两者功能等价,选择哪种取决于代码清晰度。对于复杂的条件判断,条件变量通常是更好的选择。


⚠️ 条件变量的经典陷阱:虚假唤醒与检查

上一节我们实现了条件变量的基本用法。本节中,我们来看一个使用条件变量时极易出错的场景,并学习如何避免。

假设我们有一个共享条件 bool condition = false,线程1等待条件为真,线程2负责设置条件并通知。

一个错误的实现可能如下:

// 线程1 (错误示例)
lock(mutex);
if (!condition) { // 使用 if 判断
    wait(cond, mutex);
}
// 执行需要condition为真的操作
unlock(mutex);

// 线程2
lock(mutex);
condition = true;
unlock(mutex);
signal(cond);

这个实现存在严重问题。考虑以下线程交错执行顺序:

  1. 线程1获取 mutex,检查 condition 为假,进入 wait。在调用 wait 之前,线程被挂起。
  2. 线程2执行,获取 mutex,将 condition 设为 true,释放 mutex,并调用 signal。但此时线程1还未进入等待队列,所以 signal 没有唤醒任何线程。
  3. 线程1恢复执行,调用 wait 并阻塞。此时再也没有线程会调用 signal 来唤醒它。程序死锁

问题根源在于,对 condition 变量的检查和修改没有在同一个互斥锁的保护下原子地完成。线程2在错误的时间点修改了条件。

正确的模式必须遵循以下两点:

  1. 修改条件的一方(线程2)也必须在持有相同互斥锁的情况下修改变量。通常,在锁内修改,在锁内或锁后发信号均可。
  2. 等待条件的一方(线程1)必须使用 while 循环来检查条件,而不是 if 语句。

正确示例如下:

// 线程1 (正确示例)
lock(mutex);
while (!condition) { // 使用 while 循环
    wait(cond, mutex);
}
// 执行需要condition为真的操作
unlock(mutex);

// 线程2 (正确示例)
lock(mutex);
condition = true;
unlock(mutex); // unlock可以在signal之前或之后
signal(cond);

使用 while 循环至关重要,因为:

  • 避免虚假唤醒:某些系统实现中,线程可能在没有收到 signal 的情况下被唤醒。while 循环能确保被唤醒后再次检查条件是否真正满足。
  • 处理条件状态变化:在被唤醒和重新获取锁的间隙,其他线程可能已经改变了条件状态。如上例所示,如果存在第三个线程在线程2之后又将条件改回 falsewhile 循环能迫使线程1继续等待。

核心原则:总是将 wait 放在检查条件的 while 循环中。


⚖️ 锁的粒度与死锁

上一节我们确保了条件变量的正确使用。本节中,我们讨论锁的性能影响和一个致命风险——死锁。

锁的粒度指的是临界区的大小。大粒度锁(保护大量代码或数据)会严重限制并发性,降低性能。小粒度锁(使用多个锁分别保护独立数据)可以提高并行度,但会增加复杂性和开销(如锁本身的内存和管理时间)。

引入多个锁时,最大的风险是死锁。死锁指两个或以上线程互相等待对方持有的锁,导致所有线程都无法前进。

死锁发生需要四个必要条件:

  1. 互斥:资源不能被共享。
  2. 持有并等待:线程持有一个资源,同时请求另一个资源。
  3. 不可剥夺:资源只能由持有者释放,不能被强制抢占。
  4. 循环等待:存在一个线程资源的环形等待链。

要预防死锁,只需破坏其中至少一个条件。对于互斥锁,我们无法改变条件1和3。因此,主要策略是破坏条件2或4。

破坏循环等待(条件4)——锁排序
确保所有线程以相同的全局顺序获取锁。例如,规定必须先获取 lockA,再获取 lockB

// 线程1和线程2都遵循此顺序
lock(lockA);
lock(lockB);
// 操作...
unlock(lockB);
unlock(lockA);

破坏持有并等待(条件2)——尝试锁
使用 trylock 函数,它尝试获取锁,若失败则立即返回而非阻塞。这样,线程在无法一次性获取所有所需锁时,可以释放已持有的锁,破坏“持有并等待”的状态。

while (1) {
    lock(lockA);
    if (trylock(lockB) == 0) { // 成功获取lockB
        break; // 进入临界区
    }
    // 获取lockB失败,释放lockA以避免死锁
    unlock(lockA);
    // 可选:让出CPU,给其他线程运行机会
    sched_yield();
}
// 临界区:持有lockA和lockB
unlock(lockB);
unlock(lockA);

📝 总结

本节课中我们一起学习了多线程编程中关键的加锁机制。

我们首先了解了如Java监视器这样的高级抽象如何简化锁的使用。然后,我们深入探讨了底层但更灵活的条件变量,它本质上是一个线程等待队列,必须与互斥锁配合使用,并且务必使用while循环来检查等待条件以避免竞态条件和虚假唤醒。

最后,我们讨论了锁的粒度对性能的影响,并分析了死锁产生的四个必要条件。我们学习了通过固定锁的获取顺序来预防循环等待,以及使用尝试锁来破坏持有并等待条件,从而编写出既安全又高效的多线程程序。

记住,锁是强大的工具,但需要谨慎使用以确保正确性和性能。

016:磁盘与并行化实践

概述 📚

在本节课中,我们将学习两个核心主题。首先,我们将探讨现代存储设备——固态硬盘(SSD)的工作原理及其与操作系统的交互。其次,我们将深入研究磁盘冗余阵列(RAID)技术,了解如何通过组合多个磁盘来提高性能和数据可靠性。最后,我们将通过一个银行转账模拟程序的并行化实践,综合运用多线程编程知识,解决数据竞争和死锁等并发问题。


固态硬盘(SSD)硬件基础 💾

上一节我们介绍了内存管理,本节中我们来看看持久化存储设备。与传统的机械硬盘(HDD)不同,固态硬盘(SSD)使用闪存晶体管而非磁性盘片来存储数据。

SSD的优点包括:没有活动部件或物理限制(类似于RAM)、断电后数据不丢失、更高的吞吐量、良好的随机访问性能、更节能以及更高的空间密度(在更小的空间存储相同数据)。其缺点则是价格更昂贵、写入寿命有限(有写入次数限制),并且编写驱动程序更为复杂。

SSD的硬件布局

一个SSD的存储单元按层次组织如下:

  • Die(晶圆):所有存储所在的基础单元。
  • Plane(平面):Die内的分区。
  • Block(块):Plane内的分区,是擦除操作的基本单位。
  • Page(页):Block内的分区,是读写操作的基本单位,大小通常与虚拟内存页(如4KB)匹配。

SSD的性能特征如下:

  • 读取一个页:约 10 微秒
  • 写入一个页:约 100 微秒
  • 擦除一个块:约 1 毫秒

SSD的编程约束

对于闪存编程,我们必须遵循以下规则:

  1. 只能读取完整的页。
  2. 只能写入新擦除的页(写入后无法覆盖,直到被擦除)。
  3. 只能以为单位进行擦除(每个块包含128或256个页)。

由于写入前可能需要擦除整个块,因此实际写入操作可能比单纯的写入时间慢得多。

SSD垃圾回收与TRIM命令

SSD需要对其块进行垃圾回收。当块中的部分页不再被使用时,SSD会在空闲时将这些仍被使用的页移动到新块,然后擦除旧块以供重用。

问题是,磁盘控制器并不知道操作系统仍在使用的块是哪些。如果只是删除文件,操作系统知道哪些页不再使用,但硬件本身不知道。

为了解决这个问题,操作系统可以使用 TRIM 命令(在Linux内核中称为 discard 选项)来通知SSD哪些块实际上未被使用。这样,SSD就可以在后台自由地擦除这些块,而无需等待显式的、缓慢的擦除操作。大多数现代操作系统默认启用此功能。


磁盘冗余阵列(RAID)🔧

到目前为止,我们讨论的都是单个存储设备(有时戏称为“单个大型昂贵磁盘”)。这存在单点故障风险。数据中心或重视数据的用户会使用RAID(独立磁盘冗余阵列)技术。

RAID通过将数据分布在多个物理磁盘上,主要实现两个目标:

  1. 冗余:防止数据丢失。
  2. 提升吞吐量:类似多线程思想,将工作分散到多个设备以加快速度。

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

RAID 0:条带卷

RAID 0纯粹为了追求性能。它将文件分割成多个部分(条带),并交替存储在不同的磁盘上。

工作原理示例
假设文件A被分成8部分(A1-A8),使用两个磁盘。

  • 磁盘1存储:A1, A3, A5, A7
  • 磁盘2存储:A2, A4, A6, A8

读取或写入时,可以同时从两个磁盘操作,速度理论上提升一倍。使用更多磁盘可获得更高倍速。

优点:读写性能高(N倍提升,N为磁盘数)。
缺点任何一块磁盘故障都会导致全部数据丢失,实际上增加了故障点。

RAID 1:镜像卷

RAID 1纯粹为了数据安全。所有磁盘都是彼此的精确镜像(克隆)。

工作原理示例
假设文件A有4部分(A1-A4),使用两个磁盘。

  • 磁盘1存储:A1, A2, A3, A4
  • 磁盘2存储:A1, A2, A3, A4

优点

  • 可靠性高:只要有一块磁盘存活,数据就不会丢失。使用N块磁盘,最多可容忍N-1块磁盘故障。
  • 读取性能好:可以从多个镜像并行读取数据。
    缺点
  • 成本高:存储利用率只有50%(N块磁盘只能提供1块磁盘的可用容量)。
  • 写入性能:与单块磁盘相同(因为需要写入所有镜像)。

RAID 4:带专用奇偶校验的条带

RAID 4试图在性能和冗余间取得平衡。它使用N-1块磁盘进行条带化(类似RAID 0),并用一块专用磁盘存储奇偶校验信息,用于在单块数据盘故障时恢复数据。

奇偶校验通常通过异或(XOR) 运算计算。

奇偶校验恢复原理
假设三块数据盘的比特值分别为:A1=0, A2=1, A3=0
奇偶校验位 P = A1 XOR A2 XOR A3 = 1(表示三个值的和为奇数)。
如果丢失A2磁盘,已知 A1=0, A3=0, P=1,则可通过 A2 = A1 XOR A3 XOR P = 1 计算出丢失的数据。

优点

  • 可用容量为 N-1 块磁盘的总和。
  • 可容忍一块数据磁盘故障。
  • 读取性能接近RAID 0(N-1倍)。
    缺点
  • 写入性能受影响:每次写入任何数据盘,都必须更新专用的奇偶校验盘,该盘可能成为瓶颈。
  • 无法承受两块磁盘同时故障。

RAID 5:分布式奇偶校验条带

RAID 5 改进了 RAID 4。它将奇偶校验信息均匀分布在所有磁盘上,而不是集中在一块专用磁盘上。

优点

  • 拥有RAID 4的所有优点。
  • 写入性能更好:因为奇偶校验写入负载被分散到所有磁盘,避免了单一校验盘的瓶颈。
    缺点:与RAID 4相同,无法承受两块磁盘同时故障。

RAID 6:双分布式奇偶校验条带

RAID 6 在 RAID 5 的基础上,为每个条带增加第二个独立的奇偶校验块(称为Q)。P可能仍使用XOR计算,而Q使用更复杂的算法(如基于伽罗华域的里德-所罗门编码)。

优点:可容忍任意两块磁盘同时故障。
缺点

  • 可用容量为 N-2 块磁盘的总和。
  • 写入性能略低于RAID 5,因为需要计算和写入两份奇偶校验信息。

嵌套RAID级别

可以组合RAID级别以实现特定目标。常见的组合是 RAID 10(或 RAID 1+0)。

RAID 10 工作原理
先创建多个RAID 1镜像对,再将这些镜像对组合成一个RAID 0条带卷。
例如,使用6块磁盘:先组成3个RAID 1镜像对(每对2块磁盘),再将这3个镜像对进行RAID 0条带化。

优点

  • 结合了RAID 1的高可靠性和RAID 0的高性能。
  • 数据恢复速度快(只需从镜像拷贝,无需复杂计算)。
  • 在某些故障情况下(非同一镜像对的两块磁盘故障),可容忍多于一块磁盘故障。
    缺点:存储利用率仍为50%。

并行化实践:银行转账模拟 🏦

上一节我们介绍了RAID的理论,本节中我们来看看如何将多线程知识应用于实际问题。我们将尝试并行化一个模拟银行转账的程序,目标是比单线程版本(约11秒)运行得更快。

程序概述

程序模拟进行1000万次随机转账。

  • 每个账户有唯一ID和余额(初始为1000美元)。
  • 每次转账随机选择两个账户,转移金额为转出账户余额的10%。
  • 核心函数 transfer 包含一个模拟耗时的 securely_connect_to_bank() 调用,这是我们希望并行化的部分。
  • 作为健全性检查,所有转账完成后,银行总资金应保持不变。

初始尝试与问题

我们的第一想法是创建多个线程,让每个线程处理一部分转账。以下是逐步优化中遇到的问题和解决方案:

  1. 直接并行化循环:每个线程运行相同的循环,导致总转账次数变为 线程数 * 1000万解决方案:将总工作量平均分配给每个线程。
  2. 性能未提升:即使平均分配工作,运行时间并未减少。问题根源rand() 函数虽然是线程安全的,但其内部使用全局状态和锁,导致所有线程串行化。解决方案:使用 rand_r() 函数,并为每个线程提供独立的随机数种子。
  3. 数据竞争(Data Race):多个线程可能同时读写同一个账户的余额,导致未定义行为和资金错误。解决方案:使用互斥锁保护共享数据。
  4. 全局锁导致性能差:为整个转账函数使用一个全局锁,虽然解决了数据竞争,但迫使所有转账串行执行,性能退回单线程水平。解决方案:使用更细粒度的锁,为每个账户配备一个独立的互斥锁。
  5. 死锁(Deadlock):当两个线程试图以相反顺序锁定同一对账户的锁时(例如,线程1锁A后试图锁B,线程2锁B后试图锁A),会发生死锁。解决方案:定义锁的全局获取顺序。例如,总是先锁定ID较小的账户,再锁定ID较大的账户。
  6. 自转账死锁:当转账的转出账户和转入账户是同一个时,线程会尝试两次锁定同一个互斥锁,导致自死锁。解决方案:在转账函数开始处检查两个账户指针是否相同,若相同则直接返回。

最终解决方案代码要点

以下是关键部分的伪代码,展示了如何避免数据竞争和死锁:

// 账户结构体
struct account {
    int id;
    int balance;
    pthread_mutex_t lock; // 每个账户有自己的锁
};

// 转账函数
void transfer(struct account *from, struct account *to) {
    // 避免自转账死锁
    if (from == to) {
        return;
    }

    // 确定锁的获取顺序(总是先锁ID小的账户)
    pthread_mutex_t *first_lock = &(from->lock);
    pthread_mutex_t *second_lock = &(to->lock);
    if (from->id > to->id) {
        first_lock = &(to->lock);
        second_lock = &(from->lock);
    }

    // 按顺序加锁
    pthread_mutex_lock(first_lock);
    pthread_mutex_lock(second_lock);

    // 执行转账操作(访问balance)
    int amount = from->balance / 10;
    from->balance -= amount;
    to->balance += amount;

    // 解锁(顺序无关紧要,但通常与加锁顺序相反)
    pthread_mutex_unlock(second_lock);
    pthread_mutex_unlock(first_lock);
}

线程消毒器(Thread Sanitizer)

调试多线程问题非常困难。我们可以使用 ThreadSanitizer 工具来帮助检测数据竞争和死锁。

在编译时(使用clang或配置后的gcc)添加 -fsanitize=thread 标志:

clang -fsanitize=thread -g -o banksim banksim.c -lpthread

运行程序时,ThreadSanitizer会在检测到问题时输出详细的错误信息,包括发生问题的代码行、涉及的线程和内存地址,极大辅助调试。

注意:工具未报告错误不保证程序100%正确,但报告错误则一定存在问题。


总结 🎯

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

  1. 固态硬盘:了解了SSD基于页和块的读写特性、垃圾回收机制,以及操作系统通过TRIM命令与之协作以优化性能的重要性。
  2. 磁盘阵列:探讨了从RAID 0(纯性能)、RAID 1(纯冗余)到RAID 5/6(平衡方案)等多种RAID级别的工作原理、优缺点及应用场景,理解了如何利用多磁盘提升性能和数据可靠性。
  3. 并行化实践:通过一个完整的银行转账模拟案例,亲历了将串行程序改造为并行程序的全过程。我们遇到了数据竞争死锁性能瓶颈等典型并发问题,并运用细粒度锁锁排序条件检查等技术逐一解决。最后,介绍了使用ThreadSanitizer工具辅助调试并发程序的方法。

并行编程充满挑战,但却是释放现代多核硬件性能潜力的关键。正确管理线程、同步和数据访问是构建高效、可靠系统软件的必备技能。

017:文件系统基础

在本节课中,我们将学习文件系统的基础知识,包括文件路径、文件访问方式、文件描述符的内部结构以及文件在磁盘上的存储策略。

文件系统布局与路径

上一节我们介绍了操作系统的基本概念,本节中我们来看看文件系统是如何组织的。在类Unix系统(如Linux或macOS)中,文件系统遵循一个标准的层次结构,称为文件系统层次结构标准。

  • 所有目录的起点是一个特殊的根目录,用单个斜杠 / 表示。
  • 根目录下包含一系列标准目录,例如:
    • /bin:存放可执行文件。
    • /dev:存放内核提供的设备文件。
    • /etc:存放系统配置文件。
    • /home:存放各个用户的个人目录(在macOS中是/Users)。
    • /mnt:用于挂载外部存储设备(如USB驱动器)。

当你在终端或程序中操作时,你总是位于一个当前工作目录中。要定位一个文件,可以使用两种路径:

  • 绝对路径:从根目录开始,完整描述文件位置。例如:/home/john/todo.txt
  • 相对路径:相对于当前工作目录。例如,如果当前目录是/home/john,那么todo.txt./todo.txt就是相对路径。

要访问上级目录,可以使用特殊符号 ..。例如,从/home/john到根目录下的/mnt/usb,相对路径是../../mnt/usb

关于特殊符号有一个有趣的历史:最初,程序员为了在列出目录内容时隐藏.(当前目录)和..(上级目录),写了一个判断条件“如果文件名以点开头就跳过”。这个“优化”导致所有以点开头的文件(如.bashrc)都被隐藏了,这个错误后来变成了一个功能,即Unix系统中隐藏文件都以点开头。

另一个特殊符号是~,它代表当前用户的家目录,其路径也存储在环境变量HOME中。当前工作目录的路径则存储在环境变量PWD中。

文件访问方式

理解了文件的位置后,我们来看看如何访问文件内容。文件访问主要有两种模式:

  • 顺序访问:这是最常见的方式。每次读取操作都会从文件的当前位置开始,并自动将位置向后移动已读取的字节数。写入操作通常默认是追加模式,即写入后位置移动到文件末尾。
  • 随机访问:允许你从文件的任意字节位置开始读取或写入。这需要你明确告诉操作系统起始位置。

为了实现随机访问,POSIX系统提供了几个关键的系统调用:

  • open:打开一个文件,返回一个文件描述符。参数包括路径名、标志(如O_RDONLY只读、O_WRONLY只写、O_RDWR读写、O_APPEND追加)和模式(设置文件权限)。
  • lseek:改变与文件描述符关联的文件的当前访问位置。其函数原型为:
    off_t lseek(int fd, off_t offset, int whence);
    
    其中whence参数决定offset的含义:
    • SEEK_SEToffset是相对于文件开头的绝对位置。
    • SEEK_CURoffset是相对于当前位置的偏移量。
    • SEEK_ENDoffset是相对于文件末尾的偏移量。
  • read / write:进行实际的读写操作。
  • opendir / readdir:用于读取目录中的条目。

文件描述符的内部结构

之前我们简单地将文件描述符比作指向文件的指针,实际上它的结构更精细。每个进程都有一个本地打开文件表(是其PCB的一部分),文件描述符就是这个表的索引。

本地打开文件表中的每一项并不直接指向文件,而是指向一个由内核管理的全局打开文件表中的条目。全局打开文件表的每个条目(GOT entry)包含:

  • 当前文件偏移量(访问位置)。
  • 打开文件的标志(如读、写、追加权限)。
  • 一个指向vnode的指针。

vnode是内核用于抽象任何可以读写字节的对象的通用结构,它可以代表普通文件、目录、管道或网络套接字等。

当进程调用fork()时,子进程会复制父进程的整个PCB,包括本地打开文件表。这意味着父子进程的文件描述符指向同一个全局打开文件表条目。因此,如果其中一个进程通过lseekread/write改变了文件位置,另一个进程看到的文件位置也会改变,因为它们共享同一个全局条目。

如果进程在fork()之后调用open()打开同一个文件,那么open系统调用会为每个进程创建一个新的、独立的全局打开文件表条目。这样,两个进程对该文件的访问位置就完全独立了。

让我们通过一个例子来巩固理解:假设文件A.txt内容为“this is file A”。父进程先打开A.txt得到文件描述符fd1,然后调用fork()。在子进程中,两者都通过fd1读取文件。由于它们共享同一个全局条目,谁先执行read,谁就读走全部内容,后执行者读到0字节。如果子进程在fork打开B.txt得到新的fd2,那么每个进程对自己的fd2都有独立的访问位置,互不影响。

文件存储策略

以上我们讨论了如何通过API访问文件。接下来,我们看看文件内容是如何在物理磁盘(或SSD)上存储和组织的。核心问题是:如何记录一个文件占用了哪些磁盘块?

以下是几种主要的策略:

1. 连续分配
文件的所有数据块在物理上必须连续存放。只需记录起始块号和块数即可。

  • 优点:描述信息非常简洁(只需起始块和长度),随机访问速度快(通过偏移量可直接计算物理块号)。
  • 缺点:文件难以增长(需要挪动后续文件或寻找足够大的连续空间),容易产生外部碎片(即磁盘上存在许多小的、不连续的空闲块,无法被大文件使用)。

2. 链接分配
将文件的数据块组织成一个链表。每个数据块末尾存储下一个块的指针。

  • 优点:文件可以轻松增长和缩小,没有外部碎片(任何空闲块都可用)。
  • 缺点:随机访问性能极差(必须从链表头开始遍历),且每个块需要额外空间存储指针。

3. 文件分配表
这是链接分配的一种优化,用于FAT32等文件系统。它将所有块的“下一个指针”集中存储在一个单独的、连续的区域(即FAT表)。

  • 优点:继承了链接分配的所有优点(易增长、无外部碎片),且指针集中存储更利于缓存,随机访问比普通链表快。
  • 缺点:FAT表的大小与磁盘总容量成正比,对于大磁盘会占用大量空间。

4. 索引分配
为每个文件创建一个索引块,该块是一个数组,数组的每个条目直接指向文件的一个数据块。这类似于页表的概念。

  • 优点:随机访问速度极快(通过偏移量可直接在索引块中查找),文件可以非连续存放,无外部碎片。
  • 缺点:每个文件都需要一个额外的索引块。更重要的是,单个文件的大小受限于索引块能存储的指针数量

让我们计算一下索引分配的局限性:假设磁盘块大小为8KB(213字节),指针大小为4字节(22字节)。那么一个索引块可以存储 2^13 / 2^2 = 2^11 个指针。因此,一个文件最大能寻址 2^11 个数据块,总大小为 2^11 * 2^13 = 2^24 字节,即16MB。这对于现代文件系统来说显然太小了。


本节课中我们一起学习了文件系统的基础。我们了解了文件系统的标准布局和路径解析方式,掌握了顺序访问和随机访问的区别以及lseek系统调用的用法。我们深入剖析了文件描述符的本质,明白了它通过本地和全局两级表与vnode关联的机制,并清楚了forkopen对文件共享的影响。最后,我们探讨了四种主要的磁盘文件存储策略:连续分配、链接分配、文件分配表(FAT)和索引分配,分析了它们各自的优缺点,并指出了简单索引分配在文件大小上的限制。在下一讲中,我们将学习如何通过多级索引等方法来突破这个限制。

018:索引节点 📂

在本节课中,我们将学习文件系统中一个核心的数据结构——索引节点(inode)。我们将了解它如何高效地管理文件数据块,以及它与我们熟悉的lslnrm等命令之间的关系。

概述:什么是索引节点?

索引节点是内核中用于描述文件在磁盘上存储位置及相关元信息的结构。它记录了文件的所有者、权限、时间戳、大小以及最重要的——指向文件数据块的指针。

索引节点的结构

上一节我们介绍了文件分配的几种基本方法(如链表、FAT表)。本节中我们来看看Unix/Linux系统采用的更灵活的混合策略——索引节点。

一个索引节点通常包含以下信息:

  • 文件元数据:如权限(mode)、所有者(user/group)、时间戳(创建、修改、变更时间)、文件大小、占用的块数等。
  • 数据块指针:这是一个指针数组,用于定位文件内容实际存储在哪些磁盘块上。

为了在支持大文件的同时,高效处理小文件,索引节点采用了多级指针结构:

  • 12个直接指针:直接指向存储文件数据的数据块。对于小文件(≤12块),无需额外开销。
  • 1个一级间接指针:指向一个磁盘块,该块本身是一个指针数组,数组中的每个指针再指向一个数据块。用于中等大小的文件。
  • 1个二级间接指针:指向一个指针块,该块中的指针再各自指向一个一级间接块。用于更大的文件。
  • 1个三级间接指针:原理类似三级页表,用于支持巨型文件。

这种设计是空间与效率的权衡,类似于虚拟内存中的多级页表。

最大文件尺寸计算

了解了索引节点的结构后,我们可以像计算虚拟地址空间一样,计算它所能支持的最大文件尺寸。

假设以下参数:

  • 磁盘块大小:BlockSize = 8KB = 2^13 Bytes
  • 指针大小:PointerSize = 4 Bytes = 2^2 Bytes
  • 每个间接块仅包含直接指针(即数据块指针)。

那么,一个间接块能存储的指针数量为:
PointersPerBlock = BlockSize / PointerSize = 2^13 / 2^2 = 2^11

可寻址的数据块总数为:

  • 直接块12
  • 一级间接2^11
  • 二级间接(2^11)^2 = 2^22
  • 三级间接(2^11)^3 = 2^33

显然,三级间接块占主导。因此,最大文件尺寸近似为:
MaxFileSize ≈ 2^33 * BlockSize = 2^33 * 2^13 = 2^46 Bytes = 64 TB

这个尺寸对于绝大多数应用已经足够。如果需要支持更大的文件,只需增加四级间接指针即可。

硬链接与软链接

索引节点是文件在磁盘上的唯一标识,而文件名则是我们访问它的方式。这就引出了“链接”的概念。

硬链接

一个硬链接就是一个指向索引节点的目录条目(文件名)。多个不同的文件名(硬链接)可以指向同一个索引节点。

创建硬链接的命令是 ln

ln source_file hardlink_name

例如,ln a.txt b.txt 会创建一个新文件名 b.txt,它与 a.txt 指向同一个索引节点。

关键特性

  • 删除文件(rm)实际上只是删除一个硬链接(系统调用是 unlink),只有当指向某个索引节点的硬链接数降为0时,该文件所占用的空间才会被真正释放。
  • 通过任何一个硬链接修改文件内容,所有指向该索引节点的硬链接看到的内容都会同步更新。
  • 回收站原理:回收站本质上就是保留了一个指向文件的硬链接,所以“删除”和“恢复”操作都非常快。

使用 ls -li 可以查看文件的索引节点号(第一列)和硬链接计数(第二列)。stat 命令可以显示更详细的索引节点信息。

软链接(符号链接)

软链接则是一个特殊文件,其内容存储的是另一个文件的路径名。可以理解为“指向名字的名字”。

创建软链接需要 -s 选项:

ln -s target_file symlink_name

关键特性

  • 软链接有自己的索引节点。
  • 如果目标文件被删除,软链接会成为“悬空链接”,访问时会报错。
  • 可以创建循环软链接(如A指向B,B指向A),内核会检测到这种循环并返回 ELOOP 错误。
  • 可以跨文件系统创建。

目录与文件类型

在Unix哲学中,一切皆文件。目录本身也是一种特殊类型的文件。

目录文件的内容非常简单,它不存储数据块指针,而是存储一个(文件名,索引节点号) 对的列表。此外,还包含两个特殊条目:

  • . :指向目录自身的索引节点。
  • .. :指向父目录的索引节点。

使用 ls -lia 可以查看包括 ... 在内的所有目录条目及其索引节点号。

除了常规文件和目录,索引节点还可以表示其他类型:

  • 符号链接 (l)
  • 块设备 (b),如硬盘 (/dev/sda)
  • 字符设备 (c)
  • 管道 (p)
  • 套接字 (s)

文件系统缓存与日志

文件系统缓存

由于磁盘读写速度远慢于内存,操作系统会使用文件系统缓存来提升性能。

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

因此,对文件的修改通常会先写入内存中的缓存,由内核后台线程定期或显式调用 sync/fsync 时,才将缓存中的数据刷回磁盘。这提升了速度,但也意味着突然断电可能导致数据丢失。

日志文件系统

文件操作(如删除)通常涉及多个步骤(如从目录删除条目、释放索引节点、释放数据块)。如果系统在步骤之间崩溃,会导致文件系统处于不一致状态。

日志文件系统 的解决方法是:

  1. 日志记录:在真正执行操作前,将“准备做什么”作为一条记录写入一个专门的区域(日志)。
  2. 执行操作:按步骤执行实际的文件系统操作。
  3. 提交日志:所有操作成功后,在日志中标记该事务完成。

如果系统在步骤2中崩溃,重启后文件系统会检查日志,发现未完成的事务,并重新执行或回滚这些操作,从而快速恢复一致性,无需漫长的全盘扫描 (fsck)。

总结

本节课我们一起学习了文件系统的核心——索引节点。

  • 索引节点采用混合多级指针结构,巧妙平衡了小文件的高效存储和大文件的容量支持。
  • 硬链接是多个文件名指向同一个索引节点,软链接则是一个存储目标路径的特殊文件。
  • 目录本质是(文件名,索引节点号)的列表。
  • 通过文件系统缓存提升性能,通过日志机制保证崩溃后的一致性。

理解索引节点是理解文件系统如何组织和管理数据的关键一步。

019:页面置换 🧠

在本节课中,我们将要学习操作系统中的页面置换算法。当物理内存空间不足时,操作系统需要决定将哪些页面移出内存,以为新页面腾出空间。我们将探讨几种不同的置换策略,并学习如何评估它们的效率。

内存层次结构回顾

上一节我们介绍了虚拟内存的基本概念,本节中我们来看看支撑虚拟内存的硬件基础——内存层次结构。

计算机的内存层次结构总是在容量和速度之间进行权衡。金字塔的顶端是速度最快但容量最小的部件。

  • CPU寄存器:速度最快,数量有限。
  • CPU缓存(L1, L2, L3):速度次之,容量更大。
  • 内存(RAM):易失性存储器,可视为CPU的缓存。
  • 非易失性存储(NVM/SSD):速度较慢,容量大。
  • 硬盘驱动器(HDD):速度更慢,容量更大。
  • 磁带驱动器:速度非常慢,但单位容量成本极低,用于海量数据归档。

整个层次结构的目标是对用户隐藏其复杂性。每一层都试图表现得像拥有上一层的速度和下一层的容量。对于内存管理而言,这意味着我们希望所有进程使用的内存总量能够超过物理内存的实际容量,因为我们利用了并非所有页面都会同时被使用这一事实。

按需调页与交换空间

我们可以将磁盘用作内存的缓存。如果一个页面在内存中长时间未被使用,我们可以将其“踢出”到磁盘上存储。当再次需要该页面时,再从磁盘加载回内存。这个过程使得计算机能够使用比物理内存容量更多的虚拟内存。

在典型的现代系统中,我们使用按需调页。我们将内存页面映射到文件系统的块。只有当一个页面被实际访问时(通过触发页面错误),操作系统才会将其从磁盘加载到内存中。如果该页面不代表一个文件(例如,只是进程分配的匿名内存),则可以将其映射到交换空间(Swap Space)。这就是你在Windows(页面文件)、macOS或Linux上看到的那个大文件的作用。

工作集与颠簸

一个进程的工作集是指在给定时间间隔内,该进程活跃使用的页面集合。进程的工作集必须能够放入物理内存中。如果工作集无法全部装入物理内存,进程就会陷入颠簸状态:页面频繁地在磁盘和内存之间来回交换,导致程序运行极其缓慢。我们必须不惜一切代价避免这种情况。

页面置换算法

现在,我们进入核心内容:页面置换算法。我们假设页面最初都在磁盘上,而内存中只能容纳固定数量的页面。当内存已满且需要加载新页面时,我们必须选择并“踢出”一个旧页面。

以下是几种主要的页面置换算法:

  • 最优算法(OPT):置换在未来最长时间内不会被访问的页面。这是一个理想化的算法,因为需要预知未来,仅用于理论比较。
  • 随机算法(RANDOM):随机选择一个页面进行置换。实际效果出人意料地好,通常优于FIFO。
  • 先进先出算法(FIFO):置换最早进入内存的页面。实现简单,但性能较差,并且存在“Belady异常”。
  • 最近最少使用算法(LRU):置换最长时间没有被访问的页面。它用过去的行为来预测未来,性能接近最优算法。

算法评估示例

为了评估这些算法,我们通常假设一个很小的物理内存容量(例如,只能容纳4个页面),并给定一个页面访问序列。我们的目标是计算在整个访问过程中发生的页面错误(即需要从磁盘加载页面)的总数,数量越少越好。

假设访问序列为:1, 2, 3, 4, 1, 2, 5, 1, 2, 3, 4, 5。
物理内存容量为4个页帧。

最优算法(OPT)分析

  1. 访问1,2,3,4:均为页面错误,加载入内存。[1, 2, 3, 4]
  2. 访问1,2:已在内存中,命中。
  3. 访问5:内存已满,需置换。查看未来访问序列:1和2即将被访问,3在稍后被访问,4在最久之后才被访问。因此置换4。[1, 2, 3, 5]
  4. 访问1,2,3:均在内存中,命中。
  5. 访问4:内存已满,需置换。未来序列中,5是下一个被访问的。因此可以置换1、2或3中的任意一个(只要不置换5)。假设置换1。[4, 2, 3, 5]
  6. 访问5:已在内存中,命中。
    总页面错误数:6次

先进先出算法(FIFO)分析

  1. 访问1,2,3,4:加载入内存。[1 (最早), 2, 3, 4]
  2. 访问1,2:命中。
  3. 访问5:内存已满,置换最早进入的页面1。[5, 2 (最早), 3, 4]
  4. 访问1:页面错误,置换最早进入的页面2。[5, 1, 3 (最早), 4]
  5. 访问2:页面错误,置换最早进入的页面3。[5, 1, 2, 4 (最早)]
  6. 访问3:页面错误,置换最早进入的页面4。[5, 1, 2, 3]
  7. 访问4:页面错误,置换最早进入的页面5。[4, 1 (最早), 2, 3]
  8. 访问5:页面错误,置换最早进入的页面1。[4, 5, 2 (最早), 3]
    总页面错误数:10次。比OPT多。

Belady异常

有趣的现象是,对于FIFO算法,增加物理内存页帧数有时反而会导致更多的页面错误,这被称为Belady异常。例如,将内存容量从4页帧减少到3页帧,用FIFO处理同样的序列,可能只产生9次页面错误(少于4页帧时的10次)。这违背了“内存越多,性能越好”的直觉。但请注意,这种异常仅存在于FIFO算法中,LRU等算法不存在此问题。

最近最少使用算法(LRU)分析

  1. 访问1,2,3,4:加载入内存。[1, 2, 3, 4]
  2. 访问1,2:命中,更新访问顺序(1和2变为最近使用)。
  3. 访问5:内存已满,需置换。回顾过去,最近最少使用的是3(接着是4)。置换3。[1, 2, 5, 4]
  4. 访问1,2:命中。
  5. 访问3:页面错误,内存已满。回顾过去,最近最少使用的是4。置换4。[1, 2, 5, 3]
  6. 访问4:页面错误,内存已满。回顾过去,最近最少使用的是5。置换5。[1, 2, 4, 3]
  7. 访问5:页面错误,内存已满。回顾过去,最近最少使用的是1。置换1。[5, 2, 4, 3]
    总页面错误数:8次。介于OPT和FIFO之间。

LRU的实现挑战与时钟算法

LRU虽然性能较好,但精确实现成本高昂。它需要为每个页面维护精确的访问时间戳或在每次访问时调整一个链表,这在每次内存访问时都会带来不小的开销。

因此,实际的操作系统使用LRU的近似算法。最著名的一种是时钟算法

时钟算法需要以下数据结构:

  • 一个由内存中所有页帧组成的环形链表(像时钟一样)。
  • 每个页帧关联一个引用位
  • 一个“指针”(或称为“时钟臂”),指向当前检查的位置。

算法规则如下:

  • 快速路径(页面访问):当某个页面被访问时,硬件(MMU)自动将其引用位设置为1。这是唯一且非常快速的操作。
  • 慢速路径(页面置换):当需要置换页面时,算法开始工作:
    1. 检查指针当前指向的页帧的引用位。
    2. 如果引用位为 0,则选择该页帧作为受害者进行置换。将新页面放入此位置,并将其引用位设为1。然后将指针向前移动一位
    3. 如果引用位为 1,则将其置为0(给予该页面第二次机会),然后将指针向前移动一位。重复此步骤,直到找到一个引用位为0的页帧。

时钟算法示例(简略)
假设内存有4个页帧,初始为空,访问序列:1, 2, 3, 4, 5, 2, 3, 1, 2, 3。

  1. 加载1,2,3,4:每次置换时,指针移动并设置引用位为1。
  2. 访问5:需置换。指针循环查找,将遇到的引用位为1的页帧都置为0,直到找到第一个原本就是0的页帧(或刚被置为0的页帧)进行置换。假设置换页面1,放入5,引用位置1,指针后移。
  3. 访问2,3:命中,仅将其引用位置1。
  4. 访问1:页面错误。指针开始查找,跳过引用位为1的2和3(并将它们置0),最终找到引用位为0的页面4进行置换。
    整个过程产生的页面错误数介于LRU和FIFO之间,但开销远小于精确LRU。

其他高级话题

  • 禁用交换:在内存充足的服务器上,有时会完全禁用交换功能,以避免不可预测的性能下降。当物理内存耗尽时,Linux内核的“OOM Killer”会直接终止消耗内存最多的进程。
  • 大页:使用大于传统的4KB的页面(如2MB的“大页”或1GB的“巨页”)。这可以减少页表级数和TLB未命中次数,提高性能,但代价是可能增加内部碎片。

总结

本节课中我们一起学习了操作系统中的页面置换。我们了解了内存层次结构的目标,以及按需调页和交换空间如何让系统使用比物理内存更大的虚拟地址空间。我们重点探讨了几种页面置换算法:作为理论基准的最优算法(OPT)、简单的随机算法、存在Belady异常的先进先出算法(FIFO),以及性能较好但实现代价高的最近最少使用算法(LRU)。最后,我们学习了实际系统中常用的LRU近似算法——时钟算法,它通过一个环形链表和引用位,以较低的开销实现了接近LRU的效果。理解这些算法对于分析系统性能和进行系统编程至关重要。

020:虚拟机 🖥️

在本节课中,我们将要学习虚拟机的核心概念。虚拟机是一种强大的技术,它允许我们在单个物理计算机上运行多个独立的操作系统。我们将探讨其工作原理、不同类型以及它们带来的优势。

概述

虚拟机的核心思想是抽象整个计算机硬件。与进程抽象单个程序的运行不同,虚拟机抽象了整个机器。这使得多个操作系统可以共享同一台物理硬件,而每个操作系统都认为自己独占所有资源。

虚拟机与进程的对比

上一节我们介绍了虚拟机的基本概念,本节中我们来看看它与我们熟悉的进程模型有何异同。

进程模型(左图)中,硬件之上是运行在内核模式的操作系统内核,再之上是通过系统调用接口与内核交互的用户模式进程。

虚拟机模型(右图)则在物理硬件之上增加了一个虚拟机监控程序(Hypervisor)。Hypervisor之上是多个客户机(Guest),每个客户机都包含自己的内核和用户进程。客户机看到的是由Hypervisor虚拟化出来的硬件。

虚拟机的类型与工作原理

了解了基本模型后,我们来看看虚拟机的具体类型及其运行机制。

虚拟机监控程序(Hypervisor)是创建和管理虚拟机的软件。它控制虚拟机的创建、资源分配和隔离。Hypervisor主要分为两种类型:

以下是两种主要Hypervisor类型的说明:

  • 类型1 Hypervisor(裸机Hypervisor):这种Hypervisor直接运行在主机硬件上,需要特殊的硬件支持(如Intel VT-x或AMD-V)。它通常性能更高。
  • 类型2 Hypervisor(托管式Hypervisor):这种Hypervisor作为一个普通应用程序运行在主机操作系统之上。它不需要特殊硬件,但性能通常较低,因为它需要模拟内核模式操作。

关键抽象:虚拟CPU(vCPU)
虚拟机的关键抽象是虚拟CPU(vCPU)。它类似于进程控制块(PCB),但存储了整个CPU的状态,包括仅在内核模式下可访问的寄存器。当客户机不运行时,Hypervisor会保存vCPU的状态;恢复运行时,则加载这些状态。

虚拟化面临的挑战与解决方案

上一节我们介绍了虚拟机的基本运行方式,本节中我们来看看在实现虚拟化时遇到的具体挑战及其解决方案。

对于类型2 Hypervisor,客户机操作系统实际上运行在用户模式。当它尝试执行特权指令(内核模式指令)时,会产生非法指令异常。Hypervisor必须捕获这个异常,模拟该指令应有的行为,更新vCPU状态,然后恢复客户机执行。这个过程称为陷入与模拟(Trap-and-Emulate)

然而,某些CPU指令(特别是在x86架构上)的行为会根据CPU模式(用户/内核)而改变,但指令本身并非特权指令,因此无法通过“陷入”来捕获。这些被称为特殊指令

处理特殊指令需要二进制翻译(Binary Translation)。当客户机vCPU处于内核模式时,Hypervisor必须在执行前检查每一条指令。如果发现是特殊指令,就将其翻译成一组具有相同效果的非特殊指令序列。

硬件辅助虚拟化

由于软件模拟的性能开销很大,现代CPU提供了硬件支持来简化并加速虚拟化。

硬件辅助虚拟化(如Intel VT-x和AMD-V)在CPU中引入了一个新的特权级别,通常称为Hypervisor模式(在x86上称为环-1)。这允许Hypervisor以比客户机内核更高的特权级别运行,从而能够直接、高效地控制硬件,并处理客户机的特权操作,无需复杂的二进制翻译或陷入模拟。这就是类型1 Hypervisor能够高效运行的基础。

虚拟机的调度与内存管理

现在我们已经了解了CPU的虚拟化,接下来看看如何调度虚拟机以及如何管理更复杂的虚拟化内存。

调度
虚拟机的调度与进程调度类似。Hypervisor需要将多个虚拟CPU(vCPU)映射到物理CPU核心上。如果物理核心足够,可以一对一映射。如果vCPU数量超过物理核心(称为过量使用),则Hypervisor需要使用调度算法(如轮转调度)在物理核心上分时运行vCPU。

内存管理
内存虚拟化更为复杂。客户机操作系统认为自己管理着整个物理内存,但实际上它看到的是Hypervisor提供的“虚拟物理内存”。因此,我们需要嵌套页表

嵌套页表的工作流程如下:

  1. 客户机进程使用虚拟地址(VA_G)。
  2. 客户机页表将VA_G翻译为客户机认为的“物理地址”(PA_G,实为中间物理地址)。
  3. Hypervisor维护的嵌套页表将PA_G翻译为真实的机器物理地址(PA_M)。

硬件辅助虚拟化(如扩展页表EPT或快速虚拟化索引RVI)可以缓存整个翻译过程(VA_G -> PA_M),从而显著提升性能。

此外,Hypervisor可以在不同客户机之间使用写时复制技术来共享相同的内存页(例如,相同的内核或库文件),以提高内存使用效率。

虚拟机的输入/输出与设备

除了CPU和内存,输入/输出设备的虚拟化也是关键一环。

Hypervisor可以虚拟化I/O设备,例如:

  • 复用:将一个物理设备(如网卡)复用于多个虚拟机。
  • 模拟:提供虚拟机中不存在的虚拟设备。
  • 直通:通过IOMMU技术,将特定物理设备(如GPU)直接分配给某个虚拟机独占访问,从而获得近乎原生的性能。

虚拟机的应用与优势

最后,让我们总结一下虚拟机的广泛应用场景和主要优势。

虚拟机通过磁盘镜像文件(包含完整的操作系统和文件系统)来启动,这使得它们易于迁移和分发。

以下是虚拟机的主要优势:

  • 隔离与安全:每个虚拟机相互隔离。如果一个虚拟机被攻破,不会影响主机或其他虚拟机。
  • 资源整合:在数据中心,可以将多个轻量级服务器整合到少数物理机上运行,提高资源利用率,降低成本。
  • 灵活性与可迁移性:虚拟机可以轻松暂停、恢复,并迁移到不同的物理主机上,便于维护和负载均衡。
  • 测试与兼容性:可以安全地测试软件、不同操作系统甚至恶意软件。也可以运行仅兼容特定操作系统的硬件或软件。

总结

本节课中我们一起学习了虚拟机技术。我们从虚拟机与进程的对比入手,深入探讨了Hypervisor的类型、虚拟CPU的抽象、以及虚拟化在CPU指令、内存管理和I/O设备方面面临的挑战与解决方案。我们还了解了硬件辅助虚拟化如何提升性能,并总结了虚拟机在隔离、整合、灵活迁移和测试等方面的强大优势。虚拟机是现代云计算和数据中心的基石技术。

021:内存分配策略

概述

在本节课中,我们将要学习操作系统中的内存分配策略。我们将从最简单的静态分配开始,探讨其局限性,然后深入讲解动态内存分配的核心概念、面临的挑战(特别是内存碎片问题),以及几种常见的动态内存分配实现策略,包括基于自由链表的分配器、伙伴分配器和slab分配器。

静态分配

上一节我们介绍了内存分配的基本概念,本节中我们来看看最简单的分配策略:静态分配。

静态分配是程序中最简单的内存分配策略。程序员在编写程序时创建一个固定大小的内存区域。

例如,在程序中声明一个全局变量:

char buffer[4096];

当程序加载时,内核会为这个全局变量预留内存。只要进程存在,这块内存就一直有效,无需手动释放。

然而,静态分配存在一些缺点。动态分配通常更为必要,因为程序可能只在特定条件下才需要某些内存。静态分配有时会造成浪费,因为它必须考虑最坏情况下的内存需求。如果最坏情况只发生在程序运行的极短时间内,而程序可能运行很长时间,那么大部分时间内存都被浪费了。

此外,静态分配需要考虑最大内存需求,并且我们需要决定在程序的哪个区域分配内存:栈或堆。我们知道这个区别很重要,因为栈上的变量对每个线程应该是独立的。

栈分配

在C语言中,栈分配主要由编译器自动完成。当我们声明一个普通变量时,例如:

int x;

这个变量需要存在于内存的某个位置。实际上,我们可以通过调用函数在栈上分配空间,这个函数是 alloca。你可能从未直接使用过它,因为C编译器会自动为你插入 alloca 调用。

int x; 需要分配一个 int 类型大小的空间,即4字节。这块内存存在于栈上,因此它有一个地址。我们无需手动释放 x,因为当函数执行完毕时,栈指针会恢复,从而自动释放与该函数相关的所有栈内存。

但是,如果你尝试在函数返回后使用这块内存,就会出问题。考虑以下程序:

int* f() {
    int x = 1;
    printf("Address of x: %p\n", (void*)&x);
    return &x;
}

函数 f 返回一个指向局部变量 x 的指针。从技术上讲,这是未定义行为。编译器可能会检测到这种错误并采取一些措施,例如将返回的地址设为 NULL。即使地址看起来有效,解引用它也可能得到不可预测的值,因为该栈内存可能已被后续的函数调用覆盖。

这个例子说明了为什么不能返回栈内存的地址,也引出了我们需要更灵活、生命周期可控的内存分配方式——动态分配。

动态分配与 malloc

动态分配通过C标准库中的 malloc 系列函数实现。这是使用内存最灵活的方式,你无需关心内存页等底层细节。

然而,实现 malloc 本身是具有挑战性的。使用 malloc 时,必须记住调用 free 来释放内存,必须正确处理所有内存的生命周期。确保只释放一次,否则会导致双重释放错误;同时确保不要过早释放,否则会导致释放后使用错误。

实现 malloc 时还有一个主要问题:内存碎片。这本质上是浪费的空间。碎片化问题是动态分配特有的问题,因为 malloc 分配的内存必须是连续的字节块。一旦 malloc 做出决定并返回一个指针,这个决定在调用 free 之前是永久性的,无法被压缩或移动。

碎片化要成为问题,需要满足三个条件:

  1. 分配具有不同的生命周期。
  2. 分配具有不同的大小。
  3. 无法重新定位之前的分配。

碎片分为两种类型:

  • 外部碎片:发生在分配不同大小的块时,块之间没有足够空间进行新的分配。
  • 内部碎片:发生在使用固定大小的块时,块内部存在浪费的空间(例如文件系统中只使用了块的一部分)。

实现 malloc 的目标是:

  1. 速度快。
  2. 最小化碎片(浪费的空间)。我们希望减少块之间的“空洞”,并尽可能保持“空洞”足够大,以便在不浪费空间的情况下继续分配内存。

基本分配策略

通常,malloc 的实现使用一个自由链表来跟踪空闲内存块及其大小。当收到分配请求时,分配器遍历链表,选择一个足够大的块来满足请求,将其从链表中移除。释放时,将该块添加回链表,如果它与相邻的空闲块连续,则合并它们。

在基本层面上,有三种通用的分配策略:

以下是三种策略的简要说明:

  • 最佳适配:选择能满足当前请求的最小空闲块。这需要搜索整个链表,除非找到完全匹配的块。
  • 最差适配:选择能满足当前请求的最大空闲块。直觉是,从最大的块中分配后,剩余部分可能仍然有用,从而减少碎片。这也需要搜索整个链表。
  • 首次适配:选择第一个能满足请求的空闲块。它不关心块是最小还是最大,只希望快速找到并完成分配。

通过示例比较可以发现,最佳适配容易留下许多非常小的、可能无用的碎片;最差适配在存储利用率上往往表现最差;而首次适配除了实现和执行更快外,倾向于留下平均大小的空洞,通常表现良好。

因此,如果必须实现一个简单的 malloc,首次适配是一个不错的起点。

伙伴分配器

对于内核或需要高效实现的情况,可以使用伙伴分配器。它通过限制分配大小为2的幂次方(例如2、4、8、16…字节)来简化问题,从而实现快速搜索和合并。

伙伴分配器使用多个链表(每个大小一个)而不是单个自由链表。当收到大小为 2^k 的请求时:

  1. 首先检查对应 2^k 大小的空闲链表。
  2. 如果为空,则检查 2^(k+1) 大小的链表,以此类推,直到找到可用块。
  3. 如果找到的块比需要的大,则递归地将其对半分割,直到得到所需大小的块,并将分割产生的“伙伴”块放入相应的空闲链表。
  4. 释放时,如果释放块的“伙伴”块也是空闲的,则立即将它们合并成一个更大的块,并递归向上合并。

伙伴分配器的优点是速度快、实现相对简单,并且通过保持空闲物理地址范围连续来避免外部碎片。缺点是如果请求大小不是2的幂次方,则总是存在内部碎片(因为需要向上取整)。在最坏情况下(例如请求65字节),可能浪费近一半的内存。

Linux内核广泛使用了伙伴分配器。

Slab分配器

另一种选择是slab分配器,它利用固定大小的分配。它为每种相同大小的对象分配一个专用的内存池,本质上就像一个数组。

每个对象类型都有自己的池,其中包含正确大小的块。这防止了内部碎片。Linux内核大量使用这种技术(例如,inode 池)。

一个slab就是一个槽位的缓存。每个分配大小对应一个slab。一个槽位保存一个分配。为了跟踪哪些槽位空闲,可以使用位图而不是链表。分配时,搜索位图找到一个0位,将其设为1,并返回对应槽位的地址。释放时,只需将对应的位从1清除为0。

Slab分配器可以建立在伙伴分配器之上。先用伙伴分配器获取一大块2的幂次方的内存,然后将其作为数组管理,并加上位图。这样既能利用伙伴分配器的高效,又能为特定大小的对象减少内部碎片。

总结

本节课中我们一起学习了操作系统中的内存分配策略。我们从静态分配的简单性及其局限性开始,然后深入探讨了动态分配的核心——malloc 及其面临的碎片化挑战。我们分析了外部碎片和内部碎片的区别,并介绍了三种基本的自由链表分配策略:最佳适配、最差适配和首次适配。接着,我们探讨了两种更高效、常用于内核的分配器:通过限制分配大小为2的幂次方来实现快速分配/合并的伙伴分配器,以及通过为固定大小对象预分配池来消除内部碎片的slab分配器。理解这些策略有助于我们编写更高效的程序,并深入理解操作系统如何管理宝贵的内存资源。

022:课程总复习

在本节课中,我们将对整个操作系统课程的核心概念进行一次全面的回顾。我们将梳理从操作系统基础、进程管理、虚拟内存、并发控制到文件系统和虚拟化等所有关键主题,帮助你为期末考试做好准备。

操作系统概述与角色

上一节我们介绍了课程的整体框架,本节中我们来看看操作系统的核心角色。

操作系统是介于应用程序和硬件之间的软件层。应用程序通过系统调用或库函数与操作系统交互,而内核是操作系统中唯一能直接与硬件交互的部分,运行在特殊的CPU模式下。

以下是操作系统的三个核心概念:

  • 虚拟化:通过模拟多个独立副本的方式来共享单一资源。我们虚拟化了几乎所有东西:线程是虚拟化的CPU执行单元,内存被放入进程中,甚至整个机器都可以被虚拟化。
  • 并发:处理多个同时发生的事件。这使得编程变得真实,因为程序不再是简单的顺序执行。
  • 持久性:确保数据在断电后依然保持一致。这涉及到文件系统如何存储文件,以及确保数据一致性的硬件方法(如RAID)。

CPU特权模式与内核架构

理解了操作系统的角色后,我们来看看支撑它的硬件和软件结构。

CPU存在多种特权模式:

  • 用户模式:应用程序和库在此运行,无法直接访问硬件。
  • 内核模式:操作系统内核在此运行,可以执行直接与硬件交互的指令。
  • 虚拟机监控程序模式:比内核模式权限更高,用于运行虚拟机。
  • 其他模式:在某些架构中,还存在权限最高的模式(如用于引导加载程序)。

内核是操作系统的核心。根据组织方式,主要有两种内核架构:

  • 宏内核:将所有与硬件相关的功能(如虚拟内存管理、进程调度、文件系统、设备驱动)都运行在内核空间。例如Linux内核。优点是性能高,缺点是代码庞大,潜在漏洞多。
  • 微内核:仅将最少的服务(如虚拟内存、基本进程调度、进程间通信)运行在内核模式。其他如设备驱动、文件系统等运行在用户空间。优点是更安全、模块化,缺点是因频繁的系统调用导致性能可能下降。

实际系统中常采用混合架构,例如macOS将设备驱动放在用户空间,而文件系统放在内核空间。

进程管理

内核架构决定了系统的基础,而进程管理则是其核心职责之一。

进程是运行中的程序实例。内核通过进程控制块来跟踪和管理进程。创建新进程的唯一系统调用是 fork(),它会克隆当前进程。execve() 系统调用则用于加载并执行一个新程序。

内核维护严格的父子进程关系,需要防止两种问题进程:

  • 僵尸进程:子进程已终止,但父进程未通过 wait() 系统调用读取其退出状态。
  • 孤儿进程:父进程终止时,其子进程仍在运行。孤儿进程会被 init 进程(或 systemd 等)接管。

一个进程可以同时是僵尸进程和孤儿进程(例如,父进程尚未 wait 子进程时就自己退出了)。

进程间通信与调度

管理好单个进程后,我们需要让多个进程协同工作。

最基本的IPC(进程间通信)是通过文件描述符进行读写。标准输入(fd 0)、标准输出(fd 1)和标准错误(fd 2)可以被重定向以实现进程间通信。信号是内核向进程发送的异步通知,类似于硬件中断。

接下来是进程调度。调度算法的目标是优化平均等待时间、平均响应时间,并保证公平性。

以下是几种经典调度算法:

  • 先来先服务:最简单的算法。
  • 最短作业优先:减少平均等待时间。
  • 最短剩余时间优先:可抢占的版本,进一步优化。
  • 轮转调度:优化响应时间和公平性。
  • 完全公平调度器:现代Linux内核使用的算法,试图给每个进程平等的CPU时间份额。

引入优先级后,会出现优先级反转问题(低优先级进程持有高优先级进程所需的资源)。解决方案是让持有资源的进程临时继承等待它的最高优先级。

虚拟内存

调度解决了CPU资源的分配,而内存资源的管理则通过虚拟内存实现。

虚拟内存将内存划分为固定大小的。地址转换通过页表完成,页表项包含物理页号(PPN)和标志位(如有效位、读写权限)。

如果为每个进程的每个虚拟页都维护一个页表项,页表会过于庞大。因此采用多级页表,像树一样组织,使得每一级页表的大小恰好能放入一个物理页中。例如,一个39位虚拟地址、4KB页大小的系统,其多级页表转换过程如下:

虚拟地址 = [L2索引 (9位) | L1索引 (9位) | L0索引 (9位) | 页内偏移 (12位)]
1. 从MMU寄存器获取根页表(L2)地址。
2. 用L2索引在L2页表中找到项,该项指向一个L1页表。
3. 用L1索引在L1页表中找到项,该项指向一个L0页表。
4. 用L0索引在L0页表中找到项,该项的PPN就是目标物理页号。
5. 物理地址 = PPN + 页内偏移。

由于多级页表查找慢,CPU使用转译后备缓冲器来缓存最近的虚拟页到物理页的映射,以加速访问。

线程与并发

虚拟内存为每个进程提供了独立的地址空间,而线程则在进程内部实现了更细粒度的并发。

线程是轻量级的执行流,共享进程的内存空间。一个进程启动时只有一个主线程,可以创建更多线程。线程库的实现模型主要有:

  • 一对一模型:一个用户线程对应一个内核线程。
  • 多对一模型:多个用户线程对应一个内核线程(内核无感知)。

使用线程时,需要注意:

  • fork() 系统调用创建的新进程只复制调用 fork() 的那个线程。
  • 信号会发送给进程中的任意一个线程。
  • 线程默认是可连接的,必须调用 pthread_join() 来回收资源,否则会产生“僵尸线程”。可以设置线程为分离状态来自动清理。

同步与数据竞争

当多个线程并发访问共享数据时,就会引发同步问题。

数据竞争的定义是:两个或更多线程并发访问同一内存位置,且至少有一个是写操作。

防止数据竞争的基本工具是(互斥锁、自旋锁),它确保互斥访问。实现锁需要硬件支持(如比较并交换指令)和可能的操作系统支持(用于实现非忙等待的锁)。

锁只能防止竞争,不能保证执行顺序。为此我们使用:

  • 信号量:一个具有原子 P()(等待/减一)和 V()(发信号/加一)操作的整型变量。
  • 条件变量:与互斥锁配合使用,用于在复杂条件下等待和通知线程。

使用多个锁时,需要注意锁粒度(锁保护的代码范围)和死锁。死锁发生的两个必要条件是:

  1. 循环等待:线程之间形成一个等待资源的环。
  2. 持有并等待:线程在持有至少一个资源的同时,等待获取其他资源。

打破其中任何一个条件即可预防死锁,例如规定全局的锁获取顺序(打破循环等待),或使用 trylock 并在失败时释放已持有锁(打破持有并等待)。

存储系统、文件系统与页面置换

解决了CPU和内存的并发问题,我们转向数据的持久化存储。

固态硬盘 以页和块为单位访问,需要操作系统配合(如发送 TRIM 指令)以获得最佳性能和寿命。RAID 技术用于组合多个磁盘:

  • RAID 0:条带化,提高性能,无冗余。
  • RAID 1:镜像,提供冗余,读性能提升。
  • RAID 5:带分布式奇偶校验的条带化,允许一块磁盘故障。
  • RAID 6:双重分布式奇偶校验,允许两块磁盘故障。

文件系统 管理文件和目录。内核通过全局和每进程的打开文件表来跟踪文件访问。文件数据的磁盘分配策略有:

  • 连续分配:像数组一样存储,易产生碎片。
  • 链式分配:每个数据块存储下一个块的指针,缓存不友好。
  • FAT:将链式指针集中存储在文件分配表中。
  • inode(索引节点):类Unix系统使用,是一种混合策略。inode包含:
    • 12个直接指针。
    • 1个一级间接指针。
    • 1个二级间接指针。
    • 1个三级间接指针。
      这支持从很小到极大的文件。

当物理内存不足时,需要将一些页面换出到磁盘。页面置换算法 的目标是减少缺页中断。

以下是几种页面置换算法:

  • 最优算法:未来最长时间不被使用的页被换出,仅用于理论比较。
  • 随机算法:简单,避免最坏情况。
  • 先进先出:实现简单,但可能出现Belady异常(分配更多页框反而导致更多缺页)。
  • 最近最少使用:接近最优,但实现开销大。
  • 时钟算法:LRU的近似,实际常用。它维护一个页的环形链表和一个“指针”。检查页时,如果其访问位为1,则置0并指针前移;如果为0,则置换该页。访问页时,硬件将访问位置1。

内存分配与虚拟化

最后,我们探讨内核自身的内存管理以及系统的虚拟化。

内核需要动态管理内存,面临外部碎片(空闲内存分散)和内部碎片(分配块内部未利用的空间)问题。通用动态分配策略有:

  • 首次适应:找到第一个足够大的空闲块。
  • 最佳适应:找到大小最接近请求的空闲块。
  • 最差适应:找到最大的空闲块。

此外还有:

  • 伙伴系统:所有分配大小向上对齐到2的幂,通过分裂和合并“伙伴”块来管理,操作高效。
  • slab分配器:针对固定大小对象(如inode)进行分配,减少碎片。

虚拟化 允许多个操作系统共享同一硬件。虚拟机监控程序 负责资源分配和控制硬件。

  • 类型1(裸机):直接运行在硬件上,性能好。
  • 类型2(托管):运行在宿主操作系统上,通过陷阱模拟或二进制翻译实现虚拟化,性能较低。

现代硬件为虚拟化提供了支持(如CPU的VT-x,IOMMU)。虚拟机监控程序可能过量使用资源(如CPU、内存),这带来了与普通操作系统类似的调度和分配问题。

容器(如Docker)旨在获得虚拟机的隔离性好处,同时避免其开销。容器共享宿主操作系统的内核,因此更加轻量。

总结

本节课中我们一起回顾了整个操作系统课程的核心内容。我们从操作系统的角色和三大概念(虚拟化、并发、持久性)出发,深入探讨了进程与线程的管理、CPU调度、虚拟内存与地址转换、并发编程中的同步与死锁、存储系统与文件组织、内存分配策略,最后涵盖了虚拟化与容器技术。希望这次总复习能帮助你巩固知识体系,为期末考试做好充分准备。如有任何疑问,请随时通过课程讨论区提出。

023:最终复习

在本节课中,我们将对课程的核心概念进行最终复习,重点回顾文件系统、进程同步等关键知识点。我们将通过分析具体的考试题目,来巩固对这些概念的理解。

文件系统问题解析

上一节我们介绍了复习的总体目标,本节中我们来看看一个具体的文件系统问题。这类问题通常涉及索引节点(inode)、块大小、指针和链接等概念。

考虑一个文件系统,其参数如下:

  • 块大小:4096 字节
  • 指针大小:4 字节
  • 索引节点大小:128 字节
  • 索引节点结构:包含 12 个直接指针,1 个一级间接指针,1 个二级间接指针,1 个三级间接指针。

执行 ls -l 命令后,输出格式包含索引节点号、链接数、文件大小和文件名。输出示例如下:

inode#  links  size  name
28      2      4096  .
19      5      4096  ..
20      2      4096  A
20      2      100   B
22      1      60    C -> B
26      1      5000  E

以下是针对此输出需要分析的问题:

问题一:编辑文件 C 时,哪个索引节点的内容会被修改?

文件 C 是一个符号链接(软链接)。其内容(即它指向的目标文件名 “B”)存储在索引节点 22 中。当编辑 C 时,系统会跟随链接找到目标文件 B。B 是一个硬链接,其数据由索引节点 20 管理。因此,最终被修改的是索引节点 20 的内容。

问题二:是否可能将所有与目录相关的索引节点存储在单个块中?

需要计算一个数据块能存储多少个索引节点。已知块大小为 4096 字节,索引节点大小为 128 字节。因此,每个块可存储的索引节点数为:
4096 / 128 = 32
索引节点号从 1 开始编号,所以第一个索引节点块包含节点 1 到 32。只要目录使用的索引节点号都在这个范围内,就可以存储在单个块中。

问题三:文件 E 将占用多少个数据块?

块大小为 4096 字节,文件 E 的大小为 5000 字节。

  • 第一个数据块存储 4096 字节。
  • 剩余字节为 5000 - 4096 = 904 字节,需要第二个数据块存储。
    因此,文件 E 需要占用 2 个数据块。

问题四:计算文件 E 因内部碎片而丢失的字节数。

内部碎片是指分配给文件但未被使用的块内空间。

  • 第一个块已满,无碎片。
  • 第二个块大小为 4096 字节,但只使用了 904 字节。
    因此,丢失的字节数为:
    4096 - 904 = 3192 字节。

问题五:由索引节点 19 表示的父目录中包含多少个子目录?描述它们。

链接数(links=5)提供了线索。对于一个目录,其链接数等于:
2(来自自身的 ‘.’ 和父目录中的条目) + 子目录数量(每个子目录的 ‘..’ 条目指向它)
设子目录数量为 N,则有:
2 + N = 5
解得 N = 3
因此,父目录(索引节点 19)中包含 3 个子目录。它们分别是:

  1. 当前目录 “.” (索引节点 28)。
  2. 另外两个未在给定 ls 输出中显示名称的目录(它们的 ‘..’ 条目指向索引节点 19)。

另一个文件系统问题

接下来我们分析另一个文件系统问题,以加深理解。

假设 ls -li 输出如下(块大小 4096 字节):

inode# links size name
31     1     4000 A.txt
32     1     4000 B.txt
64     1     60   C.txt -> A.txt
128    1     60   D.txt -> C.txt

以下是需要解答的问题:

问题一:输出中常规文件因内部碎片丢失了多少字节?

常规文件是 A.txt 和 B.txt。

  • 每个文件占用 1 个块(4096 字节),实际数据为 4000 字节。
  • 每个文件的内部碎片为 4096 - 4000 = 96 字节。
    总丢失字节为:
    96 + 96 = 192 字节。

问题二:存储 C.txt 的内容需要多少个 I/O 块?

C.txt 是一个符号链接。在类 Unix 系统中,如果符号链接的目标路径名很短(通常小于等于 60 字节),该路径名会直接存储在索引节点的指针区域,而无需额外的数据块。这里目标 “A.txt” 长度小于 60,因此需要 0 个额外的数据块。

问题三:用户运行 cat D.txt(假设无缓存),需要从磁盘读取多少个 I/O 块?忽略目录读取。

需要跟踪解析过程:

  1. 读取 D.txt 的索引节点(128),发现是指向 “C.txt” 的符号链接。内容已在索引节点中,无需读数据块。
  2. 读取 C.txt 的索引节点(64),发现是指向 “A.txt” 的符号链接。内容已在索引节点中,无需读数据块。
  3. 读取 A.txt 的索引节点(31),发现是常规文件。
  4. 根据索引节点 31 中的指针,读取文件 A.txt 的第一个(且唯一一个)数据块。
    因此,总共需要读取 4 个 I/O 块(3个索引节点块 + 1个数据块)。

问题四:一个非常大的文件需要 2^22 个 I/O 块来存储内容。使用所述索引节点结构,需要多少个索引块来存储所有指针?

首先,计算一个索引块能存储多少指针:
块大小 / 指针大小 = 4096 / 4 = 1024(2^10)个指针/块

索引节点指针结构的使用顺序是:先填满直接指针,然后是一级间接,接着二级间接,最后三级间接。

  1. 直接指针:覆盖前 12 个数据块。无需额外索引块。
  2. 一级间接:一个一级间接索引块可覆盖 1024 个数据块。
  3. 二级间接:一个二级间接索引块可覆盖 1024 * 1024 = 2^20 个数据块。
  4. 三级间接:一个三级间接索引块可覆盖 1024 * 1024 * 1024 = 2^30 个数据块。

现在计算覆盖 2^22 个数据块所需的结构:

  • 直接指针覆盖:12
  • 剩余:2^22 - 12 = 4194292
  • 一级间接块覆盖:1024 块 → 需要 1 个一级间接索引块。
  • 剩余:4194292 - 1024 = 4193268
  • 二级间接块覆盖:2^20 = 1048576 块 → 需要 1 个二级间接索引块,它指向 1024 个一级间接索引块。
  • 剩余:4193268 - 1048576 = 3144692 块(实际上已超过二级间接的覆盖能力,此处计算仅示意流程。对于 2^22,主要靠二级间接覆盖)

更精确的计算是:
所需数据块总数:N = 2^22 = 4194304

  1. 直接指针覆盖:12
  2. 一级间接覆盖:1024 → 需要 1 个索引块。
  3. 二级间接覆盖:1024 * 1024 = 1048576 → 需要 1 个二级间接索引块 + 1024 个一级间接索引块。
  4. 总计索引块数:1(一级间接) + 1(二级间接) + 1024(二级间接指向的一级间接) = 1026 个索引块。
    (注意:此计算假设文件足够大,用满了二级间接的所有容量。对于精确的 2^22 块,可能不需要全部1024个一级间接块,但根据填充顺序,会需要大量此类块。)

问题五:用户执行命令 mv A.txt E.txtln B.txt A.txt 后,写出目录中所有常规文件的(索引节点号,文件名)对。

  1. mv A.txt E.txt:将文件 A.txt 重命名为 E.txt。索引节点号不变(31)。
  2. ln B.txt A.txt:为 B.txt(索引节点 32)创建一个硬链接,新链接名为 A.txt。
    因此,最终的常规文件列表为:
  • (31, E.txt)
  • (32, B.txt)
  • (32, A.txt) (新创建的硬链接)

进程同步与“丢失唤醒”问题

上一节我们深入探讨了文件系统,本节中我们来看看进程同步中的一个经典问题——“丢失唤醒”(Lost Wake-up)。

考虑以下简化代码:

// 消费者线程
1: pthread_mutex_lock(&mutex);
2: while (count == 0) {
3:     pthread_cond_wait(&cond, &mutex);
4: }
5: // 消费数据...
6: pthread_mutex_unlock(&mutex);

// 生产者线程
7: pthread_mutex_lock(&mutex);
8: // 生产数据...
9: pthread_cond_signal(&cond);
10: pthread_mutex_unlock(&mutex);

问题出现在第 8 行(生产者代码中的“生产数据...”部分)。如果生产者在持有互斥锁(mutex)的情况下修改共享状态(如 count),然后调用 pthread_cond_signal,这通常是正确的。

然而,“丢失唤醒”问题发生的典型场景是条件变量等待与信号之间存在竞争条件。但上述代码结构是标准的、正确的用法。

更典型的“丢失唤醒”例子如下:

  1. 消费者检查条件(count == 0),发现为真。
  2. 在消费者调用 pthread_cond_wait 之前,操作系统调度走了消费者线程。
  3. 生产者线程运行,生产数据,增加 count,并调用 pthread_cond_signal。此时没有线程在条件变量上等待,信号丢失。
  4. 消费者线程恢复运行,调用 pthread_cond_wait 并进入等待。由于信号已经丢失,它可能永远等待下去。

解决方案:确保条件检查和进入等待是一个原子操作。这正是 pthread_cond_wait 函数的设计目的——它在内部会释放互斥锁并进入等待,然后在返回前重新获取锁,这个过程对调用者是原子的。因此,只要消费者线程像示例中那样,在 while 循环中、且在持有互斥锁的情况下调用 pthread_cond_wait,就可以避免丢失唤醒。生产者在修改状态和发送信号时也应持有同一把互斥锁。

总结

本节课中我们一起进行了课程最终复习,重点涵盖了:

  1. 文件系统:分析了索引节点结构、直接/间接指针计算、文件存储所需块数、内部碎片计算、硬链接与符号链接的区别及其对索引节点链接数的影响。
  2. 进程同步:回顾了“丢失唤醒”问题的成因,即条件变量的等待和信号之间存在竞争条件,并强调了使用互斥锁保护条件检查和 pthread_cond_wait 调用的重要性。

通过解决这些典型问题,希望你能巩固对系统软件核心概念的理解,为考试做好充分准备。

posted @ 2026-03-29 09:35  布客飞龙I  阅读(4)  评论(0)    收藏  举报