Appearance
GDB 实战:定位信号 handler 中的死锁
本文是一份真实 GDB 调试案例:程序在 SIGSEGV 崩溃后的信号处理函数里做资源 finalize 并 join 线程,结果在持有 mutex 的情况下等待另一把锁,形成死锁。我们完整演示如何用 GDB 从「一个 mutex 地址」反查锁持有者、定位线程、还原死锁闭环。
适用前提:程序已带
-g符号、ulimit -c已开、能拿到 core 或在 gdb 里附载运行。
1 背景与问题现象
现象:进程卡死(或停在 finalize 阶段不再前进),gdb attach 后怀疑某把 pthread_mutex 没被释放。已知可疑锁地址 0x1c2070a0,需要回答两个问题:
- 这把锁现在被哪个线程持有?
- 为什么它既不释放、也不前进?
下面用 4 步在 GDB 里把它坐实。
2 概念铺垫:LWP / PID / gdb 线程序号
2.1 三个线程 ID 的区别
在动手前必须厘清三个「线程 ID」的区别,否则会误把 _owner 当进程 PID。
| 概念 | 含义 | 在 GDB / 系统中的位置 |
|---|---|---|
| 进程 PID | 整个程序的主进程号,所有线程共用同一个主 PID | info proc / getpid() |
| LWP tid | 内核真正区分每一个线程的 ID(轻量级进程 ID = 内核线程 id) | gettid();pthread_mutex->_data._owner 存的正是它 |
| gdb 线程序号 | GDB 自己编的内部分号(1、2、…、31) | info threads 第一列 |
关键结论:pthread_mutex_t 的 _data._owner 字段存的是持有锁线程的 LWP tid,不是进程 PID。因此用「进程 PID 去反查线程」会找错对象——必须拿 LWP tid 去匹配。

2.2 pthread_mutex_t 内部结构(本文依赖的两个关键字段)
glibc(NPTL) 把 pthread_mutex_t 定义成 union,真正承载状态的是 __data(文中 GDB 表达式写作 _data,是同一字段——不同调试器对双下划线前缀的显示差异),其类型为 struct __pthread_mutex_s。我们定位死锁只盯其中两个成员:__owner 和 __lock。
下面是 glibc(NPTL) 中的真实类型定义(节选自 sysdeps/nptl/bits/pthreadtypes.h,去掉了对齐属性等无关细节):
c
/* 节选自 glibc: sysdeps/nptl/bits/pthreadtypes.h */
typedef union
{
struct __pthread_mutex_s
{
int __lock; /* 锁状态字 / futex 字 */
unsigned int __count; /* 递归重入计数 */
int __owner; /* 持有锁线程的 LWP tid */
unsigned int __kind; /* 锁类型: normal/recursive/robust/PI... */
#if __WORDSIZE == 64
int __spins; /* 自适应自旋次数 */
__pthread_list_t __list; /* robust / PI 链表节点 */
#else
unsigned int __nusers;
__extension__ union { int __spins; __pthread_slist_t __next; };
#endif
} __data; /* ← 本文 GDB 表达式中的 _data */
char __size[__SIZEOF_PTHREAD_MUTEX_T];
long int __align;
} pthread_mutex_t;源码字段前缀是双下划线
__data/__owner;本文 GDB 表达式写作_data._owner,是同一字段(部分调试器对双下划线前缀的显示差异),下面的表格与类图统一用_data表述。
| 成员 | 类型 | 作用 |
|---|---|---|
__lock | int | 锁状态字 / futex 字:0=未持有;>0=已被持有。内核 futex 直接拿它当等待队列的字 |
__owner | int | 持有锁线程的 LWP tid ← 本文关键,读它即可定位凶手线程 |
__kind | unsigned int | 锁类型:normal / recursive / errorcheck / robust / 优先级继承(PI) 等 |
__count | unsigned int | 递归锁的重入计数(同一线程第几次加锁) |
__spins | int | 自适应自旋次数(用户态先自旋再陷内核) |
__list | __pthread_list_t | robust / PI 模式下的链表节点 |

