try ai
科普
编辑
分享
反馈
  • 锁排序

锁排序

SciencePedia玻尔百科
核心要点
  • 当两个或多个线程形成一个循环依赖链,每个线程都在等待下一个线程持有的资源时,并发系统中就会出现死锁。
  • 锁排序通过强制执行一个单一的全局规则来预防死锁:如果必须持有多个锁,则必须始终按照预定义的相同顺序获取它们。
  • 这一原则打破了循环等待条件,确保资源依赖图保持无环状态,从而将潜在的死锁转变为暂时的阻塞。
  • 锁排序策略是一个基本概念,应用于现实世界的系统中,包括操作系统内核、文件系统和数据库。

引言

在并发编程的世界里,多个线程常常需要访问相同的共享资源。虽然锁(或称互斥锁)对于保护这些资源免受损坏至关重要,但它们也带来了一个重大风险:死锁。死锁是一种数字僵局,其中两个或多个线程被永久冻结,每个线程都在等待另一个线程持有的资源。这可能导致整个应用程序,甚至操作系统,彻底瘫痪。我们如何才能设计出既快速又安全,同时能避免这些灾难性循环依赖的复杂系统呢?

本文将揭示解决此问题最优雅、最强大的方案之一:锁排序原则。您将首先通过审视四个必要的 Coffman 条件,学习死锁发生的基本机制。然后,我们将引入锁排序作为一个黄金法则,它通过打破“循环等待”条件来主动预防死锁。接下来,本文将探讨该原则的多样化应用和跨学科联系,揭示它如何被用于确保关键软件的稳定性,从操作系统和文件系统的核心,到高性能数据结构和大规模分布式服务的设计。

原理与机制

想象一下,Alice 和 Bob 两人在一条狭窄的走廊里相向而行。为了避免碰撞,Alice 向她的左边迈了一步。与此同时,Bob 也向他的左边(也就是 Alice 的右边)迈了一步。他们仍然被挡住了。现在,Alice 向她的右边迈步,Bob 也一样。还是被挡住了。这种尴尬的挪动,一场礼貌性的滑稽舞蹈,可能会无限期地持续下去。谁也无法前进,因为每个人都在等待对方让路,但每个人为让路而采取的行动却无意中挡住了对方。

这就是​​死锁​​的本质。在计算世界里,我们的“人”是执行线程,而“走廊里的空间”是它们需要的资源,比如一段数据、一个文件或一个硬件设备。为了保护这些资源不被同时访问而损坏,我们使用锁,或​​互斥锁​​(mutexes,mutual exclusion 的缩写)。一个锁一次只能被一个线程持有。问题出现在一个线程需要多个锁来完成其工作时。

死锁之舞

让我们更正式地编排一下这支舞。考虑两个线程 TAT_ATA​ 和 TBT_BTB​,以及两个资源,一个用户账户余额 (SacctS_{acct}Sacct​) 和一个交易缓冲区 (SbufS_{buf}Sbuf​),它们分别由锁 LacctL_{acct}Lacct​ 和 LbufL_{buf}Lbuf​ 保护。假设 TAT_ATA​ 需要从账户中扣款并将其记录到缓冲区中,而 TBT_BTB​ 需要为另一笔交易做同样的事情。它们的舞蹈可能是这样的:

  1. ​​时间 1:​​ TAT_ATA​ 开始工作。它获取了锁 LacctL_{acct}Lacct​。
  2. ​​时间 2:​​ 操作系统决定轮到 TBT_BTB​ 运行。TBT_BTB​ 获取了锁 LbufL_{buf}Lbuf​。
  3. ​​时间 3:​​ TAT_ATA​ 又获得了一次运行机会。它现在需要写入缓冲区,所以它尝试获取 LbufL_{buf}Lbuf​。但是 LbufL_{buf}Lbuf​ 被 TBT_BTB​ 持有。因此,TAT_ATA​ 必须等待。它进入睡眠状态,同时仍然持有 LacctL_{acct}Lacct​。
  4. ​​时间 4:​​ TBT_BTB​ 再次运行。它现在需要访问账户,所以它尝试获取 LacctL_{acct}Lacct​。但是 LacctL_{acct}Lacct​ 被沉睡的 TAT_ATA​ 持有。因此,TBT_BTB​ 也进入睡眠状态。

