第13讲 · 多处理器编程:从入门到放弃

2026 南京大学《操作系统原理》第十三讲 · 课堂实录重构

主讲 蒋炎岩(Yanyan Jiang)
视频 https://www.bilibili.com/video/BV1vgQGBREyJ/
课程主页 https://jyywiki.cn/OS/2026/
本讲讲义 https://jyywiki.cn/OS/2026/lect13.md
讲义 / 幻灯片作者 蒋炎岩,https://jyywiki.cn/
讲义 / 幻灯片许可 CC BY-NC 4.0
图片来源 书中插图为课堂演示与讲义渲染图;演示类截图为视频画面

对应讲次:第13讲(南京大学 操作系统原理)

制作模型:deepseek-flash(coding-agent / pi;据 ~/.pi/agent/settings.json)

编者说明:本书由课堂录像的语音字幕重构而成,术语、章节骨架与参考链接已按课程官方讲义(lect13.md)校准。讲师的口语风格、类比与案例被尽量保留;编辑性的补充一律以「编者说明」或「编者拓展」标出。


引子:从「系统调用」到「并发的魔鬼」

(参考时间: 00:00)

前面几讲走完了虚拟化:从 CPU Reset 开始,固件加载操作系统,操作系统加载 initramfs 里的 init,pivot_root 到真正的文件系统(systemd,pid=1),再用一系列系统调用创建出你看到的整个应用世界。

值得总结的一点是:「系统调用」这层 API 很好地隔离了应用和指令集——x86、AArch64、RISC-V 共享同一份应用生态(Linux 内核里体系结构相关的代码只占很小一部分)。这样就构建出了一个「上面是庞大应用生态、下面是多种硬件」的计算机系统。

系统调用 API 隔离应用与指令集:x86/ARM64/RISC-V 共享同一份生态

📷 系统调用 API 隔离应用与指令集:x86/ARM64/RISC-V 共享同一份生态
回到原视频 (00:03:00)

从这一讲起,进入新部分:并发编程。标题叫「从入门到放弃」——不是让你放弃并发,而是让你知道多处理器并发编程会遇到什么困难,后面几讲再教你怎么写出「性能又好、又不容易错」的并发程序。


一、并发编程入门

1.1 两个动机

(参考时间: 00:04)

动机 1:系统调用可能执行很长时间。看一个 HTTP 服务器的顺序实现:

void http_server(int fd) {
    while (1) {
        buf = alloc_buf();
        nread = read(fd, buf, 1024);   // syscall:可能要等很久
        handle_request(buf, nread);
    }
}

执行 read 时手上明明还有别的事情可做,却只能等——「我的 CPU 被卡住了,在浪费可同时处理的时间」。能不能同时做几件事?

动机 2:多处理器系统是共享内存的,不用白不用。用 lscpu 看一下,连树莓派都有 4 个 CPU(你的机器可能有几十个)。它们的硬件模型是共享内存:CPU1 写地址 1234,CPU2 过一会儿读同一地址就能读到。但——fork 出来的进程「形同陌路」,无法直接用内存通信,只能用管道等机制把结果送回。

编者拓展|「先有鸡还是先有蛋」:一旦一开始设计了 SMP(Symmetric Multiprocessing)就「下不了车了」——即便是 NUMA(Non-uniform Memory Access),也要维持共享内存模型。

lscpu:多处理器 + 共享内存的硬件模型

📷 lscpu:多处理器 + 共享内存的硬件模型
回到原视频 (00:07:00)

1.2 线程:共享内存的「进程」

(参考时间: 00:10)

把 SimpleC 的状态机模型扩展一下:

graph TD
    G["全局变量(共享)"] --- T1["线程 T1:自己的栈"]
    G --- T2["线程 T2:自己的栈"]
    G --- T3["线程 T3:自己的栈"]

为什么要独立的栈? 因为「执行一条语句」的含义就是「从栈顶帧的 PC 取一条语句执行」;如果两个线程共享栈,就全乱套了。

黑板上的线程模型:各线程独立的栈 + 共享的全局数据

📷 黑板上的线程模型:各线程独立的栈 + 共享的全局数据
回到原视频 (00:11:00)

