在并发编程的世界里,即便最资深的程序员也难免会写出带有 Bug 的代码。OSTEP (Operating Systems: Three Easy Pieces) 的第 32 章基于一项对开源项目(如 MySQL、Apache、Mozilla)的经典研究,将并发 Bug 归纳为两大类:非死锁缺陷 (Non-Deadlock Bugs) 和 死锁缺陷 (Deadlock Bugs)。
本文将带你梳理这些 Bug 的成因,并结合书中的典型案例探讨如何优雅地解决它们。
一、 非死锁缺陷 (Non-Deadlock Bugs)
研究表明,非死锁缺陷占了并发 Bug 的绝大多数(约 97%)。其中最典型的是“违反原子性”和“违反顺序”。
1. 违反原子性 (Atomicity-Violation)
定义:程序员预期一段代码块的执行是原子的(不可分割),但实际运行时由于线程切换,导致中间状态被其他线程破坏。
案例:MySQL 中的 proc_info 崩溃
在 MySQL 的一段代码中,线程 1 检查 proc_info 是否非空,然后调用 fputs 使用它。
1 | // Thread 1 |
后果:如果线程 1 在检查之后、使用之前被中断,线程 2 将指针置空,线程 1 恢复后就会触发段错误(Segmentation Fault)。
应对策略:加锁 (Locking)
通过互斥锁(Mutex)强制保证代码块的原子性:
1 | pthread_mutex_lock(&lock); |
2. 违反顺序 (Order-Violation)
定义:两个线程的执行顺序被假设了(例如:A 必须在 B 之前完成),但在运行中没有得到强制保证。
案例:未初始化的引用
Thread 2 假设 Thread 1 已经完成了 mThread 的创建。
1 | // Thread 1 (Main) |
后果:如果子线程(Thread 2)在主线程完成赋值前就开始运行,mThread 仍为 NULL,导致程序崩溃。
应对策略:条件变量 (Condition Variables)
使用同步原语来“强制执行顺序”,而不是靠运气。
1 | // 使用条件变量同步顺序 |
二、 死锁缺陷 (Deadlock Bugs)
死锁是指一组线程互相等待对方持有的资源,导致所有线程都无法继续执行。
1. 死锁发生的四个必要条件
要发生死锁,必须同时满足以下 Coffman 条件:
- 互斥 (Mutual Exclusion):资源不能被共享。
- 持有并等待 (Hold-and-wait):线程持有了资源,同时在申请新资源。
- 非抢占 (No preemption):不能强行剥夺线程已持有的资源。
- 循环等待 (Circular wait):线程之间形成了一个闭环的等待链。
2. 应对策略
A. 预防 (Prevention):打破“循环等待”
这是最常用的方法。通过规定加锁顺序来打破循环。
- 例子:如果有两个锁
L1和L2,所有线程必须先获取L1再获取L2。 - 进阶技巧:按地址排序。在封装好的函数中,如果不知道传入参数锁的顺序,可以比较指针地址:
1
2
3
4
5
6
7
8
9void do_something(mutex_t *m1, mutex_t *m2) {
if (m1 > m2) { // 永远先锁地址大的
pthread_mutex_lock(m1);
pthread_mutex_lock(m2);
} else {
pthread_mutex_lock(m2);
pthread_mutex_lock(m1);
}
}
B. 预防:打破“持有并等待”
通过一个“上帝锁”来保证获取所有锁的过程是原子的。1
2
3
4pthread_mutex_lock(&prevention_lock); // 一次性拿走所有需要的资源
pthread_mutex_lock(&L1);
pthread_mutex_lock(&L2);
pthread_mutex_unlock(&prevention_lock);
C. 预防:打破“非抢占”
使用 pthread_mutex_trylock()。如果拿不到第二个锁,就主动释放第一个锁并重试。这样可以避免死等,但需要注意防止活锁 (Livelock)(两个线程不断重复释放和获取的过程,虽没死锁但也没进度)。1
2
3
4
5pthread_mutex_lock(L1);
if (pthread_mutex_trylock(L2) != 0) {
pthread_mutex_unlock(L1);5
goto top;
}
D. 互斥的终极方案:无锁编程 (Lock-free)
利用硬件原子指令(如 Compare-And-Swap)构建数据结构,从根本上消除锁。
- 课本例子(链表插入):
1
2
3
4
5
6
7void insert(int value) {
node_t *n = malloc(sizeof(node_t));
n->value = value;
do {
n->next = head;
} while (CompareAndSwap(&head, n->next, n) == 0);
}
E. 死锁避免 (Deadlock Avoidance)
- 它不破坏四个条件,它允许进程互斥、允许占有并等待、也允许不剥夺资源。它的逻辑是:“我可以让这些条件存在,但我通过动态监控,不让系统进入‘不安全状态’,从而让‘循环等待’这个最终条件无法成真。
- 有序资源分配法
思路:如果无法改变代码逻辑,可以通过“智能调度”来避免。前提是需要预知每个线程将要请求哪些锁。
案例与逻辑:
假设有 4 个线程(T1, T2, T3, T4)和 2 个锁(L1, L2):- T1 需要 L1, L2
- T2 需要 L1, L2
- T3 需要 L2
- T4 不需要锁
调度策略:
调度器发现 T1 和 T2 都有死锁风险。因此,调度器会规定:T1 和 T2 绝对不能在两个 CPU 上同时运行。只要 T1 和 T2 串行执行,死锁就永远不会发生。局限性:这种方案(如经典的银行家算法)需要对线程行为了如指掌,且会限制系统的并发度。
- 银行家算法
- Dijkstra提出
算法
- 当新进程进入系统时,必须说明对各个资源的最大需求量d(0),并且不能超过系统中各资源的总数w(0)。(前提条件)
- 当进程申请一类资源时,如果同意该申请后,不会导致系统进入不安全状态(安全状态:有安全序列),才同意该请求;否则不同意该请求,客户必须等待。
太保守
F. 死锁检测与恢复 (Detect and Recover)
思路:允许死锁发生,但定期检查是否存在死锁,一旦发现则采取行动。
- 检测机制:系统周期性地构建资源分配图。如果图中出现了环(Cycle),则说明发生了死锁。
- 恢复策略:
- 终止线程:强制杀死环中的一个或多个线程。
- 回滚 (Rollback):将系统恢复到之前没有死锁的安全检查点。
实用主义视角:鸵鸟算法与 Tom West 法则
思路:如果死锁发生的概率极低,且预防成本极高,那么最务实的做法就是“重启”。
- Tom West 的法则:“不是所有值得做的事情都值得做好。” (Not everything worth doing is worth doing well)。
- 案例:如果一个操作系统一年才死锁冻结一次,相比于设计极其复杂的预防算法,直接重启系统可能是更经济、更高效的方案。
总结
应对并发 Bug 没有银弹,我们需要根据场景选择:
- 对于简单的原子性/顺序问题:使用锁和条件变量。
- 对于复杂的锁竞争:
- 工程首选:严格规定加锁顺序(预防)。
- 系统层面:在数据库等复杂系统中,结合死锁检测与自动恢复。
- 务实层面:权衡死锁频率与性能损耗,必要时允许重启。