崩溃至少有一件事值得感激:它留下一根”有问题”的栈。哪怕那根栈指向的往往是受害者而不是凶手,它至少给了你一个起点、一次报警、一个可以开始办案的地方。
卡死连这点诚意都没有。它交给你一个还在运行的进程:内存不涨,CPU 可能是零也可能满格,线程全都在,全都在等,而且–这是最气人的–每一根栈单独看都完全合法。
先把案发现场摆出来。二十行代码:
// deadlock.c - 双锁逆序:每根栈都清白
#define _GNU_SOURCE
#include <pthread.h>
#include <stdio.h>
#include <unistd.h>
#include <sys/prctl.h>
pthread_mutex_t m1 = PTHREAD_MUTEX_INITIALIZER;
pthread_mutex_t m2 = PTHREAD_MUTEX_INITIALIZER;
void* worker_a(void* arg) {
(void)arg;
pthread_mutex_lock(&m1);
printf("[A] got m1\n");
sleep(1); // 给 B 时间把 m2 拿走,摆好逆序
pthread_mutex_lock(&m2); // 永远等不到:m2 在 B 手里
printf("[A] got both\n"); // 不可达
pthread_mutex_unlock(&m2);
pthread_mutex_unlock(&m1);
return NULL;
}
void* worker_b(void* arg) {
(void)arg;
sleep(1); // 等 A 先拿 m1
pthread_mutex_lock(&m2);
printf("[B] got m2\n");
pthread_mutex_lock(&m1); // 永远等不到:m1 在 A 手里
printf("[B] got both\n"); // 不可达
pthread_mutex_unlock(&m1);
pthread_mutex_unlock(&m2);
return NULL;
}
void* worker_c(void* arg) {
(void)arg;
sleep(2); // 无辜群众:晚到,只是想借 m1 用一下
pthread_mutex_lock(&m1); // 不可达:它不是环的成员,是受害者
printf("[C] got m1\n");
pthread_mutex_unlock(&m1);
return NULL;
}
int main(void) {
// 演示用:Ubuntu 默认 ptrace_scope=1 禁止兄弟进程附加,这里放行调试器
prctl(PR_SET_PTRACER, PR_SET_PTRACER_ANY);
setvbuf(stdout, NULL, _IONBF, 0); // 卡死后缓冲区永不刷新,关掉它
printf("m1 @ %p\n", (void*)&m1);
printf("m2 @ %p\n", (void*)&m2);
pthread_t a, b, c;
pthread_create(&a, NULL, worker_a, NULL);
pthread_create(&b, NULL, worker_b, NULL);
pthread_create(&c, NULL, worker_c, NULL);
pthread_join(a, NULL);
pthread_join(b, NULL);
pthread_join(c, NULL);
printf("all done\n"); // 不可达
return 0;
}
跑起来(gcc -g -O0 -pthread deadlock.c -o deadlock),程序打印三行之后静止不动。按崩溃时代养成的本能,抓全线程栈,看”哪里出了问题”:
$ gdb -p $(pidof deadlock) -batch -ex "thread apply all bt"
Thread 4 (Thread 0x74056f9ff6c0 (LWP 500) "deadlock"):
#0 futex_wait (private=0, expected=2, futex_word=0x5b3b71bd3080 <m2>) at ../sysdeps/nptl/futex-internal.h:146
#1 __GI___lll_lock_wait (futex=futex@entry=0x5b3b71bd3080 <m2>, private=0) at ./nptl/lowlevellock.c:49
#2 lll_mutex_lock_optimized (mutex=0x5b3b71bd3080 <m2>) at ./nptl/pthread_mutex_lock.c:48
#3 ___pthread_mutex_lock (mutex=0x5b3b71bd3080 <m2>) at ./nptl/pthread_mutex_lock.c:93
#4 worker_a (arg=0x0) at deadlock.c:16
#5 start_thread (arg=<optimized out>) at ./nptl/pthread_create.c:447
#6 clone3 () at ../sysdeps/unix/sysv/linux/x86_64/clone3.S:78
Thread 3 (Thread 0x74056f1fe6c0 (LWP 501) "deadlock"):
#0 futex_wait (private=0, expected=2, futex_word=0x5b3b71bd3040 <m1>) at ../sysdeps/nptl/futex-internal.h:146
#1 __GI___lll_lock_wait (futex=futex@entry=0x5b3b71bd3040 <m1>, private=0) at ./nptl/lowlevellock.c:49
...(中间 glibc 帧与 Thread 4 同构,等待对象换成 m1)
#4 worker_b (arg=0x0) at deadlock.c:28
Thread 2 (Thread 0x74056e9fd6c0 (LWP 502) "deadlock"):
#0 futex_wait (private=0, expected=2, futex_word=0x5b3b71bd3040 <m1>) at ../sysdeps/nptl/futex-internal.h:146
...(同上)
#4 worker_c (arg=0x0) at deadlock.c:38
Thread 1 (Thread 0x74056fc57740 (LWP 498) "deadlock"):
#3 __pthread_clockjoin_ex (...) at ./nptl/pthread_join_common.c:102
#4 main () at deadlock.c:54
逐根审问。线程 A 在 pthread_mutex_lock 里等一把锁–合法,谁都这么写。线程 B 也在 pthread_mutex_lock 里–同样合法。线程 C 只是”想借 m1 用一下”,更无辜。主线程在 pthread_join 等孩子们回家,合法得无懈可击。没有一根栈包含错误。没有一行代码值得怀疑。
病因确实存在,但它不在任何一根栈里,它在栈与栈的关系里:A 拿着 m1 等 m2,B 拿着 m2 等 m1。这是一个图上的环,而”环”这个事实,任何单根栈都无法表达。
这篇文章讲的就是这门”静止的鉴别诊断学”。它接着《你越想看清它,它就越不存在:海森堡 bug 与调试的观察者效应》往下走:那篇的敌人是”观察会改变系统”,堆腐烂类问题的敌人是”崩溃现场会蒸发”,这篇的敌人是它们的镜像–系统连崩溃都不给你:没有异常,没有报警,你读到的每一个状态都是合法状态。
文中 Windows 侧输出在 Windows 11 + MSVC 2026 + WinDbg(cdb 命令行)实测,Linux 侧输出在 Ubuntu 24.04 + gcc 13.3 + gdb 15.1 + glibc 2.39 实测,代码全部可复现。三个实测小坑先替你踩了:其一,Ubuntu 默认 yama.ptrace_scope=1,gdb 不能附加”兄弟进程”,演示程序里那行 prctl(PR_SET_PTRACER) 就是为此;其二,程序卡死后 stdout 缓冲区永不刷新,demo 里用 setvbuf 关掉缓冲,否则你连”案发前发生了什么”都看不到;其三,WSL2 上跑 TSan 可能报 unexpected memory mapping(内核高熵 ASLR 顶掉了影子内存布局),setarch $(uname -m) -R 关掉随机化即可。
一、”卡死”是一个读数,不是一个机理
绝大多数人排查卡死的第一句话是”程序不动了”。这句话被当成了一句机理描述,其实它只是一次测量报告。
你观察响应性:请求超时,进度条静止,日志不再滚动。你选了一个进展函数(请求完成数、日志行数、心跳次数),在某段时间窗口内对它求导,得到零。这就是”卡死”的全部内容:某个你选定的进展函数,在你选定的观测维度上,返回了零。
问题在于,同一个零读数背后,至少藏着五种完全不同的机理,它们的病因位于时间轴的不同位置,作用于不同的参与者范围,而且需要完全相反的取证手段:
| 类型 | 病因的时态 | 一句话机理 |
|---|---|---|
| 死锁 | 将来被结构性取消 | 等待关系图上有环,等的东西永远不会来 |
| 丢失唤醒 | 过去已消逝 | 唤醒事件在等待开始之前就发生完了,没人登记 |
| 活锁 | 当下高速空转 | 每个瞬时快照都合法,每一分努力都在抵消上一分 |
| 饿死 | 当下持续被推迟 | 系统整体在进展,只是永远轮不到某个参与者 |
| 假卡死 | 在观察者的预期里 | 不是不动,是慢,或在你没观测的维度上动 |
活锁的 CPU 可以满格,饿死的其他线程进展飞快,假卡死的进程可能正在疯狂做 GC 或等网络–它们在你的进度条上都是零。把读数当诊断,是一切弯路的源头。“卡死了”和”发烧了”一样,是症状,不是病。
还有一个更隐蔽的推论:崩溃类问题的排查顺序是”先取证再推理”(dump 抓了再说);卡死类问题必须倒过来–先推理再取证。因为五种病对”现场”的要求是正交的:死锁的现场永冻,你什么时候抓 dump 都行;活锁的现场是流动的,抓单次 dump 是浪费;丢失唤醒的现场已经死亡,抓多少次都拍不到病因;饿死的现场根本不是一次观测而是一段分布;假卡死的现场在别的维度上,你得先换尺子。取证手段是诊断结论的函数。崩溃排查教的是抢救现场,这篇教你先判断现场值不值得抢。
二、分诊总纲:三层廉价读数,先把病族分开
好消息是,分诊不需要任何重型工具。三层读数,一层比一层细,全部来自操作系统自带的观察面:
第一层:CPU。任务管理器或 top。CPU 满格的”卡死”和 CPU 为零的”卡死”是两个世界:前者要么活锁要么是”假卡死里的忙等变体”,后者是等待类。一行命令先砍掉一半假设空间。
第二层:状态字。Linux 的 ps STAT 列:R 是在跑,S 是在睡(可中断),D 是在睡(不可中断,通常是磁盘/网络 I/O,kill -9 都杀不掉,假卡死里最迷惑人的形态)。Windows 侧对应任务管理器里线程的”已暂停/正在运行”,或者 dump 里的线程状态。
第三层:等待通道(wchan)。Linux 独有的廉价福利:ps -eLo pid,tid,stat,wchan:24,comm 直接告诉你每个线程睡在内核的哪个函数里。这是分诊的显微镜。三个真实现场并排放着看(输出均为实测):
# 现场 A:死锁
$ ps -eLo pid,tid,stat,wchan:24,comm | grep deadlock
PID TID STAT WCHAN COMMAND
498 498 Sl+ futex_do_wait deadlock
498 500 Sl+ futex_do_wait deadlock
498 501 Sl+ futex_do_wait deadlock
498 502 Sl+ futex_do_wait deadlock
# 现场 B:假卡死(阻塞在 I/O)
$ ps -eLo pid,tid,stat,wchan:24,comm | grep fakehang
PID TID STAT WCHAN COMMAND
543 543 Sl+ futex_do_wait fakehang
543 545 Sl+ anon_pipe_read fakehang
543 546 Sl+ hrtimer_nanosleep fakehang
# 现场 C:饿死(活锁同象)
$ ps -eLo pid,tid,stat,wchan:24,comm | grep starvation
PID TID STAT WCHAN COMMAND
610 610 Sl+ hrtimer_nanosleep starvation
610 612 Rl+ - starvation
610 613 Rl+ - starvation
三个进程在用户眼里都是”卡死了”。但现场 A 的四根线程全部睡在 futex_do_wait—整张表就是等待图的压缩版,所有人都在等锁;现场 B 的三根线程睡在三个不同频道(等锁、等管道数据、等定时器)–没有环,只有一根线程在等一个永远不会来的输入;现场 C 的两根线程是 R 状态,连 wchan 都没有–它们根本没在等,在跑。
分诊到这里,五种病的家族归属基本清楚,接下来才是各家的取证与办案。下面逐个解剖。
三、死锁:病因在将来,现场永冻
机制:环是数学事实,与时间无关
死锁的标准定义是四个条件同时成立(互斥、持有并等待、不可剥夺、循环等待),但排查时真正有用的只有最后一条:等待关系图上有环。把每个线程看成一个节点,”线程 X 正在等线程 Y 持有的资源”画一条边,图上出现环,环上所有线程永久静止。
这里有个值得咀嚼的性质:环是一个结构事实,不依赖任何时序巧合。它成立之后不会因为”再等等”而自愈,也不会因为系统负载变化而解开。这就是死锁现场”永冻”的原因–不是还没发生,是永远不会发生。对取证者来说这是罕见的好消息:崩溃现场会蒸发(堆腐烂排查的全部焦虑就在这里),死锁现场永远新鲜,你想什么时候验尸都行。
代价是:环不存在于任何单根栈里。回到开头的 gdb 输出–四根栈全合法。破案要的是把”关系”挖出来,物证在两个地方。
物证一:栈上的等待对象,物证二:锁里的持有者
开头的 gdb 输出里其实已经藏了半张图:futex_wait (futex_word=0x5b3b71bd3080 <m2>)–gdb 直接标注了每根线程在等哪把锁(程序开头打印的 m1、m2 地址与它互相印证)。另一半在锁自己身上:
(gdb) p m1
$1 = {__data = {__lock = 2, __count = 0, __owner = 500, __nusers = 1, ...
(gdb) p m2
$2 = {__data = {__lock = 2, __count = 0, __owner = 501, __nusers = 1, ...
__owner 是 glibc 互斥量内部的持有者线程号(LWP)。对着 info threads 一查:LWP 500 是 worker_a,LWP 501 是 worker_b。两边一合,图闭合成环:
worker_a (LWP 500) --持有 m1,等 m2--> m2 的 owner 是 LWP 501 (worker_b)
worker_b (LWP 501) --持有 m2,等 m1--> m1 的 owner 是 LWP 500 (worker_a)
worker_c (LWP 502) --等 m1--> 排在案发现场门口的受害者
main (LWP 498) --join--> 同上
注意后两行。你抓到的 dump 里有四根”卡死”的栈,其中只有两根是凶手,另外两根是受害者。真实服务里这个比例是反过来的:环上两个人,环外一百个受害者(线程池全部堆在门口)。把受害者当凶手排查,是死锁办案的第一大弯路–堆内存被写坏时”崩的是受害者”的陷阱在这里原样重演,只不过这一回骗你的不是一根栈,是九十八根。
Windows 侧:!cs 把物证链做成一条命令
同一出戏在 Windows 上演,道具换成 CRITICAL_SECTION。程序(cs_deadlock.cpp,与 Linux 版逐行对应)卡死后,cdb 附加:
0:004> ~*k
0 Id: 69f0.6e74
ntdll!NtWaitForMultipleObjects+0x14
KERNELBASE!WaitForMultipleObjectsEx+0x123
KERNELBASE!WaitForMultipleObjects+0x11
cs_deadlock!main+0xe2 [D:\tmp\hang\cs_deadlock.cpp @ 46] <- 主线程在等三个孩子
1 Id: 69f0.3710
ntdll!NtWaitForAlertByThreadId+0x14
ntdll!RtlpWaitOnCriticalSection+0x5ad
ntdll!RtlpEnterCriticalSectionContended+0x1ef
ntdll!RtlEnterCriticalSection+0xf2
cs_deadlock!worker_a+0x3a [D:\tmp\hang\cs_deadlock.cpp @ 13] <- 在等第二把锁
2 Id: 69f0.6f80
...(与线程 1 同构)
cs_deadlock!worker_b+0x3a [D:\tmp\hang\cs_deadlock.cpp @ 24]
3 Id: 69f0.6a08
...(同构,无辜群众)
cs_deadlock!worker_c+0x21 [D:\tmp\hang\cs_deadlock.cpp @ 33]
栈只给了”谁在等”。谁持有?!cs 上场(需要从符号服务器加载带类型信息的 ntdll 符号,纯导出表不够):
0:004> !cs -l
-----------------------------------------
Critical section = 0x00007ff68467d1d8 (cs_deadlock!g_cs2+0x0)
LOCKED
LockCount = 0x1
OwningThread = 0x0000000000006f80 <- = 线程 2 (worker_b) 的 TID
RecursionCount = 0x1
-----------------------------------------
Critical section = 0x00007ff68467d1b0 (cs_deadlock!g_cs1+0x0)
LOCKED
LockCount = 0x2
OwningThread = 0x0000000000003710 <- = 线程 1 (worker_a) 的 TID
RecursionCount = 0x1
OwningThread 对上 ~*k 里的线程号,环闭了。LockCount 的两个值(2 和 1)恰好等于等它的人数:g_cs1 门口排着 worker_b 和 worker_c 两个,g_cs2 门口排着 worker_a 一个。
还有一张王牌,!cs -o 直接吐出持有者的栈:
0:004> !cs -o cs_deadlock!g_cs2
-----------------------------------------
Critical section = 0x00007ff68467d1d8 (cs_deadlock!g_cs2+0x0)
LOCKED
OwningThread = 0x0000000000006f80
OwningThread Stack =
ntdll!NtWaitForAlertByThreadId+0x14
ntdll!RtlpWaitOnCriticalSection+0x5ad
ntdll!RtlpEnterCriticalSectionContended+0x1ef
ntdll!RtlEnterCriticalSection+0xf2
cs_deadlock!worker_b+0x3a
读一遍这段对话:g_cs2 说”我的主人是 6f80″;6f80 的栈说”我正卡在 RtlEnterCriticalSection 上”。它在进哪把锁?RtlpEnterCriticalSectionContended 帧的参数里躺着 0x00007ff68467d1b0–正是 g_cs1 的地址。锁供出了持有者,持有者的栈供出了它在等下一把锁,下一把锁又指回第一把。环的三条边,一条命令全部验完。
隐形锁:你没写过的锁最危险
上面两案的环上都是自己的锁,栈里至少有 EnterCriticalSection、pthread_mutex_lock 这种显眼信号。最迷惑的死锁,环上的锁一把都不是你写的。经典中的经典:在 DllMain 里创建线程并等它结束。
// ll_dll.cpp - DllMain 里创建线程并等它:死在看不见的锁上
#include <windows.h>
#include <stdio.h>
static DWORD WINAPI work(void*) {
printf("[thread] new thread alive\n"); // 不可达:新线程起不来
return 0;
}
BOOL WINAPI DllMain(HINSTANCE h, DWORD reason, LPVOID) {
if (reason == DLL_PROCESS_ATTACH) {
HANDLE t = CreateThread(0, 0, work, 0, 0, 0);
WaitForSingleObject(t, INFINITE); // 主线程抱着 loader lock 等新线程
CloseHandle(t);
}
return TRUE;
}
主程序 LoadLibraryA("ll_dll.dll") 后整个进程卡死。抓全线程栈:
0:002> ~*k
0 Id: 618c.6d28
ntdll!NtWaitForSingleObject+0x14
KERNELBASE!WaitForSingleObjectEx+0xaf
ll_dll!DllMain+0x53 <- 主线程:在你的代码里等线程
ll_dll!dllmain_dispatch+0x96
ntdll!LdrpCallInitRoutineInternal+0x22
ntdll!LdrpCallInitRoutine+0x93
ntdll!LdrpInitializeNode+0x19c
ntdll!LdrpInitializeGraphRecurse+0x6a <- 但它同时在加载器的初始化路径里
1 Id: 618c.5890
ntdll!NtWaitForSingleObject+0x14
ntdll!LdrpDrainWorkQueue+0x199
ntdll!LdrpInitializeThread+0xef <- 新线程:起不来,卡在加载器初始化
ntdll!LdrpInitialize+0xa7
ntdll!LdrpInitializeInternal+0x5a
ntdll!LdrInitializeThunk+0xe
栈上没有任何一把”你的锁”,两根栈看起来都在等最无辜的东西(一个等线程句柄,一个在”初始化”)。环的证据在栈的语义里:主线程的栈穿过了加载器初始化帧(LdrpInitializeGraphRecurse 一族)–那意味着它持有 loader lock;新线程的栈在 LdrpInitializeThread 里–那意味着它需要 loader lock(新线程启动要向所有 DLL 广播 THREAD_ATTACH,广播要拿加载器锁)。验尸那把看不见的锁:
0:002> !cs ntdll!LdrpLoaderLock
-----------------------------------------
Critical section = 0x00007ffcd522c898 (ntdll!LdrpLoaderLock+0x0)
LOCKED
OwningThread = 0x0000000000006d28 <- = 主线程 (618c.6d28)
RecursionCount = 0x1
OwningThread = 0x6d28,正是卡在 DllMain 里的主线程。环闭合:主线程持有 loader lock,等新线程;新线程等 loader lock。(顺带一个版本细节:新版 ntdll 里 LdrpLoaderLock 是临界区结构体本体而不是指针,所以这里不需要 poi() 解引用;另外 !cs -l 在 Win11 上只能列出少数带 DebugInfo 的临界区,别指望它列全。)
这类”隐形锁”是死锁家族里最阴险的一支,共同点只有一个:锁不出现在你的代码里,只出现在别人的栈上。你的代码环上没有任何 EnterCriticalSection,可环照样存在。附录 C 列了常见的隐形锁清单。鉴别要点已经写在上面了:看栈有没有穿过持锁的代码路径(加载器初始化、堆分配器内部、CRT 启动),而不是找锁函数名。
死锁的误诊陷阱小结
- 把环外受害者当凶手(一百根栈里九十八根是排队的)。
- 只看单根栈(环是关系,单点无环)。
- 隐形锁死锁里找不到”锁的样子”就排除死锁(看栈穿过的路径,不看锁函数名)。
- 现场永冻带来的唯一坏习惯:以为”随时能抓”就先干别的去–生产进程往往会被运维先重启,永冻现场照样会被外力销毁。分诊完立刻取证。
四、活锁:病因在快照之间,现场是流动的
死锁是系统说”我停了”;活锁是系统对你说”我在努力”–而每一次努力都在取消上一次努力。两个线程都想拿两把锁,都很有礼貌:拿不到第二把就放开第一把,退回去重来。如果两人永远差半拍,就永远拿不齐。CPU 烧满,正事一件没做。
演示程序为了把”永远差半拍”钉成确定性行为,用了一个两线程屏障(pthread_barrier_t)保证每一轮两人都”同时各持一把”再同时尝试对方的锁;真实世界的活锁不需要这副脚手架,两个执行流自己就会撞出相位:
// livelock.c - 重试风暴:两人各持一把锁,疯狂尝试对方的锁
#define _GNU_SOURCE
#include <pthread.h>
#include <stdatomic.h>
#include <stdio.h>
#include <unistd.h>
#include <sys/prctl.h>
pthread_mutex_t m1 = PTHREAD_MUTEX_INITIALIZER;
pthread_mutex_t m2 = PTHREAD_MUTEX_INITIALIZER;
pthread_barrier_t bar; // 两线程屏障:保证两人永远"同时各持一把"
atomic_long attempts = 0; // 重试次数
atomic_long progress = 0; // 做成的正事
enum { RETRIES = 10000 }; // 每轮死磕的次数
void* worker(void* arg) {
int id = *(int*)arg;
pthread_mutex_t* first = (id == 0) ? &m1 : &m2; // A 先拿 m1,B 先拿 m2
pthread_mutex_t* second = (id == 0) ? &m2 : &m1;
for (;;) {
pthread_mutex_lock(first); // 我拿到第一把
pthread_barrier_wait(&bar); // 等对方也拿好他的第一把
// 此刻可以确定:second 在对方手里。重试再多次也是白费--
// 这就是活锁的"努力":不是不动,是每一分努力都在互相抵消
for (int i = 0; i < RETRIES; ++i) {
atomic_fetch_add(&attempts, 1);
if (pthread_mutex_trylock(second) == 0) { // 永远 EBUSY
atomic_fetch_add(&progress, 1);
pthread_mutex_unlock(second);
break;
}
}
pthread_barrier_wait(&bar); // 等对方也试完,再一起放手
pthread_mutex_unlock(first); // 放手,立刻从头再来
}
return NULL;
}
int main(void) {
prctl(PR_SET_PTRACER, PR_SET_PTRACER_ANY); // 放行调试器附加
pthread_barrier_init(&bar, NULL, 2);
int ids[2] = {0, 1};
pthread_t t[2];
pthread_create(&t[0], NULL, worker, &ids[0]);
pthread_create(&t[1], NULL, worker, &ids[1]);
for (int s = 1; s <= 5; ++s) {
sleep(1);
printf("t+%ds : attempts = %-14ld progress = %ld\n",
s, atomic_load(&attempts), atomic_load(&progress));
}
return 0;
}
先看分诊读数(第一层 CPU):
$ top -H -p $(pidof livelock)
PID USER PR NI VIRT RES SHR S %CPU %MEM TIME+ COMMAND
413 feng-yr 20 0 19080 1752 1636 R 81.8 0.0 0:01.05 livelock
414 feng-yr 20 0 19080 1752 1636 R 81.8 0.0 0:01.04 livelock
411 feng-yr 20 0 19080 1752 1636 S 0.0 0.0 0:00.00 livelock
两根工作线程 R 状态、各烧八成核。五秒的账本:
t+1s : attempts = 25792070 progress = 0
t+2s : attempts = 43455439 progress = 0
t+3s : attempts = 57866690 progress = 0
t+4s : attempts = 73520000 progress = 0
t+5s : attempts = 100716754 progress = 0
五秒钟一亿次尝试,零次成功。”努力”的具象化。
现在做那个关键的对照实验:对着死锁你可以抓一次 dump 高枕无忧,对着活锁抓三次,间隔半秒(输出为实测,三次快照取自同一进程):
$ gdb -p <pid> -batch -ex "thread apply all bt 4" -ex "p attempts" -ex "p progress"
--- snapshot 1 ---
Thread 3: #0 worker (...) at livelock.c:29 <- A 在重试循环里
Thread 2: #0 ___pthread_mutex_trylock (mutex=0x5bbf5719c0c0 <m1>) <- B 深入到了 trylock 内部
$1 = 35406813
$2 = 0
--- snapshot 2 ---
Thread 3: #0 ___pthread_mutex_trylock (mutex=0x5bbf5719c080 <m2>) <- A 换了个位置
Thread 2: #0 futex_wait (expected=9986, futex_word=0x5bbf5719c064 <bar+4>)
#2 ___pthread_barrier_wait (barrier=0x5bbf5719c060 <bar>) <- B 在屏障上打了个盹
$1 = 49936699
$2 = 0
--- snapshot 3 ---
Thread 3: #0 worker (...) at livelock.c:29
Thread 2: #0 futex_wait (expected=12334, futex_word=0x5bbf5719c064 <bar+4>)
$1 = 61676615
$2 = 0
三个细节值得盯住。其一,栈在动:两次快照里线程的落点不同(重试循环、trylock 内部、屏障等待之间跳)。其二,attempts 在两次快照之间涨了一千多万次–这个计数器就是”流动现场”的脉搏。其三,最漂亮的:屏障 futex 的 expected 值从 9986 涨到了 12334–那是个序号,每过一轮屏障加一,它自己就在告诉你现场在动。对比死锁:同一个程序你抓一百次 dump,栈、__owner、futex 的 expected 值一个字都不会变。
这就是”现场的可冻结性”差异的全部含义:死锁的现场是照片,活锁的现场是电影。给电影拍照片不是取证,是浪费–单次 dump 里两根线程一个在重试一个在等待,和健康程序没有区别。活锁的正确取证是采样与计数:perf/ETW 的采样剖面(热函数是 trylock 与自旋)、业务计数器(重试数 vs 成功数)的比值。判断标准一句话:快照之间的差分,才是活锁的现场。
(真实世界里的活锁长得比这个 demo 体面得多:两台服务器同时检测到冲突同时重启、两个消息消费者互相把失败消息推给对方、两个格式的转换器来回转码–凡是”重试逻辑撞上重试逻辑”的地方,都是它的栖息地。)
五、丢失唤醒:病因在过去,现场已死
死锁的病因在将来(等的东西永远不会来),活锁的病因在当下(正在空转)。第三种静止更刁钻:病因在过去,而且已经消费完毕。
条件变量的唤醒不是寄给未来的邮件,是对着当时在场的人喊的一声。你 sleep 的时候人家喊过了,那一声就永远消散了–条件变量不为”来过没人的信号”保留任何排队记录。除非,账本上记着事件发生过。
// lostwakeup.c - 唤醒是历史事件:呐喊发生在空房间
#define _GNU_SOURCE
#include <pthread.h>
#include <stdio.h>
#include <unistd.h>
#include <sys/prctl.h>
pthread_mutex_t m = PTHREAD_MUTEX_INITIALIZER;
pthread_cond_t cv = PTHREAD_COND_INITIALIZER;
int ready = 0; // 账本:事件是否已经发生
void* producer(void* arg) {
(void)arg;
sleep(1); // 先跑:事件在 t=1 发生
pthread_mutex_lock(&m);
ready = 1; // 记账
pthread_cond_signal(&cv); // 对着空房间喊了一声"醒醒"
pthread_mutex_unlock(&m);
printf("[producer] event published, signal fired to empty room\n");
return NULL;
}
void* consumer(void* arg) {
(void)arg;
sleep(2); // 后到:t=2 才来
pthread_mutex_lock(&m);
// 病灶:不看账本,无条件睡。唤醒在 t=1 已经发生完了,没留任何排队记录
#ifdef FIXED
while (!ready) pthread_cond_wait(&cv, &m);
#else
pthread_cond_wait(&cv, &m);
#endif
pthread_mutex_unlock(&m);
printf("[consumer] woke up, working\n"); // 不可达
return NULL;
}
程序输出一行 [producer] event published, signal fired to empty room 之后静止。抓现场:
$ gdb -p $(pidof lostwakeup) -batch -ex "thread apply all bt" -ex "p ready" -ex "p cv"
Thread 2 (Thread 0x76b9c77fe6c0 (LWP 524) "lostwakeup"):
#0 __futex_abstimed_wait_common64 (futex_word=0x57dd1e2ed0a8 <cv+40>) at ./nptl/futex-internal.c:57
#3 __pthread_cond_wait_common (mutex=0x57dd1e2ed040 <m>, cond=0x57dd1e2ed080 <cv>) at ./nptl/pthread_cond_wait.c:503
#4 ___pthread_cond_wait (cond=0x57dd1e2ed080 <cv>, mutex=0x57dd1e2ed040 <m>) at ./nptl/pthread_cond_wait.c:627
#5 consumer (arg=0x0) at lostwakeup.c:33
...
(gdb) p ready
$1 = 1
(gdb) p cv
$2 = {__data = {__wseq = {__value64 = 2, ...
读一读这份验尸报告。栈只拍到了”等的姿势”(pthread_cond_wait 睡在 cv 的内部 futex 上)–和一次健康的等待毫无区别。真正指认病因的是那个账本:ready = 1。事件已经发生,账本记得清清楚楚,而睡着的线程从未看它一眼。甚至条件变量的内部计数器 __wseq = 2(glibc 用它给 wait 和 signal 事件编号:t=1 的一声呐喊 + t=2 的一次入睡,共两笔)也留下了痕迹–只是那是个没人承诺语义的内部结构,你不能拿它写业务逻辑,但它证明:那一声呐喊确实存在过。
这就是丢失唤醒比崩溃更彻底的地方。崩溃好歹留下一具尸体和一个死亡时刻;丢失唤醒只留下等的姿势,病因是一次没有任何数据结构登记的历史事件。你抓一百次 dump,一百次都是这个姿势。dump 对它的信息量是零。
能救它的只有两样东西。第一样是账本(谓词变量):如果等待方在睡前先查账,ready == 1,根本不睡–这就是 while 铁律的全部内容:
醒来从来不证明事件发生过;只有账本证明。醒来可能因为幽灵唤醒(spurious wakeup)、因为别人的事件、甚至因为纯属骚扰,所以醒来后必须重新查账;而查完账发现没轮到自己,必须继续睡。
while不是风格偏好,是正确性边界–if把”醒来”当作”事件发生”的证据,这个推断在并发世界里不成立。
顺手验证自愈:把那行 while 打开(gcc -DFIXED lostwakeup.c -o fixed),同一个程序立刻痊愈:
$ ./lostwakeup_fixed
[producer] event published, signal fired to empty room
[consumer] woke up, working
all done
第二样能救它的是时间机器:录制回放。WinDbg TTD 或 rr 能把执行过程录下来倒放,直接看到”t=1 的 signal 打在空房间、t=2 的 wait 才姗姗来迟”这一时序。丢失唤醒是反向调试最完美的适用场景,因为它的病因是一次性历史事件–不重现、不驻留、不留现场,只有录像能回看。
六、饿死:不公平住在分布里,现场是统计
第四种静止里,系统整体在健康地进展,只有一个参与者被无限推迟。它的刁钻之处在于:任何单次快照里,饿死的线程和饱食的线程长得一模一样。
// starvation.c - 饿死住在统计里:任何单次快照都清白
#define _GNU_SOURCE
#include <pthread.h>
#include <stdatomic.h>
#include <stdio.h>
#include <unistd.h>
#include <sys/prctl.h>
atomic_int tas = 0; // 裸 TAS 锁:谁抢到算谁的,没有公平承诺
void lock_tas(void) {
while (atomic_exchange_explicit(&tas, 1, memory_order_relaxed)) { }
}
void unlock_tas(void) {
atomic_store_explicit(&tas, 0, memory_order_relaxed);
}
atomic_long wins_hog = 0; // 快手:用完立刻再抢(零间隙)
atomic_long wins_slow = 0; // 慢手:用完出门办事,回来再抢
void* hog(void* arg) {
(void)arg;
for (;;) {
lock_tas();
atomic_fetch_add(&wins_hog, 1);
unlock_tas();
}
return NULL;
}
void* slow(void* arg) {
(void)arg;
for (;;) {
lock_tas();
atomic_fetch_add(&wins_slow, 1);
unlock_tas();
for (volatile int i = 0; i < 300; ++i) { } // 出门办点小事
}
return NULL;
}
int main(void) {
prctl(PR_SET_PTRACER, PR_SET_PTRACER_ANY); // 放行调试器附加
pthread_t t1, t2;
pthread_create(&t1, NULL, hog, NULL);
pthread_create(&t2, NULL, slow, NULL);
sleep(1);
printf("t+1s : wins_hog = %-12ld wins_slow = %ld\n",
atomic_load(&wins_hog), atomic_load(&wins_slow));
sleep(1);
printf("t+2s : wins_hog = %-12ld wins_slow = %ld\n",
atomic_load(&wins_hog), atomic_load(&wins_slow));
return 0;
}
运行中途 gdb 附加上去看一眼(实测):
Thread 3: #0 hog (arg=<optimized out>) at starvation.c:26 <- 在干活,健康的样子
Thread 2: #0 lock_tas () at starvation.c:12 <- 在抢锁,也是健康的样子
#1 slow (arg=<optimized out>) at starvation.c:34
一帧之内,谁也没病。病在帧与帧的分配里:
t+1s : wins_hog = 45790715 wins_slow = 1395787
t+2s : wins_hog = 100211313 wins_slow = 3054837
三十三比一。饿死的线程不是”卡住了”–它每秒还赢一百多万次,看起来甚至挺忙。但相对份额趋近于零,而且永远追不上。饿死不存在于任何一次观测里,只存在于观测的分布里:平均延迟正常(被饱和线程拉平)、瞬时状态正常(在跑)、单次 dump 正常。能指认它的只有长时间统计:各线程的获取计数、等待直方图、尾延迟。
顺带一句实话:glibc 的 pthread_mutex 相当公平(futex 等待队列大致先进先出),所以演示用裸 TAS 锁–解锁的人缓存行还在手里热着,转身就重新抢到,慢一拍的对手永远差半步。饿死住在“没有人承诺公平”的地方:自旋锁、无优先级继承的调度、消息队列的轮询顺序、线程池的任务窃取边界。你在锁的文档里没看到的那个词,就是将来夜里三点爬起来查的原因。
七、假卡死:病因在观察者的预期里,现场在别的维度
前四种病都是系统的病。第五种是观察者的病:系统没死,是你的尺子量不到它活着的地方。
// fakehang.c - 假卡死:不是死了,是在合法地等一个不会来的输入
#define _GNU_SOURCE
#include <pthread.h>
#include <stdio.h>
#include <unistd.h>
#include <sys/prctl.h>
int fd[2]; // 管道:写端永远没人写
void* reader(void* arg) {
(void)arg;
char buf[16];
printf("[reader] about to read ...\n");
ssize_t n = read(fd[0], buf, sizeof buf); // 合法、健康、无限期地等
printf("[reader] got %zd bytes\n", n);
return NULL;
}
void* heartbeat(void* arg) {
(void)arg;
for (;;) {
putchar('.'); fflush(stdout);
usleep(300000);
}
return NULL;
}
int main(void) {
prctl(PR_SET_PTRACER, PR_SET_PTRACER_ANY);
pipe(fd);
pthread_t r, h;
pthread_create(&r, NULL, reader, NULL);
pthread_create(&h, NULL, heartbeat, NULL);
pthread_join(r, NULL);
printf("all done\n"); // 不可达
return 0;
}
在用户眼里这个程序”卡死了”:读线程不干活。但看输出,心跳在跳:
[reader] about to read ...
.......
再看第二节那张 ps 表的现场 B:三根线程,三个等待频道(futex_do_wait / anon_pipe_read / hrtimer_nanosleep)–进程活得好好的,只是你观测的那个维度(读取完成数)上没有事件。抓 dump 你会看到 reader 合法地睡在 read 系统调用里,fd=3–诊断价值为零,因为这个现场里没有病变。病变在你的预期里:你假设了”有人会往这根管道里写”。
假卡死是一族而非一种:阻塞在慢 I/O 上(磁盘冷缓存、NFS 超时、杀毒软件扫描你刚打开的文件)、GC 暂停、换页风暴、等一个永远不来的上游响应。它的两个亚型用第二节的三层读数可以分开:CPU 为零的(睡在 I/O 上,S 或 D 状态)和 CPU 满格的(在别的维度忙,比如 GC 或自旋等待的变体)。其中 D 状态最迷惑–不可中断的磁盘等待,kill -9 都无效,看起来和死锁的”杀不死”一模一样,但它不是环,是尺子问题。
假卡死的第一修正动作不是抓 dump,是换尺子:CPU、磁盘 I/O、网络、GC 时间、换页,把每个维度都测一遍,通常问题自己就现形了。它也是五种病里唯一和环境差异深度耦合的一种–换个环境”卡死”消失的,多半是假卡死,因为慢和快本来就是环境变量。杀毒软件只在生产机的镜像上装,NFS 挂载只在办公网可达,冷缓存只在重启后出现:你测到的”卡死”,很多时候测的是环境,不是代码。
八、统一理论:把”不动”翻译成序的语言
五种病,五种”零进展”,看起来像一张杂乱的清单。但它们全部可以用同一种语言改写–序(ordering):
| 类型 | 序的病变 |
|---|---|
| 死锁 | 等待偏序图上出现环(结构矛盾:A 先于 B 且 B 先于 A) |
| 丢失唤醒 | 唤醒事件在等待事件之前定序完成(历史矛盾:该发生的关系没建立) |
| 活锁 | 执行序列无限延长,进展谓词永不满足(活性矛盾) |
| 饿死 | 资源分配序持续偏向他人(公平矛盾) |
| 假卡死 | 进展事件存在,只是不在被观测的维度上(观测矛盾:前四种是系统的病,这是观察者的病) |
这个视角立刻给出一个工程红利:有的序矛盾可以不发作就证明。死锁的判据是”锁序图上有环”,而锁序图可以在每次成功拿锁时增量维护–不需要真的死,只需要两次观察到矛盾的顺序。ThreadSanitizer 的死锁检测器干的就是这件事:
// deadlock_tsan.c - TSan 的死锁判定:不需要真死,只需要顺序矛盾
#include <pthread.h>
#include <stdio.h>
#include <unistd.h>
pthread_mutex_t m1 = PTHREAD_MUTEX_INITIALIZER;
pthread_mutex_t m2 = PTHREAD_MUTEX_INITIALIZER;
void* worker_a(void* arg) {
(void)arg;
pthread_mutex_lock(&m1);
pthread_mutex_lock(&m2); // 顺序证据一:m1 -> m2
printf("[A] m1 -> m2 OK\n");
pthread_mutex_unlock(&m2);
pthread_mutex_unlock(&m1);
return NULL;
}
void* worker_b(void* arg) {
(void)arg;
sleep(1); // 等 A 的顺序先入图
pthread_mutex_lock(&m2);
printf("[B] hold m2, want m1\n");
pthread_mutex_lock(&m1); // 顺序证据二:m2 -> m1(此刻 m1 没人拿,拿得到)
printf("[B] m2 -> m1 OK\n");
pthread_mutex_unlock(&m1);
pthread_mutex_unlock(&m2);
return NULL;
}
int main(void) {
pthread_t a, b;
pthread_create(&a, NULL, worker_a, NULL);
pthread_create(&b, NULL, worker_b, NULL);
pthread_join(a, NULL);
pthread_join(b, NULL);
printf("all done\n"); // 程序全程没有死锁,但判决书已经写好
return 0;
}
编译运行(gcc -O1 -g -fsanitize=thread -pthread deadlock_tsan.c -o deadlock_tsan;WSL2 记得套上 setarch $(uname -m) -R):
==================
WARNING: ThreadSanitizer: lock-order-inversion (potential deadlock) (pid=464)
Cycle in lock order graph: M0 (0x555555558080) => M1 (0x555555558040) => M0
Mutex M1 acquired here while holding mutex M0 in thread T1:
#0 pthread_mutex_lock ... (libtsan.so.2+0x59a13)
#1 worker_a /mnt/d/tmp/hang/deadlock_tsan.c:12 (deadlock_tsan+0x12ba)
Mutex M0 acquired here while holding mutex M1 in thread T2:
#0 pthread_mutex_lock ... (libtsan.so.2+0x59a13)
#1 worker_b /mnt/d/tmp/hang/deadlock_tsan.c:24 (deadlock_tsan+0x132f)
...
SUMMARY: ThreadSanitizer: lock-order-inversion (potential deadlock) /mnt/d/tmp/hang/deadlock_tsan.c:12 in worker_a
==================
[A] m1 -> m2 OK
[B] hold m2, want m1
[B] m2 -> m1 OK
all done
注意最后四行:程序顺利跑完了,all done。它一次都没死锁,但判决书已经写好:这两把锁的顺序规则自相矛盾,环在锁序图里已经闭合,剩下的只是等一次合适的交错把它变成物理事故。这正是《非侵入探针:ASan/TSan/UBSan 如何让 bug 在发作之前显形》讲的核心在这里的兑现:TSan 的判定项是锁序图上的环(逻辑事实),扰动项是时序–而时序不在判定式里。它证明”存在”,不需要”发生”。
再往上走一层,就到了这篇文章在系列里的位置。TSan 判定数据竞争用的 happens-before 偏序,正是 Lamport 1978 年为分布式系统发明的定序语言;死锁的环、丢失唤醒的”唤醒先于等待”、饿死的分配偏向,全部都能用这套语言陈述。这不是巧合:共享内存的多线程程序,逻辑上就是一个分布式系统–没有特权观测者,每个线程有自己的视角和因果史,全局一致的”现在”并不存在。《非侵入探针》的结尾埋过这个钩子,这篇把它落到了”静止”的地面上。谜底在未来某篇里:当你的系统跨了进程、跨了机器,”卡死的鉴别诊断”这门手艺会原封不动地跟着你走过去。
九、主案例:一次”全请求超时”的两副面孔
把前面的零件装成一次完整办案。案情是第三节实验的生产化版本。
背景:一个配置热更新的服务。请求路径上,worker 线程拿 stats_lock 更新计数,偶尔要读配置(拿 cfg_lock)。热更新路径上,reload 线程拿 cfg_lock 替换配置,顺手清空统计(拿 stats_lock)。两把锁,两条路径,顺序相反。上线三个月没事–直到某天流量高峰,一次 reload 恰好撞上一批在途请求:全部请求超时,监控一片红。
错误办案路径:值班同学的第一反应是怀疑网络(”超时嘛”)。翻网关日志、ping 上游、看连接池–全正常。半小时烧完,才想起看服务本身。
正确办案路径,四步:
第一步,分诊。CPU 接近零、全部线程 S 状态、wchan 表里一片 futex_do_wait、请求队列只涨不消。读数分类:静止家族、全局零进展、全部在等锁–死锁或丢失唤醒二选一,先按死锁查(取证便宜且现场永冻)。
第二步,取证。全线程 dump 抓下来(现场永冻,不必慌,但立刻抓–生产进程随时可能被运维重启,永冻挡不住外力)。一百多根栈,九成睡在 stats_lock 门口。
第三步,拼图。从栈提取等待边(每根栈的 pthread_mutex_lock 参数),从锁提取持有边(__owner / !cs 的 OwningThread),画出等待图。环浮出来:worker 持 stats_lock 等 cfg_lock,reload 持 cfg_lock 等 stats_lock。环上只有两根栈,其余一百根全是排队的受害者–这就是为什么”盯着最卡的那根栈看”永远找不到凶手。
第四步,结案与回归。修复是统一锁序:规定 cfg_lock 永远先于 stats_lock(或者干脆合并成一把,或用 std::scoped_lock 一次拿齐)。回归不是重跑一遍碰运气,而是 TSan 构建重放两条路径–lock-order-inversion 报告从有到无,才叫关案。
变奏(三周后):同样的症状复发。分诊读数一模一样,于是轻车熟路抓 dump、拼图–环不闭合。所有人都在合法等待,等待图是一棵树而不是环。抓十次 dump,十次一样。办案卡死在了”卡死办案”上。
这次要回头看账本:reload 完成标志 reload_done 的值是 1–事件已经发生,但 worker 线程在无条件 pthread_cond_wait。丢失唤醒:reload 完成时的 broadcast 打在空房间,worker 是后来才来睡的。修复是 while (!reload_done) wait。复盘时才会后怕:这次 dump 的信息量为什么是零?因为病因在过去(broadcast 那一毫秒),dump 只能拍到现在。若不是账本上留着那笔记录,唯一的取证手段是把录制回放架起来,看 t=reload 完成时刻到 worker 入睡时刻之间发生了什么。
两副面孔,一个读数。第一次病因在将来(等一个永不会来的东西),第二次病因在过去(错过一个已经来的东西)。第一次现场永冻是恩赐,第二次现场不存在是必然。分诊表上它们是邻居,取证工具箱里它们隔着一个时间机器。
十、方法论与预防:先推理再取证,先免疫再发作
四步办案法浓缩成一张表:
| 步骤 | 动作 | 关键心法 |
|---|---|---|
| 1 分诊 | CPU / 状态字 / wchan 三层读数 | 先定病族,再谈取证;”卡死了”是读数不是诊断 |
| 2 取证 | 按病族选工具:永冻抓 dump,流动做采样,历史靠录制,分布靠统计,维度错就换尺子 | 取证手段是诊断结论的函数 |
| 3 拼图 | 等待边(栈)+ 持有边(owner 字段)合成等待图;无环则查谓词账本 | 环是关系不是单点;账本是历史的唯一合法证人 |
| 4 结案 | 修复 + 机制性回归(TSan 报告从有到无) | 重跑一遍通过不叫回归,叫运气 |
预防端的要点是:五种病各有各的免疫术,一种疫苗防不了五种病。
- 死锁:锁层级规约(每把锁一个级别,拿锁只允许升序,静态可查)或
std::scoped_lock一次性原子获取多把锁(C++17,内部用死锁避免算法);Windows 侧等价物是一次性EnterCriticalSection序列 + 评审纪律。TSan 并发构建进 CI,lock-order-inversion在合入前就报。 - 丢失唤醒:
while (!pred) wait铁律,无例外;谓词必须和条件变量共享同一把锁保护。 - 活锁:重试加退避(backoff)加上限;以太网用了五十年的方案。冲突双方随机化退避时长,相位差自然散开。
- 饿死:需要公平的场合用排队锁/公平锁,或者至少把”各线程获取计数”做成监控指标–饿死的诊断依赖分布数据,那就让分布数据存在。
- 假卡死:可观测性建设–进展心跳按维度分拆(业务进展、I/O 进展、GC 占比),让”零进展”这个读数天然多维。一个只有一根心跳线的服务,等于把假卡死留给自己。
- 组织层:把分诊三层做成值班手册第一页(一行
ps命令顶半小时瞎猜);ProcDump 的卡死触发条件(窗口无响应)挂上自动采集,让 dump 自动进入分析管线。
最后是那条贯穿的纪律,对五种病一体适用:先想清楚”不动”发生在哪个维度、对哪个参与者、病因在时间轴的哪一格,再伸手拿工具。
十一、结语:崩溃在空间上撒谎,卡死在时间上撒谎
回头看这个系列拆掉的前提:《AI 写的 bug 像正确的代码》拆了”作者知道意图”,《海森堡 bug》拆了”观察不改变系统”。这篇拆掉的是最日常的一个:”程序卡住了,所以程序有问题。”零进展可能意味着环、错过、空转、不公,甚至只是–你的尺子量错了维度。
把崩溃和卡死合在一起看,是一副对仗:崩溃在空间上撒谎(崩在哪一行,不等于伤在哪一行);卡死在时间上撒谎(停在现在,不等于病因在现在)。两者的合流就是海森堡篇那句话的完全体:调试是与一个会因你而变的系统对话,你读到的永远是观测方式下的投影–崩溃的投影偏移在空间距离上,卡死的投影偏移在时间坐标上。
工具永远在换:futex 的 owner 字段、!cs -o 的持有者栈、TSan 的锁序图,十年后可能都换了名字。但这张鉴别诊断表不会过时,因为它的基础不是任何工具,而是一个朴素的事实:“不动”是你的一次测量,不是系统的一种状态。下次面对一个”卡死”的进程,先默念这句话,然后问自己三个问题–哪个维度不动?谁的病因?在时间轴的哪一格?答完再动手。
附录 A:卡死鉴别诊断速查表
| 类型 | 病因时态 | 现场性质 | 分诊指纹 | 首选取证 | 误诊陷阱 | 免疫手段 |
|---|---|---|---|---|---|---|
| 死锁 | 将来被取消 | 永冻 | CPU≈0,全部 S+futex 等待 | 全线程 dump + 等待图 + owner 字段 | 把环外受害者当凶手;隐形锁看不到锁函数 | 锁层级 / scoped_lock / TSan |
| 丢失唤醒 | 过去已消逝 | 已死 | 与死锁同象,等待图无环 | 查谓词账本;录制回放(TTD/rr) | 反复抓 dump(信息量为零) | while + 谓词 |
| 活锁 | 当下空转 | 流动 | CPU 满格,R 状态 | 采样剖面 + 重试/成功计数比 | 把高 CPU 当”在忙正事” | 退避 + 上限 |
| 饿死 | 当下被推迟 | 统计分布 | 单次快照一切正常 | 长时间计数统计、等待直方图 | 平均值正常掩盖个别线程饥饿 | 公平锁 / 饥饿监控 |
| 假卡死 | 观察者预期 | 在别的维度 | CPU/IO/GC 在动,心跳活着 | 换维度测量(CPU/磁盘/网络/GC/换页) | 抓 dump(现场无病变) | 多维进展指标 |
用法:先跑第二节的三层分诊(一行 ps),把读数对到”分诊指纹”列定病族,再按”首选取证”列动手。分诊不清时,先假设自己拿错了尺子(第五行),这是最便宜的假设。
附录 B:等待关系物证命令对照
Linux(Ubuntu 24.04 实测):
top -H -p <pid> # 分诊一层:每线程 CPU(活锁满格/死锁为零)
ps -eLo pid,tid,stat,wchan:24,comm # 分诊三层:状态字 + 内核等待通道
cat /proc/<pid>/status | grep -E "Threads|switch" # 线程数 + 自愿/非自愿切换计数
gdb -p <pid> -batch -ex "thread apply all bt" # 全线程栈:等待姿势 + 等待对象
gdb -p <pid> -batch -ex "p m1" # 互斥量物证:__lock / __owner(持有者 TID)
gdb -p <pid> -batch -ex "p cv" # 条件变量内部计数(历史事件的微弱痕迹)
pstack <pid> / eu-stack -p <pid> # 轻量全栈抓取(不想惊动 gdb 时)
perf record -F 997 -p <pid> + perf report # 采样剖面:活锁的流动现场
setarch $(uname -m) -R ./prog # WSL2 跑 TSan 前关地址随机化
Windows(Win11 + cdb 实测):
任务管理器 -> 详细信息 -> 添加列(CPU、线程数、I/O) # 分诊第一层
procdump -ma <pid> hang.dmp # 全线程 dump(现场永冻时)
procdump -h <exe> hang.dmp # 按窗口无响应自动抓(假卡死鉴别利器)
cdb -z hang.dmp -c "~*k; !cs -l; q" # 全栈 + 已锁临界区清单
!cs <addr> # 单个临界区:LOCKED / OwningThread / LockCount
!cs -o <addr> # 王牌:直接吐出持有者的栈
!cs ntdll!LdrpLoaderLock # 验尸 loader lock(新版 ntdll 中它是结构体本体,不要 poi)
dt ntdll!_RTL_CRITICAL_SECTION <addr> # 手验字段(需要符号服务器的完整 ntdll 符号)
注意:!cs 家族需要带类型信息的 ntdll 符号(配置符号服务器),纯导出符号会报 “Bad symbols for NTDLL”;!cs -l 在 Win11 上只列得出少数带 DebugInfo 的临界区,锁的完整清单要用 !cs -o <你的锁地址> 逐个验。
附录 C:隐形锁清单(环上没有你的锁时,按这张表找)
| 隐形锁 | 持有者 | 典型撞法 |
|---|---|---|
| loader lock(ntdll!LdrpLoaderLock) | 系统加载器(DllMain / LoadLibrary 执行期间) | DllMain 里 CreateThread + Wait;DllMain 里调任何可能加载 DLL 的 API(COM、注册表、某些 CRT 函数) |
| 进程堆锁(堆分配器内部) | malloc/HeapAlloc 自己 | 一个线程在 malloc 里被换出,另一个线程在持有你锁的回调里 malloc |
| CRT 初始化锁 | C 运行时启动代码 | 静态对象构造与 DllMain / 线程启动交错 |
| 环境变量锁 | getenv / putenv | 某线程持它调你的回调,回调里又拿你的锁(经典锁反转来源) |
| GDI / USER32 内部锁 | 窗口子系统 | 窗口过程与消息泵跨线程,或持 GUI 锁调用回业务代码 |
| 你依赖的库的内部锁 | 第三方库 | 持库锁的路径回调你的代码,你的代码又拿自己的锁–环跨过库边界闭合 |
鉴别要领:隐形锁不出现在你的代码里,只出现在别人的栈上。找环的时候别只盯 EnterCriticalSection/pthread_mutex_lock 这类显式信号,看每根栈穿过了哪些持锁路径(加载器初始化帧、分配器内部帧、CRT 启动帧),以及持有边可以从 !cs / __owner 反查。判断”这根栈有没有持锁”靠的是语义,不是函数名。
相关文章
- 你越想看清它,它就越不存在:海森堡 bug 与调试的观察者效应 – 观察如何改变系统:分诊与”换尺子”的认识论源头
- 非侵入探针:ASan/TSan/UBSan 如何让 bug 在发作之前显形 – TSan 锁序图判定项的完整原理
- AI 写的 bug 像正确的代码 – 系列认识论的起点
- Visual Studio 的 Debug 与 Release 到底差在哪 – 时序家族的环境侧背景
- WinDbg 应用层实战 – 附录 B 命令的系统教程

发表回复