第 4 章 · 进程同步

先看一个我大二时亲手制造、又花了整晚才看懂的 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)。本章的全部内容就是对付它:临界区的定义、硬件给了什么原子武器、锁和信号量怎么用、以及三个写进所有教材的经典同步问题。

4.1 临界区:给危险代码段立规矩

访问共享资源的代码段叫临界区。多线程要安全,临界区协议得满足三条性质:

  • 互斥:任一时刻至多一个线程在临界区里。
  • 前进:临界区空着时,想进的线程要能进得去(不许互相谦让到死)。
  • 有限等待:想进的线程等待时间有上界(不许饿死)。

最早的实现是纯软件的(Peterson 算法,两个线程互相举手谦让),思想优美但在现代 CPU 上真的不能用:处理器和编译器会重排内存访问,软件假设的「顺序」在硬件层不存在了。这条路告诉我们的教训是:并发正确性必须依赖硬件提供的原子操作,不能靠代码写法的自觉。

硬件给的原语是「读-改-写一条龙不可分割」的指令,典型是 test-and-set(读旧值并置位)和 compare-and-swap(比较相等才交换,x86 的 cmpxchg)。单条指令的原子性由缓存一致性协议保证,这里不展开,记住结论:有了它,锁才造得出来。

4.2 锁:把原子性租给你用

有了原子原语,互斥锁的骨架只要几行伪码:

lock():
    while (test_and_set(&flag) == 1)
        ;    // 抢不到就在这转圈
unlock():
    flag = 0

抢锁失败的线程怎么办,分两个流派,区别值得想透:

自旋锁:原地循环重试,不交还 CPU。零切换开销,拿锁延迟极小;代价是白白烧 CPU。适用场景极窄:多核 + 临界区短到纳秒级(内核里大量使用)。单核机器上自旋是纯灾难(持锁者被你抢了时间片,你得等它,它得先跑完)。

阻塞锁:抢不到就把自己挂起,进等待队列,让出 CPU;解锁时唤醒一个等待者。切换有开销(微秒级),但临界区稍长它就赢。应用程序默认用它,俗称 mutex。

自旋和阻塞还可以混合:先自旋一小会儿,还不行再睡,Java 的自适应锁、Linux 的 futex 都是这个套路。实际工程里锁还有一条隐形规则:谁加锁谁解锁(互斥锁有属主),这样才能实现递归检测、优先级继承(第 3 章火星车的补丁就是打在锁上)这些高级功能。

4.3 信号量:P/V 与三种玩法

信号量(semaphore)比锁更抽象一档:一个整型计数,配两个原子操作。P(荷兰语 proberen,测试)把计数减一,减到负就把自己挂起;V(verhogen,增加)把计数加一,如果有等待者就唤醒一个。Dijkstra 起的名,考试会考。

它的妙处在计数可以是任何语义,三种典型玩法:

  • 初值 1:二元信号量,退化成互斥锁。
  • 初值 N:资源池,N 个资源,N 个线程可同时拿。
  • 初值 0:「等待某个事件发生」,消费者 P 一个还没生产的东西,挂起;生产者做完 V 一下,消费者醒来。用它表达「先后顺序」。

生产者-消费者是三个信号量合体的经典,缓冲区 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 的顺序倒是无所谓,不影响正确性,只影响性能细节。

4.4 两个经典问题:读者的宽容与哲学家的筷子

读者-写者问题:一堆读者(只读不写,随便并发)和写者(必须独占)。核心技巧是「第一个读者把门关上,最后一个读者把门打开」,用读者计数器 + 计数器自己的锁。第一个读者锁住写者,后续读者直接跟进。这个基本版对写者残忍:读者源源不断,写者一个字节都写不进去(写者饿死)。改进版加一个排队闸门,写者一来就关闸,后续读者在闸外排队。两版都写一遍再对比,你就理解「优先级策略」到底是什么意思了。

哲学家就餐:五位哲学家围一桌,相邻两人之间一支筷子,吃饭要同时拿起左右两支。天真的「先拿左再拿右」有个漂亮的死锁:五个人同时拿起左手,五支筷子全部离桌,每人都在等右手那支,无限僵持。解法都有趣:

  • 让某个倒霉蛋先拿右手(破坏循环等待,下一章的死锁四条件之一);
  • 桌上只放四支筷子(限制并发数,信号量初值 4);
  • 或者一位哲学家左右手筷子原子地「一把抓」(把拿两支变成一个临界区)。

它的现实投影是「每个资源都要等另一个资源」的任何场景:转账(锁两个账户)、数据库迁移(锁两张表)。判断思路通用:找出资源的获取顺序,打破它的全局一致性。

常见错误

  • 只加锁不解锁,或提前 return 绕过解锁。C 里最靠谱的写法是锁的获取和释放封进成对的宏或 goto 统一出口;Java/Python 用 try-finally 或 with,忘记解锁这种事在高级语言里是语法可以防的。
  • 锁的粒度两头翻车。粒度太大(一把大锁包整个函数),并发度归一,多线程退化单线程;粒度太细(每个字段一把锁),组合操作(改 A 必须同时改 B)顾此失彼。先粗后细、用性能剖析驱动加锁范围,是靠谱路径。
  • 以为 int 的读写天生原子。在 64 位机器上 64 位整数的读写通常原子,但 int64 的 += 永远三条指令,和本章开头的计数器是同一个坑。C 语言的正确工具是 <stdatomic.h>。

练习

  1. 动手:复现本章开头的计数器实验(四个线程各加十万次,不加锁),跑十次记录输出;再加一把 pthread_mutex 锁,对比结果和耗时,算算加锁的性能代价。
  2. 手算:生产者-消费者问题里,把消费者改成「先 P(mutex) 再 P(full)」,构造一个具体的交错序列,展示死锁如何形成。
  3. 手算:读者-写者问题,写者 W 想写时已有 3 个读者在读,之后新读者 R1、R2 陆续到达。分别画出「读者优先」和「写者优先」下各方的执行顺序。
  4. 思考:自旋锁在单核机器上为什么必然灾难?那内核里(单核时代)为什么还敢用自旋锁?提示:内核自旋锁的持有者会不会被抢走时间片?