ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

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

典型死锁问题:从复现、定位到预防的完整实践指南 简介针对操作系统与并发编程学习者压缩包收录了三个经典死锁案例哲学家问题、生产者-消费者问题以及管道通信问题。每个案例均提供一份可运行的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编号1lock2编号2thread_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 (arg0x0) at demo.c:10 #3 start_thread ... Thread 1 (Thread 0x...): #0 __lll_lock_wait () #1 pthread_mutex_lock #2 thread_b (arg0x0) 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或BlockedCPU占用很低线程不前进。判断方法并不玄学用 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上才明白——死锁不是名词是动词。希望这段从复现到定位的路能帮你在操作系统这条路上少卡几次。本文还有配套的精品资源点击获取
返回列表