于是,我们就看到了。一个数字僵局。TAT_ATA​ 在等待 TBT_BTB​,TBT_BTB​ 在等待 TAT_ATA​。两者都无法继续。两者都无法释放自己持有的锁,因为它需要另一个锁来完成任务。它们被永久地卡住了。这是一个典型的死锁。

计算机科学家已经确定了死锁发生必须同时满足的四个条件,即 Coffman 条件。它们是:

  • ​​互斥(Mutual Exclusion):​​ 所涉及的资源不能被共享。(我们的锁是排他性的)。
  • ​​持有并等待(Hold and Wait):​​ 一个线程在等待另一个资源时,至少持有一个资源。(TAT_ATA​ 持有 LacctL_{acct}Lacct​ 并等待 LbufL_{buf}Lbuf​)。
  • ​​不可抢占(No Preemption):​​ 资源不能被强行从线程手中夺走。(操作系统不会从线程手中撬走一个锁)。
  • ​​循环等待(Circular Wait):​​ 存在一个由线程组成的闭环,每个线程都在等待链中下一个线程所持有的资源。(即循环 TA→Lbuf→TB→Lacct→TAT_A \to L_{buf} \to T_B \to L_{acct} \to T_ATA​→Lbuf​→TB​→Lacct​→TA​)。

为了预防死锁,我们只需要打破这四个条件中的一个。尝试打破前三个条件可能困难或低效。强制资源可共享并非总是可行。强制一个线程在无法获得新锁时释放其所有锁可能很复杂,并导致工作浪费。强行拿走一个锁(抢占)可能会破坏它所保护数据的完整性。这就使得第四个条件,循环等待,成为我们的主要目标。我们如何打破这个循环呢?

黄金法则:从环到线

解决方案既优雅又简单。我们强加一条规则。一条所有线程都必须遵守的、全局的、不可破坏的法则:​​如果你需要获取多个锁,你必须始终按照相同的、预定义的顺序获取它们。​​

这就是​​锁排序​​原则。让我们看看它如何打破我们的死锁。假设我们规定,系统中任何地方的任何线程,如果同时需要 LacctL_{acct}Lacct​ 和 LbufL_{buf}Lbuf​,都必须先获取 LacctL_{acct}Lacct​,再获取 LbufL_{buf}Lbuf​。让我们在这条新法则下重演我们的舞蹈。

TAT_ATA​ 的逻辑已经合规:先获取 LacctL_{acct}Lacct​,再获取 LbufL_{buf}Lbuf​。但是 TBT_BTB​ 的逻辑必须改变。它现在也必须先获取 LacctL_{acct}Lacct​。现在,无论调度器如何交错它们,死锁都是不可能的。

  • 如果 TAT_ATA​ 先运行,它会拿到 LacctL_{acct}Lacct​。如果它随后被中断,TBT_BTB​ 会尝试运行,但它会立即因尝试获取 LacctL_{acct}Lacct​ 而阻塞。TAT_ATA​ 最终会恢复,获取 LbufL_{buf}Lbuf​,完成工作,并释放两个锁。然后 TBT_BTB​ 就可以继续了。
  • 如果 TBT_BTB​ 先运行,它会拿到 LacctL_{acct}Lacct​。情况是对称的。

循环等待消失了。这两个线程再也不能抓住链条的相对两端。它们都被迫从同一端开始。危险的环被打破,并被拉直成一条安全的、有序的线。

这个原则可以很好地扩展。想象三个线程陷入一个致命的三角关系:T1T_1T1​ 持有 L1L_1L1​ 并想要 L2L_2L2​;T2T_2T2​ 持有 L2L_2L2​ 并想要 L3L_3L3​;而 T3T_3T3​ 持有 L3L_3L3​ 并想要 L1L_1L1​。如果我们强加一个全序——比如说,L1≺L2≺L3L_1 \prec L_2 \prec L_3L1​≺L2​≺L3​——这个循环就无法形成。一个持有 L2L_2L2​ 的线程可以请求 L3L_3L3​,但一个持有 L3L_3L3​ 的线程绝不能请求 L1L_1L1​。任何请求都必须“向上”遵循这个顺序。

