news 2026/10/7 10:02:57

典型死锁问题:从复现、定位到预防的完整实践指南

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
典型死锁问题:从复现、定位到预防的完整实践指南

简介:针对操作系统与并发编程学习者,压缩包收录了三个经典死锁案例:哲学家问题、生产者-消费者问题以及管道通信问题。每个案例均提供一份可运行的C++源码,通过模拟并发执行场景,直观展示资源竞争、循环等待等死锁产生条件,并配有对应的解决方案,例如固定取筷顺序、信号量/条件变量协调缓冲区、非阻塞I/O与互斥锁避免管道阻塞。包内共3个文件,全部为.cpp源文件,整体大小仅2KB,轻量易读,方便直接编译调试和对照分析。资源从资源分配、进程调度到同步机制等角度切入,既适合课堂演示与课后复习,也能帮助自学读者梳理预防和解除死锁的核心思路。目前已有284人学习使用,是操作系统原理和并发编程入门阶段值得参考的配读材料,也可用于实验课上快速复现死锁场景。

1. 典型死锁问题不是背概念,是要在代码里亲眼撞一次

也许你是在操作系统期末复习或者实验课上把这个标题存下来的——典型死锁问题。死锁在教材里就八个字:互斥、持有并等待、不可剥夺、循环等待。但真到实验课,你在Linux下用pthread写一个加锁程序,两个线程互相等对方释放锁,程序直接睡死,CPU占用掉到0,这才叫学会死锁。这篇笔记就把“典型死锁问题”这个主题拆成一条从复现、定位到预防的完整路线,配套的思路适合正在做操作系统实验、准备期末复习的本科生,也适合刚接触多线程编程、想知道程序为什么卡死的人。先动手撞一次死锁,再谈别的。

2. 复现一个典型死锁:用两把锁让两个线程互相“绑架”

这一章要把死锁从概念变成能跑的程序。不要只背四个必要条件,要在代码里给它们一一对上号。理解了怎么“制造”死锁,后面检测和预防才有抓手。

2.1 死锁的四个必要条件,先从代码里找对应

很多同学问“死锁问题怎么理解”,我的做法是:先拿一段最典型的双线程、双互斥锁代码,逐行对应教材里的四个条件。互斥条件对应pthread_mutex_lock不让两个人同时持有同一把锁;持有并等待对应线程A拿着锁1不放手、同时去等锁2;不可剥夺对应lock等待时系统不会强制回收锁1;循环等待对应A等B、B等A形成的环形依赖。

为了便于写实验报告,我常用一张表把这组对应关系列出来:

死锁必要条件代码中的表现
互斥每把mutex同一时刻只有一个线程持有
持有并等待线程持有第一把锁,调用lock去等第二把
不可剥夺等待锁时不会强制抢其他线程手里的锁
循环等待线程A占锁1等锁2,线程B占锁2等锁1

这组对应关系不是死记硬背,而是作为复现的检查清单。如果你的代码里缺少其中任何一个“表现”,程序就算卡住也可能不是死锁,而是别的问题。比如线程A和B都要先抢同一把全局锁,那就只有竞争,没有循环等待,自然产生不了死锁。

2.2 经典双锁demo:复现死锁的最小代码

常见做法是直接在Linux主机上写C程序,我一般用下面这个demo.c。它短小、现象明确,也是操作系统实验里最常见的一个范例。

#include <stdio.h> #include <pthread.h> #include <unistd.h> pthread_mutex_t lock1 = PTHREAD_MUTEX_INITIALIZER; pthread_mutex_t lock2 = PTHREAD_MUTEX_INITIALIZER; void *thread_a(void *arg) { pthread_mutex_lock(&lock1); sleep(1); // 确保线程B也拿到第一把锁 printf("A 拿到 lock1,准备拿 lock2\n"); pthread_mutex_lock(&lock2); // 在这里等待 B 释放 lock2 printf("A 拿到了两把锁\n"); pthread_mutex_unlock(&lock2); pthread_mutex_unlock(&lock1); return NULL; } void *thread_b(void *arg) { pthread_mutex_lock(&lock2); sleep(1); printf("B 拿到 lock2,准备拿 lock1\n"); pthread_mutex_lock(&lock1); // 在这里等待 A 释放 lock1 printf("B 拿到了两把锁\n"); pthread_mutex_unlock(&lock1); pthread_mutex_unlock(&lock2); return NULL; } int main() { pthread_t a, b; pthread_create(&a, NULL, thread_a, NULL); pthread_create(&b, NULL, thread_b, NULL); pthread_join(a, NULL); pthread_join(b, NULL); return 0; }