工作原理(为什么读 __owner 就能定位死锁持有者)
加锁 pthread_mutex_lock 的简化流程:
- 原子地尝试把
__lock从 0 改成 1(或 CAS)。成功 →__owner = gettid()(记下自己 LWP),立即返回; - 若已锁且
__owner == 自己且是 recursive 类型 →__count++,返回(可重入); - 否则 → 调用内核
futex(__lock, FUTEX_WAIT, ...)阻塞睡眠,直到持有者释放; - 解锁
pthread_mutex_unlock:把__owner清 0、__lock清 0,再futex(__lock, FUTEX_WAKE, 1)唤醒一个等待者。
关于
__lock是不是「原子变量」:它声明时只是普通的int,不是 C11 的_Atomic类型;其原子性来自操作它的 CPU 原子指令——带LOCK前缀的XCHG/CMPXCHG(glibc 通过__atomic_exchange_n/__atomic_compare_exchange_n等内建实现)。所以「用 CAS 操作__lock」指的是:对这块普通内存用原子指令去读-改-写,而不是它天生是原子类型。futex 内核侧也只把它当普通 4 字节整数来读。
关键:
__owner在「持锁期间」恒等于持有线程的 LWP tid,释放瞬间清零。所以只要锁还被拿着(死锁必然还拿着),读__owner就稳稳拿到凶手线程的 LWP——这正是第 3 步print mutex->_data._owner的物理依据。而__lock/__owner配合的futex,正是我们在syscall-trace实验里看到的海量futex系统调用(见 syscall-trace 实验)。
2.3 futex:连接用户态与内核的等待原语
上面流程里的 futex(...) 不是一笔带过——它正是 pthread_mutex 既能「无竞争时零开销」、又能在「竞争时睡眠」的关键。先澄清一个高频误解:
futex 既是用户态的东西,也是系统调用。
- 它的核心数据是用户态的一个 32 位整数(futex word),在 mutex 里就是
__lock。快路径(无人竞争)完全在用户态靠原子指令完成,根本不进内核; - 只有发生竞争、需要睡眠 / 唤醒时,才通过
futex()系统调用陷入内核兜底。
所以「futex 是用户态还是系统调用」的标准答案是:一套「用户态原子变量 + 内核等待队列」的混合机制,系统调用只是它应对竞争时的慢路径。
API 原型
glibc 不提供 futex() 的 C 库封装函数,需直接用 syscall(SYS_futex, ...) 调用(Linux 专有,<linux/futex.h> 定义操作码):
c
#include <linux/futex.h> /* FUTEX_WAIT / FUTEX_WAKE / ... */
#include <sys/syscall.h> /* SYS_futex */
#include <unistd.h>
long syscall(SYS_futex,
uint32_t *uaddr, /* ① 用户态 4 字节 futex 字(即 __lock) */
int futex_op, /* ② 操作码: FUTEX_WAIT / FUTEX_WAKE / ... */
uint32_t val, /* ③ WAIT: 期望的当前值(防丢失唤醒) */
const struct timespec *timeout, /* ④ 可选超时(对应 timedlock) */
uint32_t *uaddr2, /* ⑤ requeue 类用的第二个字 */
uint32_t val3); /* ⑥ 扩展参数(bitset 等) */参数要点
| 参数 | 含义 |
|---|---|
uaddr | 指向用户态那个 4 字节整数。内核按它的物理页 + 偏移做哈希来挂等待队列,所以同一块共享内存(或同进程私有锁)的线程能用同一 uaddr 互相唤醒 |
futex_op | 主操作码 + 标志位。FUTEX_PRIVATE_FLAG 表示「仅进程内使用」,内核跳过跨进程校验,更快(pthread_mutex 默认私有都会带此 flag) |
val(FUTEX_WAIT) | 调用前用户态认为 uaddr 应还是这个值;内核在原子地把它挂队列之前再比一次,不等就立刻返回——这是「检查-睡眠」原子化,避免 owner 已解锁却还睡死的丢失唤醒竞态 |
timeout | 超时返回 ETIMEDOUT(pthread_mutex_timedlock 就是它) |
uaddr2 / val3 | FUTEX_CMP_REQUEUE / FUTEX_WAKE_BITSET 等高级操作使用 |
等待 / 唤醒全流程(lost-wakeup 闭环)
- 等待方(拿不到锁):用户态 CAS 失败 → 调
futex(uaddr, FUTEX_WAIT, 期望值)。内核确认*uaddr == 期望值后,把线程挂到以uaddr物理地址为键的等待队列上睡眠; - 唤醒方(释放锁):用户态把
__lock置 0,再调futex(uaddr, FUTEX_WAKE, 1),内核唤醒最多 1 个等待者; - 内核那一端是一张全局哈希表(
futex_queues,按页 + 偏移索引),任何共享该内存的线程都能在同一种 word 上 wait / wake。
为什么叫「fast」
无竞争时全是用户态原子操作,零系统调用;只有真正竞争才陷内核。这正是 pthread_mutex 未竞争时几乎零开销、竞争时才付出 syscall 代价的原因——也是 syscall-trace 锁模式里海量 futex 系统调用的来源(每轮「争用 → 睡眠 → 唤醒」都走一次 futex)。
与本案例死锁的关系
线程 31 拿着锁(__lock = 1、__owner = 247206)后卡在 pthread_join;被 join 的监控线程想拿同一把锁 → pthread_mutex_lock 的 CAS 失败 → 调 futex(&__lock, FUTEX_WAIT, 1) 睡下;而 owner 线程 31 永远不会解锁(它自己阻塞在 join 等对方退出),于是监控线程永远睡在 futex 队列上 → 死锁。futex 负责「睡」,但没人来发 FUTEX_WAKE,环就锁死了。
3 实战定位四步
3.1 第一步:从 mutex 地址读出锁持有者
gdb
(gdb) print ((pthread_mutex_t *)0x1c2070a0)->_data._owner
$1 = 247206247206 是 LWP tid(见第 2 节),即「持有这把锁的线程在内核里的真实编号」。
3.2 第二步:thread find LWP 把 LWP 映射到 gdb 线程序号
gdb
(gdb) thread find 247206
Thread 31 has target id 'Thread 0x7fff7ad55700 (LWP 247206)'GDB 的 thread find LWP号 专门按内核 LWP tid 匹配它管理的线程序号。输出确认:gdb 线程 31 就是当前持有 0x1c2070a0 这把锁的线程。
3.3 第三步:切到该线程看栈回溯
gdb
(gdb) thread 31
(gdb) bt
#0 __pthread_timedjoin_ex
#1 pthread_join
#2 std::thread::join
#3 WorkerMgr::ensureWorkerExited
#4 Runtime::doFinalize
#5 Runtime::finalize
#6 Package::finalize
#7 finalizeAllPackages
#8 finalizeApplication
#9 SignalHandler (sig=11) // SIGSEGV 信号触发最终化流程
#10 ~ #13 真正业务栈:业务计算逻辑注:栈里的符号名(
Runtime/Package/WorkerMgr/SignalHandler等)均为脱敏后的通用占位名,仅用于演示调用链结构,与原工程无关。
关键信息:线程 31 此刻正卡在 std::thread::join 上等待另一个线程退出,而它自己还握着 0x1c2070a0 这把锁。
3.4 第四步:确认被等待线程也要这把锁 → 死锁闭环
被 join 等待的监控线程,其执行逻辑里也需要获取同一把 mutex。于是形成:
- 线程 31 拿着锁,等监控线程退出;
- 监控线程需要拿锁才能正常结束退出;
双向互相等待 → 经典死锁。

