写在前面:这是本系列的第十三篇。
在 UNIX 有了基础的系统调用 API (进程、地址空间、对象访问) 之后,系统随即爆火。但新的需求也随之而来:例如进程在
read()等操作等待 I/O 时,如果还能同时完成其他任务该多好;加上硬件逐渐发展出了多个 CPU 处理器……传统的“进程级并行”显得有些不太够用了。我们需要一个新的机制,能让多个执行流共享内存——于是,线程(Thread)诞生了。本讲内容:多线程编程模型、线程库,以及为什么在现代多处理器系统上进行并发编程极其困难(甚至会让你觉得编译器和 CPU 在对你施展黑魔法)。
入门:共享内存线程模型与线程库
并发编程:动机
voidhttp_server(intfd){while(1){nread=read(fd,buf,1024);handle_request(buf,nread);// read 和 handle 有一块共享的 buf}}如果 buf 到来的时间不确定?
- 瞬间有大量请求到来。
- 传统的单线程代码必须等
handle_request完成后,才能去读取下一个请求。 - 如果系统里有多个 CPU,这种串行处理就太浪费算力了。
- 于是,我们想要有共享内存的并发执行流。
解决方法:加一个操作系统 API
C 程序的状态机模型:
- 初始状态:
main(argc, argv, envp) - 状态迁移:执行一条语句 (指令)
多线程程序的状态机模型:
- 增加一个特殊的系统调用:
spawn() - 它能增加一个“状态机”,这个状态机有自己独立的栈,但和原状态机共享全局变量。
- 从此,状态机可以选择不同的方向进行状态变换。
- 状态迁移:每次随机选择一个状态机,执行一条语句 (指令)。
并发 v.s. 并行
- 并发 (Concurrency):
- 逻辑上的“同时执行”。
- 可以由操作系统/运行库在单核 CPU 上模拟出的“轮流执行”(时间片轮转)。
- 包含了真正同时执行的情况。
- 并行 (Parallelism):
- 真正意义上的物理“同时执行”。
- 必须有(共享内存的)多个物理处理器。
- 多个 CPU 同时执行指令(load/store 访问共享内存)。
多处理器编程:入门
简化的线程 API (thread.h)
spawn(fn)- 创建一个入口函数是
fn的线程,并立即开始执行。 - 例如
void fn(int tid) { ... },参数tid从 1 开始编号,fn是线程内部的逻辑。 join()- 等待所有正在运行的线程返回。
- main 函数返回前默认会
join所有线程。 - 底层行为类似:
while (num_done != num_threads) ;
多线程代码初体验
#include<thread.h>intx=0,y=0;// 预期:x 增长速度是 y 的两倍voidinc_x(){while(1){x++;sleep(1);}}voidinc_y(){while(1){y++;sleep(2);}}intmain(){spawn(inc_x);spawn(inc_y);while(1){// 这里实现实时监控printf("\033[2J\033[H");printf("x = %d, y = %d",x,y);fflush(stdout);}}这个简单的程序直接“证明”了全局变量确实是被多个线程共享的。
更多需要思考的问题 (More Problems)
- 多线程程序真的利用了多处理器吗?
- 代码层面并发确定了,那是不是真并行?
- 会不会是虚拟的模拟???我们是否被 OS 骗了?
- 提示:在
Linux系统中,你可以使用top或htop命令,按1展开查看各个 CPU 核心的真实利用率。 - 线程是否具有独立堆栈?
- 是的。栈的范围通常是 8M,并在上下两端设置了不可访问的 4M 红色警戒区 (Red Zone)。
- 栈用于存储局部变量、函数调用的上下文信息(如返回地址、寄存器值等)。
- 一旦深度递归导致占用超过栈的大小,触及 Red Zone,系统就会发出
Stack Overflow错误并 Crash。 - 如何用 GDB 单步调试多线程程序?
- 建议让 LLM 帮你阅读 GDB 官方手册中的 Threads 章节。
- EuroSys 会议上的趣闻:System 领域的研究人员曾经最擅长的就是底层复杂工具的使用,这曾是“做 system”的壁垒;但现在有了 LLM,工具的使用门槛被彻底抹平了。
放弃(1):状态迁移的确定性
确定性的彻底丧失
- 虚拟化使进程认为“世界上只有自己”。
- 除了系统调用,单线程程序的行为是 Deterministic (确定性) 的。只要初始状态(
argv,envp)一样、系统调用行为一样,程序无论运行多少次,结果都是绝对一样的。
并发彻底打破了这一点:
- 并发程序每次会 Non-deterministically (非确定性地) 选一个线程执行。
- 这意味你的
load指令可能读到其他线程刚刚store的值,也可能读不到! - 非确定性的程序理解起来相当困难。
- 千万不能再用以前线性程序的思维,去理解多线程程序!
确定性丧失的真实灾难
unsignedintbalance=100;intT_alipay_withdraw(intamount){if(balance>=amount){balance-=amount;returnSUCCESS;}else{returnFAIL;}}如果两个线程并发去扣款 ¥100 会发生什么?
- 并发 Bug 会导致账户里多出用不完的钱!
- Bug 和漏洞绝不跟你开玩笑:著名的 Mt. Gox 黑客事件,正是利用并发漏洞盗取了 650,000 枚比特币,时值约 280 亿美元。
- 很多的并发 Bug 触发条件非常苛刻,平时测试根本测不出,就等着黑客在极端情况下触发并导致巨额亏损!
你发现你连 1+1 都不会了!
计算 1+1+1+…+1,共计 $ 2n $ 个 1,分 2 个线程计算:
#defineN100000000longsum=0;voidT_sum(){for(inti=0;i<N;i++)sum++;}intmain(){spawn(T_sum);spawn(T_sum);join();printf("sum = %ld\n",sum);}最终你会得到怎样的结果?绝不可能是 200000000!每次运行的结果可能都不一样。
失去确定性的后果
思考题:如果并发执行三个T_sum(每个循环 3 次),sum** 的最小值是多少?**
假设单行语句的执行被拆解为底层汇编:
voidT_sum(){for(inti=0;i<3;i++){intt=load(sum);t+=1;store(sum,t);}}- AI 时代大模型测试:DeepSeek-r1 和 o3-mini 经过极其漫长的思考,给出的答案是
3。 - 正确答案 (通过 Model Checker 穷举得出):
sum = 2! - 为什么不是 1?因为无论如何穿插,三个线程各自的 3 次循环必然会导致某些写操作被覆盖,但绝不可能被覆盖得只剩下 1。(具体推导证明留给读者,提示:Trace recovery is NP-Complete)。
“数学视角”的价值:
- Nondeterminism (非确定性) 对人类大脑来说是本质困难的。
- 只有严格的数学证明才是解决并发问题的方法(证明:对于 $ \forall $ 的线程调度,程序都满足某某性质)。
放弃(2):代码按顺序执行
编译器教你做人
- 虚拟化:进程只需要看到自己和操作系统。除了系统调用,没人能“干涉”程序的状态。
- 编译器:会利用上述假设,进行极其激进的代码优化!
- 语句和指令根本不需要按你代码写的顺序执行!编译器可以任意调换、重排甚至删除(死代码消除)你的语句,只要保证单线程视角下的最终结果一致即可。
但这和多线程的并发共享是绝对矛盾的!
- 你的
load可能会读到来自其他线程写入的值。 - 如果你依赖共享内存做逻辑,编译器会把你觉得极其重要、但它觉得“没用”的代码直接删掉,导致你觉得程序里有黑魔法!
一个自作聪明的例子
intflag=0;voidthread1(){// 做一些准备工作...flag=1;}voidthread2(){while(!flag);// 自旋等待,等线程 1 举起旗子,我再继续// 继续执行...}你以为这样就能实现线程同步了?太天真了,编译器比你聪明得多。
在单线程视角下,编译器发现thread2里的flag在循环内部根本没有被修改,于是它会直接把代码优化成死循环:
// 编译器优化后的实际逻辑:if(!flag){while(1);// 彻底死循环,哪怕后来 thread1 把 flag 改成了 1,它也永远看不见!}回到刚才的求和问题
voidT_sum(){for(inti=0;i<N;i++)sum++;}如果开启编译优化呢?
-O1优化:打印出100000000($ N $)。-O2优化:居然奇迹般地打印出了正确的200000000($ 2N $)!
编译器干了什么?
对于T_sum,编译器发现你只是对sum加了 N 次,于是它帮你做了等价的改写:
// 等价改写 1:把变量提到寄存器里加,最后写回内存t=load(sum);while(n--)t++;store(sum,t);// 等价改写 2:直接变成加法常量公式t=load(sum);store(sum,t+n);正因为编译器把它优化成了只读一次、只写一次,锁冲突的时间窗被无限压缩,所以-O2反而得到了“正确”的结果!
但这证明了:编译优化是建立在 Determinism (确定性) 绝对必要的假设上的。否则单线程程序的性能就没法看了。
如何强行控制编译器的优化行为?
- 方法 1:插入“不可优化”的内联汇编 (Memory Barrier)
while(!flag){// 告诉编译器:这段代码可能修改了内存,别给我乱优化!asmvolatile("":::"memory");}- 方法 2:使用
volatile关键字
// 告诉编译器:这个变量可能会被外部因素(硬件或异核线程)修改,每次必须去内存里老老实实读!intvolatileflag;while(!flag);- 终极法则:
以上都不是《操作系统》课推荐的方法!真正的法则是:Don’t play with shared memory! (不要用裸露的共享内存玩火,老老实实用锁!)
放弃(3):全局的指令执行顺序 (Spicy 🌶️)
哪怕我们搞定了编译器,甚至直接手写汇编,在多核处理器上依然会出大问题。
曾经美好的并发幻觉
我们天真地以为:并发只是选择一个线程执行一条指令。共享内存会“立即写入”、“立即读出”,因此世界上存在一个所有 CPU 都能看到的“全局指令执行顺序”。
过度简化的幻觉:
现代多处理器系统非常努力地在维持这个幻觉,但这幻觉在极致的性能面前是绝对靠不住的。在 NUMA 架构甚至分离核心的体系下,跨 CPU 同步数据就像在不同星球之间传递信息一样存在光速延迟。
真实的无序世界:宽松内存模型 (Relaxed Memory Model)
为了压榨物理性能,现代 CPU 采用了“宽松内存模型”:
- 当 CPU 1 执行
Store写入时,它其实只是写到了自己的 Local Memory (L1 Cache/Store Buffer) 中,然后再慢慢同步给其他处理器。 - 此时 CPU 2 执行
Load,它读到的依然是旧值!
“乱序执行”带来的毁灭性后果
不仅跨 CPU 同步有延迟,CPU 处理器内部也会乱序!
CPU 发现对不同内存地址的load和store没有依赖关系时,为了流水线满载,它会自动将它们重排 (Out-of-order execution)。处理器本身也是一个微观的编译器!
来看看这段恐怖的代码:
intx=0,y=0;voidT1(){x=1;intt=y;// 先写 x,后读 yprintf("%d",t);}voidT2(){y=1;intt=x;// 先写 y,后读 xprintf("%d",t);}在严格的全局顺序下,无论怎么交替执行,最终的结果只可能是01,10, 或11。
但是,在实际的多核机器上运行,你可能会惊恐地得到00!!!
这就是因为 CPU 的乱序执行,把读指令排到了写指令前面,或者写指令还没同步到另一个 CPU。
CPU 设计者面临的世纪难题
- 更有序的内存模型 = 更容易编程,但性能更糟糕。
- 更宽松的内存模型 = 性能极高,但程序员每天都在拔头发。
- x86 架构:拥有市面上“最强”的内存模型(几乎保证了顺序一致性),让程序员过得很舒服。
- ARM / RISC-V 架构:采用了极致的弱内存模型(Weak Memory Model),性能极高,但经常乱序。
因此,在 ARM 处理器的 Mac 上用虚拟机模拟 x86 是个世界性的性能难题。苹果 M1 芯片是怎么解决的?Apple cheated!M1 芯片内部直接做了一个特殊的寄存器开关,一键把自己“硬件配置”成了 x86-TSO 强内存模型,从而实现了 Rosetta 2 的恐怖模拟性能!
共享内存与 TLB 的幽灵
不仅普通数据会出问题,虚拟内存映射也会出问题。
- 每条指令执行都会访问 TLB (Translation Lookaside Buffer,页表缓存)。
- 如果线程 1 调用
munmap或mprotect删除了某段内存,但线程 2 正在另一个 CPU 上狂奔。 - 线程 2 的 TLB 缓存里依然存着旧的映射关系!它甚至还能继续读写那段已经被释放的内存!
- 为了解决这个问题,操作系统必须发起极其昂贵的“TLB Shootdown” (TLB 击落),强行中断其他 CPU,清空它们的缓存。
总结
Take-away Messages:
我们可以很容易地把状态机模型扩展为共享内存的多线程模型:每次选择一个状态机执行一步,通过spawn和join来利用多核 CPU 的算力。
然而,由于编译优化的“无处不在”(编译器会优化,CPU 乱序执行机制也是一种编译器),共享内存并发的行为变得诡异且复杂。与此同时,我们人类大脑恰恰是物理世界中的 “Sequential Creature” (顺序生物),我们对程序的直觉全是围绕单线顺阻展开的。
因此,共享内存并发编程是非常具有挑战性的“底层暗黑技术”。在《操作系统》课中,我们强烈不建议大家“玩火”——不要用裸奔的全局变量和死循环去做同步。在后续的课程中,我们将学习多种强大的并发控制技术(互斥锁、条件变量、信号量),迫使并发程序在关键时刻退回顺序执行,从而让我们能够驾驭这股狂暴的并发洪流。