ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

死锁排查全指南:从pthread到jstack,从原理到预防

死锁排查全指南:从pthread到jstack,从原理到预防

简介:面向操作系统课程与并发编程学习者,压缩包精选三个经典死锁案例:Dijkstra提出的哲学家就餐、生产者消费者以及管道通信问题,覆盖了资源分配、线程同步与进程间通信三类典型场景,并给出可运行的C++实现与对应解决方案。哲学家示例通过固定取筷顺序等策略打破循环等待;生产者消费者示例利用信号量或条件变量协调有限缓冲区,展示多线程共享有限缓冲区的同步难题;管道示例以非阻塞I/O或互斥锁避免读写阻塞,体现进程间通信的协调要点。资源共3个文件,均为cpp源码,压缩包大小仅2KB,代码精简、无需复杂环境即可直接阅读与编译调试。已有284人学习下载,适合用作操作系统实验、考研复试、课程设计或自学并发的参考资料。阅读这些示例代码,可直观对比不同同步机制的实际用法,也能深入理解死锁成因、预防与检测思路,为系统设计与并发编程打下基础。

1. 典型死锁问题:这包资料不是期末考题,是生产事故的缩小版

死锁,操作系统里最经典的并发故障。典型死锁问题.rar 这类资源包几乎人手一份,里面装着课程作业、实验报告和期末复习题,但很少有人意识到:四个必要条件背得再熟,碰到线上服务线程集体卡死、数据库事务互相锁死时,你还是会慌。原因很简单,考试考的是判断,生产考的是定位。这篇笔记会把死锁从原理、复现、检测到预防按工程路径重讲一遍,用最小可编译的代码把问题摆到桌面上。适合正在刷操作系统题库和头歌实训作业的在校生,也适合在 Linux 下写多线程程序时被卡死问题折磨过的后端开发。

2. 死锁判定四条件的可执行版本:用 pthread 把黑匣子打开

2.1 四个条件为什么“缺一不可”:从概念到资源分配图

操作系统教材里对死锁的定义是:一组进程中的每个进程都在等待一个只有该组中其他进程才能引发的事件。落到代码上,最常见的事件就是“释放锁”。判定死锁要同时满足四个条件:互斥、持有并等待、不可剥夺、循环等待。很多人背得滚瓜烂熟却不会用,是因为没理解“缺一不可”的反向价值——只要破坏掉任意一个,死锁立刻解除,这是后面所有预防策略的出发点。

判断一个系统是不是死锁,我一般会先画资源分配图:进程画圆圈,资源画方框,进程指向资源表示“请求”,资源指向进程表示“分配”。找环是核心动作。如果图里没有环,那最多是阻塞,不是死锁;如果有环,再看每个资源是不是只有一份,如果某种资源有多份副本,环里那一段实际不构成互相等待,死锁也不成立。

典型死锁问题.rar 里的作业题,绝大多数是给你一张资源分配图让你判断“是否死锁”,或者给你一段多线程代码让你找死锁。做题时用纸笔画图,调试时把代码里的锁当作资源、把线程当作进程,画成图再看,两边是同一套思维模型。这个转换能力才是这份资料真正值钱的地方。

2.2 最小死锁 Demo:两个锁,两个线程,一段 C 代码

在 Linux 下复现死锁,最直接的方式就是用 pthread 写“AB-BA”锁序反转。两个线程各自先拿一把锁,再去抢对方手里的另一把锁。为了让死锁稳定出现,中间加一个 sleep,把竞争窗口人为拉大,避免某个线程连续抢到两把锁导致程序直接跑完。

#include <pthread.h> #include <stdio.h> #include <unistd.h> pthread_mutex_t lock_a = PTHREAD_MUTEX_INITIALIZER; pthread_mutex_t lock_b = PTHREAD_MUTEX_INITIALIZER; void *worker1(void *arg) { pthread_mutex_lock(&lock_a); sleep(1); // 持有 A,等 B pthread_mutex_lock(&lock_b); // 此时 B 已被 worker2 持有 pthread_mutex_unlock(&lock_b); pthread_mutex_unlock(&lock_a); return NULL; } void *worker2(void *arg) { pthread_mutex_lock(&lock_b); sleep(1); // 持有 B,等 A pthread_mutex_lock(&lock_a); // 此时 A 已被 worker1 持有 pthread_mutex_unlock(&lock_a); pthread_mutex_unlock(&lock_b); return NULL; } int main(void) { pthread_t t1, t2; pthread_create(&t1, NULL, worker1, NULL); pthread_create(&t2, NULL, worker2, NULL); pthread_join(t1, NULL); pthread_join(t2, NULL); return 0; }

