先看一个我大二时亲手制造、又花了整晚才看懂的 bug。四个线程,每个往同一个计数器里加十万次:
void *worker(void *arg) {
for (int i = 0; i < 100000; i++)
counter++; // 四个线程都执行这句
return NULL;
}
// main 里 pthread_create 四个线程,join 后打印
数学上答案应该是 400000。真实输出:
run 1: counter = 267931
run 2: counter = 310558
run 3: counter = 400000 ← 偶尔还对一次,更迷惑
每跑一次都不一样,有时候还「碰巧正确」。原因藏在一句看似无害的 counter++ 里:它在机器层面是三条指令(读内存到寄存器、寄存器加一、写回内存)。两个线程交错执行,可能都读到旧值 100,各自加一写回 101,两万次自增凭空蒸发一次。结果取决于线程交错顺序的程序缺陷,叫竞态条件(race condition)。本章的全部内容就是对付它:临界区的定义、硬件给了什么原子武器、锁和信号量怎么用、以及三个写进所有教材的经典同步问题。
访问共享资源的代码段叫临界区。多线程要安全,临界区协议得满足三条性质:
最早的实现是纯软件的(Peterson 算法,两个线程互相举手谦让),思想优美但在现代 CPU 上真的不能用:处理器和编译器会重排内存访问,软件假设的「顺序」在硬件层不存在了。这条路告诉我们的教训是:并发正确性必须依赖硬件提供的原子操作,不能靠代码写法的自觉。
硬件给的原语是「读-改-写一条龙不可分割」的指令,典型是 test-and-set(读旧值并置位)和 compare-and-swap(比较相等才交换,x86 的 cmpxchg)。单条指令的原子性由缓存一致性协议保证,这里不展开,记住结论:有了它,锁才造得出来。
有了原子原语,互斥锁的骨架只要几行伪码:
lock():
while (test_and_set(&flag) == 1)
; // 抢不到就在这转圈
unlock():
flag = 0
抢锁失败的线程怎么办,分两个流派,区别值得想透:
自旋锁:原地循环重试,不交还 CPU。零切换开销,拿锁延迟极小;代价是白白烧 CPU。适用场景极窄:多核 + 临界区短到纳秒级(内核里大量使用)。单核机器上自旋是纯灾难(持锁者被你抢了时间片,你得等它,它得先跑完)。
阻塞锁:抢不到就把自己挂起,进等待队列,让出 CPU;解锁时唤醒一个等待者。切换有开销(微秒级),但临界区稍长它就赢。应用程序默认用它,俗称 mutex。
自旋和阻塞还可以混合:先自旋一小会儿,还不行再睡,Java 的自适应锁、Linux 的 futex 都是这个套路。实际工程里锁还有一条隐形规则:谁加锁谁解锁(互斥锁有属主),这样才能实现递归检测、优先级继承(第 3 章火星车的补丁就是打在锁上)这些高级功能。
信号量(semaphore)比锁更抽象一档:一个整型计数,配两个原子操作。P(荷兰语 proberen,测试)把计数减一,减到负就把自己挂起;V(verhogen,增加)把计数加一,如果有等待者就唤醒一个。Dijkstra 起的名,考试会考。
它的妙处在计数可以是任何语义,三种典型玩法:
生产者-消费者是三个信号量合体的经典,缓冲区 N 格,生产者往里放、消费者往里取,满则停产,空则停取:
sem_t empty = N; // 空格子数
sem_t full = 0; // 有货的格子数
sem_t mutex = 1; // 保护缓冲区本身
// 生产者 // 消费者
P(&empty); P(&full);
P(&mutex); P(&mutex);
放入一格; 取出一格;
V(&mutex); V(&mutex);
V(&full); V(&empty);
注意 P 的顺序,先资源后互斥,反了就死锁:假如生产者先 P(mutex) 再 P(empty),缓冲区满时它拿着 mutex 等 empty;消费者想取货得先 P(mutex),永远拿不到,双方互等。这个顺序错误是所有教材都会出的送分陷阱,也是我实习第一周真实踩过的坑(当时的现象:程序跑得好好的,一压测就整组线程集体失踪,top 一看全在睡)。
V 的顺序倒是无所谓,不影响正确性,只影响性能细节。
读者-写者问题:一堆读者(只读不写,随便并发)和写者(必须独占)。核心技巧是「第一个读者把门关上,最后一个读者把门打开」,用读者计数器 + 计数器自己的锁。第一个读者锁住写者,后续读者直接跟进。这个基本版对写者残忍:读者源源不断,写者一个字节都写不进去(写者饿死)。改进版加一个排队闸门,写者一来就关闸,后续读者在闸外排队。两版都写一遍再对比,你就理解「优先级策略」到底是什么意思了。
哲学家就餐:五位哲学家围一桌,相邻两人之间一支筷子,吃饭要同时拿起左右两支。天真的「先拿左再拿右」有个漂亮的死锁:五个人同时拿起左手,五支筷子全部离桌,每人都在等右手那支,无限僵持。解法都有趣:
它的现实投影是「每个资源都要等另一个资源」的任何场景:转账(锁两个账户)、数据库迁移(锁两张表)。判断思路通用:找出资源的获取顺序,打破它的全局一致性。
<stdatomic.h>。