编译命令是 gcc demo.c -o deadlock -lpthread,然后跑 ./deadlock。其中-lpthread必须加,否则旧版glibc链接期会报pthread_create未定义。程序运行后会打出“准备拿”的提示,然后卡住,不再输出“拿到了两把锁”。

关键点是两处 sleep(1)。没有它们,两个线程可能不会同时各拿一把锁,死锁就偶发。加了睡眠后,线程A必然持锁1,线程B必然持锁2,然后互相等对方,循环等待条件必然成立。程序既不退出,也不消耗CPU,因为阻塞发生在mutex的futex等待上,进程状态是S。这些现象建议写进实验报告,当作死锁触发成功的证据。

2.3 对照实验:调整加锁顺序为什么能救回死锁

同样是两个线程、两把锁,只要让两边都按相同顺序拿锁,死锁就消失了。把thread_b函数里的lock顺序改成和thread_a一样,也就是先lock1再lock2,编译运行后两个线程都能正常执行完。这个改动很小,但演示了一个很重要的工程结论:破坏循环等待,只需要统一加锁顺序。

如果在自己的代码里不想改线程函数,也有另一种做法:把锁整体编号,约定所有线程必须从小到大拿。Lock1编号1,lock2编号2,thread_b本来想先拿2,但规则要求先拿1,于是线程B先等lock1。这是很多数据库系统、分布式锁组件内部约定锁顺序的原理。做完这个对照实验,最好把两种运行结果记录到实验报告里,对比表和运行截图排在一起,老师一看就知道你是真跑了代码而不是抄结论。

3. 死锁的检测与定位:从黑匣子到线程级证据

程序卡死了,怎么确定它一定死锁,而不是普通忙等、无穷循环或者单纯的线程饥饿?这是排查的核心。实际工作里,新手第一反应是直接kill进程,但正确做法是先留下证据,再动手杀进程。死锁实验里也要求“证明”这是死锁,而不是靠猜。

3.1 用pstack、gdb、jstack定位阻塞点

在Linux平台上,三个工具基本够用:pstack、gdb、jstack。C程序优先用pstack,它一条命令打出所有线程的调用栈,不需要交互。如果拿不到“典型死锁问题”资源包里的现成脚本,这个命令可以自己敲。

pstack 12345

假设进程PID是12345,输出里会出现每个线程的调用栈。以demo.c为例,能看到类似这样的信息:

Thread 2 (Thread 0x7f...): #0 __lll_lock_wait () #1 pthread_mutex_lock #2 thread_a (arg=0x0) at demo.c:10 #3 start_thread ... Thread 1 (Thread 0x...): #0 __lll_lock_wait () #1 pthread_mutex_lock #2 thread_b (arg=0x0) at demo.c:20 #3 start_thread ...

两个线程都停在 pthread_mutex_lock 上,且后面的源码行号一个是进锁12的入口,一个是进锁1的入口,这就足以说明双方都在等待互斥锁。要更细致地确认锁的持有者,再用gdb attach到进程,执行thread apply all bt打印全部线程栈,然后在卡住的线程里frame切到 pthread_mutex_lock 上一层,看传入的是哪把锁的地址,对比两个地址是否与对方持有锁对应。

Java后端碰到死锁,常见做法是jstack -l <pid>。输出末尾会给出 “Found one Java-level deadlock” 的检测结果,直接列出锁环和涉及的线程号。这在线上服务排查里能省很多时间,虽然是考试范围外的内容,但真正写多线程服务时很有用。

3.2 区分死锁和活锁:看CPU占用和线程状态