编译命令是gcc -o deadlock deadlock.c -lpthread,然后./deadlock。程序会永久卡住,Ctrl+C 都未必能立刻终止,因为信号默认动作是结束进程,但如果进程卡在锁等待里,信号处理仍然是有效的,真正的问题是主线程在pthread_join里等两个 worker 永远结束不了。

这段代码的关键参数其实是sleep(1)。它的作用不是模拟“慢操作”,而是让两个线程在时间上对齐:worker1 拿到 A 之后必须等到 worker2 拿到 B,两个线程再同时发起第二次加锁,循环等待才成立。把 sleep 换成 0,进程可能就直接退出了,因为 worker1 连续拿到 A、B 两把锁,worker2 再来时锁早已释放,整段程序不会死锁。调试死锁时,这类“时序窗口”是最容易忽略的变量。

2.3 死锁之后的第一现场:ps、top 和 gdb 怎么配合

死锁一旦触发,先别急着杀进程。./deadlock卡住后,另开一个终端,用ps -eLf | grep deadlock能看到两个线程都还在,状态列是 S 或 D,CPU 占用接近 0。这不是机器负载高,而是线程在等待锁时进入了睡眠,不消耗计算资源。用top -H -p <pid>能更直观地看到每个线程的 CPU 时间没有增长。

接着用 gdb 附着到进程上,拿到每个线程的调用栈:

gdb -p $(pgrep -n deadlock) -batch \ -ex "set pagination off" \ -ex "thread apply all bt"

输出里会出现两个线程的栈全部停在pthread_mutex_lock,且各自带着另一把锁的地址。看到这种“两边都在 lock,且锁地址能形成环路”的栈,基本可以当场定罪。这一步比任何理论分析都更能说服同事——死锁的问题不在业务逻辑,而在锁的获取顺序写反了。把 gdb 抓到的栈保存下来,连同 gdb 的 thread 信息一起贴到工单里,就是一个完整的故障证据链。

3. 死锁定位实战:jstack、gdb 与数据库死锁日志的三种读法

3.1 线程死锁定位:jstack 里的那条 “Found one Java-level deadlock”

后端服务的死锁大部分发生在 Java 应用里。JVM 自带的 jstack 能直接检测并打印出死锁现场,这个能力比 C 语言环境友好得多。先写一个会死锁的最小 Java 类:

public class DeadlockDemo { static final Object lockA = new Object(); static final Object lockB = new Object(); public static void main(String[] args) throws Exception { Thread t1 = new Thread(() -> { synchronized (lockA) { try { Thread.sleep(100); } catch (InterruptedException e) { } synchronized (lockB) { } } }, "worker-1"); Thread t2 = new Thread(() -> { synchronized (lockB) { try { Thread.sleep(100); } catch (InterruptedException e) { } synchronized (lockA) { } } }, "worker-2"); t1.start(); t2.start(); t1.join(); t2.join(); } }

编译运行后,用jstack <pid>查看线程栈,输出尾部会出现一行“Found one Java-level deadlock”,下面跟着两个线程的完整锁信息。类似这样:

Found one Java-level deadlock: "worker-2": waiting to lock <0x00000000d5a5b730> (a java.lang.Object) locked <0x00000000d5a5b720> (a java.lang.Object) "worker-1": waiting to lock <0x00000000d5a5b720> (a java.lang.Object) locked <0x00000000d5a5b730> (a java.lang.Object)

注意看锁地址:worker-1 持有 0x...720 等待 0x...730,worker-2 持有 0x...730 等待 0x...720,两个地址互相咬合成一个环。jstack 已经把“谁持有、谁等待”列清楚了,不需要自己翻源码。线上遇到线程卡死,第一步永远是jstack pid而不是重启,重启只是把现场清零。

