南京大学 操作系统 (JYY) 学习笔记:并发编程、线程与多处理器的深渊 (Concurrency)

写在前面:这是本系列的第十三篇。

在 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系统中,你可以使用tophtop命令,按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 发现对不同内存地址的loadstore没有依赖关系时,为了流水线满载,它会自动将它们重排 (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 调用munmapmprotect删除了某段内存,但线程 2 正在另一个 CPU 上狂奔。
  • 线程 2 的 TLB 缓存里依然存着旧的映射关系!它甚至还能继续读写那段已经被释放的内存!
  • 为了解决这个问题,操作系统必须发起极其昂贵的“TLB Shootdown” (TLB 击落),强行中断其他 CPU,清空它们的缓存。

总结

Take-away Messages:

我们可以很容易地把状态机模型扩展为共享内存的多线程模型:每次选择一个状态机执行一步,通过spawnjoin来利用多核 CPU 的算力。

然而,由于编译优化的“无处不在”(编译器会优化,CPU 乱序执行机制也是一种编译器),共享内存并发的行为变得诡异且复杂。与此同时,我们人类大脑恰恰是物理世界中的 “Sequential Creature” (顺序生物),我们对程序的直觉全是围绕单线顺阻展开的。

因此,共享内存并发编程是非常具有挑战性的“底层暗黑技术”。在《操作系统》课中,我们强烈不建议大家“玩火”——不要用裸奔的全局变量和死循环去做同步。在后续的课程中,我们将学习多种强大的并发控制技术(互斥锁、条件变量、信号量),迫使并发程序在关键时刻退回顺序执行,从而让我们能够驾驭这股狂暴的并发洪流。