关键问题与本章位置

如何处理常见的并发缺陷?并发缺陷往往呈现为几种常见的模式。知道该提防哪些模式,是写出更健壮、更正确的并发代码的第一步。

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数据库服务器14923
ApacheWeb 服务器13417
MozillaWeb 浏览器411657
OpenOffice办公套件628
合计7431105
非死锁缺陷 死锁缺陷 MySQL 14 9 Apache 13 4 Mozilla 41 16 OpenOffice 6 2 合计 105 非死锁 74(≈70%) 死锁 31(≈30%)
Figure 32.1 的数据:上面四行每格 = 1 个缺陷 × 7px;最后一行按比例画出 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,程序崩溃。

线程 1 线程 2 ① if (thd->proc_info) 非 NULL,检查通过 中断 ③ proc_info = NULL 线程 2 插队 ② fputs(proc_info) 传入 NULL → 崩溃 时间 检查…… ……使用:此时前提已失效
原子性违反:线程 1 默认"检查"和"使用"之间不会有人改 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,更糟:会访问任意内存)。

线程 1 线程 2 PR_CreateThread(mMain) 线程 2 诞生,尚未返回 mState = mThread->State mThread 仍是 NULL → 崩溃 mThread = 返回值 本该在前,却来晚了 时间
顺序违反:"赋值 mThread"应该总在"读取 mThread"之前,但程序没有强制这个先后关系。

正式定义:"两组内存访问之间所期望的顺序被颠倒了(即 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% 要么是原子性违反,要么是顺序违反。所以写并发代码时重点审视两件事:"这段代码是否默认自己不会被打断?"以及"这段代码是否默认别人已经做完了某事?"自动化检查工具也应该重点针对这两类。不过也不是所有缺陷都这么好修,有的需要深入理解程序、大幅重组代码或数据结构。

模拟器

交错选择器:让缺陷现形,再用修复版本对照

选择缺陷类型和"线程 2 在什么时候插进来",逐步执行,观察共享变量如何变化;勾选"修复版本"后,同样的插队时机会发生什么?

死锁:为什么会发生

除了上面两类,另一类经典问题出现在锁协议复杂的系统里:死锁。比如线程 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,二者才会各自握着一把锁去等另一把——然后永远等下去。依赖图里出现了环,就是死锁的标志:

Thread 1 Thread 2 Lock L2 Lock L1 Thread 1 想要 L2(Wanted by) L2 被 Thread 2 持有(Holds) Thread 2 想要 L1(Wanted by) L1 被 Thread 1 持有 环 = 死锁
原书 Figure 32.7 的依赖图:实线"锁 → 线程"表示持有,虚线"线程 → 锁"表示等待。沿箭头走一圈回到起点。

如何应对死锁?应该如何构建系统,才能预防、避免死锁,或者至少能检测到死锁并从中恢复?这在今天的系统里真的是个问题吗?

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?

既然这么明显,为什么还会发生?

上面的例子只要两个线程按相同顺序拿锁就能避免。可死锁在真实系统里仍然屡见不鲜,原书给了两个原因:

① 大型代码库中组件之间有复杂的依赖。以操作系统为例:虚拟内存系统换入页面时要访问文件系统读磁盘块;文件系统读块时又需要一页内存来存放数据,于是回头找虚拟内存系统。两个子系统各自持有自己的锁再调用对方,就天然存在循环依赖,锁策略必须精心设计。

虚拟内存子系统 持有 VM 的锁 文件系统 持有 FS 的锁 缺页:要从磁盘读一个块 读块:需要一页内存来装数据
子系统之间的自然循环依赖:各自持锁再调用对方,就可能形成等待环。

② 封装(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)

线程对所需资源独占控制(例如线程抓住一把锁)。

打破:不用锁——借助 CompareAndSwap 等硬件指令写无锁(lock-free)/ 无等待(wait-free)数据结构。

② 持有并等待(Hold-and-wait)

线程持有已分配给它的资源(如已拿到的锁),同时等待更多资源。

打破:一次性、原子地拿到所有锁:先抢一把全局的 prevention 锁。

③ 非抢占(No preemption)

资源(如锁)不能被强行从持有它的线程手中夺走。

打破(近似):trylock 拿不到就主动放掉已持有的锁、从头再来。

④ 循环等待(Circular wait)

存在一个线程环,每个线程都持有下一个线程正在请求的资源。

打破:规定锁的全序 / 偏序,所有人都按顺序拿锁。最实用。