我们可以用​​等待图​​(wait-for graph)来可视化这一点,其中锁是节点,从 LiL_iLi​ 到 LjL_jLj​ 的有向边表示一个线程在持有 LiL_iLi​ 的同时请求 LjL_jLj​。死锁就是这个图中的一个环。通过对锁的获取强制执行一个全序,我们保证了图中所有的边都必须从一个较低序的锁指向一个较高序的锁。在这样的图中形成一个环在数学上是不可能的。依赖结构变成了一个​​有向无环图(DAG)​​。我们用一个清晰的、单向的流代替了可能纠缠不清的依赖网络。

建立规范顺序

“黄金法则”在理论上很棒,但我们如何在真实、复杂的软件系统中建立这个“预定义顺序”呢?工程师们已经开发了几种实用的策略。

一个简单的方法是按锁的名称,以字典序对锁进行排序。对于我们的锁 LacctL_{acct}Lacct​、LbufL_{buf}Lbuf​ 和 LuserL_{user}Luser​,顺序将是 Lacct≺Lbuf≺LuserL_{acct} \prec L_{buf} \prec L_{user}Lacct​≺Lbuf​≺Luser​。任何需要 LuserL_{user}Luser​ 和 LacctL_{acct}Lacct​ 的代码都必须先获取 LacctL_{acct}Lacct​。虽然直观,但这可能出奇地脆弱。如果一个开发者重命名了一个锁,改变了它在顺序中的位置怎么办?如果系统的不同部分使用不同的字符串比较规则(例如,区分大小写与不区分大小写)怎么办?为了使一个顺序可靠,它必须是​​规范的​​(canonical)——即全局一致、无歧义且稳定。

一种远为稳健的方法是为每个锁分配一个唯一的、不可变的数字排名,并严格按照其排名的升序获取锁。在高性能操作系统中,这通常就是这样做的。

一个美丽的现实世界例子发生在文件系统中。想象一下试图重命名一个文件,将其从目录 A 移动到目录 B。这个操作需要锁定两个目录以防止它们被同时修改。如果一个线程将 file1 从 A 重命名到 B(先锁定 A 后锁定 B),而另一个线程将 file2 从 B 重命名到 A(先锁定 B 后锁定 A),我们就有了经典的死锁配方。解决方案非常优雅:在类 Unix 系统中,每个目录的 inode 都有一个唯一的、稳定的整数 ID。规则很简单:当锁定两个目录时,总是先锁定 inode 编号较小的那个。这就建立了一个完美的、规范的全序,并完全防止了这类死锁。如果 inode 编号相等(对于不同的目录这是不可能的,但作为一个好的思想实验),可以使用像锁对象的内存地址这样的决胜规则。

微妙之处与副作用:伪装下的原则

排序打破对称冲突的力量也延伸到比仅仅获取两个不同锁更微妙的情况。

考虑一个​​读写锁​​,它允许多个并发的“读者”但只有一个“写者”。如果我们增加一个将读锁“升级”为写锁的功能会怎样?假设线程 T1T_1T1​ 和 T2T_2T2​ 都持有一个读锁。现在,它们都决定需要写入。每个都尝试升级。升级逻辑说:“我会等到我是唯一的读者,然后我将成为写者。”结果如何?死锁。T1T_1T1​ 在等待 T2T_2T2​ 释放其读锁,而 T2T_2T2​ 在等待 T1T_1T1​ 释放其读锁。这是一个在单个对象上的循环等待!

解决方案再次是排序。我们必须打破对称性。例如,我们可以使用线程 ID 来建立一个任意但一致的顺序。规则变成:如果多个读者想要升级,只允许线程 ID 最小的那个等待;所有其他读者必须释放它们的读锁,然后从头开始尝试获取一个完整的写锁。对称性被打破,死锁得以避免。

这突显了排序原则的深刻性:它是一个解决冲突的通用工具,通过防止循环依赖来做到这一点,即使这些依赖不是立即可见的。

当然,规则只有在被遵守时才有效。在复杂的系统中,我们如何发现违规行为?可以构建调试工具来在运行时监控锁的获取。它们动态地构建等待图,并在每次线程请求锁时检查是否存在环。如果一个请求会创建一个环,工具可以立即停止程序并报告锁排序违规的确切位置,从而使开发者免于花费数小时调试一个神秘冻结的应用程序。

