C++用户态死锁检测器实现:wait-for graph建模与DFS找环

发布时间:2026/10/9 8:42:06
C++用户态死锁检测器实现:wait-for graph建模与DFS找环 我最早意识到死锁检测器得自己写一个是在一次线上事故复盘的时候。某个后台服务每隔几天就卡死一次进程还活着线程全部阻塞在pthread_mutex_lock上但就是查不出是谁握着锁没放。gdb 挂上去能看到每个线程的调用栈可调用栈里只有当前在等待哪把锁根本没有这把锁正被谁持有的信息。人工对着栈猜了一下午最后靠重启恢复下次又随机复现。从那天起我就决定必须给这种场景做一个自动化的死锁检测工具思路就是项目标题里写的采集线程与锁的持有关系把它建模成一张有向图然后找环。这篇文章把完整的实现路径、核心算法和我在真实环境里踩过的坑都整理出来给同样被这类问题折磨的人一个可以照着抄的方案。1. 为什么现成方案不够用从一次深夜排查说起1.1 当时看到的现场所有线程都停在锁上那个服务用的是 C 和 pthread崩溃现场大致是这样Thread 1 (Thread 0x7f...): #0 __futex_abstimed_wait_common #1 pthread_mutex_lock #2 Worker::Process() #3 ... Thread 2 (Thread 0x7f...): #0 __futex_abstimed_wait_common #1 pthread_mutex_lock #2 Worker::Process() #3 ...几十个线程清一色卡在pthread_mutex_lock上。但问题来了线程 1 在等哪把锁、这把锁被谁持有栈里看不到。我能看到线程 1 的代码路径能看到它正尝试加锁却看不到mutex内部记录的所有者是谁。用户态互斥锁不像内核锁那样有现成的/proc/lockdep可以查。这就是最尴尬的地方死锁症状很明显证据链却不完整。1.2 现成工具的硬伤当时我把能想到的工具挨个过了一遍结论都不太满意方案能解决的问题用不起来的原因gdb attach 人工看栈能看到线程栈要暂停服务慢且只有正在等锁没有锁被谁持有valgrind --toolhelgrind能自动检测死锁性能开销太大生产环境根本跑不动内核 lockdep检测内核死锁管不到用户态 mutex打日志 事后分析也许能定位加日志要改代码发版周期一拖就是几天gdb 还有个致命问题线上核心业务没法轻易 attach一挂就丢请求。valgrind 更是只适合测试环境。最气的是内核里有 lockdep 这么成熟的东西但那是给spinlock、mutex这类内核对象用的用户态的pthread_mutex_t内核根本不认识。所以结论很明确得自己做一套用户态的检测器并且得满足三个条件——不改用户代码、能在生产进程里自动运行、只在真正死锁时报警。2. 检测模型把谁在等什么锁翻译成一张有向图2.1 死锁四个必要条件里只有循环等待能自动检测教科书上写得很清楚互斥、持有并等待、不可剥夺、循环等待。前面三个条件在用户态代码里没法轻易判断是否可能发生因为它们描述的是锁的使用模式需要静态分析整个工程。而循环等待是运行时状态只要一个线程在等某把锁、这把锁又恰好被另一个线程持有等待链就成形了。如果这条链首尾相接那就是死锁。所以基于有向图找环是天然适合做运行时检测的。注意一个容易混淆的细节等待链成环意味着死锁必然发生吗也不是。如果某个线程在等锁时带超时比如带超时的pthread_mutex_timedlock等待超时后它会放弃环就断了。因此检测器要识别的是正在发生的、持续性的循环等待。2.2 wait-for graph 的节点和边定义于是我把问题模型抽象成一张 wait-for graph等待图节点线程边线程 A 正在等待一把锁这把锁当前被线程 B 持有则画一条有向边 A → B这张图很好用因为每个节点只有两个状态等在锁上、持有锁。一个线程持有锁时它不能被等待边指向自己一个线程等待锁时它会指向当前持有者。图中每个线程最多有一个正在等待的锁因为同一线程同一时刻只能阻塞在一个锁上。这样一来图的规模是可控的节点数是线程数边数不超过线程数判环算法非常轻量。这里我吃了个亏要提醒一句边的方向千万别画反。我当时第一版就画成了持有者 → 等待者结果检测出的环毫无意义因为资源分配图里的边方向和数据流图里是反的。死锁检测这个场景语义是等待者依赖持有者边必须从等待者出发指向持有者环才能对应到互锁关系。3. 数据采集用 LD_PRELOAD 挂钩子记录线程与锁的持有关系3.1 拦截 pthread_mutex_lock/unlock不需要改业务代码要让检测器在用户进程里跑起来最现实的做法是LD_PRELOAD一个共享库在库里劫持pthread_mutex_lock、pthread_mutex_trylock、pthread_mutex_unlock。业务代码完全不用动部署时改一下启动脚本即可。核心逻辑不复杂在锁函数入口处记录当前线程准备获取哪把锁在返回成功后记录这把锁的持有者是当前线程在解锁后记录这把锁已释放。我实现的代码骨架是这样// dl_deadlock.c #define _GNU_SOURCE #include dlfcn.h #include pthread.h #include stdio.h #include stdint.h #include string.h #include unistd.h #include sys/syscall.h #include stdatomic.h typedef int (*lock_fn_t)(pthread_mutex_t *); typedef int (*unlock_fn_t)(pthread_mutex_t *); static lock_fn_t real_lock; static unlock_fn_t real_unlock; static lock_fn_t real_trylock; static void init_hooks(void) { if (real_lock) return; real_lock (lock_fn_t)dlsym(RTLD_NEXT, pthread_mutex_lock); real_unlock (unlock_fn_t)dlsym(RTLD_NEXT, pthread_mutex_unlock); real_trylock (lock_fn_t)dlsym(RTLD_NEXT, pthread_mutex_trylock); } int pthread_mutex_lock(pthread_mutex_t *m) { init_hooks(); thread_set_waiting(m); int rc real_lock(m); thread_clear_waiting(); if (rc 0) { lock_mark_acquired(m, (pid_t)syscall(SYS_gettid)); } return rc; } int pthread_mutex_unlock(pthread_mutex_t *m) { init_hooks(); int rc real_unlock(m); lock_mark_released(m); return rc; } int pthread_mutex_trylock(pthread_mutex_t *m) { init_hooks(); int rc real_trylock(m); if (rc 0) { lock_mark_acquired(m, (pid_t)syscall(SYS_gettid)); } return rc; }两个细节值得展开一下。一是用syscall(SYS_gettid)拿线程 ID而不是pthread_self()。因为/proc/[pid]/task/[tid]里的目录名是内核线程 ID检测线程需要把图上节点和系统线程对应起来方便后续对照日志。二是 trylock 不阻塞所以它没有等待阶段只在成功时登记持有者。这个区分很重要否则 trylock 失败的短暂尝试会被误判成死锁。3.2 锁表的数据结构哈希表 自旋锁pthread_mutex_lock可能被并发调用所以检测器自己的锁表必须支持并发访问。我把锁存在一个哈希表里以pthread_mutex_t *为 keyvalue 是持有者线程 ID#define HASH_SIZE 512 typedef struct lock_entry { pthread_mutex_t *addr; pid_t holder; struct lock_entry *next; } lock_entry_t; static lock_entry_t *buckets[HASH_SIZE]; static atomic_flag table_spin ATOMIC_FLAG_INIT; static void table_lock(void) { while (atomic_flag_test_and_set(table_spin)) {} } static void table_unlock(void) { atomic_flag_clear(table_spin); } static uint32_t hash_ptr(pthread_mutex_t *p) { return (uint32_t)(((uintptr_t)p 4) % HASH_SIZE); } static void lock_mark_acquired(pthread_mutex_t *m, pid_t tid) { table_lock(); uint32_t h hash_ptr(m); lock_entry_t *e buckets[h]; while (e e-addr ! m) e e-next; if (!e) { e (lock_entry_t *)calloc(1, sizeof(*e)); e-addr m; e-next buckets[h]; buckets[h] e; } e-holder tid; table_unlock(); }这里有个常见坑保护锁表不能再用pthread_mutex_t否则检测器本身可能参与死锁。比如线程 A 正卡在业务锁上它的 hook 还没返回如果此时锁表也需要抢业务锁等于雪上加霜。所以锁表用自旋锁且临界区里只做简单的查找和赋值不打印、不申请大内存、不调用任何可能阻塞的库函数。我在第一版里为了打印日志在持锁期间调了snprintf和write结果在网络文件系统上轻微阻塞拖慢了所有加锁路径。这个教训后面细说。3.3 线程等待关系靠进入锁调用但迟迟未返回来判断真正难搞的是记录线程正在等待哪把锁。因为pthread_mutex_lock一旦阻塞我们的 hook 就卡在real_lock(m)那一行后续代码根本执行不到自然没法在阻塞瞬间把等待中状态写进去。我的方案是分两个步骤hook 进入pthread_mutex_lock时先记录当前线程试图获取的锁和开始时间假如锁很快拿到函数返回后再清除这个等待标记假如真死锁了这个标记会一直挂着。后台检测线程每隔 2 秒扫描一次线程表把等待标记存在且等待时间超过阈值的线程认定为疑似等待者。这样既能过滤短时间的锁竞争又不用在真实锁上做任何侵入。线程等待表我用了一个固定数组生产环境可以用哈希表换成线程 ID 索引但教学原型够用了#define MAX_THREADS 1024 typedef struct thread_wait { pid_t tid; pthread_mutex_t *waiting_lock; uint64_t wait_start_ms; atomic_int in_wait; } thread_wait_t; static thread_wait_t threads[MAX_THREADS]; static int thread_count; static void thread_set_waiting(pthread_mutex_t *m) { pid_t tid (pid_t)syscall(SYS_gettid); table_lock(); thread_wait_t *t NULL; for (int i 0; i thread_count; i) { if (threads[i].tid tid) { t threads[i]; break; } } if (!t thread_count MAX_THREADS) { t threads[thread_count]; t-tid tid; } if (t) { t-waiting_lock m; t-wait_start_ms now_ms(); atomic_store(t-in_wait, 1); } table_unlock(); }这个设计有个隐含假设一个线程同一时刻只会尝试获取一把锁。对 pthread 互斥锁是成立的因为同步调用不可能嵌套等待。如果将来要支持读写锁pthread_rwlock_t结构还得扩展。4. 找环算法三色 DFS 的核心实现与环还原4.1 为什么选 DFS 而不是拓扑排序图建好之后判环最直观的算法是拓扑排序把入度为 0 的节点不断删掉删不完说明有环。它实现简单复杂度也是 O(VE)但它只能告诉我有环要想输出环上有哪些线程、各自等哪个锁还得另外重建路径。DFS 三色标记则不一样它在深度优先遍历过程中就能捕获回边并且能把环路径直接保存在递归栈里。对于死锁排查这个问题输出完整环路径远比只输出布尔值有用。三色标记的含义白色节点还没被访问过灰色节点在当前的递归栈中正在扩展它的后继黑色节点处理完毕从它出发不可能找到环当 DFS 从某个灰色节点出发又碰到另一个灰色节点时说明递归栈里出现了一条回边环就存在。所有不在环上的节点最终都会变成黑色。4.2 从锁表构建邻接矩阵构建图的输入首先要有节点集合。我以所有正在等待的线程为候选节点然后补充它们等待锁的持有者。因为边是从等待者指向持有者持有者可能不在等待列表中但一旦构成环持有者一定也在等待列表中否则环连不起来。所以候选节点集合的起点就是等待者线程。#define MAX_N 1024 static pid_t node_tids[MAX_N]; static int node_count; static int adj[MAX_N][MAX_N]; static int get_node(pid_t tid) { for (int i 0; i node_count; i) { if (node_tids[i] tid) return i; } if (node_count MAX_N) return -1; node_tids[node_count] tid; return node_count; } static void build_graph(void) { memset(adj, 0, sizeof(adj)); node_count 0; table_lock(); for (int i 0; i thread_count; i) { if (!atomic_load(threads[i].in_wait)) continue; pid_t waiter threads[i].tid; pthread_mutex_t *m threads[i].waiting_lock; pid_t holder lock_find_holder_locked(m); if (holder 0 || holder waiter) continue; int u get_node(waiter); int v get_node(holder); if (u 0 v 0) { adj[u][v] 1; } } table_unlock(); }这里我用了一个小的内部函数lock_find_holder_locked它要求调用方已经持有表锁避免重复加同一把锁。build_graph全程持锁是安全的因为临界区只是扫数组和赋值没有 IO。按这个构建逻辑一个常见的 AB-BA 死锁会形成这样的图T1 → T2 T2 → T1DFS 从 T1 出发访问 T2T2 的后继又回到 T1而 T1 正在递归栈里灰色于是环被捕获。4.3 DFS 三色标记和环路径还原下面这段是整个检测器的核心。我用全局数组color记录节点颜色path记录当前递归栈上的节点顺序。static int color[MAX_N]; // 0: white, 1: gray, 2: black static int path[MAX_N]; static int path_len 0; static int found_cycle 0; static void dfs(int u) { color[u] 1; path[path_len] u; for (int v 0; v node_count; v) { if (!adj[u][v]) continue; if (color[v] 0) { dfs(v); if (found_cycle) return; } else if (color[v] 1) { found_cycle 1; // 从 path 中找到 v 的位置path[vi..path_len-1] 就是环 int start 0; while (path[start] ! v) start; dump_cycle(start, path_len - start); return; } } path_len--; color[u] 2; } static void run_deadlock_check(void) { found_cycle 0; memset(color, 0, sizeof(color)); build_graph(); for (int i 0; i node_count; i) { if (found_cycle) break; path_len 0; if (color[i] 0) { dfs(i); } } }dump_cycle负责把环上的线程 ID 映射回事先记录的锁等待信息。我输出到 stderr这样即使业务程序把 stdout 重定向走报警信息也不会丢static void dump_cycle(int start, int len) { fprintf(stderr, \n[dl-detector] DEADLOCK DETECTED, cycle len %d\n, len); for (int i 0; i len; i) { pid_t tid node_tids[path[start i]]; pid_t next node_tids[path[start (i 1) % len]]; fprintf(stderr, thread %d - thread %d\n, tid, next); } }这里有个细节path里的起点不一定是环的逻辑起点。比如图中有个前驱线程 T0 等锁它指向 T1T1 和 T2 之间才成环那么 DFS 栈可能是[T0, T1, T2]访问 T2 时发现后继是 T1 于是返回环。我通过while (path[start] ! v) start跳过 T0只打印从 T1 开始的那一段否则会把一个不相干的等待者也算进死锁链。4.4 顺手聊聊强连通分量为什么简单场景用不上有些读者看到有向图环可能会想到 Tarjan 或 Kosaraju 求强连通分量。确实SCC 能一次性找出所有成环节点而且逻辑上和 DFS 三色标记一脉相承。但死锁检测这个场景一般不需要所有环——我们只需要知道有没有死锁、死锁链是谁。一个死锁报警就足够触发值班同学的注意了把系统里所有成环结构一次性列出来反而不利于快速定位。只有当你想做完整的图形化报告需要在一次扫描中标记多个互不相交的死锁组时Tarjan 才值得引入。5. 跑起来看看构造 AB-BA 死锁并解读检测器输出5.1 一个教科书级的死锁复现程序为了验证检测器我写了个经典的双线程 AB-BA 死锁程序// demo.c #include pthread.h #include stdio.h #include unistd.h static pthread_mutex_t m1 PTHREAD_MUTEX_INITIALIZER; static pthread_mutex_t m2 PTHREAD_MUTEX_INITIALIZER; void *worker1(void *arg) { pthread_mutex_lock(m1); sleep(1); pthread_mutex_lock(m2); // 此时 m2 已被 worker2 持有 pthread_mutex_unlock(m2); pthread_mutex_unlock(m1); return NULL; } void *worker2(void *arg) { pthread_mutex_lock(m2); sleep(1); pthread_mutex_lock(m1); // 此时 m1 已被 worker1 持有 pthread_mutex_unlock(m1); pthread_mutex_unlock(m2); return NULL; } int main(void) { pthread_t a, b; pthread_create(a, NULL, worker1, NULL); pthread_create(b, NULL, worker2, NULL); pthread_join(a, NULL); pthread_join(b, NULL); return 0; }这里sleep(1)是为了让两个线程都拿到自己的第一把锁再开始抢第二把确保死锁稳定复现。真实工程里不需要人为制造这种时序。5.2 编译和运行编译检测器和被测程序gcc -fPIC -shared -o libdl_detector.so dl_deadlock.c -lpthread -ldl gcc -o demo demo.c -lpthread跑的时候用LD_PRELOAD注入检测器LD_PRELOAD./libdl_detector.so ./demo实际运行输出大致是这样的[dl-detector] thread 9876 starts waiting on mutex 0x7f... [dl-detector] thread 9877 starts waiting on mutex 0x7f... [dl-detector] DEADLOCK DETECTED, cycle len 2 thread 9876 - thread 9877 thread 9877 - thread 9876两行starts waiting说明两个线程在 2 秒扫描周期到来之前就卡住了。检测器把持有者信息补齐后形成了两条边闭环成立。注意这个输出是后台线程从stderr打出来的主线程的pthread_join永远不会返回但不影响检测线程运行。我还专门验证过一件事让检测线程的启动尽量提前。我在库里用__attribute__((constructor))在加载时启动后台线程这样main还没执行时检测器就已经在跑了。代码很短__attribute__((constructor)) static void start_detector(void) { pthread_t th; pthread_create(th, NULL, detector_entry, NULL); pthread_detach(th); }在实际部署中这个构造函数会随LD_PRELOAD的库加载而执行不依赖用户代码的任何初始化。5.3 怎么读输出从有环到具体定位这个输出看着简单但信息量已经足够定位问题。你可以顺着环上的线程 ID去业务日志里查这些线程的调用栈。如果日志恰好记录了线程 9876 正在处理请求 X线程 9877 正在处理请求 Y死锁成因基本就锁定了X 和 Y 以相反顺序访问了同一组锁。我在真实场景里遇到过更长的环三个线程两两互锁输出是这样的thread 1001 - thread 1002 thread 1002 - thread 1003 thread 1003 - thread 1001这种多节点环的定位价值更高因为三把锁的加锁顺序往往横跨三个模块人工查栈几乎不可能拼出完整链路而有向图直接帮你串好了。6. 真实环境里踩过的坑误报、开销与生产化取舍6.1 自旋锁临界区里的禁忌不能干重活第一版我把打印日志写进了锁表临界区结果生产环境压力测试时整个服务的锁操作延迟暴涨。问题在于自旋锁临界区里一旦调用write到慢速设备比如网络日志盘其他线程都在那原地自旋等锁CPU 白白烧掉业务锁反而被拖慢。正确做法是临界区里只做内存操作要输出的信息先拷贝到临时缓冲区释放锁之后再打印。这在lock_mark_acquired里尤其重要因为加锁操作本身是高频路径。6.2 超时阈值怎么定误报和漏报之间的平衡后台检测线程每 2 秒扫描一次发现正在等待且等待超过阈值的线程才认为它疑似死锁。阈值太短会把正常的锁竞争当成死锁阈值太长真死锁了报警不够及时。我实践下来5 秒是比较稳的起点。但这里有个微妙情况如果线程等待的时间明明超过了 5 秒可最终拿到了锁检测器会误报吗不会。我的实现里thread_clear_waiting()会在pthread_mutex_lock返回时把in_wait清除。后台扫描是瞬时的只要它扫描的那一刻该线程还在等它就会进入疑似等待集合。如果 5 秒前它在等、扫描时它已经拿到锁了in_wait已经是 0不会被算进去。真正的死锁场景下等待是持续的所以这个方案天然抓住的是持续性等待而非曾经等待过。更极端的场景是持锁线程本身非常慢比如持锁做磁盘 IO 阻塞了几十秒。这时候可能有多个线程排队等这把锁图上会形成多个线程指向同一个持有者的扇出结构但不会成环。检测器会输出一堆疑似等待但 DFS 判环失败不会报警。这正是我想要的长期持锁不是死锁只是性能问题不应该上报死锁告警。6.3 性能开销实测我在一个 64 线程的压力程序上做了简单测试业务代码每秒加解锁约 20 万次检测器引入的额外开销大概在 3%5%。这部分开销主要来自哈希查找和自旋锁竞争。如果锁竞争特别激烈的程序可以进一步优化把锁表从自旋锁改成读写锁加解锁高频路径用读锁检测线程和登记新锁才用写锁。不过自旋锁的临界区足够短大部分场景够用我没有过度优化。6.4 生产化方向从 LD_PRELOAD 到 eBPFLD_PRELOAD 方案的最大问题是要改动进程启动方式。有些生产系统由容器编排平台统一拉起不方便注入环境变量。另一个问题是 hook 覆盖不全如果程序通过 vtable 或汇编直接调用sys_futex就绕过了pthread_mutex_lock的符号。虽然这种写法很少见但存在。更现代化的替代方案是用 eBPF 的 uprobe 挂在pthread_mutex_lock和pthread_mutex_unlock上通过 BPF map 维护锁表。这样不需要预加载任何库内核帮你完成采样性能开销也更低。代价是开发调试成本高而且需要 root 权限和较新的内核。我个人的经验是先跑通 LD_PRELOAD 这套逻辑等锁表的数据结构稳定下来再把它翻译成 eBPF 版本边际成本很低。如果读者所在环境支持 eBPF这确实值得直接尝试。另外还有一个小技巧检测器输出最好带上进程名和 PID。多条命令下会有多个进程同时被注入没有进程标识的输出在日志系统里根本没法串联。我用getpid()拼在每行报警前缀里这个细节在排查多进程服务时帮了大忙。总的来说这个项目的核心价值不在于算法本身有多高级——三色 DFS 是本科生就能实现的东西——而在于它把运行时状态采集和图判环这两个环节打通了。很多死锁问题看不到证据不是因为锁难查而是因为缺少一个在生产环境里一直盯着锁关系的旁观者。检测线程就是这个旁观者。它不是万能的覆盖不了读写锁的偏好死锁也覆盖不了条件变量饥饿但在互斥锁循环等待这个最常见的死锁形态上它能稳定地帮你把证据链补齐。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询