每根栈都是清白的:程序”卡死”的鉴别诊断学

作者:

崩溃至少有一件事值得感激:它留下一根”有问题”的栈。哪怕那根栈指向的往往是受害者而不是凶手,它至少给了你一个起点、一次报警、一个可以开始办案的地方。

卡死连这点诚意都没有。它交给你一个还在运行的进程:内存不涨,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 的地址。锁供出了持有者,持有者的栈供出了它在等下一把锁,下一把锁又指回第一把。环的三条边,一条命令全部验完。

隐形锁:你没写过的锁最危险

上面两案的环上都是自己的锁,栈里至少有 EnterCriticalSectionpthread_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 启动),而不是找锁函数名。

死锁的误诊陷阱小结

  1. 把环外受害者当凶手(一百根栈里九十八根是排队的)。
  2. 只看单根栈(环是关系,单点无环)。
  3. 隐形锁死锁里找不到”锁的样子”就排除死锁(看栈穿过的路径,不看锁函数名)。
  4. 现场永冻带来的唯一坏习惯:以为”随时能抓”就先干别的去–生产进程往往会被运维先重启,永冻现场照样会被外力销毁。分诊完立刻取证。

四、活锁:病因在快照之间,现场是流动的

死锁是系统说”我停了”;活锁是系统对你说”我在努力”–而每一次努力都在取消上一次努力。两个线程都想拿两把锁,都很有礼貌:拿不到第二把就放开第一把,退回去重来。如果两人永远差半拍,就永远拿不齐。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_lockcfg_lock,reload 持 cfg_lockstats_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 反查。判断”这根栈有没有持锁”靠的是语义,不是函数名。

相关文章

评论

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注