死锁排查全指南:从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。程序会永久卡住CtrlC 都未必能立刻终止因为信号默认动作是结束进程但如果进程卡在锁等待里信号处理仍然是有效的真正的问题是主线程在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 或 DCPU 占用接近 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...730worker-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 如下进程MaxAllocationNeedP0(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 ≤ NeedRequest ≤ 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 21 kill -9 $PID else echo [$i] exited normally fi done脚本里的 sleep 2 是经验值留给两个线程足够的时间在锁上相遇。如果程序没卡住说明这次运行时序没对上记录下“正常退出”也是一种数据。抓到的 stack 文件里如果两个线程的栈同时停在pthread_mutex_lock且锁地址互相咬合那完整验证闭环就成立了。这个脚本也适合测试修复效果修完锁顺序后连续跑几十轮只要不再捕获到死锁日志基本可以放心提交。我现在的习惯是写完任何一段带锁的代码先自己在注释里标出锁的获取顺序号再跑一遍这个抓栈脚本。以前我也觉得死锁是玄学直到有一次线上故障两个服务互相持有对方需要的分布式锁日志刷了十几万条现场全被覆盖花了一整天才从调用链里把环翻出来。从那以后我养成的教训是死锁不能靠事后分析要让证据自动留底。这套方法不复杂但它能让死锁从黑匣子变成可重复的实验希望帮到你。本文还有配套的精品资源点击获取