尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

死锁原理与解决方案:从多线程到分布式系统的并发难题

死锁原理与解决方案:从多线程到分布式系统的并发难题 1. 死锁一个让系统“卡死”的经典难题在软件开发和系统运维的日常里最让人头疼的故障之一莫过于系统运行得好好的突然就“卡住”不动了。界面没反应请求超时日志也不再滚动仿佛整个程序都陷入了沉睡。如果你去检查资源使用率CPU可能不高内存也还充足但任务就是无法推进。这种“假死”状态很多时候其罪魁祸首就是“死锁”。无论是多线程编程、数据库事务还是分布式系统间的资源协调死锁就像一个幽灵总在不经意间出现让精心设计的系统陷入僵局。今天我们就来彻底拆解这个经典问题从它的核心概念、形成的四个必要条件到工程实践中那些行之有效的解决方案和排查技巧。无论你是正在学习并发编程的开发者还是需要维护高可用服务的工程师理解死锁都是绕不开的一课。2. 死锁的核心概念与本质剖析2.1 什么是死锁一个生活化的类比教科书上对死锁的定义通常是两个或两个以上的进程或线程在执行过程中因争夺资源而造成的一种互相等待的现象若无外力干涉它们都将无法推进下去。这个定义很准确但有点抽象。我们可以用一个非常生活化的“哲学家就餐”问题来理解它。想象一张圆桌坐着五位哲学家他们的生活只有思考和吃饭两件事。桌子中央有一大盘面条每两个哲学家之间放着一把叉子总共五把。哲学家必须同时拿到左手边和右手边的两把叉子才能开始吃饭吃完后会放下叉子继续思考。现在假设一个极端情况所有哲学家在同一时刻都感到饥饿同时拿起了自己左手边的叉子。此时每个哲学家都持有一把叉子并等待其右手边的哲学家放下另一把叉子。结果就是每个人都拿着一把叉子望着另一把永远等下去——所有人都吃不上饭也做不了其他事。这就是死锁。在计算机世界里“哲学家”就是进程或线程“叉子”就是各种互斥资源如锁、数据库连接、文件句柄、网络端口等。当多个执行单元以不当的顺序申请和持有这些资源时就可能陷入这种永恒的等待。2.2 死锁与相关概念的区分在深入之前有必要厘清几个容易混淆的概念死锁 vs. 活锁死锁是线程被阻塞一直在等待什么都不做。活锁则不同线程并没有被阻塞而是在持续不断地改变状态以响应其他线程的状态变化但整体上看没有任何实质性的进展。就像两个人在走廊迎面相遇都试图让路但总是同时移动到同一侧结果还是堵着。活锁通常由过于“礼貌”的重试逻辑引起。死锁 vs. 饥饿饥饿是指某个或某些线程因为优先级太低或资源分配策略问题长期甚至永远得不到执行所需的资源而其他线程却能正常推进。死锁则不同它是涉及多个线程的循环等待所有相关方都被卡住。死锁 vs. 性能瓶颈性能瓶颈是系统处理速度慢但请求仍在被缓慢处理。而死锁是彻底的停止相关任务的处理进度为零。理解这些区别有助于我们在排查问题时快速定位根因。3. 死锁产生的四个必要条件死锁的发生不是偶然的它需要同时满足四个缺一不可的条件。这是由Coffman、Elphick和Shoshani在1971年总结的也被称为“Coffman条件”。理解这四个条件是预防和解决死锁的理论基石。3.1 互斥条件资源在任意时刻只能被一个执行单元进程/线程占用。如果资源可以被共享就不会有争夺自然也不会死锁。例如一个可重入锁ReentrantLock对于不同线程是互斥的但同一个线程可以多次获取它可重入性避免了该线程自身的死锁。为什么是必要的如果资源可以同时共享线程A和B可以同时持有“打印机”资源那么它们就不会因为等待对方释放打印机而卡住。3.2 占有且等待条件一个执行单元在持有至少一个资源的同时又提出对新的资源的申请而该新资源目前正被其他单元占有因此申请者进入等待状态但对自己已持有的资源保持不放。为什么是必要的如果线程在申请新资源时被强制要求释放所有已持有资源一种可能的解决方案那么它就无法同时占有多个资源循环等待的链条就无法形成。3.3 不可剥夺条件执行单元已获得的资源在其使用完之前不能被其他执行单元强行抢占只能由持有者主动释放。为什么是必要的如果可以强行剥夺资源那么当死锁即将发生时系统可以强制从某个线程那里拿走它占有的资源分配给其他线程从而打破等待链。许多操作系统对CPU资源就是可剥夺的通过时间片轮转但对大部分锁、文件句柄等资源通常不支持强制剥夺。3.4 循环等待条件存在一个进程/线程资源的环形等待链。链中的每一个单元都在等待下一个单元所占有的资源。例如线程T1持有资源R1等待R2线程T2持有资源R2等待R1。这就形成了一个最简单的循环等待。为什么是必要的这是死锁状态的直观体现。如果所有线程的等待关系是一个有向图那么死锁发生时这个图中必然存在一个环。没有环就只是普通的线性等待最终可能解开。注意这四个条件是同时成立时才会导致死锁。因此我们的任何解决方案其核心思想都是想方设法破坏其中至少一个条件。4. 死锁的解决方案从理论到实践知道了病因就可以对症下药。解决方案大体分为三类预防、避免、检测与恢复。它们在复杂性和系统开销上各有不同。4.1 死锁预防防患于未然预防策略是在系统设计时就通过约束资源申请方式确保四个条件中至少有一个永不成立。4.1.1 破坏“占有且等待”条件最直接的方法是要求线程一次性申请它在整个运行过程中所需的全部资源。如果系统能满足则一次性分配只要有一种资源无法满足那么即使其他资源空闲该线程也必须等待且不持有任何资源。优点简单粗暴有效。缺点资源利用率极低。线程可能在很晚才用到某个资源但很早就要申请并独占它导致该资源长期闲置。此外线程在编程时必须预先知道所需全部资源这通常很困难。4.1.2 破坏“不可剥夺”条件当线程在申请新资源得不到满足时系统可以强制其释放已持有的所有资源待以后需要时再重新申请。优点能有效防止死锁。缺点实现复杂成本高。对于如打印机、数据库事务中的锁等资源强制剥夺可能导致任务状态不一致或产生副作用例如打印一半的文档作废。通常只适用于易于保存和恢复状态的资源如CPU寄存器。4.1.3 破坏“循环等待”条件——资源有序分配法最常用、最实用这是工程实践中最常用且有效的预防方法。其核心思想是给系统中所有资源类型定义一个全局的、严格的线性顺序例如按资源ID排序。规定所有线程必须以递增的顺序申请资源。原理假设资源顺序为锁A 锁B 锁C。如果线程1需要锁A和锁C它必须先申请A再申请C。线程2如果需要锁B和锁C必须先申请B再申请C。如果线程2需要锁C和锁A由于顺序要求它也必须先申请A即使它先需要C再申请C。这样就保证了所有线程的申请方向是一致的不可能出现“线程1持A等B线程2持B等A”这种反向依赖从而杜绝了循环等待。实操要点定义清晰的资源层级在项目初期或设计模块时就明确各类锁、连接池等资源的顺序。可以将顺序写入文档或通过常量定义。使用工具辅助一些静态代码分析工具如FindBugs、SpotBugs可以检测潜在的锁顺序问题。封装获取逻辑对于需要获取多个资源的复杂操作将其封装成一个函数在函数内部严格遵循预定义的顺序获取资源。缺点对编程规范要求高需要所有开发者遵守同一套顺序规则。对于动态创建的资源如临时文件定义全局顺序可能比较困难。另外可能降低并发度因为按顺序获取可能无法在最需要的时候拿到资源。4.2 死锁避免动态审慎的分配预防策略比较保守而死锁避免策略则更加动态和灵活。系统在每次进行资源分配时都会先计算一下这次分配是否会导致系统进入“不安全状态”即可能发生死锁的状态。如果安全才分配否则让申请线程等待。最著名的算法是银行家算法。优点资源利用率比预防策略高。缺点需要线程预先声明其最大资源需求这在实际中往往难以精确预估。算法本身有时间复杂度开销每次分配都需要进行安全性检查。系统中进程/线程数量和资源种类必须是固定且已知的这对于动态变化的现代应用如Web服务不太适用。 因此银行家算法更多出现在操作系统教科书和理论中在实际的应用程序开发里很少直接使用。4.3 死锁的检测与恢复如果预防和避免的成本都太高或者系统允许死锁偶尔发生那么可以采用“事后处理”的策略允许死锁发生但系统必须具备检测和恢复的能力。4.3.1 死锁检测系统会定期例如每分钟或根据特定事件如线程等待超时启动一个检测算法。该算法通过分析当前的资源分配图和等待图来判断图中是否存在环路。这本质上是一个在有向图中寻找环的问题可以用深度优先搜索DFS等算法实现。数据库系统的实践大多数关系型数据库如MySQL InnoDB、Oracle都内置了死锁检测机制。它们会维护一个事务等待图定期检查。一旦检测到死锁会立即采取行动通常是回滚其中一个事务。4.3.2 死锁恢复检测到死锁后就需要打破它。常见方法有资源剥夺挂起某些死锁进程剥夺其资源分配给其他进程。需要处理好进程的恢复和状态回滚。进程/线程终止终止所有死锁进程简单粗暴但代价可能很大。逐个终止进程每终止一个就调用检测算法看死锁是否解除直到解除为止。这涉及到终止顺序的选择策略如基于优先级、已计算时间、剩余时间等。事务回滚数据库中最常见数据库管理系统检测到事务死锁后会选择其中一个事务作为“牺牲品”将其完全回滚Rollback释放其持有的所有锁。其他事务因此得以继续。选择牺牲品的策略可能是回滚代价最小的如修改数据量最少的事务。5. 不同场景下的死锁实战分析与解决方案理论需要结合实践。我们来看几个典型场景下的死锁是如何发生的以及如何应对。5.1 多线程编程中的锁顺序死锁这是最常见的死锁形式。我们来看一个Java例子public class LockOrderDeadlock { private final Object lockA new Object(); private final Object lockB new Object(); public void method1() { synchronized (lockA) { // 线程1先获取lockA try { Thread.sleep(50); } catch (InterruptedException e) {} synchronized (lockB) { // 然后尝试获取lockB System.out.println(Method1 executed); } } } public void method2() { synchronized (lockB) { // 线程2先获取lockB try { Thread.sleep(50); } catch (InterruptedException e) {} synchronized (lockA) { // 然后尝试获取lockA System.out.println(Method2 executed); } } } }如果线程1执行method1的同时线程2执行method2就极有可能发生死锁。解决方案应用资源有序分配法。规定所有线程必须先锁lockA再锁lockB。因此需要修改method2使其也先获取lockA。但这里lockA被method2外的synchronized块占用了所以更好的设计是重构代码将需要多个锁的操作抽取到一个方法中并在该方法内部遵循统一的锁顺序。5.2 数据库事务死锁两个事务互相等待对方持有的锁。例如事务T1UPDATE account SET balance balance - 100 WHERE id 1;持有id1的行锁事务T2UPDATE account SET balance balance - 200 WHERE id 2;持有id2的行锁事务T1UPDATE account SET balance balance 100 WHERE id 2;尝试获取id2的行锁等待T2事务T2UPDATE account SET balance balance 200 WHERE id 1;尝试获取id1的行锁等待T1 死锁形成。解决方案保持事务简短尽快提交或回滚事务减少锁的持有时间。以固定的顺序访问数据如果所有业务逻辑都约定先操作id小的账户再操作id大的账户上例中的死锁就不会发生。T1和T2都会先尝试锁id1的记录。使用乐观锁通过版本号version或时间戳timestamp机制在更新时检查数据是否被其他事务修改过避免长时间持有悲观锁。这适用于冲突较少的场景。依赖数据库的死锁检测与回滚为事务设置合理的超时时间如innodb_lock_wait_timeout并准备好重试机制。当数据库检测到死锁并回滚其中一个事务时应用程序应能捕获异常并安全地重试整个事务单元。5.3 分布式系统死锁在微服务或分布式架构中死锁可能跨越多个服务和数据库。例如服务A调用服务B并持有资源R1同时等待服务B的响应而服务B在处理请求时需要资源R2但R2正被服务A持有的另一个事务占用或者服务B又调用了服务A。这形成了分布式的循环等待。解决方案更加复杂通常结合多种策略。设计避免循环调用仔细设计服务间的调用链避免形成环。可以使用有向无环图DAG来建模服务依赖。使用分布式事务协调器如Seata通过全局锁和两阶段提交2PC等协议来协调资源但其本身会引入性能和复杂度问题。最终一致性补偿事务Saga模式放弃强一致性每个服务完成自己的本地事务后发布事件。如果后续步骤失败则触发一系列补偿操作逆向操作来回滚。这避免了长时间持有分布式锁。设置超时与重试为所有远程调用设置合理的超时时间并配合幂等性设计实现安全重试。当等待超时主动释放本地资源或进行回滚。6. 死锁的排查、诊断与工具使用当系统疑似发生死锁时如何快速定位和证实6.1 现象识别系统表现部分或全部请求无响应CPU利用率可能很低但线程数居高不下。日志线索可能出现大量TimeoutException、锁获取超时的日志。数据库可能记录死锁错误如MySQL的ERROR 1213 (40001): Deadlock found when trying to get lock。6.2 诊断工具与方法6.2.1 JVM应用Javajstack这是最常用的工具。jstack pid可以打印出Java进程所有线程的堆栈信息。在死锁时你可能会看到两个或多个线程处于BLOCKED状态并且互相等待对方持有的锁。jstack的输出通常会在最后有一个明确的“Found one Java-level deadlock”部分并详细列出死锁涉及的线程和锁。JConsole / VisualVM这些图形化工具可以连接JVM查看线程面板。它们通常能直接检测并提示死锁并可视化线程的依赖关系。线程Dump分析将jstack输出保存为文件使用在线分析工具或IDE进行分析可以更清晰地看到锁的持有和等待关系链。6.2.2 数据库以MySQL InnoDB为例查看最近死锁信息执行命令SHOW ENGINE INNODB STATUS\G。在输出结果中找到LATEST DETECTED DEADLOCK部分。这里会详细记录死锁发生的时间、涉及的事务、正在执行的SQL语句、以及每个事务持有和等待的锁信息。这是分析数据库死锁的黄金资料。监控锁等待通过information_schema库中的INNODB_LOCKS和INNODB_LOCK_WAITS表在MySQL 8.0中相关视图已更新可以实时查看当前的锁和等待关系。6.2.3 通用系统层面pstack/gdb对于Linux下的C/C程序可以使用pstack pid打印所有线程的调用栈或者用gdb附加到进程进行分析。性能分析器Profiler如async-profiler不仅可以分析CPU还可以分析锁竞争情况看到哪些锁是热点。6.3 排查流程 checklist确认症状服务是否完全停滞还是仅部分功能慢错误日志中是否有明确的死锁或超时报错获取现场信息立即捕获线程Dumpjstack、数据库状态SHOW ENGINE INNODB STATUS或系统级线程栈信息。信息越及时越好因为有些死锁可能会被系统自动解开如数据库回滚事务后。分析依赖环从获取的信息中找出哪些线程或事务在等待哪些资源锁而这些资源又被谁持有。画出简单的等待图寻找环路。定位代码根据堆栈信息中的类名、方法名和行号定位到引发死锁的具体代码段。复现与修复分析代码中的资源申请顺序设计修复方案通常是统一申请顺序。编写单元测试或集成测试尝试复现死锁场景并验证修复是否有效。7. 工程实践中的防死锁最佳实践与心得结合多年经验预防死锁远比解决已发生的死锁更重要。以下是一些接地气的实践建议7.1 锁顺序锁顺序还是锁顺序这是避免死锁最有效、成本最低的方法。在项目组内建立锁顺序规范。对于全局性的、重要的锁如涉及多个模块的在架构设计文档中明确它们的获取顺序。代码审查时重点关注涉及多个锁的代码块。7.2 尽量使用更高级的并发工具现代编程语言提供了许多比原生锁synchronized、Lock更安全、功能更强大的并发工具。并发容器如Java的ConcurrentHashMap、CopyOnWriteArrayList它们在内部实现了高效的线程安全控制大多数情况下无需你手动加锁。原子变量如AtomicInteger对于简单的计数器、状态标志使用原子变量可以避免锁。java.util.concurrent包中的高级同步器如Semaphore、CountDownLatch、CyclicBarrier、Phaser等它们被设计用于解决特定的同步问题比直接用锁更不易出错。7.3 尝试锁超时机制如果业务允许在获取锁时使用尝试获取或带超时的获取而不是无限期等待。Java中Lock接口的tryLock(long time, TimeUnit unit)方法可以指定超时时间。数据库操作设置合理的事务超时和锁等待超时参数。 这样做的好处是即使发生了死锁的“苗头”线程也会在超时后抛出异常释放自己已持有的资源从而打破僵局。你可以在捕获超时异常后进行重试、回滚或降级处理。这实质上是破坏了“不可剥夺”条件的一种温和形式线程主动释放而非被系统强制剥夺。7.4 保持事务精简尽快释放资源这条原则放之四海而皆准。锁的范围要尽可能小锁细化持有时间要尽可能短。在数据库中避免在事务中执行不必要的查询尤其要避免在事务内进行网络调用、文件IO等耗时操作。7.5 编写可重试的幂等操作当使用超时机制或系统自动回滚后重试是常见的恢复手段。确保你的业务逻辑特别是释放资源后准备重试的那部分操作是幂等的。即同一操作执行一次或多次对系统状态的影响是一致的。例如使用唯一请求ID来防止重复扣款。7.6 借助代码分析和测试工具静态代码分析集成SpotBugs、SonarQube等工具到CI/CD流程中它们可以识别出一些明显的潜在死锁代码模式如不同的锁顺序。压力测试与混沌工程在测试环境进行高并发压力测试可以暴露许多在低并发下隐藏的死锁问题。混沌工程中故意模拟慢网络、服务延迟等也能测试系统在异常情况下的韧性包括对死锁的容忍和恢复能力。死锁问题就像并发编程中的“必修课”理解其原理和解决方案是构建稳定、高可用系统的必备技能。它要求开发者在设计之初就具备资源管理的全局视角在编码时保持对锁的敬畏在排查时像侦探一样缜密。记住最好的死锁解决方案就是让它不要发生。而当你不得不面对它时清晰的思路和合适的工具就是你最好的武器。在实际项目中我个人的体会是建立团队共识如锁顺序规范和将防死锁检查纳入代码审查流程其长期收益远大于事后一个个地去扑灭死锁的火焰。
返回列表