3.2 内核态死锁:spinlock 睡眠时的 CPU 软锁死

用户态的锁等不到会睡下去,内核里的自旋锁(spinlock)不一样,它是忙等的,拿不到锁就在原地反复试探。如果持有自旋锁的核睡着了或被人为抢占,其他核就只能空转,整个系统像冻住一样,外设中断也得不到响应。arm64 多核环境下,这个问题更突出,内核会通过 NMI watchdog 检测到某个核长时间不调度,然后打出 soft lockup 警告。

dmesg | tail里如果出现 “soft lockup” 或者 “BUG: spinlock lockup suspected”,多半就是有驱动或内核模块在持锁期间调用了可能睡眠的函数,比如kmalloc(GFP_KERNEL)、mutex_lock、sleep。定位方式不是用 gdb,而是看内核线程的栈回溯:

cat /proc/<pid>/stack

这个文件能看到内核态线程当前阻塞在哪个函数,再配合dmesg里的锁地址,判断是哪条调用路径在持锁睡眠。修复方向一般很明确:自旋锁临界区里禁止任何睡眠,需要睡眠的逻辑换成 mutex 或完成量。这条血泪经验适用于所有写内核模块或驱动的人。

3.3 数据库死锁:数据库怎么“抓”到死锁并主动回滚

死锁不只在操作系统里出现,数据库事务同样会锁。两个事务各自更新一行,再去更新对方持有的行,就构成了行级锁死锁。InnoDB 引擎不会让死锁无限延续,它维护一个等待图,周期性检测环的深度,发现环后主动回滚代价较小的事务,让另一个事务继续。

MySQL 里查看死锁现场的标准姿势是:

SHOW ENGINE INNODB STATUS\G;

在输出里找到LATEST DETECTED DEADLOCK段落,里面会列出两个事务各自的 SQL 语句、持有的行锁、等待的行锁。和操作系统死锁的区别在于,数据库通过回滚强制剥夺了其中一个事务的资源,这相当于从侧面破坏了“不可剥夺”条件,所以数据库服务不会整体卡死,只是个别连接报错。

理解数据库死锁和操作系统死锁的异同,对排查线上问题很重要:数据库死锁有日志、有回滚,是“带保护”的;操作系统级死锁没有这种回收机制,只能靠外部干预或人工放锁。从 .rar 里的典型死锁问题出发学透这套模型,再看数据库死锁日志,思路会顺很多。

4. 死锁预防与避免:从加锁顺序到银行家算法的完整决策链

4.1 工程上破坏四个条件的优先级

操作系统课程里讲死锁预防,是让你去破坏四个必要条件。落到真实代码里,这四个条件破坏起来的代价完全不同,我按优先顺序列一张表:

必要条件工程手段代价与风险
互斥用读写锁、无锁数据结构替代独占锁只读为主的场景有效,写多场景收益有限
持有并等待一次申请全部资源(批量加锁后启动任务)资源利用率低,且容易引入新的锁嵌套
不可剥夺trylock + 超时重试,拿不到就释放已持有的锁需要处理“部分完成”的写入,业务逻辑变复杂
循环等待全局锁顺序编号,按固定序号获取跨模块协调难,但性价比最高,是首选手段

最常见的工程做法是破坏循环等待。给每把锁定义一个全局唯一顺序,所有代码都按这个顺序获取,循环等待就不可能成立。比如两个数据库表的数据同步任务,约定“先锁订单表,再锁库存表”,无论哪个线程进来都守这条规矩,AB-BA 死锁从源头消失。

破坏互斥条件听着美好,但绝大多数业务资源天生就是独占的,强行改成无锁结构往往引入更隐蔽的并发问题。破坏持有并等待的典型做法是批量加锁,但如果你在持有锁 A 的时候还要等锁 B,那就必须先一次性拿到 A 和 B 再开始干活,这会把临界区拖得极长,性能下降明显。trylock 方案适合短临界区,但重试的间隔值设多少是个玄学,设短了忙等加剧,设长了事务延迟上升。

