关键问题与本章位置
如何处理常见的并发缺陷?并发缺陷往往呈现为几种常见的模式。知道该提防哪些模式,是写出更健壮、更正确的并发代码的第一步。
CRUX: How to handle common concurrency bugs — Concurrency bugs tend to come in a variety of common patterns. Knowing which ones to look out for is the first step to writing more robust, correct concurrent code.
前几章我们一直在"造工具":锁(lock)、条件变量(condition variable)、信号量(semaphore)。这一章换个角度——真实世界里的程序员拿这些工具写代码时,到底犯了哪些错?
原书借助 Lu 等人对 MySQL、Apache、Mozilla、OpenOffice 四个成熟开源项目的研究,把并发缺陷分成两大类:非死锁缺陷(non-deadlock bug),其中绝大多数是"原子性违反"和"顺序违反";以及经典的死锁(deadlock)。对死锁,本章系统地讲四个必要条件,以及预防(prevention)、避免(avoidance)、检测与恢复(detect and recover)三类对策。下一章(基于事件的并发)则干脆换一种不靠多线程的并发方式,从根上绕开其中一部分问题。
核心概念速览
原子性违反(atomicity violation)
一段本应"一口气"执行完的代码(如先检查再使用),执行时却被别的线程插了队。
顺序违反(order violation)
A 本该总在 B 之前发生,但程序没有强制这个顺序,偶尔 B 先跑了。
死锁(deadlock)
一组线程互相等待对方手里的资源,谁也无法前进,永远卡住。
四个必要条件
互斥、持有并等待、非抢占、循环等待——四者同时成立才会死锁,破坏任意一个即可预防。
锁顺序(lock ordering)
所有线程按同一个全序(或偏序)获取锁,就不可能形成等待环。实践中最常用。
活锁(livelock)
线程都在运行、都在不停重试,却谁也没有进展。trylock 回退方案可能出现。
通过调度避免(avoidance)
事先知道每个线程要拿哪些锁,调度时不让"危险组合"同时运行。需要全局知识。
检测与恢复(detect & recover)
允许偶尔死锁,定期在资源图里找环,找到就重启或人工修复。数据库常用。
真实世界里有哪些并发缺陷?
Lu 等人(ASPLOS '08)逐个分析了四个著名开源项目中已经被发现并修复的并发缺陷,把开发者的修复记录变成了定量统计。原书 Figure 32.1 的数据如下:
| 应用 | 是什么 | 非死锁 | 死锁 | 合计 |
|---|---|---|---|---|
| MySQL | 数据库服务器 | 14 | 9 | 23 |
| Apache | Web 服务器 | 13 | 4 | 17 |
| Mozilla | Web 浏览器 | 41 | 16 | 57 |
| OpenOffice | 办公套件 | 6 | 2 | 8 |
| 合计 | 74 | 31 | 105 |
两个结论值得记住:第一,大多数(74/105)并发缺陷不是死锁,所以只盯着死锁是不够的;第二,缺陷数与代码规模、并发复杂度相关——OpenOffice 只有 8 个,Mozilla 将近 60 个。
非死锁缺陷:原子性违反与顺序违反
原子性违反(Atomicity Violation)——MySQL 的例子
先别看解释,自己找找下面这段 MySQL 代码(原书 Figure 32.2)的问题:
Thread 1::
if (thd->proc_info) { // ① 检查:非 NULL 吗?
fputs(thd->proc_info, ...); // ② 使用:打印它
}
Thread 2::
thd->proc_info = NULL; // ③ 置空
线程 1 先检查 proc_info 非 NULL,再去用它。如果恰好在 ① 和 ② 之间发生中断、线程 2 执行了 ③,线程 1 恢复后就会把 NULL 交给 fputs,程序崩溃。
proc_info,但这个假设没有任何机制保证。Lu 等人的正式定义是:"多次内存访问之间所期望的可串行性被违反了(即一段代码本意是原子的,但执行时原子性没有被强制保证)。"这里的"原子性假设"就是:对 proc_info 的非 NULL 检查与 fputs() 中对它的使用是一个整体。
修复通常很直接:给共享变量的访问加锁(原书 Figure 32.3)。注意所有访问这个结构的代码都要拿同一把锁,只给线程 1 加锁是没用的:
pthread_mutex_t proc_info_lock = PTHREAD_MUTEX_INITIALIZER;
Thread 1::
pthread_mutex_lock(&proc_info_lock);
if (thd->proc_info) {
fputs(thd->proc_info, ...);
}
pthread_mutex_unlock(&proc_info_lock);
Thread 2::
pthread_mutex_lock(&proc_info_lock);
thd->proc_info = NULL;
pthread_mutex_unlock(&proc_info_lock);
顺序违反(Order Violation)——Mozilla 的例子
再看原书 Figure 32.4:
Thread 1::
void init() {
mThread = PR_CreateThread(mMain, ...); // 创建线程 2,返回后才赋值给 mThread
}
Thread 2::
void mMain(...) {
mState = mThread->State; // 假设 mThread 已经被赋值
}
线程 2 的代码默认 mThread 已经初始化好了。但新线程一被创建就可能立即运行——那时 PR_CreateThread 还没返回,mThread 仍是 NULL,线程 2 解引用 NULL 就崩溃(如果初值不是 NULL,更糟:会访问任意内存)。
正式定义:"两组内存访问之间所期望的顺序被颠倒了(即 A 应该总在 B 之前执行,但执行时这个顺序没有被强制保证)。"修复方法就是强制顺序——条件变量正是为此而生(原书 Figure 32.5):
pthread_mutex_t mtLock = PTHREAD_MUTEX_INITIALIZER;
pthread_cond_t mtCond = PTHREAD_COND_INITIALIZER;
int mtInit = 0; // 状态变量:线程是否已初始化
Thread 1::
void init() {
mThread = PR_CreateThread(mMain, ...);
pthread_mutex_lock(&mtLock); // 通知:线程已创建好
mtInit = 1;
pthread_cond_signal(&mtCond);
pthread_mutex_unlock(&mtLock);
}
Thread 2::
void mMain(...) {
pthread_mutex_lock(&mtLock); // 等待初始化完成
while (mtInit == 0)
pthread_cond_wait(&mtCond, &mtLock);
pthread_mutex_unlock(&mtLock);
mState = mThread->State; // 现在一定安全
}
如果线程 2 先跑,它会在 wait 上睡着,等线程 1 设置 mtInit=1 并发信号;如果线程 2 后跑,它看到 mtInit 已是 1,直接继续。两种顺序都正确。(原书提到也可以直接用 mThread 本身当状态变量,这里为了清晰单独用了 mtInit。)
小结:Lu 等人研究的非死锁缺陷中,97% 要么是原子性违反,要么是顺序违反。所以写并发代码时重点审视两件事:"这段代码是否默认自己不会被打断?"以及"这段代码是否默认别人已经做完了某事?"自动化检查工具也应该重点针对这两类。不过也不是所有缺陷都这么好修,有的需要深入理解程序、大幅重组代码或数据结构。
交错选择器:让缺陷现形,再用修复版本对照
死锁:为什么会发生
除了上面两类,另一类经典问题出现在锁协议复杂的系统里:死锁。比如线程 1 持有锁 L1、正在等 L2,而持有 L2 的线程 2 正在等 L1 被释放。下面这段代码(原书 Figure 32.6)就可能死锁:
Thread 1: Thread 2:
pthread_mutex_lock(L1); pthread_mutex_lock(L2);
pthread_mutex_lock(L2); pthread_mutex_lock(L1);
注意是"可能":如果线程 1 一口气拿到 L1 和 L2,就不会出事。只有当线程 1 拿到 L1 后发生上下文切换、线程 2 又拿到了 L2,二者才会各自握着一把锁去等另一把——然后永远等下去。依赖图里出现了环,就是死锁的标志:
如何应对死锁?应该如何构建系统,才能预防、避免死锁,或者至少能检测到死锁并从中恢复?这在今天的系统里真的是个问题吗?
CRUX: How to deal with deadlock — How should we build systems to prevent, avoid, or at least detect and recover from deadlock? Is this a real problem in systems today?
既然这么明显,为什么还会发生?
上面的例子只要两个线程按相同顺序拿锁就能避免。可死锁在真实系统里仍然屡见不鲜,原书给了两个原因:
① 大型代码库中组件之间有复杂的依赖。以操作系统为例:虚拟内存系统换入页面时要访问文件系统读磁盘块;文件系统读块时又需要一页内存来存放数据,于是回头找虚拟内存系统。两个子系统各自持有自己的锁再调用对方,就天然存在循环依赖,锁策略必须精心设计。
② 封装(encapsulation)与锁不太合拍。我们被教导要隐藏实现细节,可有些看似无害的接口简直是在"邀请"你死锁。比如 Java 的 Vector.AddAll():
Vector v1, v2;
v1.AddAll(v2); // 内部:先锁 v1,再锁 v2
为了线程安全,这个方法内部要同时锁住 v1 和参数 v2,以某种固定顺序(比如先 v1 后 v2)。如果另一个线程几乎同时调用 v2.AddAll(v1),它就会先锁 v2 再锁 v1——和 Figure 32.6 一模一样的死锁,而调用者完全看不出来。(下面的模拟器里有这个预设。)
死锁的四个必要条件
Coffman 等人(1971)指出,死锁发生需要同时满足四个条件。只要有任何一个不成立,死锁就不可能发生。因此每一种"预防"技术,都是想办法让其中一个条件不成立:
① 互斥(Mutual exclusion)
线程对所需资源独占控制(例如线程抓住一把锁)。
② 持有并等待(Hold-and-wait)
线程持有已分配给它的资源(如已拿到的锁),同时等待更多资源。
prevention 锁。③ 非抢占(No preemption)
资源(如锁)不能被强行从持有它的线程手中夺走。
trylock 拿不到就主动放掉已持有的锁、从头再来。④ 循环等待(Circular wait)
存在一个线程环,每个线程都持有下一个线程正在请求的资源。
预防(Prevention):逐个打破四个条件
循环等待:给锁定一个顺序
最实用、也最常用的预防技术:写加锁代码时保证永远不会产生循环等待。最直接的办法是给锁的获取规定一个全序(total ordering):系统里只有 L1、L2 两把锁时,规定总是先拿 L1 再拿 L2,就不会形成环。
更复杂的系统里锁很多,全序难以做到(也未必需要),偏序(partial ordering)就是一种有用的折中。原书举了 Linux 内存映射代码(mm/filemap.c)的真实例子:文件开头的注释列出了十组加锁顺序,简单的如"i_mutex 在 i_mmap_rwsem 之前",复杂的如"i_mmap_rwsem 在 private_lock 之前,在 swap_lock 之前,在 i_pages lock 之前"。
当然,全序和偏序都需要精心设计,而且顺序只是约定:一个粗心的程序员无视它,就可能导致死锁。它还要求对代码库、各函数如何被调用有深入理解,一个失误就会招来那个"D"开头的词。
按锁的地址强制加锁顺序。如果一个函数要拿两把作为参数传进来的锁,如 do_something(mutex_t *m1, mutex_t *m2),总是先拿 m1 就会出问题:一个线程调 do_something(L1, L2),另一个调 do_something(L2, L1)。聪明的做法是按锁的地址排序,无论参数顺序如何,加锁顺序都一样:
if (m1 > m2) { // 按地址从高到低加锁
pthread_mutex_lock(m1);
pthread_mutex_lock(m2);
} else {
pthread_mutex_lock(m2);
pthread_mutex_lock(m1);
}
// 假设 m1 != m2(不是同一把锁)
下方实验里的 ordered 模式就是这段代码。
持有并等待:一次性拿齐所有锁
让线程原子地一次拿到所有需要的锁,就不存在"拿着一些等另一些"的情况:
pthread_mutex_lock(prevention); // 开始获取
pthread_mutex_lock(L1);
pthread_mutex_lock(L2);
...
pthread_mutex_unlock(prevention); // 结束
先抢到全局的 prevention 锁,就保证了获取锁的过程中不会有"不合时宜"的线程切换插进来。另一个线程即使以不同顺序拿 L1、L2 也没关系,因为它也得先持有 prevention。前提是:任何线程拿任何锁之前都要先拿 prevention。
缺点很明显:封装又一次和我们作对——调用一个函数前必须准确知道它要拿哪些锁并提前拿好;而且所有锁都得提前一起拿,而不是真正需要时再拿,并发度会下降。
非抢占:trylock,拿不到就退回去
锁一般要等到 unlock 才释放,这正是"拿着一个、等着另一个"的根源。很多线程库提供 pthread_mutex_trylock():锁空闲就拿到并返回成功,否则立即返回错误码,而不是睡眠等待。用它可以构造一个不怕加锁顺序、不会死锁的协议:
top:
pthread_mutex_lock(L1);
if (pthread_mutex_trylock(L2) != 0) { // L2 拿不到?
pthread_mutex_unlock(L1); // 那就把 L1 也放掉
goto top; // 从头再来
}
另一个线程按 L2、L1 的顺序走同样的协议,程序依然不会死锁。但出现了新问题——活锁(livelock):两个线程可能一直重复这个序列、一直失败,都在运行(所以不是死锁),却都没有进展。
解决活锁也有办法:比如在跳回开头前随机延迟一段时间,降低两个线程反复互相干扰的概率(下面模拟器的"随机退避"选项)。
这个方案还回避了 trylock 的一些难点。首先又是封装:如果某把锁藏在被调用的某个函数深处,"跳回开头"就很难实现;其次,如果拿到 L1 之后还申请了别的资源(例如分配了内存),失败时必须小心地把这些也都释放掉。不过在一些受限场景(比如前面的 Java vector 方法)里,这种方法可以工作得很好。还要注意,它并没有真正实现"抢占"(强行从别人手里夺走锁),而是让开发者体面地主动放弃自己的所有权——原书承认它在这一点上不够完美,但它很实用。
互斥:干脆不用锁(lock-free)
最后一种预防技术是彻底避免互斥。一般来说这很难,因为代码里确实有临界区。Herlihy 提出可以借助强大的硬件指令,设计完全不用锁的数据结构:无锁(lock-free)以及相关的无等待(wait-free)方法。回忆比较并交换(compare-and-swap)指令:
int CompareAndSwap(int *address, int expected, int new) {
if (*address == expected) { // 整个函数由硬件原子执行
*address = new;
return 1; // 成功
}
return 0; // 失败
}
void AtomicIncrement(int *value, int amount) {
do {
int old = *value;
} while (CompareAndSwap(value, old, old + amount) == 0);
}
AtomicIncrement 不获取任何锁,只是不断尝试用 CAS 把值更新为新值:如果期间别人改过,CAS 失败就重试。没有锁,就不会死锁(但活锁仍可能存在,健壮的实现会比这复杂)。
稍复杂一点的例子是链表头插。朴素版本在多线程下有竞态:
void insert(int value) {
node_t *n = malloc(sizeof(node_t));
assert(n != NULL);
n->value = value;
n->next = head; // 两个线程可能读到同一个 head……
head = n; // ……后写的覆盖先写的,丢一个节点
}
传统做法是在最后两行外面加锁(listlock)。原书留了个思考题:为什么锁拿得这么晚、不在函数一开头拿?——因为前面的 malloc 和初始化只涉及新节点这个私有对象(假设 malloc 本身线程安全),不必放进临界区。无锁版本则用 CAS:
void insert(int value) {
node_t *n = malloc(sizeof(node_t));
assert(n != NULL);
n->value = value;
do {
n->next = head;
} while (CompareAndSwap(&head, n->next, n) == 0);
}
当然,一个真正可用的链表不止插入,还要删除、查找;构建一个无锁的完整链表并不简单,有大量文献专门研究它。
模拟器:死锁检测器
下面你来当调度器:每一步选一个线程执行一条"指令"(加锁、临界区、解锁)。右侧实时画出资源分配图——实线"锁 → 线程"表示持有,虚线"线程 → 锁"表示等待。一旦图中出现环,模拟器会把环标红,并逐条说明四个必要条件如何同时成立。换一种策略,再试试能不能把它逼进死锁。
死锁检测器:你来调度,看等待图里何时出现环
L1 L2),选择策略,然后逐步选择下一个运行的线程,或随机调度。每个线程执行:按序加锁 → 临界区 → 逆序解锁。避免(Avoidance):通过调度绕开死锁
另一条思路不是"预防",而是避免:需要事先掌握全局信息——各个线程在执行过程中可能会拿哪些锁——然后据此调度,保证死锁不可能发生。
假设有 2 个 CPU、4 个线程。T1 会拿 L1 和 L2(顺序不定、时机不定),T2 也会拿 L1 和 L2,T3 只拿 L2,T4 不拿锁:
| 例 1 | T1 | T2 | T3 | T4 |
|---|---|---|---|---|
| L1 | yes | yes | no | no |
| L2 | yes | yes | yes | no |
聪明的调度器能算出:只要 T1 和 T2 不同时运行,就不会死锁。T3 与 T1(或 T2)重叠没关系——T3 只拿一把锁,永远不会因为"拿着一把等另一把"而参与到环里。
第二个例子竞争更激烈:T1、T2、T3 都要拿 L1 和 L2:
| 例 2 | T1 | T2 | T3 | T4 |
|---|---|---|---|---|
| L1 | yes | yes | yes | no |
| L2 | yes | yes | yes | no |
这类方法的著名代表是 Dijkstra 的银行家算法(Banker's Algorithm)(1964 年;有趣的是,Dijkstra 当时把死锁叫做"致命拥抱"(deadly embrace),好在这个名字没流行开来)。遗憾的是,它们只在非常受限的环境里有用——比如嵌入式系统,事先完全知道要运行哪些任务、它们需要哪些锁;而且如例 2 所示,它们会限制并发。所以通过调度避免死锁并不是被广泛使用的通用方案。
调度避免:在 2 个 CPU 上排出安全又快的调度
T1 T2)。模拟器会标出"两个都需要 L1 和 L2 的线程同时运行"的危险区间,并与穷举得到的最优调度对比。Tom West(经典计算机行业纪实《新机器的灵魂》(Soul of a New Machine)的主人公)有句名言:"并不是所有值得做的事都值得做好。"如果一件坏事很少发生、代价也小,就不值得花大力气去预防;如果你在造航天飞机,出错的代价是飞机爆炸,那就请忽略这条建议。难点在于判断什么时候该做到哪一步——这只能靠经验和投入。
检测与恢复(Detect and Recover)
最后一种通用策略:允许死锁偶尔发生,发生后再处理。如果操作系统一年才死机一次,重启一下接着干活就是了——当死锁足够罕见时,这种"非方案"其实相当务实。
先预测,再验证
L1 L2,T2: L2 L1),策略"无策略"。最少需要几步才能进入死锁?必须满足什么先后关系?先想,再手动点出来。看答案
L1 L2,T2: L2 L3,T3: L3 L1。如果只把 T3 改成 L1 L3,还能死锁吗?用"每种策略随机跑 1000 次"验证。看答案
看答案
看答案
看答案
proc_info_lock。线程 2 想在危险窗口里插队,第一步 lock 就会睡眠等待;线程 1 顺利完成 fputs 并解锁后,线程 2 才被唤醒、拿锁、置 NULL。"检查 + 使用"被锁保护成了一个整体,危险窗口消失了。vector_deadlock naive 会不会每次都死锁?如果只有一个线程做 vector_add(不管顺序),会死锁吗?看答案
naive 1000)时更常侥幸跑完——它取决于两个线程是否恰好各拿一把锁时发生交错。只有一个线程时永远不会死锁:环至少需要两个线程互相等待。这也是为什么死锁很难靠测试发现。动手实验:亲手制造(和消灭)一次死锁
实验目录 labs/32-concurrency-bugs/ 里有两个程序:
vector_deadlock.c:两个线程反复vector_add,线程 0 做add(v0, v1),线程 1 做add(v1, v0)——正是 Vector.AddAll 那种隐藏的相反加锁顺序。四种模式:naive(先锁 dst 再锁 src)、ordered(按锁地址排序)、trylock(lock + trylock,失败全部释放重来)、global(先拿 prevention 锁)。主线程当看门狗:1 秒内毫无进展就报告"疑似死锁"并退出,不会永远挂住。atomicity.c:MySQLproc_info的原子性违反。为了不真的段错误,"使用"时再读一次指针,若已变成 NULL 就计一次违反(真实程序会在这里崩溃)。
cd labs/32-concurrency-bugs && make
./vector_deadlock naive
./vector_deadlock ordered 1000000
./vector_deadlock trylock 1000000
./vector_deadlock global 1000000
./atomicity bug
./atomicity fixed
示例输出(WSL2、gcc 14;你的机器上数字会不同,每次运行也不同):
[naive] 疑似死锁!已完成 3 / 200000 次 vector_add,1 秒内毫无进展
线程 0: 持有 v0 的锁,等待 v1 的锁
线程 1: 持有 v1 的锁,等待 v0 的锁
等待图: T0 -> v1 -> T1 -> v0 -> T0 (出现环)
[naive] 完成 200000 次 vector_add,用时 0.014 秒 ← 另一次运行:侥幸没死锁
[ordered] 完成 2000000 次 vector_add,用时 0.157 秒
[trylock] 完成 2000000 次 vector_add,用时 0.250 秒,trylock 失败重试 3420323 次
[global] 完成 2000000 次 vector_add,用时 0.178 秒
[bug] 检查通过后成功使用 1757021 次;检查通过、使用时却已是 NULL(原子性违反)3103 次
[fixed] 检查通过后成功使用 683798 次;检查通过、使用时却已是 NULL(原子性违反)0 次
我们用默认参数把 naive 连跑 20 次,其中 10 次死锁。观察要点:
- naive 的死锁是概率性的:同一个程序有时死锁、有时顺利跑完——测试通过不代表没有死锁。死锁报告里的"持有 v0 等 v1 / 持有 v1 等 v0"就是 Figure 32.7 那个环。
- ordered 与 global 从不死锁,分别破坏了"循环等待"和"持有并等待"。
- trylock 也不死锁,但重试次数可能比完成次数还多——这就是活锁的苗头:两个线程都在忙,大量工作白做。
- 原子性违反真实存在:几十万次里出现几千次"检查时非 NULL、使用时 NULL";加锁后恒为 0,代价是两个线程互相等锁。
自测题
thd->proc_info 非 NULL、再 fputs 它",而另一线程可能把它置 NULL,这属于:while (mtInit == 0) wait() 里等待,线程 1 赋值后设置 mtInit = 1 并 signal。volatile 保证不了先后。常见误区与本章小结
- "测试跑了一百遍都没死锁,所以没有死锁。"死锁取决于交错,可能千万次才出现一次。实验里同一个程序大约一半运行会死锁,另一半顺利跑完。
- "检查一下非 NULL 就安全了。"检查与使用之间若没有同一把锁保护,检查结果在使用时可能已失效——这正是原子性违反。
- "只给出错的那个线程加锁。"所有访问该共享数据的代码都必须拿同一把锁,否则锁形同虚设。
- "trylock 方案就是抢占。"它只是让线程主动放弃自己的锁,并没有强行夺走别人的锁;而且会引入活锁。
- "无锁就万事大吉。"无锁结构仍可能活锁,完整的无锁数据结构(插入、删除、查找都支持)非常难写对。
- "定好锁顺序就一劳永逸。"顺序只是约定,一个不遵守的调用(或一个隐藏在封装后面的加锁)就能破坏它。
- 真实程序中非死锁缺陷占多数(74/105),其中 97% 是原子性违反(用锁把本应原子的代码保护起来)或顺序违反(用条件变量/信号量强制先后)。
- 死锁需要四个条件同时成立:互斥、持有并等待、非抢占、循环等待。资源图中的环就是死锁的标志。
- 预防:全序/偏序加锁(最实用,可按锁地址排序);prevention 全局锁(并发度低);trylock 回退(小心活锁,加随机退避);无锁数据结构(CAS,通用性有限、实现复杂)。
- 避免:银行家算法式的调度,需要全局知识、牺牲并发,只适合受限环境。检测与恢复:定期找环、重启,数据库常用。
- 原书的结论:实践中最好的办法是小心谨慎、规定加锁顺序,从源头预防死锁;无等待数据结构正逐步进入 Linux 等关键系统;更根本的也许是新的并发编程模型,例如 MapReduce 让程序员不用任何锁就能描述某些并行计算。
- 下一章换一种思路:基于事件的并发(event-based concurrency)用单线程事件循环,在单 CPU 上完全不需要锁,从而绕开本章的许多问题——但它也有自己的难处(阻塞调用、状态管理)。