新手最容易把活锁当成死锁。活锁时线程没有阻塞,CPU占用很高,只是两个线程互相谦让、做无用重试,典型实现是 while (try_lock(a)) { release; retry; } 反复循环。而死锁的线程状态是Sleeping或Blocked,CPU占用很低,线程不前进。

判断方法并不玄学:用 top 或 htop 看进程CPU和线程状态。活锁时线程处于R状态,CPU%接近100;死锁时线程处于S状态,CPU%几乎为0。可以隔1秒采样两次,如果线程连续停在同一个 lock 调用上,且CPU始终是0.0%,基本可以判定是死锁候选。

另外,如果看到进程CPU忽高忽低、线程在疯狂打印日志,那更可能是忙等或者锁竞争严重,不是典型死锁。实验报告里写“通过htop观察可见进程长时间占用0% CPU,线程均处于sleeping状态”,比单纯写一句“卡住了”更能拿分。

3.3 用strace跟踪系统调用,确认线程卡在futex上

pstack能帮助我们看函数调用,strace能看内核交互。死锁时线程进入pthread_mutex_lock后,会通过futex系统调用等待,这是Linux互斥锁的实现基础。执行下面的命令,可以看到线程卡在什么系统调用上。

strace -p 12345 -f -tt

输出里会看到这样的信息:

[pid 12346] futex(0x7f..., FUTEX_WAIT_PRIVATE, 0, NULL) = ? ERESTARTSYS

多个线程都停在 FUTEX_WAIT 等待同一批地址,说明系统处于等待状态。这个证据配合pstack调用栈,就构成了一条完整的“函数级-内核级”定位链。写操作系统实验报告时,把 strace 的输出摘一段进去,再注明这些都是等待futex而非读文件或睡定时,就能有力排除其他故障。

4. 防止死锁:银行家算法与破坏必要条件

复现死锁是为了对付死锁。操作系统课程里有两条路线:死锁预防和死锁避免。预防是写代码时直接不让四个必要条件同时成立;避免的核心算法是银行家算法。这部分是期末复习里的高频考点,也是实验报告里方案对比的核心内容。

4.1 银行家算法的本质与可运行代码

银行家算法解决的核心问题是:系统有若干资源,多个进程提出资源申请,分配后会不会导致死锁。它的做法是预先判断分配后是否存在一个安全序列,如果存在就分配,否则等待。下面这段Python代码是教材伪代码的等价实现,足够用来验证你手工演算的结果。

def is_safe(available, allocated, need): work = available[:] finish = [False] * len(allocated) while True: found = False for i in range(len(allocated)): if not finish[i] and all(need[i][j] <= work[j] for j in range(len(work))): work[j] += allocated[i][j] # 模拟进程结束后释放资源 finish[i] = True found = True break if not found: break return all(finish)

入参是三个二维/一维列表:available是当前可用资源数,allocated[i]是进程i已分配的资源,need[i]是进程i还需要的资源。函数内部反复扫描所有未完成进程,找到一个need[i] <= work的进程,就假设它运行完毕,释放它占用的资源,加到 work 里,直到找不到可推进进程为止。最后所有进程都 finish 就是安全状态。

这段代码不追求性能,因为课程例子普遍是5进程3资源,线性搜索完全够用。写实验代码时,建议把 work 数组每一步的变化打印出来,方便比对教材里的演算表。很多同学手算结果和程序不一样,问题往往出在“是否加了释放资源”这一步,打印后一眼就能看出差异。

4.2 手工算安全序列时最容易错的三个点

第一是忘记“进程结束后马上释放资源”。很多人算到某个进程满足条件后,只标记finish,没有把 allocated 加回 work。这样后面的进程需求永远得不到满足,本来安全的系统也被判断为不安全。

第二是初始扫描没有把所有进程全看一遍。教材算法每轮是“从第一个进程重新扫起”,而不是只扫一趟。用上面代码里的 break 加 while 循环,才能保证每一轮都从头找。手算时如果不小心从上次位置继续,就可能漏掉前面已经满足的进程。