4.2 银行家算法的手工推演:Available、Need 与安全序列

银行家算法是操作系统中死锁避免的经典方案,它的核心是:每次资源分配前先假装分配出去,然后检查系统是否仍处于安全状态。如果存在一个安全序列,满足所有进程都能依次完成,那这次分配就允许;否则拒绝。很多学校的操作系统期末复习题和头歌实训作业都会出这类题,题目格式高度统一:给你 Available、Max、Allocation,求安全序列。

拿一个典型例子手推一遍,系统有 A、B、C 三类资源,当前 Available = (3, 3, 2),五个进程的 Max 和 Allocation 如下:

进程MaxAllocationNeed
P0(7, 5, 3)(0, 1, 0)(7, 4, 3)
P1(3, 2, 2)(2, 0, 0)(1, 2, 2)
P2(9, 0, 2)(3, 0, 2)(6, 0, 0)
P3(2, 2, 2)(2, 1, 1)(0, 1, 1)
P4(4, 3, 3)(0, 0, 2)(4, 3, 1)

Need = Max - Allocation,这是第一件要算对的事。接下来 Work 初始等于 Available = (3, 3, 2),每次找一个 Need 的每一项都小于等于 Work 且还没完成的进程,分配给它,然后等它执行完回收 Allocation,把资源加回 Work。这个例子里的推导过程是:P1 的 (1,2,2) ≤ (3,3,2),回收后 Work 变 (5,3,2);P3 的 (0,1,1) ≤ (5,3,2),回收后 Work 变 (7,4,3);接着 P0、P2、P4 依次满足,安全序列是 P1→P3→P0→P2→P4。

手工推演的要点是每次只能挑一个满足条件的进程先跑,不能跳步。如果推到某一步发现没有任何进程的 Need 小于等于 Work,就说明当前状态不安全,这次分配必须拒绝。银行家算法的局限性也很明显:进程最大需求必须是提前声明的,现实中业务负载动态变化,很难预估每个线程的资源峰值,所以纯银行家算法在通用操作系统里几乎见不到,在嵌入式中间件和内核某些模块里倒是偶有落地。

4.3 用 Python 写可抄的安全性检查函数

课程实验里实现银行家算法,最核心的代码块就是安全性检查。下面这个 Python 函数可以直接抄下来做验证:

def is_safe(available, max_need, allocation): """检查当前状态是否存在安全序列。 available: 每种资源的剩余数量列表,如 [3, 3, 2] max_need: 每个进程的最大需求矩阵,行为进程,列为资源 allocation: 每个进程已分配的资源矩阵 返回值: (是否安全, 安全序列列表) """ num_process = len(max_need) num_resource = len(available) # Need = Max - Allocation need = [[max_need[i][j] - allocation[i][j] for j in range(num_resource)] for i in range(num_process)] work = available[:] finish = [False] * num_process safe_seq = [] while len(safe_seq) < num_process: progressed = False for i in range(num_process): if not finish[i] and all(need[i][j] <= work[j] for j in range(num_resource)): # 找到满足条件的进程,回收它的资源 for j in range(num_resource): work[j] += allocation[i][j] finish[i] = True safe_seq.append(i) progressed = True if not progressed: # 一轮扫描没有任何进展,说明存在死锁风险 return False, [] return True, safe_seq

这个函数只做安全性判定,完整流程里还得先检查本次请求:Request ≤ Need,Request ≤ Available,然后试分配并调用 is_safe 看结果。参数里最容易出错的坑是 max_need 必须已经是“总需求”,而不是“还需要多少”,如果传错,计算出的 Need 可能小于实际需求,安全判断会失真。

5. 避坑指南:死锁排查里最常见的 5 个翻车现场

5.1 现象:进程“假死”但 CPU 占用 0%

线上服务突然不响应,top一看进程还在,CPU 几乎为 0。很多人第一反应是“机器负载不高,为什么服务不动”,其实这正是死锁的典型特征:线程在等待锁时是睡眠状态,不消耗 CPU。如果进程 CPU 一直很高而请求卡住,那更可能是死循环或 IO 阻塞,不是死锁。解决方法是直接抓线程栈,用 jstack 或 gdb 看线程停在哪里,而不是盯着 CPU 数值猜。