于是在这个模型里,只需要给操作系统加一个 spawn()(创建新线程,有独立栈、共享全局)+ thread_exit()——「恭喜你,你已经了解了并发的全部……吗?不,你打开了魔鬼的盒子。」(讲师把讲义发给 AI,AI 直接让他「顺着补几刀,把魔鬼具体化」,完美预言了本讲后面要讲的一切。)

在指令集层面,进程只有一个平坦的地址空间;操作系统只需要为每个线程在地址空间里划分一块区域作为它的栈,并让每个线程的寄存器(尤其 sp)指向自己的栈。「如果你想要设计一套共享内存的『进程』,你就会实现 spawn 和 exit 这两个系统调用。」

1.3 并发 vs 并行

(参考时间: 00:16)

对应到概念模型:SimpleC 每次选一个线程执行一条语句 = 并发模型;每个线程同时执行一条语句 = 并行模型。(后者其实过于简化——现实里每条指令执行速度不同。)

编者拓展|这里埋了一个「魔鬼的伏笔」:真实处理器上,你的指令执行得快慢取决于整个系统复杂的状态(hit/miss、load/store 天生慢、乘除要多个周期)。所以真实并行程序到底发生了什么,其实很难理解。这个伏笔,会在本讲最后爆发。

1.4 迷你线程库与「亲手验证」

(参考时间: 00:19)

讲师提供了一个最小线程库 thread.h(对 POSIX pthreads 做减法,只适合理解原理):

用它就能「证明」一些事:

hello.c:两个线程交替打印 A / B,顺序不确定

📷 hello.c:两个线程交替打印 A / B,顺序不确定
回到原视频 (00:23:00)

x++/y++:证明线程间「全局变量共享、栈独立」

📷 x++/y++:证明线程间「全局变量共享、栈独立」
回到原视频 (00:26:00)

编者拓展|每个线程的栈有多大? test_stack.c 打印出 T1 的栈「大于等于 8192(KB)」——每个线程的栈默认是 8 MB。这与「主程序在 Linux 里几乎可以一直往后用栈」不同:因为每个线程的栈都必须预先在地址空间里分配好一段,所以创建线程时就得决定它有多大。

test_stack:递归探测,测出每个线程栈约 8MB

📷 test_stack:递归探测,测出每个线程栈约 8MB
回到原视频 (00:29:00)

1.5 用 gdb 调试多线程

(参考时间: 00:31)

多线程程序当然也能调试。以前讲师的第一句话是「去 read the friendly manual」(gdb Threads),现在 AI 帮你读过了:

gdb info threads:在多个线程之间切换、单步

📷 gdb info threads:在多个线程之间切换、单步
回到原视频 (00:32:30)

gdb set scheduler-locking on:一次只让一个线程执行一步

📷 gdb set scheduler-locking on:一次只让一个线程执行一步
回到原视频 (00:34:00)

编者说明:讲师的评价很值得记:「System 人最擅长的底层工具使用,曾经是做 system 的壁垒;现在有 LLM 了,这都不算什么。」(那时候 agent 还不那么好用,现在 agent 可以直接替你用工具。)


二、放弃(1):状态迁移的确定性

2.1 数学的确定性 vs 程序的确定性

(参考时间: 00:35)

确定性给了我们「可控」的感觉:程序总是可重复的(这也是我们喜欢 functional programming 的原因)。

编者说明:实际程序也有不确定性,但都来自系统调用(读键盘、getpid、rddrand/rdtsc……)和初始状态(argv/envp/auxv、随机化地址)。一旦初始状态确定,含迁移的函数就是确定的。

2.2 共享内存打破了确定性

线程执行的速度是没有保证的——这意味着一次 load 可能读到其他线程的 store,也可能不。看一个「山寨支付宝」:

unsigned int balance = 100;
int T_alipay_withdraw(int amount) {
    if (balance >= amount) {   // 检查
        balance -= amount;     // 写入
        return SUCCESS;
    } else return FAIL;
}

两个线程并发各取 ¥100:如果它们「同时」判断余额都是 100,就会各扣一次,余额变成 100 - 100 - 100 = -100——而它是 unsigned,于是账户里多出了无穷的钱。

编者拓展|这不只是理论:历史上真实的 Mt. Gox 黑客事件损失 650,000 BTC(时值约 $28,000,000,000);还有经典的 Diablo I「物品复制」bug:鼠标事件覆盖了「拿起的物品」变量,你把 1 块钱复制成了两份物品。「检查和写入分开」是这一大类 bug 的共同结构。