最后,值得注意的是,一些工具可能会无意中隐藏这些问题。​​可重入互斥锁​​是线程可以多次锁定而不会与自身死锁的锁。虽然这在防止复杂、分层代码中的自我死锁方面很有用,但它可能掩盖了潜在的锁排序违规。如果一个函数错误地重新获取它已经持有的锁,可重入互斥锁会允许它,而非可重入的互斥锁则会冻结程序,立即标记出设计缺陷。

锁排序原则是并发编程的基石。它证明了一个简单的、优雅的规则,在普遍应用时,如何能将一个复杂、混乱且危险的情境——死锁之舞——转变为一个可预测、安全且高效的系统。它揭示了关于管理复杂性的一个深刻真理:当面对一个纠缠的循环时,解决方案往往是找到一种方法将其拉直成一条线。

应用与跨学科联系

掌握了锁排序的基本原则后,你可能感觉自己像是刚学会了国际象棋规则的人。你知道棋子如何移动,但你尚未见识过赢得比赛的宏大策略。现在,我们将踏上一段旅程,去看看这个原则在实践中的应用。我们将逐层剥开数字世界的面纱,从赋予你电脑生命的操作系统,到你每天使用的复杂应用,去发现锁排序并非某个晦涩的学术概念。它是沉默的、无名的英雄,是防止我们繁华的数字城市陷入永久性交通瘫痪的隐藏纪律。

想象一个城市,每个司机都遵循他们自己的“逻辑”规则——“我的路一清空我就转弯”——没有任何红绿灯或停车标志。这可能在一段时间内行得通,但在第一个繁忙的十字路口,你就会遇到僵局。锁排序就是这个红绿灯系统,是全球公认的一套规则,确保交通流畅,即使在空无一人的街道上等红灯似乎有违直觉。让我们去寻找这些十字路口。

机器的心脏:操作系统

没有比操作系统本身更好的起点来开始我们的旅程了——它是所有活动的总指挥。操作系统是并发的热点,有无数线程在执行,其稳定性取决于无懈可击的锁管理。

思考一下你做的最基本的事情之一:重命名一个文件或文件夹。对用户来说,这是一个单一的、原子的操作。但对文件系统来说,它涉及到将一个条目从源目录(比如 XXX)移动到目标目录(比如 YYY)。一个看似合理但天真的内核方法是先锁定源目录,然后锁定目标目录来执行移动。现在,如果两个独立的进程几乎同时开始会发生什么?一个试图将文件从 XXX 移动到 YYY,而另一个试图将不同的文件从 YYY 移动到 XXX。第一个进程 T1T_1T1​ 锁定了 XXX。第二个进程 T2T_2T2​ 锁定了 YYY。现在,T1T_1T1​ 需要 YYY 的锁(被 T2T_2T2​ 持有),而 T2T_2T2​ 需要 XXX 的锁(被 T1T_1T1​ 持有)。它们被卡住了,在数字僵局中互相凝视。你的文件系统冻结了。解决方案非常简单:强加一个全局的、任意的顺序。例如,系统可以规定目录锁必须始终按其内部标识符(“inode 编号”)的递增顺序获取。所以,如果 inode iXi_XiX​ 的 ID 小于 inode iYi_YiY​ 的 ID,任何涉及这两个目录的操作都必须先锁定 XXX 再锁定 YYY,无论哪个是源或目标。这个简单的、不可破坏的规则防止了循环等待,让你的文件顺畅移动。

有时,正确的锁顺序不是任意的,而是由系统本身的逻辑决定的。想想一个现代的日志文件系统,它被设计用来从崩溃中恢复。在它对其主要结构(如文件元数据或数据块)进行任何更改之前,它首先将其意图的记录写入一个日志或“journal”。在崩溃恢复期间,线程会重放这些日志条目。这个过程可能涉及一个日志锁 LJL_JLJ​,一个元数据锁 LML_MLM​,以及一个数据锁 LDL_DLD​。一个恢复线程可能需要根据元数据中的信息更新一个数据块,而元数据本身正在从日志中恢复。工作的自然流程意味着一个自然的锁层次结构:你必须先获得日志锁(LJL_JLJ​)才能从中读取以更新元数据(LML_MLM​),并且你必须先获得元数据锁(LML_MLM​)才能修改它所描述的数据块(LDL_DLD​)。正确的锁顺序 LJ≺LM≺LDL_J \prec L_M \prec L_DLJ​≺LM​≺LD​ 不仅仅是避免死锁的约定;它直接反映了保证一致性的基本“先记日志后更新”原则。