5.2 现象:加了一行 printf,死锁就消失了

这是复现死锁时最容易踩的坑。代码原本稳定死锁,你为了观察加了条日志,程序反而跑通了。原因不是日志“治好”了死锁,而是日志改变了线程执行的时序,竞争窗口被压缩或错开,循环等待没能形成。反过来说,如果手动测试程序总是正常退出,不代表代码没有死锁风险,只是窗口没被命中。解决思路是用 gdb 断点控制线程执行顺序,或者在代码里故意增大竞争窗口,比如把前文的 sleep(1) 留在线程拿到锁之后。

5.3 现象:同一个线程重复加同一把锁,把自己锁死

pthread_mutex 默认是普通锁,同一线程对同一把锁连续加锁两次,会永久阻塞在自己手上,这叫自死锁。C 语言里常见的翻车点是递归函数里加锁,或者回调函数触发了同一把锁的再次获取。解决办法是把锁改成可重入的递归锁PTHREAD_MUTEX_RECURSIVE,或者重构代码,避免在持锁路径上再次进入加锁函数。可重入锁听着方便,但它会掩盖锁设计问题,能不用尽量不用。

5.4 现象:没有嵌套锁,sem_wait 却永久阻塞

有些场景根本没有 A、B 两把锁互相嵌套,信号量却卡死了。典型原因是sem_post的调用次数少于sem_wait,信号量计数被耗尽。比如消费者任务异常退出,信号量没归还,后续所有消费者全部阻塞在sem_wait上。解决方法是检查信号量初值和每次 post 的路径,用sem_getvalue看当前计数,如果长期为 0 且业务没有积压,基本可以判定有资源泄漏。这类问题“看起来像死锁”,但不是循环等待,排查时要分清。

5.5 现象:每个函数看着都正常,整体却死锁

单看任何一个模块,锁的使用都是对的:先拿本地锁再做业务,然后调用下游。但把两个模块拼在一起,A 模块持有日志锁去调 B 模块,B 模块持有连接池锁又回调 A 模块,环在模块边界处形成。这种跨模块死锁最难定位,因为每个函数的锁序单独看都成立。解决办法是从全局梳理锁顺序清单,给每个锁编号,代码评审时按编号核对加锁路径。内核的 lockdep 机制就是干这个的,用户态代码则要靠团队约定和静态检查。

6. 用自动抓栈脚本把死锁变成可重复实验,并把检测动作练成肌肉记忆

死锁排查的本质是锁定“谁持有、谁等待”,最快的验证方式是把死锁变成一个能重复触发的实验,而不是靠运气复现。我常用的做法是写一个外壳脚本,反复启动死锁程序,卡住就自动用 gdb 抓栈:

#!/bin/bash for i in $(seq 1 30); do ./deadlock & PID=$! sleep 2 if kill -0 $PID 2>/dev/null; then echo "[$i] deadlock captured, pid=$PID" gdb -p $PID -batch \ -ex "set pagination off" \ -ex "thread apply all bt" > stack_$i.txt 2>&1 kill -9 $PID else echo "[$i] exited normally" fi done

脚本里的 sleep 2 是经验值,留给两个线程足够的时间在锁上相遇。如果程序没卡住,说明这次运行时序没对上,记录下“正常退出”也是一种数据。抓到的 stack 文件里,如果两个线程的栈同时停在pthread_mutex_lock,且锁地址互相咬合,那完整验证闭环就成立了。这个脚本也适合测试修复效果:修完锁顺序后连续跑几十轮,只要不再捕获到死锁日志,基本可以放心提交。

我现在的习惯是,写完任何一段带锁的代码,先自己在注释里标出锁的获取顺序号,再跑一遍这个抓栈脚本。以前我也觉得死锁是玄学,直到有一次线上故障,两个服务互相持有对方需要的分布式锁,日志刷了十几万条,现场全被覆盖,花了一整天才从调用链里把环翻出来。从那以后,我养成的教训是:死锁不能靠事后分析,要让证据自动留底。这套方法不复杂,但它能让死锁从黑匣子变成可重复的实验,希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进