预防(Prevention):逐个打破四个条件

循环等待:给锁定一个顺序

最实用、也最常用的预防技术:写加锁代码时保证永远不会产生循环等待。最直接的办法是给锁的获取规定一个全序(total ordering):系统里只有 L1、L2 两把锁时,规定总是先拿 L1 再拿 L2,就不会形成环。

T1: L1→L2 T2: L2→L1(无顺序) T1 T2 L1 L2 两人各握一把、互等对方:有环 T1、T2 都是 L1→L2(全局顺序) T1 T2 L1 L2 空闲 T2 在 L1 处排队、手里空空:无环
有了顺序,后来者在"第一把锁"处就被挡住,它不持有任何东西,自然不会成为环的一部分;持有 L1 的 T1 总能拿到 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):两个线程可能一直重复这个序列、一直失败,都在运行(所以不是死锁),却都没有进展。

CPU1 · T1 CPU2 · T2 lock(L1) lock(L2) trylock(L2) ✗ trylock(L1) ✗ unlock(L1) unlock(L2) lock(L1) lock(L2) trylock(L2) ✗ trylock(L1) ✗ …… 两个线程步调一致地"拿一把 → 抢不到另一把 → 放掉重来",永远在忙,永远没进展
活锁:与死锁不同,线程并没有睡眠,CPU 一直在转,但全是白做的工作。

解决活锁也有办法:比如在跳回开头前随机延迟一段时间,降低两个线程反复互相干扰的概率(下面模拟器的"随机退避"选项)。

这个方案还回避了 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);
}
① 准备:n->next = head(此刻 head 指向 A) head A B n n->next 指向 A ② 没人打扰:CAS(&head, A, n) 成功 → head 指向 n head n A B ③ 别人抢先插入了 m:head 已不是 A head → m → A → B CAS(&head, A, n) 失败(head ≠ A) → 重试:n->next = m,再 CAS(&head, m, n) CAS 保证"检查 head 没变"和"把 head 改成 n"是一个原子动作——正是原子性违反缺失的那一步
无锁头插:先把新节点指向当前 head,再用 CAS 尝试把它换成新 head;如果期间有人改过 head,就用新的 head 重试。

当然,一个真正可用的链表不止插入,还要删除、查找;构建一个无锁的完整链表并不简单,有大量文献专门研究它。

模拟器:死锁检测器

下面你来当调度器:每一步选一个线程执行一条"指令"(加锁、临界区、解锁)。右侧实时画出资源分配图——实线"锁 → 线程"表示持有,虚线"线程 → 锁"表示等待。一旦图中出现环,模拟器会把环标红,并逐条说明四个必要条件如何同时成立。换一种策略,再试试能不能把它逼进死锁。

模拟器

死锁检测器:你来调度,看等待图里何时出现环

编辑每个线程的加锁顺序(如 L1 L2),选择策略,然后逐步选择下一个运行的线程,或随机调度。每个线程执行:按序加锁 → 临界区 → 逆序解锁。
预设:
每次随机调度直到全部完成、出现死锁,或超过 2000 步(记为疑似活锁)

避免(Avoidance):通过调度绕开死锁

另一条思路不是"预防",而是避免:需要事先掌握全局信息——各个线程在执行过程中可能会拿哪些锁——然后据此调度,保证死锁不可能发生。

假设有 2 个 CPU、4 个线程。T1 会拿 L1 和 L2(顺序不定、时机不定),T2 也会拿 L1 和 L2,T3 只拿 L2,T4 不拿锁:

例 1T1T2T3T4
L1yesyesnono
L2yesyesyesno

聪明的调度器能算出:只要 T1 和 T2 不同时运行,就不会死锁。T3 与 T1(或 T2)重叠没关系——T3 只拿一把锁,永远不会因为"拿着一把等另一把"而参与到环里。

第二个例子竞争更激烈:T1、T2、T3 都要拿 L1 和 L2:

例 2T1T2T3T4
L1yesyesyesno
L2yesyesyesno
例 1 的一种安全调度 CPU 1CPU 2 T1 T2 T3 T4 T1、T2 错开;T3 可以与任何一个重叠 例 2 的保守调度 CPU 1CPU 2 T1 T2 T3 T4 CPU 2 空闲 T1、T2、T3 挤在同一个 CPU 上串行
静态调度导致保守:例 2 里本可并发的任务被串行化,总完成时间明显变长。代价是性能。