第三是混淆“Need”和“Request”。银行家算法判断的是尚未满足的部分,不是进程一次新申请的量。实验题有时候给的是Request,要把Need - Request之后的新Need算出来,再代入安全检测。这类细节在期末复习里特别阴险,建议把经典测例按照“available、allocated、need”三组数据格式整理到操作系统笔记里,考试前重跑一遍代码验证一次。

4.3 工程上更常用的做法:破坏持有并等待或循环等待

银行家算法在真实OS里很少被完整实现,因为预先知道每个进程最大需求量这件事,在业务层几乎做不到。工程上防死锁主要靠两大类手段。

第一类是破坏“持有并等待”:要么让线程一次性申请所有资源,拿不到任何一把就全不拿;要么用锁级别约定,在拿新锁前先释放旧锁。数据库里下单同时锁订单表和库存表时,可以先按固定顺序一次性拿两个锁,全部成功才继续;拿不到就回滚等待。

第二类是破坏“循环等待”:给所有锁编号,强制线程按编号升序加锁。刚才2.3里的对照实验已经演示过这个原理。很多分布式锁框架也默认要求按固定key顺序加锁,否则集群环境下同样会产生死锁。理解了破坏循环等待,等于理解了大部分分布式锁死锁规避方案。这部分虽然不在考试代码题里,但对保研面试、软考等场景很加分。

5. 死锁实验的几个经典踩坑:从编译到复现的血泪经验

这一章写给正在跑“典型死锁问题”实验的人。下面这些坑我几乎每年都会在同学代码里看到一遍,每一条都按现象、原因、解决来整理,建议直接对照检查。

5.1 线程加了sleep也会闪退,原来是缺-pthread

现象:编译命令只用了 gcc demo.c -o deadlock,运行后段错误,或者报 “undefined reference to pthread_create”。

原因:不同版本的glibc对线程库的链接要求不一致。老版本Linux环境如果-lpthread缺失,链接就会失败;有些机器上虽然链接成功,但缺少头文件声明,线程函数返回值被当成int处理,也会崩。

解决:编译命令固定写成gcc demo.c -o deadlock -lpthread -g。-g必须带上,后面用gdb定位栈时调试信息不全就抓瞎。另外确认#include <pthread.h>写在源文件开头,不要只从网上复制片段。

5.2 死锁偶尔出现偶尔正常,不是玄学

现象:代码逻辑和示例一致,但一次能卡死,一次直接跑完,最终结果看运气。

原因:两个线程竞争第一把锁的顺序不确定。可能线程B先拿到了lock1,后面就构不成环;也可能线程A已经在趁系统调度间隙执行完并释放了锁。死锁触发需要严格的时序交集。

解决:在拿第一把锁之后加入 sleep(1)。这会保证两个线程都先占有自己的第一把锁,再同时去等第二把。注意睡眠时间不是越久越好,1秒足够覆盖最慢的线程启动,再长的话示教会让人等得着急。

5.3 Windows控制台跑死锁程序,窗口直接“假死”关不掉

现象:在Windows原生开发环境里复现同一段逻辑,控制台窗口卡死,直接点关闭没反应,只能任务管理器结束进程。

原因:Windows的CRITICAL_SECTION或mutex和Linux互斥锁行为不完全一样,但循环等待同样会造成进程永久阻塞。问题在于Windows终端进程被挂起时,窗口消息处理也停了,看上去像“系统死机”,其实只是这个进程卡住。

解决:实验建议在Linux虚拟机或WSL里跑,既能保持环境一致,又方便用pstack和strace。如果必须在Windows演示,就用任务管理器结束对应进程,或者写一个带超时保护的调用脚本,测试2秒后强制结束子进程,免得灼烧桌面体验。

5.4 银行家算法手工算的安全序列,和代码结果不一致

现象:手算认为当前系统安全,但自己写的代码返回False,或者反过来代码说安全、手算认为危险。

原因:常见错误是“进程结束后没有释放资源”。不少人在算到某个进程满足需求时,只标记finish,忘记把allocated加回work。另外,如果扫描循环只在所有进程上跑一趟,就可能导致后面有满足条件的进程却没被找到,因为前面较早的进程已经提前满足了。