最微妙的死锁发生在系统不同部分之间的边界上。想象一下,你应用程序中的一个线程持有一个锁,我们称之为用户锁 UUU。然后,它无意中尝试访问一块当前未加载的内存,触发了一个页错误。控制权立即转移到内核。为了处理这个错误,内核需要更新其页表,而页表受一个页表锁 PPP 保护。所以,该线程现在持有 UUU 并等待内核获取 PPP。但如果就在那一刻,另一个内核任务——比如说,一个内存回收进程——正在运行呢?它可能持有页表锁 PPP 并需要检查你应用程序的状态,而这需要获取用户锁 UUU。于是,就出现了:一个跨越用户-内核边界的致命拥抱。解决方案是建立一个严格的分层。系统架构师定义了一个层次结构,其中高层用户空间锁比底层内核页表锁具有更高的“级别”。规则是绝对的:你总是可以在持有较高级别锁的同时获取较低级别的锁,但反之则不行。一个持有像 PPP 这样的底层锁的内核任务被禁止尝试获取像 UUU 这样的高层锁。这种严格的分层防止了这些隐蔽的跨层死锁。这种分层原则无处不在,从管道和套接字的交互 到像 USB 集线器这样的硬件的设备驱动程序,确保了不同的子系统可以共存而不会冻结整个机器。

软件的脚手架:并发数据结构

如果说操作系统是地基,那么并发数据结构就是用来构建现代应用程序的钢梁和脚手架。让这些结构能够被多个线程同时使用而不破坏其状态,是细粒度锁定的一堂大师课,而锁排序是其中的关键。

以不起眼的哈希表为例,它是程序员普遍使用的工具。为了使其快速,我们可以使用分桶锁,允许多个线程同时访问不同的桶。但是当哈希表变得太满需要调整大小时会发生什么?一个调整大小的线程可能需要获取一个全局调整大小锁 RRR,然后有条不紊地锁定每个旧桶 BiB_iBi​,将其内容移动到一组新的桶中,每个新桶都有自己的锁 Bj′B'_jBj′​。这个过程充满了危险。一个插入者线程可能持有桶锁 BkB_kBk​,然后决定需要调整大小,尝试获取 RRR。但调整大小的线程已经持有 RRR,并且现在正在等待获取 BkB_kBk​。死锁!更糟的是,将数据从旧桶 BiB_iBi​ 移动到新桶 Bj′B'_jBj′​ 的辅助线程如果以不一致的顺序锁定它们,它们之间也可能发生死锁。解决方案是一个全面的锁层次结构:全局调整大小锁 RRR 必须在任何旧桶锁 BiB_iBi​ 之前获取,而所有旧桶锁 BiB_iBi​ 必须在任何新桶锁 Bj′B'_jBj′​ 之前获取。这种多级排序编排了调整大小的复杂舞蹈,确保线程们像有礼貌的搬运工一样,永远不会在走廊里互相挡路。

对于更复杂、动态的结构,如自平衡树(例如红黑树),策略必须更加精巧。线程可以使用一种称为“锁耦合”的技术并发地遍历树,即线程在释放其父节点的锁之前锁定子节点。这自然地遵循了从祖先到后代的顺序。麻烦始于当一次插入需要一个“修复”操作时,比如一次旋转,这可能需要修改一个节点、它的父节点和它的祖父节点。此时,线程可能只持有该节点及其父节点的锁,在向下遍历时早已释放了祖父节点的锁。它不能简单地“向上”回溯去锁定祖父节点;那会违反祖先优先的规则并有死锁的风险。优雅而又令人惊讶的解决方案是一次策略性撤退:线程释放它持有的锁,回到祖父节点,并以正确的自顶向下顺序(g→p→xg \to p \to xg→p→x)重新获取所有三个节点的排他锁。只有这样,它才能安全地执行旋转。这是一个绝佳的例子,说明了严格的锁定纪律有时需要你放手,以便安全地前进。