这类方法的著名代表是 Dijkstra 的银行家算法(Banker's Algorithm)(1964 年;有趣的是,Dijkstra 当时把死锁叫做"致命拥抱"(deadly embrace),好在这个名字没流行开来)。遗憾的是,它们只在非常受限的环境里有用——比如嵌入式系统,事先完全知道要运行哪些任务、它们需要哪些锁;而且如例 2 所示,它们会限制并发。所以通过调度避免死锁并不是被广泛使用的通用方案。

模拟器

调度避免:在 2 个 CPU 上排出安全又快的调度

勾选每个线程需要的锁、设定运行时长,在两个 CPU 的输入框里写出执行顺序(如 T1 T2)。模拟器会标出"两个都需要 L1 和 L2 的线程同时运行"的危险区间,并与穷举得到的最优调度对比。

Tom West(经典计算机行业纪实《新机器的灵魂》(Soul of a New Machine)的主人公)有句名言:"并不是所有值得做的事都值得做好。"如果一件坏事很少发生、代价也小,就不值得花大力气去预防;如果你在造航天飞机,出错的代价是飞机爆炸,那就请忽略这条建议。难点在于判断什么时候该做到哪一步——这只能靠经验和投入。

检测与恢复(Detect and Recover)

最后一种通用策略:允许死锁偶尔发生,发生后再处理。如果操作系统一年才死机一次,重启一下接着干活就是了——当死锁足够罕见时,这种"非方案"其实相当务实。

定期运行检测器 构建资源图 有环吗? 有 重启 / 修复必要时人工介入 没有:过一会儿再查
许多数据库系统采用检测与恢复:死锁检测器周期性地构建资源图并找环;找到就需要重启,若数据结构需要更复杂的修复,可能要人来帮忙。上面的死锁检测器模拟器做的正是"找环"这一步。

先预测,再验证

在死锁检测器里载入 Figure 32.6 的加锁顺序(T1: L1 L2,T2: L2 L1),策略"无策略"。最少需要几步才能进入死锁?必须满足什么先后关系?先想,再手动点出来。
看答案
最少 4 步:T1 lock(L1) → T2 lock(L2) → T1 lock(L2)(阻塞)→ T2 lock(L1)(阻塞),此时出现环 T1→L2→T2→L1→T1。关键在于:两个线程都在对方拿第二把锁之前拿到了自己的第一把锁。只要某个线程一口气拿到两把锁(比如 T1、T1 连着走两步),就不会死锁。这也是为什么死锁"可能"发生而不是"必然"发生。
3 线程预设是 T1: L1 L2,T2: L2 L3,T3: L3 L1。如果只把 T3 改成 L1 L3,还能死锁吗?用"每种策略随机跑 1000 次"验证。
看答案
不能。改完后三个线程都按 L1 < L2 < L3 的递增顺序加锁,这就是一个全局锁顺序;无策略一行的死锁数会变成 0。环需要"有人先拿编号大的、再等编号小的",全序让这件事不可能发生。
用"trylock 活锁交错"预设:两个线程会一直失败重试吗?预测"随机跑 1000 次"里 trylock(不退避)和 trylock(退避)两行的死锁数和平均重试次数。
看答案
预设按"T1、T2、T1、T1、T2、T2……"的节奏调度,两个线程每轮都各自拿到第一把锁、trylock 第二把失败、放掉重来,重试次数不断增长却没有任何进展——活锁。只要这个节奏一直持续就会一直失败;随机调度时,这种不幸的节奏很难一直持续,所以两行的死锁数都是 0,全部完成。退避让一个失败的线程暂停几拍,另一个线程更容易连续拿到两把锁,所以平均重试次数通常更低(具体数值取决于随机种子)。
在调度避免模拟器里载入例 2(每个线程时长 1)。最快的安全调度要多长时间?不管死锁的最快调度呢?再把 T4 的时长改成 3,差距还在吗?
看答案
例 2 中 T1、T2、T3 两两都不能同时运行,只能串行,安全调度最少 3 个时间单位;不管死锁时两 CPU 各分两个任务,2 个单位就够。把 T4 改成 3 后,不管死锁的最优调度也要 3(T4 一个人就要 3),安全调度 CPU1 跑 T1 T2 T3、CPU2 跑 T4,同样是 3——这时"保守"没有代价。避免策略的性能损失取决于具体负载。
在交错选择器里选"原子性违反"、线程 2 在"检查之后、使用之前"运行。先不勾修复版本看它崩溃;再勾上修复版本,同样的时机线程 2 会发生什么?
看答案
修复版本里,线程 1 在检查之前就拿到了 proc_info_lock。线程 2 想在危险窗口里插队,第一步 lock 就会睡眠等待;线程 1 顺利完成 fputs 并解锁后,线程 2 才被唤醒、拿锁、置 NULL。"检查 + 使用"被锁保护成了一个整体,危险窗口消失了。
(对应原书作业第 2、3 题)实验程序 vector_deadlock naive 会不会每次都死锁?如果只有一个线程做 vector_add(不管顺序),会死锁吗?
看答案
不会每次都死锁:在我们的机器上默认参数大约一半的运行会死锁,循环次数调小(naive 1000)时更常侥幸跑完——它取决于两个线程是否恰好各拿一把锁时发生交错。只有一个线程时永远不会死锁:环至少需要两个线程互相等待。这也是为什么死锁很难靠测试发现。

动手实验:亲手制造(和消灭)一次死锁

实验目录 labs/32-concurrency-bugs/ 里有两个程序:

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 次死锁。观察要点:

  1. naive 的死锁是概率性的:同一个程序有时死锁、有时顺利跑完——测试通过不代表没有死锁。死锁报告里的"持有 v0 等 v1 / 持有 v1 等 v0"就是 Figure 32.7 那个环。
  2. ordered 与 global 从不死锁,分别破坏了"循环等待"和"持有并等待"。
  3. trylock 也不死锁,但重试次数可能比完成次数还多——这就是活锁的苗头:两个线程都在忙,大量工作白做。
  4. 原子性违反真实存在:几十万次里出现几千次"检查时非 NULL、使用时 NULL";加锁后恒为 0,代价是两个线程互相等锁。

自测题

1. 在 Lu 等人研究的 105 个并发缺陷中,非死锁缺陷大约占多少?
74/105 ≈ 70%。97% 是另一个数字:非死锁缺陷中属于原子性违反或顺序违反的比例。别把两个数混了。
2. MySQL 中"先检查 thd->proc_info 非 NULL、再 fputs 它",而另一线程可能把它置 NULL,这属于:
"检查"与"使用"本应构成一个原子整体,却没有被保护,这正是原子性违反。顺序违反关心的是"A 必须在 B 之前",如 Mozilla 的 mThread 例子。
3. 下列哪一项不是死锁的四个必要条件之一?
四个条件是互斥、持有并等待、非抢占、循环等待。优先级反转是另一类调度问题。
4. "按锁的地址大小决定加锁顺序"这个技巧打破的是哪个条件?
它给所有锁规定了一个全序,于是任何线程都不会"先拿大的、再等小的",环无法形成。线程依然会持有一把锁去等另一把(持有并等待仍成立),但没关系——四个条件破坏一个就够了。
5. "lock(L1); 若 trylock(L2) 失败就 unlock(L1) 并 goto top"这一方案会引入什么新问题?
两个线程可能步调一致地反复"拿一把、抢不到另一把、全部放掉"。它们都没睡眠,所以不是死锁,而是活锁;加随机延迟可以降低这种概率。
6. 关于"先获取全局 prevention 锁、再依次获取所需锁"的方案,哪个说法正确?
prevention 让"获取全部锁"变成一个原子过程。缺点:封装让你很难提前知道所有要拿的锁;锁被提前一起拿走,并发度下降;而且要求所有线程都先拿 prevention。
7. 在例 1(T1、T2 都要 L1 和 L2,T3 只要 L2,T4 不要锁)中,调度器只需保证哪两个线程不同时运行?
只有同时需要两把锁的线程之间才可能形成"各持一把、互等另一把"的环。T3 只拿一把锁,与谁重叠都不会死锁。
8. 对于 Mozilla 的 mThread 顺序违反,原书推荐的修复方式是:
顺序违反的修复就是强制顺序:线程 2 在 while (mtInit == 0) wait() 里等待,线程 1 赋值后设置 mtInit = 1 并 signal。volatile 保证不了先后。
9. Figure 32.6 的代码(T1 先 L1 后 L2,T2 先 L2 后 L1)每次运行都会死锁吗?
只有当两个线程都在对方拿第二把锁之前拿到了第一把锁时才死锁。单 CPU 上也会发生:T1 拿到 L1 后恰好被切换出去即可。

常见误区与本章小结