解决:按4.1的代码逻辑逐行检查,重点看work[j] += allocated[i][j]这一行,并确保用 while 循环反复扫描。手算时按“每一轮都从头扫”的方式列表格,每个进程占一行,逐步标记work演变。

5.5 实验PPT和资源包里给的是伪代码,抄不运行

现象:从网上下载的“典型死锁问题”压缩包解压后,里面常常只有一个C文件或几张PPT,复制过来的代码缺头文件、缺main函数,甚至把教材里的中文分号也贴进去了。

原因:很多课程资源本身是教学残片,不是完整工程。它们更强调展示结论,不保证直接可运行。

解决:无论资源包里的代码长什么样,都建议自己按2.2的做法重写一个最小可运行版本。别在意代码简略,关键是复现现象。把实验包里的代码当作参考,能跑通自己的版本才算掌握。

6. 用自动化检测脚本守护后续实验:一个小巧的trick

到这一步,你已经能从复现到定位把死锁问题走一遍。剩下的进阶动作,是给后续所有并发实验加一个“自动死锁检测”的守护脚本。我在做网络编程大作业时,就把这套流程写成了一个shell函数,每次程序卡住都不用手动打断。

check_lock() { local pid=$1 for i in $(seq 1 10); do sleep 1 cpu=$(ps -o %cpu= -p $pid) if [ "$cpu" = "0.0" ]; then state=$(ps -o stat= -p $pid) if [[ "$state" == *S* ]]; then echo "suspected deadlock, pid=$pid" pstack $pid break fi fi done }

脚本逻辑是每1秒采样一次CPU和线程状态,连续多次保持0%且状态为S,就判定疑似死锁,自动输出pstack。它不能100%区分死锁和IO阻塞,但足以在实验课里快速定位。实际工作里,我会把这个思路延伸到数据库事务锁等待的排查,用 information_schema 里的锁等待表做同样的“采样-状态-调用栈”诊断。

说到底,死锁问题唯一值得相信的检测方式就是复现并留下线程级证据。我自己最早学死锁时也抄过一份实验报告,以为看懂了,直到代码卡在sleep上才明白——死锁不是名词,是动词。希望这段从复现到定位的路,能帮你在操作系统这条路上少卡几次。

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/7 10:02:51

Okbiye 开题报告模块深度测评|搞定论文开题不再头疼

前言 开题报告是毕业论文的第一道关卡&#xff0c;也是很多应届生最先遇到的难题。开题不是简单写一段文字&#xff0c;需要确定研究选题、梳理国内外研究现状、明确研究目的与意义、设计研究内容、规划技术路线&#xff0c;还要拟定参考文献。开题一旦逻辑不通、选题过大或没…

作者头像 李华
网站建设 2026/10/7 10:01:53

五指山(信息学奥赛一本通- P1638)

【题目描述】原题来自&#xff1a;NEFU 84大圣在佛祖的手掌中。我们假设佛祖的手掌是一个圆圈&#xff0c;圆圈的长为 n&#xff0c;逆时针记为&#xff1a;0,1,2,⋯,n−1&#xff0c;而大圣每次飞的距离为 d。现在大圣所在的位置记为 x&#xff0c;而大圣想去的地方在 y。要你…

作者头像 李华
网站建设 2026/10/7 10:00:08

仿真强化学习实战指南:从建模训练到sim2real迁移的完整流程

最近在做电机调速控制器的时候&#xff0c;被一个问题反复折磨&#xff1a;算法在仿真模型里跑得好好的&#xff0c;一搬到实物测试台上就开始抽风。换参数、调噪声、改奖励&#xff0c;折腾了几轮之后我终于想明白&#xff0c;问题不在某个算法或某个环节上&#xff0c;而是我…

作者头像 李华
网站建设 2026/10/7 9:59:26

C++学习闭环:从语法基础到工程实战的完整路径

C学了两年&#xff0c;最后让我觉得自己真正“会了”的&#xff0c;不是又啃完哪本大部头&#xff0c;也不是刷完第几百道题&#xff0c;而是某天晚上我发现自己能独立把一个想法从“脑子里的思路”变成“跑起来的程序”&#xff0c;再变成“能交付给别人的东西”&#xff0c;整…

作者头像 李华