4 死锁根因:SIGSEGV 在信号 handler 里做 finalize
完整链路:
- 业务线程正在执行业务计算任务(
doCompute/doEval),运行中触发 SIGSEGV 段错误(sig=11),进入信号处理函数SignalHandler; - 信号 handler 主动拉起整个 APP 的析构 / 收尾(finalize)流程;
- finalize 一路调用到
WorkerMgr,执行std::thread::join等待监控线程退出; - 当前线程(LWP 247206 / 线程 31)持有
0x1c2070a0这把 mutex,同时它自己在join阻塞等待另一线程; - 被等待的监控线程逻辑里也需要这把 mutex → 双向等待 → 死锁。
为什么是「特殊场景」:Linux 信号处理函数是异步执行的,它打断了正常的业务流程。此时线程还握着 mutex(业务正跑到一半),在 handler 里又去 join 其他线程——等于在「锁未释放的异步上下文」里等别人拿锁,极易构成闭环。
5 关键结论
mutex->_data._owner存的是 LWP tid(内核线程 id),不是进程 PID;GDBthread find LWP能精准把它映射到 gdb 线程序号,本文用法完全正确。- 死锁触发在特殊场景:SIGSEGV 崩溃后,在信号处理函数里做资源 finalize +
join线程;异步信号上下文持有锁、又等待其他线程,形成闭环死锁。 - 高危隐患:信号 handler 不可重入、锁状态不确定,在 handler 里
join线程 / 加解锁是典型反模式,一遇到「被等待线程也要同一把锁」就死锁。
6 修复建议
| 序号 | 建议 | 理由 |
|---|---|---|
| 1 | 禁止在 SIGSEGV handler 里做 join、加解锁、复杂资源销毁 | 段错误是致命故障,信号上下文不可重入、锁状态不确定 |
| 2 | 把优雅收尾 finalize 挪到正常退出路径(main 返回 / 正常退出函数);崩溃信号只做极简动作(打日志、留 core dump),不 wait 线程、不加减锁 | 避免「异步上下文持锁等待」这一定死局 |
| 3 | 排查 WorkerMgr::ensureWorkerExited:确认被 join 的线程是否依赖这把 mutex;解耦锁的持有范围,绝不拿着锁调用 thread_join | 打破「持锁等待」闭环 |
| 4 | 增加死锁健壮性:用 pthread robust 鲁棒锁(PTHREAD_MUTEX_ROBUST),或收缩加锁范围,保证加锁/解锁在同一函数分支、绝不在信号异步路径持锁 | 即便出错也能被系统检测/恢复 |
7 补充小知识点
- 正常运行时核对:
gettid()拿到的就是 LWP,和mutex->_data._owner完全对应——脚本里可直接用gettid()打印持有者。 - 离线不依赖 GDB:
ps -T -p <主进程PID>列出该进程所有线程的 LWP,无需 gdb 也能核对锁持有者是否在线程列表里。 - 批量排多把锁:GDB
info threads一次性列出所有线程的 LWP 与栈,适合同时持有多把锁、需要核对多锁持有关系的复杂死锁。
8 GDB 线程与互斥锁分析工具箱
本文第 3 步只用了 thread find,其实 GDB 针对「多线程 + 锁」有一整套工具。按 定位 → 观察 → 控制 → 局限 四层梳理,排查死锁时基本就是这套组合拳。
8.1 定位线程:按 tid / 按状态
| 命令 | 作用 | 本文用到 |
|---|---|---|
thread find <LWP> | 按内核 LWP tid 反查 gdb 线程序号,并直接切过去 | ✅ 核心 |
info threads | 列出全部线程:gdb 序号、LWP(target id)、当前栈帧;一眼看谁卡在哪 | 配套 |
thread <n> | 切到指定 gdb 线程序号 | ✅ |
info thread <n> | 查看单个线程详情 |
thread find LWPvsinfo threads:前者是「已知 LWP 想直达」的精确武器;后者是「地毯式扫一遍所有线程栈」的概览。死锁排查通常是先info threads看谁阻塞在pthread_join/futex_wait,再用thread find钻进具体线程。
8.2 观察栈与锁对象
| 命令 | 作用 |
|---|---|
bt / bt full | 当前线程栈回溯;bt full 连局部变量一起打(常能看到函数里持有的 mutex 指针) |
thread apply all bt | 一次性打印所有线程的栈——死锁分析标配,谁阻塞、谁持锁一目了然 |
thread apply <n> <cmd> | 对指定线程单独执行命令,如 thread apply 31 print *mutex |
print ((pthread_mutex_t*)0xaddr)->_data.__owner | 直接读锁持有者 LWP(第 3.1 步核心技巧) |
print *((pthread_mutex_t*)0xaddr) | 打印整个 mutex 对象;glibc 自带 Python pretty-printer 会把 __owner/__kind 渲染成可读形式 |
8.3 锁对象可读化:glibc pretty-printer
现代 glibc 给 GDB 提供了 Python 美化打印机,print 一个 pthread_mutex_t 时不再是一堆十六进制,而是直接显示 owner = 247206、type = normal 之类。若没生效,检查 ~/.gdbinit 是否 source 了 glibc/libstdc++ 的 .py(发行版通常默认开启)。配合它,__owner 字段无需手算就能读到。
8.4 控制执行(调试并发时很有用)
| 命令 | 作用 |
|---|---|
set scheduler-locking on | 单步当前线程时冻结其它线程不调度,避免并发干扰复现 |
set scheduler-locking off | 恢复默认(所有线程都参与调度) |
catch syscall futex | 在每次 futex 系统调用上下断点,观察加锁/解锁的精确时机与参数 |
p gettid() | 若被调试程序带符号,可直接调用 gettid() 拿当前线程 LWP,与 __owner 对账 |
8.5 局限与补充
- GDB 没有内置死锁检测器:
thread apply all bt拿到全栈后,死锁环仍需人工/脚本判断(谁持 A 等 B、谁持 B 等 A)。 - 离线批量分析:
gdb -p <pid> -ex "thread apply all bt" -ex "quit" > allbt.txt,或先gcore <pid>抓 core 再慢慢看。 - 语言级辅助:Go 的 goroutine 栈自带阻塞信息;Python 用
py-spy/faulthandler——但底层思路一致:先拿到每个执行流的栈,再看谁在等谁。
8.6 与 GDB 基础衔接
本文聚焦「死锁定位」这一实战场景,只用了 GDB 多线程能力的一个子集。GDB 的断点 / 观测点 / 汇编级调试等基础命令,见 C/C++ 高级调试技巧 的「GDB 高级调试」一节。
9 相关文档
| 文档 | 与本文关系 |
|---|---|
| C/C++ 高级调试技巧 | GDB 基础命令(断点 / 观测点 / 汇编级 / 多线程),本文是其「死锁定位」实战延伸 |
| Core dump 分析 | 死锁常配合 gcore 抓 core 离线分析,见本文 8.5;该文讲 gdb <程序> <core> 的用法 |
| strace 系统调用追踪 | 线上无 GDB 时,可用 strace -f -e futex 看锁的等待/唤醒 syscall 轨迹 |
| syscall-trace 实验 | 用 perf trace 观察 futex 模式下的海量 futex 系统调用(呼应本文 2.3) |
| 锁争用演示 | 用 perf lock / pthread_mutex 实测锁争用与 futex 睡眠,配合本文原理看数据 |
一句话总结:用
print mutex->_data._owner拿到的是 LWP tid(不是 PID),thread find LWP把它映射到 gdb 线程序号,再bt一看栈——本案根因是 SIGSEGV 信号 handler 在持锁的异步上下文里join等待也需要该锁的线程,构成经典死锁,正确做法是把 finalize 移出信号 handler、绝不在 handler 里持锁等待。