山寨支付宝:两个线程并发取款,balance 被扣成负数

📷 山寨支付宝:两个线程并发取款,balance 被扣成负数
回到原视频 (00:43:00)

2.3 你连 1 + 1 都不会求了

(参考时间: 00:47)

#define N 100000000
long sum = 0;
void T_sum() { for (int i = 0; i < N; i++) sum++; }
int main() { create(T_sum); create(T_sum); join(); printf("sum = %ld\n", sum); }

两个线程各加 N 次,结果却不是 2N,而且每次运行结果都不一样。

两个线程各 `sum++`:结果每次不同(lost update)

📷 两个线程各 `sum++`:结果每次不同(lost update)
回到原视频 (00:47:00)

原因:sum++ 包含 load + 加法 + store 三个操作;线程 A 读到 1000、算出 1001、还没写回,线程 B 已经把值写到 2000——A 把 2000 覆盖回 1001,两个线程互相读、互相覆盖(lost update / race condition)。

这个问题本质上很难:给定每个线程看到的历史,恢复出全局顺序是 NP 完全的(Trace recovery is NP-Complete)。讲师做过一个 sequentially-consistent 小游戏(拖动事件、让每次读都读到最近的写),还把它做成 OJ 题:三个线程、每个 load; +1; store,sum 的最小值是多少?(第一反应是 3,答案是 2。)

编者拓展|模型智力测试:2025 年,deepseek-r1、o3-mini 全军覆没;2026 年,Deepseek Expert、Qwen-3.5-max、GLM-5.1 都做对了(不允许调用工具);而小一号的 Deepseek Instant、Qwen-3.6-plus 还做不对。

OJ 题 / 小游戏:求 sum 的最小值(NP 完全)

📷 OJ 题 / 小游戏:求 sum 的最小值(NP 完全)
回到原视频 (00:50:30)

2.4 确定性丧失的历史与后果

Peterson / Dekker:实现「正确的 1+1」比想象中困难得多

📷 Peterson / Dekker:实现「正确的 1+1」比想象中困难得多
回到原视频 (00:55:00)


三、放弃(2):顺序执行的幻想

(参考时间: 00:59)

如果只看 SimpleC,世界是清楚的——但所有人都想要极致的性能(「性能就是钱」,AI 时代更是如此;Fran Allen 2006 年因编译优化获图灵奖)。而编译器看 SimpleC 时,会大杀四方:

3.1 while (!flag); 与 ad hoc synchronization

(参考时间: 01:03)

你想等另一个线程「举起旗子」:

while (!flag);   // 「等旗子举起来,我就能继续」

你聪明,编译器更聪明:如果 flag 没人能改(编译器的假设),这就是个死循环,它可以把它优化成「先判断一次,如果不成立就永远转」——于是真的死循环了。这类代码(Ad hoc synchronization considered harmful)正是很多「聪明」的系统程序员写的。

3.2 求和(再次出现):O1 得到 N,O2 得到 2N

(参考时间: 01:07)

同一份 sum++ 代码:

这才是最危险的:「你测试了 100 万次都正确,交付给客户某一天程序就挂了。」(讲师说现在不用再手撕汇编解释了,让 AI 读汇编就行。)

O1/O2 对 sum++ 的优化:结果从 N 变到 2N

📷 O1/O2 对 sum++ 的优化:结果从 N 变到 2N
回到原视频 (01:07:30)

编者拓展|控制编译优化的两种方法(但课程不推荐): 1. 插入 compiler barrier:asm volatile("" ::: "memory");——告诉编译器「我可能改了任意内存」,优化不能跨过它; 2. 把变量标记为 volatile:volatile int flag;——禁止对它的访问被优化(这也是 MMIO 寄存器的常见写法:你先读一个字节「扔掉」再读一个,编译器不能合并这两次读)。

但《操作系统》课的忠告是:Don't play with shared memory. 两个线程同时访问同一内存地址(data race)在 C/C++ 里是 undefined behavior,非常危险。

`-O1` 的优化:load/store 被提到循环外,中间是长时间的寄存器自增

📷 `-O1` 的优化:load/store 被提到循环外,中间是长时间的寄存器自增
回到原视频 (01:11:00)