编排复杂性:大规模系统

当我们把视野拉远,我们看到锁排序原则被放大以编排整个软件生态系统。

在现代图形栈中,一个合成器线程负责将各种视觉元素排列成屏幕上的最终场景。它可能需要锁定场景图 LsL_sLs​ 来完成其工作。与此同时,一个应用程序线程正忙于生成一个纹理,并用纹理锁 LtL_tLt​ 来保护它。当合成器持有 LsL_sLs​ 需要读取纹理而等待 LtL_tLt​ 时,而应用程序持有 LtL_tLt​ 需要更新场景图而等待 LsL_sLs​ 时,死锁就发生了。这里的解决方案不需要基于任何复杂的逻辑;它可以是一个简单的、不可破坏的约定。系统中的每个锁都被分配一个唯一的、不可变的编号。全局规则是:你必须始终按其 ID 的递增顺序获取锁。如果 ID(Lt)=7ID(L_t) = 7ID(Lt​)=7 且 ID(Ls)=10ID(L_s) = 10ID(Ls​)=10,那么任何同时需要两者的线程都必须始终先获取 LtL_tLt​ 再获取 LsL_sLs​。这个简单的决胜规则使得循环等待成为不可能。

在整个子系统之间创建层次结构的想法至关重要。许多大型服务使用数据库(DBMS)进行持久化存储,并使用一个操作系统级的应用程序处理业务逻辑,该应用程序有自己的由互斥锁保护的内存状态。一个请求可能会启动一个数据库事务,获取一个 DBMS 锁,然后需要访问内存缓存,这需要一个操作系统互斥锁。另一个请求可能会反过来做。这是一个随时可能发生的死锁,而且它特别棘手,因为无论是 DBMS 的锁管理器还是操作系统都不知道对方的资源。解决方案是在两个系统之间建立一个“超级层次结构”。一个常见的策略是定义所有数据库锁的级别都“低于”所有应用程序互斥锁。这意味着一个线程在持有数据库锁的同时可以获取操作系统互斥锁,但它被严格禁止在持有应用程序级互斥锁的同时尝试启动或参与数据库事务。这个清晰的界限防止了死锁跨越这些原本独立的世界。

最终,许多复杂的死锁都归结为一个简单的循环依赖。想象三个服务 H1H_1H1​、H2H_2H2​ 和 H3H_3H3​,它们依赖于共享的状态对象 XXX、YYY 和 ZZZ。H1H_1H1​ 需要先锁定 XXX 再锁定 YYY。H2H_2H2​ 需要先锁定 YYY 再锁定 ZZZ。而为了完成这个循环,H3H_3H3​ 需要先锁定 ZZZ 再锁定 XXX。如果它们都获取了第一个锁,它们将全部卡住等待第二个锁。死锁是一个完美的、对称的循环:H1→H2→H3→H1H_1 \to H_2 \to H_3 \to H_1H1​→H2​→H3​→H1​。正如我们所见,我们可以通过强加一个全序,比如 X≺Y≺ZX \prec Y \prec ZX≺Y≺Z,来打破这个循环,迫使处理程序 H3H_3H3​ 改变其行为。或者,我们可以采取不同的方法:如果我们能打破资源依赖本身呢?如果 H1H_1H1​ 需要的 XXX 的部分与 H3H_3H3​ 需要的部分不同,我们可以将 XXX 分区为两个分片,XaX_aXa​ 和 XbX_bXb​,每个都有自己的锁。现在,H1H_1H1​ 和 H3H_3H3​ 不再竞争,循环被打破,死锁消失了。

从内核的深处到分布式服务的宏伟架构,锁排序是使并发成为可能的无形纪律框架。它可以是一个任意的约定,一个逻辑必然性的反映,一个组件的严格分层,或一个动态协议。在其所有形式中,其目的都是相同的:将一个简单、无环的秩序强加于一个否则会退化为复杂、混乱循环的世界。它证明了计算机科学中一个深刻的思想:有时,通往自由和高性能的道路是由严格的规则铺就的。