简介这份资源面向操作系统课程学习者与并发编程入门者聚焦多任务环境下的死锁问题通过三个经典案例帮助理解资源分配、进程调度与系统安全之间的关联。包内共3个cpp源文件压缩包约2KB分别对应哲学家进餐、生产者消费者与管道通信三类典型场景代码结构紧凑便于直接编译运行与调试观察。已有284人学习下载说明其在教学与自学场景中具有一定参考价值。读者可借助这些示例梳理死锁产生的四个必要条件对比固定取放顺序、信号量协调、非阻塞I/O等不同解决思路的差异并进一步延伸到死锁预防与检测策略。对于需要完成操作系统实验、准备课程设计或夯实并发编程基础的学习者这是一份轻量但指向明确的练习素材适合配合教材章节边读边改加深对同步互斥机制的理解。1. 典型死锁问题从操作系统实验里最容易翻车的那一类说起操作系统课程里死锁是少数几个「理论看着简单、动手一跑就懵」的知识点。教材上四个必要条件写得清清楚楚可真到写代码复现时很多人卡在同一个地方线程明明按顺序申请锁程序却卡死不动日志也不打CPU 占用还是 0。这就是典型死锁问题——两个或多个线程互相持有对方需要的资源又都不肯释放最后集体僵住。它不只出现在课本里数据库死锁、线程死锁、分布式锁超时本质都是同一套逻辑。这篇文章面向正在做操作系统实验、准备期末复习或者在生产环境里被死锁坑过的工程师。我会把死锁的判定、复现、排查、预防一条线讲透代码可以直接抄参数可以照着调坑也提前标出来。读完你至少能做到两件事自己写出一个稳定复现的死锁 demo以及拿到一个卡死的进程时知道从哪下手。2. 死锁的四个必要条件与两种典型场景2.1 互斥、占有并等待、不可抢占、循环等待死锁不是随便就能发生的它必须同时满足四个条件缺一个都构不成。互斥指的是资源同一时间只能被一个线程占用比如一把互斥锁占有并等待指的是线程已经拿着一个资源又去申请另一个资源不可抢占指的是资源只能由持有者主动释放别人抢不走循环等待指的是存在一个线程环每个线程都在等下一个线程持有的资源。这四个条件里互斥和不可抢占通常由锁的语义决定改不了真正能动手脚的是「占有并等待」和「循环等待」。所以工程上预防死锁基本都围绕这两条做文章。理解这四个条件有个好处当你看到程序卡死可以逐条对照快速判断是不是死锁而不是瞎猜。比如线程池满了、任务互相等待也可能是死锁数据库里两个事务更新同一批行顺序相反也是死锁。判定逻辑是一样的。2.2 线程死锁两把锁、两个线程、相反顺序最常见的复现方式就是两个线程、两把锁、加锁顺序相反。下面这段 Python 代码可以直接跑跑起来大概率卡住需要手动 CtrlC 才能退出。import threading import time lock_a threading.Lock() lock_b threading.Lock() def worker_one(): with lock_a: print(worker_one 拿到 lock_a) time.sleep(0.5) # 给另一个线程留出拿 lock_b 的时间 with lock_b: print(worker_one 拿到 lock_b) def worker_two(): with lock_b: print(worker_two 拿到 lock_b) time.sleep(0.5) with lock_a: print(worker_two 拿到 lock_a) t1 threading.Thread(targetworker_one) t2 threading.Thread(targetworker_two) t1.start() t2.start() t1.join() t2.join() print(全部完成)逻辑说明worker_one 先拿 lock_a 再拿 lock_bworker_two 反过来。两个线程各自拿到第一把锁后sleep 0.5 秒足以让另一个线程也拿到它的第一把锁。之后双方都在等对方的第二把锁循环等待形成程序永久卡住。参数说明sleep 的时间不能太短太短可能一个线程还没拿到第一把锁另一个就跑完了复现不稳定0.5 秒在本地和实验环境都比较稳。如果想更快复现可以改成 0.2 秒但机器负载高时可能失效。2.3 数据库死锁两个事务更新顺序相反数据库死锁更隐蔽因为你看不到锁对象只能看到事务卡住或者被数据库主动回滚。下面用 SQL 演示两个事务互相等待的场景MySQL、PostgreSQL 都适用。-- 事务 A BEGIN; UPDATE accounts SET balance balance - 100 WHERE id 1; -- 此处不提交继续执行下一条 UPDATE accounts SET balance balance 100 WHERE id 2; -- 事务 B在另一个连接里执行 BEGIN; UPDATE accounts SET balance balance 100 WHERE id 2; UPDATE accounts SET balance balance - 100 WHERE id 1;逻辑说明事务 A 先锁住 id1 的行事务 B 先锁住 id2 的行。接着 A 要更新 id2B 要更新 id1双方都在等对方释放行锁死锁形成。参数说明这里的关键不是 SQL 语法而是执行顺序。只要两个事务对同一组资源的加锁顺序不一致就有死锁风险。数据库通常会检测到死锁并回滚其中一个事务报错类似「Deadlock found when trying to get lock」但应用层如果不重试业务就失败了。3. 用工具定位死锁从卡死进程到具体线程3.1 Linux 下用 gdb 和 pstack 看线程栈程序卡死之后第一步不是改代码而是先确认它到底卡在哪。Linux 上最直接的工具是 gdb 和 pstack。假设你的程序叫 deadlock_demo先找到进程号再 attach 上去看所有线程的调用栈。# 找到进程号 ps -ef | grep deadlock_demo # 方式一gdb attach gdb -p pid (gdb) thread apply all bt (gdb) detach (gdb) quit # 方式二pstack部分系统需要安装 pstack pid逻辑说明thread apply all bt 会打印每个线程的调用栈。如果两个线程分别停在 lock 相关的函数上比如 pthread_mutex_lock 或 PyThread_acquire_lock而且各自持有的锁在对方栈里出现基本可以判定死锁。参数说明gdb attach 会暂停进程生产环境慎用pstack 相对轻量但输出信息少一些。如果程序是 Python 写的还可以用 py-spy dump --pid 不用暂停进程就能看栈。3.2 Java 应用用 jstack 和 jconsoleJava 生态里定位死锁更方便JDK 自带 jstack能直接告诉你有没有死锁。下面命令跑一下输出里会有一段「Found one Java-level deadlock」。# 找到 Java 进程号 jps -l # 打印线程栈重点看 deadlock 段落 jstack pid thread_dump.txt # 图形化工具 jconsole pid逻辑说明jstack 的输出里每个线程会显示它持有的锁和正在等待的锁。如果两个线程互相等待对方持有的锁jstack 会直接给出死锁结论并指出是哪两个线程、哪两把锁。参数说明jstack 对进程影响很小可以反复执行jconsole 需要图形界面适合本地调试。注意 jstack 有时需要和进程相同的用户权限否则 attach 不上。3.3 数据库死锁看错误日志和锁等待视图数据库死锁一般不用外部工具数据库自己会记录。MySQL 用 SHOW ENGINE INNODB STATUSPostgreSQL 查 pg_locks 和 pg_stat_activity。-- MySQL 查看最近一次死锁 SHOW ENGINE INNODB STATUS\G -- PostgreSQL 查看当前锁等待 SELECT blocked.pid AS blocked_pid, blocking.pid AS blocking_pid, blocked.query AS blocked_query FROM pg_locks blocked JOIN pg_locks blocking ON blocked.locktype blocking.locktype AND blocked.relation blocking.relation AND blocked.pid ! blocking.pid JOIN pg_stat_activity blocked_activity ON blocked.pid blocked_activity.pid WHERE NOT blocked.granted;逻辑说明MySQL 的 INNODB STATUS 里 LATEST DETECTED DEADLOCK 段落会显示两个事务分别执行了什么 SQL、持有什么锁、等待什么锁。PostgreSQL 的查询能实时看到谁被谁堵住。参数说明MySQL 需要 SUPER 或 PROCESS 权限PostgreSQL 的 pg_locks 查询在锁多的时候可能较慢建议加时间过滤。4. 避坑与排查死锁实验里最容易踩的五个坑4.1 现象程序卡死但 CPU 占用为 0以为程序挂了原因死锁的本质是等待线程都阻塞在锁上不消耗 CPU。很多人看到 CPU 0% 就以为程序异常退出或者死循环其实恰恰相反死循环会跑满一个核。解决先用 ps 确认进程还在再用 gdb 或 jstack 看线程栈。如果所有线程都停在锁函数上基本就是死锁。4.2 现象加了 sleep 才能复现去掉 sleep 就正常原因死锁需要精确的时序两个线程必须各自先拿到第一把锁。如果线程启动和调度太快一个线程可能连续拿到两把锁再释放另一个线程根本没机会形成循环等待。解决sleep 是实验里常用的「放大时序窗口」手段但生产环境不能靠 sleep。更稳的复现方式是用 threading.Barrier 或 CountDownLatch 让两个线程在拿第二把锁之前同步一次确保双方都持有第一把锁。4.3 现象数据库报死锁但代码里加锁顺序明明一样原因数据库的加锁顺序不一定等于 SQL 书写顺序。比如 UPDATE 带 WHERE 条件时如果走的是二级索引可能先锁索引再锁主键两个事务的 WHERE 条件不同实际加锁顺序就可能不同。解决用 EXPLAIN 看执行计划确认实际访问路径尽量让并发事务按相同顺序访问相同索引必要时用 SELECT ... FOR UPDATE 显式加锁把顺序控制在自己手里。4.4 现象用了 tryLock 还是死锁原因tryLock 只解决「拿不到就返回」的问题如果代码里拿到一把锁之后又去 tryLock 另一把失败后不释放已持有的锁仍然可能形成占有并等待。解决tryLock 失败时必须释放已经拿到的所有锁或者用带超时的 tryLock 并统一处理失败路径。更彻底的做法是只用一把大锁或者用无锁数据结构。4.5 现象死锁检测工具没报死锁但程序确实卡住原因有些死锁不是锁对象层面的比如线程池任务互相等待、Future.get 嵌套调用、信号量配额耗尽。这些工具不一定能识别。解决看线程栈里有没有 wait、park、await 之类的调用检查线程池大小和任务依赖关系用 jstack 看有没有线程在等另一个线程的结果。这类「逻辑死锁」比锁死锁更难查但排查思路一样找到等待环。5. 预防死锁的三种落地手段与一个验证技巧5.1 固定加锁顺序最简单也最有效最实用的预防手段就是全局规定加锁顺序。比如所有需要同时拿锁 A 和锁 B 的地方都按「先 A 后 B」的顺序写。这样循环等待条件直接被破坏。实现上可以给每把锁分配一个唯一编号加锁前先比较编号永远先拿编号小的。下面是一个 Python 示例。import threading class OrderedLock: def __init__(self, name, order): self._lock threading.Lock() self.name name self.order order def __enter__(self): self._lock.acquire() return self def __exit__(self, exc_type, exc_val, exc_tb): self._lock.release() def safe_transfer(lock_a, lock_b): first, second sorted([lock_a, lock_b], keylambda l: l.order) with first: with second: print(f按顺序拿到 {first.name} 和 {second.name}) lock_x OrderedLock(X, 1) lock_y OrderedLock(Y, 2) safe_transfer(lock_x, lock_y) safe_transfer(lock_y, lock_x) # 内部仍按 X - Y 顺序加锁逻辑说明sorted 按 order 排序保证无论调用方传参顺序如何实际加锁顺序一致。参数说明order 需要全局唯一可以在模块初始化时统一分配。这个方案适合锁数量固定的场景如果锁是动态创建的需要额外维护编号表。5.2 加锁超时与重试给死锁一个后悔药如果无法保证加锁顺序可以给锁加超时。拿不到就释放已有锁等一会儿重试。这样即使形成循环等待也会因为超时而打破。Java 里用 tryLock(timeout)Python 里可以用 acquire(timeout...)。import threading import time lock_a threading.Lock() lock_b threading.Lock() def worker_with_timeout(): while True: if lock_a.acquire(timeout1): try: if lock_b.acquire(timeout1): try: print(拿到两把锁执行业务) return finally: lock_b.release() finally: lock_a.release() print(获取锁超时重试) time.sleep(0.1)逻辑说明每次 acquire 最多等 1 秒拿不到就释放已持有的锁避免占有并等待。参数说明timeout 不能太短否则正常竞争也会频繁失败也不能太长否则死锁时恢复慢。一般设成业务平均处理时间的 2 到 3 倍。重试间隔加一点随机抖动避免多个线程同时重试再次碰撞。5.3 用银行家算法做资源分配检查银行家算法是操作系统课上的经典死锁避免算法核心思想是每次分配资源前先模拟分配检查系统是否还存在安全序列。如果不存在就拒绝这次分配。实际工程里很少直接照搬但它的思路可以用在资源池、连接池的分配上。下面是一个简化版的安全检查函数。def is_safe(available, max_need, allocation): available: 当前可用资源列表 max_need: 每个线程最大需求矩阵 allocation: 当前已分配矩阵 n len(max_need) m len(available) work available[:] finish [False] * n safe_seq [] while len(safe_seq) n: found False for i in range(n): if not finish[i]: need [max_need[i][j] - allocation[i][j] for j in range(m)] if all(need[j] work[j] for j in range(m)): work [work[j] allocation[i][j] for j in range(m)] finish[i] True safe_seq.append(i) found True if not found: return False, [] return True, safe_seq逻辑说明每次找一个需求能被当前可用资源满足的线程假设它执行完并释放资源再继续找下一个。如果所有线程都能按某个顺序完成说明当前状态安全。参数说明max_need 和 allocation 都是二维矩阵行是线程、列是资源类型。这个函数适合资源类型少、线程数固定的场景比如嵌入式系统或实验环境。5.4 验证手段用压力测试和超时监控兜底预防手段做完还需要验证。最直接的办法是写一个压力测试让多个线程随机顺序申请多把锁跑几千次看有没有卡死。同时给关键锁加超时监控一旦等待超过阈值就打日志。下面是一个简单的压测框架。import threading import random import time locks [threading.Lock() for _ in range(5)] errors [] def random_worker(worker_id): for _ in range(1000): selected random.sample(locks, 2) selected.sort(keyid) # 固定顺序模拟预防后的代码 with selected[0]: with selected[1]: pass threads [threading.Thread(targetrandom_worker, args(i,)) for i in range(20)] start time.time() for t in threads: t.start() for t in threads: t.join(timeout30) if t.is_alive(): errors.append(检测到可能死锁) print(f耗时 {time.time() - start:.2f} 秒异常 {len(errors)} 条)逻辑说明20 个线程各跑 1000 次每次随机选两把锁按 id 排序后加锁。如果预防措施有效程序会在几十秒内跑完如果卡住join 超时会报出来。参数说明线程数和循环次数可以按机器性能调整重点是让竞争足够激烈。id 排序只是演示实际项目里应该用业务定义的顺序。我自己的习惯是每次写完多锁代码先跑一遍这个压测再上代码审查。死锁这东西测试环境不出现不代表没有一旦到生产就是半夜被叫起来的那种。把加锁顺序固定、超时加上、压测跑通三件事做完心里才踏实。希望帮到你。本文还有配套的精品资源点击获取