四、放弃(3):指令的顺序执行

(参考时间: 01:12)

你以为只有 gcc 是编译器?处理器也是一个编译器:

CPU 也是编译器:乱序执行、动态数据流分析、分支预测

📷 CPU 也是编译器:乱序执行、动态数据流分析、分支预测
回到原视频 (01:15:30)

编者拓展|宽松内存模型(Relaxed Memory Model):为了让 <1, 2, 3> 数得对(见下),硬件也「一切为了性能」——store 先写进 local memory(cache),再慢慢同步给其他处理器;load 允许读到 cache 的旧值。所以多处理器系统的「内存」其实像每个线程各有一份副本,它们之间只保证 eventual consistency,直到执行特殊的同步指令才达成一致。推荐阅读 Russ Cox 的 Memory Models。

4.1 Litmus test:你可能会看到「00」

(参考时间: 01:17)

int x = 0, y = 0;
void T_1() { x = 1; int t = y; }   // Store(x); Load(y)
void T_2() { y = 1; int t = x; }   // Store(y); Load(x)

按「顺序执行」的直觉推理:

顺序 结果
1,2,3,4 y=0,x=1 → (0,1)
1,3,2,4 x=1,y=1 → (1,1)
1,3,4,2 (1,1)
…… 至少能看到 (0,1)、(1,0)、(1,1)

但唯独「(0,0)」在顺序模型下不可能出现。然而真实运行几乎只能看到 00:因为 x=1 只写进了自己的 cache,还没送到另一个 CPU 的内存里,另一线程就读了。

litmus test:反复实验 + `sort | uniq -c`,看到大量 00

📷 litmus test:反复实验 + `sort | uniq -c`,看到大量 00
回到原视频 (01:20:00)

编者拓展|x86 的 TSO 与 Rosetta 2:Intel 当年让 x86 有一个「很强」的内存模型(TSO,Total Store Order)——既没有强到好编程,又给性能拖了后腿(还让在 ARM 上模拟 x86 变得很麻烦)。Apple 的 Rosetta 2 为此用了 TSOEnabler 这样的「作弊」手段。这些 litmus tests 可以用来逆向出一个 CPU 属于哪种内存模型。


总结


附:官方参考与延伸阅读

本讲的讲义见课程主页,以下链接按书中出现顺序整理:

主题 链接
迷你线程库(thread-lib) https://jyywiki.cn/OS/demos/concurrency/thread-lib
通过线程库理解线程行为(thread-examples) https://jyywiki.cn/OS/demos/concurrency/thread-examples
山寨支付宝(alipay) https://jyywiki.cn/OS/demos/concurrency/alipay
使用 mutex API 实现互斥(sum-mutexapi) https://jyywiki.cn/OS/demos/concurrency/sum-mutexapi
宽松内存模型(mem-model) https://jyywiki.cn/OS/demos/concurrency/mem-model
gdb 多线程调试手册 https://sourceware.org/gdb/onlinedocs/gdb/Threads.html
Russ Cox, Memory Models https://research.swtch.com/mm
Trace recovery is NP-Complete https://epubs.siam.org/doi/10.1137/S0097539794279614
Ad hoc synchronization considered harmful(OSDI'10) https://www.usenix.org/conference/osdi10/ad-hoc-synchronization-considered-harmful
Dekker's Algorithm https://en.wikipedia.org/wiki/Dekker%27s_algorithm
TSOEnabler(Rosetta 2 的 x86 内存模型) https://github.com/saagarjha/TSOEnabler
OSTEP:Operating Systems: Three Easy Pieces https://pages.cs.wisc.edu/~remzi/OSTEP/
pthreads(7) 手册 https://man7.org/linux/man-pages/man7/pthreads.7.html

阅读材料:教科书 Operating Systems: Three Easy Pieces 第 25 章(Dialogue on Concurrency)、第 26 章(Concurrency and Threads)、第 27 章(Thread API);延伸阅读 Russ Cox 的 Memory Models。

版权说明:本讲讲义与幻灯片作者为蒋炎岩,采用 Creative Commons BY-NC 4.0 许可,来源于 https://jyywiki.cn/OS/2026/。视频画面与字幕版权归原作者所有。本书为课堂内容的学习性重构。