try ai
科普
编辑
分享
反馈
  • 细粒度锁定

细粒度锁定

SciencePedia玻尔百科
  • 细粒度锁定通过用多个小锁代替一个大锁来增加并行性,从而允许对资源的不同部分进行并发访问。
  • 细粒度锁定的主要风险是死锁,这可以通过强制执行一个全局的锁获取顺序来系统地避免。
  • 有效使用细粒度锁定需要考虑隐藏成本,如上下文切换开销、伪共享等硬件效应以及优先级反转等操作系统交互。
  • 该技术对于现代并发数据结构、数据库和操作系统在多核处理器上的性能至关重要。

引言

在多核处理器主导的时代,释放计算能力的关键不仅在于硬件,还在于软件同时执行多项任务的能力。这就是并发编程的领域,在该领域中,安全高效地管理共享资源是至关重要的挑战。一种常见的方法,即粗粒度锁定,使用一个简单的单一锁来保护数据,但这会造成串行瓶颈,从而抵消了多核带来的好处。对真正并行性的追求迫使我们采用一种更复杂的策略:细粒度锁定。

本文深入探讨了细粒度锁定的世界,这是一种功能强大的最大化并发性的技术,但它也伴随着其自身深刻的复杂性。通过将大锁分解为更小、更有针对性的锁,我们可以实现巨大的性能提升,但同时也打开了通向危险问题的大门,如死锁、性能下降以及微妙的硬件级冲突。

为了驾驭这一领域,我们将首先探索细粒度锁定的基本​​原理与机制​​,从并行性的前景到死锁的危险及其解决方案的精妙之处。然后,我们将考察其在​​应用与跨学科联系​​中的实际影响,探索这些技术如何构成现代并发数据结构、数据库和操作系统的支柱。这段旅程将揭示在并行世界中平衡安全性、复杂性和性能的艺术与科学。

原理与机制

想象你是一个巨大图书馆里唯一的图书管理员。为了防止混乱,你拿着唯一一把通往前门的钥匙。一次只能有一个人进入。这很简单,也绝对安全;没有两个顾客会为同一本书争吵。但这样效率极低。随着图书馆越来越受欢迎,门外排起了长队。这就是​​粗粒度锁定​​的本质:一个单一的大锁保护着整个系统。它简单而安全,但从根本上限制了可扩展性。

现在,想象你给图书馆的每个房间都配了一把钥匙。许多顾客现在可以同时在不同的房间里工作——一个在历史区,一个在物理区,一个在艺术区。图书馆的吞吐量飙升。这就是​​细粒度锁定​​的前景:将一个大的受保护资源分解成更小的部分,每个部分都有自己的锁。这种方法允许​​并行性​​,即多个线程或进程可以同时取得进展。但你可能猜到了,管理数百把钥匙引入了一个全新的复杂世界。当一个顾客需要历史和物理两个房间的书时会发生什么?又会出现什么新问题?从一把钥匙到多把钥匙的这个过程,就是现代并发编程的故事。

全有或全无的方法:粗粒度锁定

在计算中,这把唯一的图书馆钥匙被称为​​全局锁​​或​​粗粒度互斥锁​​。​​互斥锁​​(mutex,mutual exclusion 的缩写)是一种数字钥匙,确保在任何给定时间只有一个线程可以执行特定的代码块——即​​临界区​​。粗粒度锁定将这一点推向极致,用它来保护大量且多样的资源。

也许最著名的现实世界例子是在像 CPython 这样的语言运行时中发现的全局解释器锁(GIL)。GIL 是一个单一的互斥锁,线程在执行 Python 字节码之前必须获取它。设计者为什么会实现这样一个看似限制性的机制呢?答案是简单性和安全性。GIL 保护了解释器所有的内部数据结构,如内存分配和垃圾回收信息,使其免受并发访问的破坏。这使得编写解释器本身以及为其编写 C 扩展变得极为简单。

然而,这种简单性的代价是高昂的。在拥有多个处理器核心的现代计算机上,GIL 意味着无论有多少核心可用,一次只能有一个线程在执行 Python 代码。真正实现并行性的潜力丧失了。我们可以使用类似于阿姆达尔定律的模型来量化这一点。如果一个线程的工作中有 fff 的部分需要 GIL,而剩下的 1−f1-f1−f 可以在 CCC 个核心上并行完成,那么可能达到的最佳加速比由下式给出:

S=1f+1−fCS = \frac{1}{f + \frac{1-f}{C}}S=f+C1−f​1​

随着工作变得越来越 CPU 密集型,比例 fff 接近 111。在此极限下,加速比 SSS 接近 111,意味着增加更多核心不会带来任何好处。你有一条 16 车道的超级高速公路,但每个人都在一个单一的收费站前排着单行队。

这并不意味着粗粒度锁是无用的。对于以输入/输出(I/O)为主的任务——比如等待网络请求或从磁盘读取——线程可以在等待时释放 GIL。这允许其他线程运行,从而创造出一种并发性(随时间管理多个任务),即使没有 CPU 并行性。但要释放多核硬件的真正威力,我们必须找到使用不止一把钥匙的方法。

众锁的世界:细粒度锁定的前景

对于单锁瓶颈,直观的解决方案是使用许多更小、更有针对性的锁。这就是​​细粒度锁定​​。我们可能不再为整个数据结构使用一个锁,而是为每个元素或每个区域设置一个锁。

考虑一个数据库中使用的 B 树数据结构,它必须处理许多并发更新。粗粒度的方法是在每次操作时都锁定整个树的根。而细粒度的方法则涉及当线程向下遍历到叶子节点时,沿途对单个节点加锁。类似地,对于哈希表,我们可以为每个桶设置一个单独的锁,而不是为整个表设置一个单一的锁。性能上的影响可能是惊人的。在一个对 B 树进行高负载的真实模拟中,单一的粗粒度锁可能导致 80% 的传入操作需要等待。通过切换到细粒度的节点锁,这个等待概率可能会骤降至仅 4%。瓶颈就这样消失了,对结构不同部分的操作可以并行进行。

这种分解大锁的过程是一种常见的设计模式,称为​​锁拆分​​ (lock splitting)。如果一个单一的锁保护着两个独立的子系统,比如 A 和 B,我们可以用两个锁 LaL_aLa​ 和 LbL_bLb​ 来替换它。现在,只需要访问 A 的线程可以与只需要访问 B 的线程并行运行,这在以前是不可能的。这就是细粒度锁定的美好前景:通过只锁定你真正需要的部分,且只在需要的时间内锁定,来启用并行性。

自由的代价:死锁

但这种新获得的自由伴随着一个隐藏的危险。回到我们的图书馆,一个顾客可能为了拿书而锁上了历史室,然后走到物理室,却发现它被另一个顾客锁住了。但如果第二个顾客现在正等着历史室的钥匙被归还呢?两人都无法继续。他们陷入了​​死锁​​状态。

这个经典问题通常用​​哲学家就餐​​问题来说明。五个哲学家围坐在一张桌子旁,他们之间有五把叉子。为了吃饭,每个人都需要两把叉子。如果每个哲学家同时拿起他们左边的叉子,他们将永远等待右边的叉子,而那把叉子正被他们的邻居拿着。他们会饿死。

这不仅仅是一个哲学难题;它是并发系统中真实且灾难性的故障模式。当四个条件,即​​Coffman 条件​​,同时满足时,就会发生死锁:

  1. ​​互斥​​:资源(锁)一次只能由一个线程使用。
  2. ​​持有并等待​​:一个线程在等待另一个资源时,至少持有一个资源。
  3. ​​不可抢占​​:资源不能被强行从线程中夺走。
  4. ​​循环等待​​:存在一个线程链,其中每个线程都在等待链中下一个线程所持有的资源。

细粒度锁定系统是死锁的温床。想象一个程序将数据从一个哈希表 HHH 迁移到另一个哈希表 GGG。线程 T1T_1T1​ 需要将一个项从桶 hih_ihi​ 移动到 gjg_jgj​。它锁定了 hih_ihi​,然后尝试锁定 gjg_jgj​。同时,线程 T2T_2T2​ 正在将一个项从 gjg_jgj​ 移动到 hih_ihi​。它锁定了 gjg_jgj​,然后尝试锁定 hih_ihi​。我们遇到了一个完美的死锁。T1T_1T1​ 持有 hih_ihi​ 并等待 gjg_jgj​,而 T2T_2T2​ 持有 gjg_jgj​ 并等待 hih_ihi​。程序冻结了。

秩序之美:驯服死锁

我们如何摆脱这个陷阱?我们可以回到单一的全局锁,它通过暴力手段防止死锁——它消除了跨多个资源的“持有并等待”条件。但这牺牲了我们所有来之不易的并行性。

幸运的是,有一个更为优雅的解决方案:​​对锁的获取施加一个全序​​。这个简单的规则直接攻击了循环等待条件。如果我们建立一个规则,即锁必须始终按照一个特定的、预定义的顺序来获取,那么循环依赖就变得不可能了。

要理解这一点,想象一下将哲学家就餐问题中的叉子从 1 到 5 编号。如果规则是“总是先获取编号较小的叉子,再获取编号较大的叉子”,死锁就消失了。想要 5 号和 1 号叉子的哲学家必须先获取 1 号叉子。这打破了循环的对称性,总会有人能够吃到饭。

这个强大的原则普遍适用。对于我们的哈希表迁移问题,我们可以为所有桶锁定义一个全局排序。例如,我们可以规定任何来自表 HHH 的锁的等级都高于任何来自表 GGG 的锁。然后,任何需要同时获取这两个表中的锁的线程,都必须始终先获取 HHH 的锁,再获取 GGG 的锁。导致死锁的逆向获取现在被禁止了。对于锁拆分,如果一个操作需要锁 LaL_aLa​ 和 LbL_bLb​,我们强制执行一个规则,即必须始终先获取 LaL_aLa​ 再获取 LbL_bLb​。循环等待现在在结构上变得不可能。这是一个美丽的例子,说明一个简单、抽象的原则——全序——如何能解决系统设计中一个复杂、具体的问题。

隐藏的成本:死锁之外

即使有了消除死锁的完美排序协议,细粒度锁定也不是免费的午餐。从一个锁到多个锁的转变揭示了更微妙、更有趣的复杂性层面。

并发性的开销

并发本身有其成本。在单核处理器上,没有真正的并行性。任务可以交错执行,但不能同时运行。在这种环境下,细粒-度锁定实际上可能让情况变得更糟。想象一下,在单个核心上有四个线程都在争夺锁。每当一个线程试图获取另一个线程持有的锁时,它就会阻塞。操作系统必须执行一次​​上下文切换​​——保存阻塞线程的状态并加载另一个线程的状态——这会耗费宝贵的微秒。这可能导致一个“护航队效应”,线程反复阻塞和切换,为零并行性增益带来了显著的开销。详细分析表明,这很容易使总运行时间增加超过 10%,纯粹是由于锁管理和上下文切换的成本。这说明了​​并发性​​(多个任务的逻辑管理)和​​并行性​​(任务的物理同步执行)之间的关键区别。

机器中的幽灵:伪共享

最微妙的成本来自于我们的软件与底层硬件之间的相互作用。现代 CPU 使用缓存——小而快的内存库——来加速对主内存的访问。数据在主内存和这些缓存之间以固定大小的块移动,这些块称为​​缓存行​​(通常为 64 字节)。当一个 CPU 核心写入一个内存位置时,其缓存必须获得包含该位置的整个缓存行的独占所有权。这个动作会使所有其他核心中相同的缓存行失效。

现在,考虑一个并行的垃圾回收器,它使用一个​​位图​​来标记活动对象,每个对象一位。一个 64 字节的缓存行可以容纳 64×8=51264 \times 8 = 51264×8=512 个不同对象的标记位。如果核心 1 上的线程 1 标记对象 #100(设置一个位),而核心 2 上的线程 2 同时标记对象 #105(在同一个缓存行上设置另一个位),它们没有访问相同的数据。但因为它们的数据位于同一个缓存行上,硬件将其视为冲突。缓存行将在核心之间来回穿梭,这种现象被称为​​伪共享​​。线程争夺的是缓存行,而不是数据本身,导致了巨大的性能下降。解决方案需要在硬件层面思考:要么填充数据,使独立项不共享一个缓存行,要么更巧妙地,将位图划分为缓存行大小的块,并使用软件锁来串行化对每个块的访问。

优先级的暴政:优先级反转

最后,我们的锁定策略与操作系统的调度器相互作用。在实时系统中,任务具有优先级。高优先级任务应始终能够抢占低优先级任务。但如果一个低优先级任务获取了一个高优先级任务需要的锁,会发生什么?高优先级任务会阻塞,这是预料之中的。但如果允许抢占,一个中等优先级的任务(根本不需要那个锁)现在可以抢占持有锁的低优先级任务。高优先级任务现在实际上被一个中等优先级的任务阻塞了,这种情况被称为​​优先级反转​​。这可能导致无限制的延迟,在需要满足最后期限的系统中是灾难性的。需要像​​优先级继承​​这样的解决方案,即低优先级任务临时继承等待任务的高优先级,来解决这个问题。这表明临界区的持续时间不是唯一的因素;它与系统调度器的交互同样至关重要。

权衡的艺术:整体视角

并发控制策略的选择不是一个简单的粗粒度与细粒度之争。它是一个深刻的工程权衡。你应该花时间优化一个算法,使其临界区速度加倍,还是应该从全局锁切换到按桶锁定?事实证明,答案取决于一切:线程数、桶数、读操作与写操作的比例,以及操作本身的成本。

细粒度锁定是构建可扩展、高性能系统的极其强大的工具。但要有效地运用它,需要一种整体的理解。它要求对诸如排序以防止死锁等抽象原则的领悟,对开销和争用的量化分析,以及对底层硬件和操作系统物理现实的深刻尊重。其美妙之处不在于一个单一的“最佳”答案,而在于驾驭这些相互关联的复杂性,为手头的问题找到正确平衡的科学。

应用与跨学科联系

在经历了细粒度锁定的原理之旅后,我们可能感觉自己一直在打磨一套非常精确、非常专业的工具。但这些工具是用来做什么的呢?它们在何处构筑了现代计算的宏伟结构?事实证明,它们无处不在,隐藏在众目睽睽之下,在我们几乎每一次数字互动背后辛勤工作。从粗糙、笨重的锁转向精细、灵巧的锁,不仅仅是一项学术活动;它是一个关于我们现在所生活的并行世界如何成为可能的故事。

串行的暴政与对并行性的追求

我们之所以如此痴迷于锁定粒度,其根本原因是一个相当严苛的原则,即阿姆达尔定律。想象你有一项宏伟的任务,比如建造一座城堡。大部分工作,如采石和将石头吊装到位,可以由一千名工人并行完成。但一小部分工作,比如说,首席建筑师最终确定蓝图,必须由一个人完成。阿姆达尔定律告诉我们,无论你雇佣多少千名工人,总项目时间永远不会快于那位建筑师完成其工作所需的时间。那个串行部分成了一个无法逃避的瓶颈。

在计算中,“串行部分”是程序中任何一次只能由一个处理器核心执行的部分——一个由锁保护的临界区。如果一个程序有 10% 是串行的,那么即使有无限数量的处理器,你也永远无法获得超过 10 倍的加速。为了实现多核处理器所承诺的大规模性能提升,我们必须对这个串行部分发动一场不懈的战争。正如一项分析所示,要在 24 核机器上获得 15 倍的加速,代码必须有超过 97% 的可并行性。细粒度锁定是我们在这场战争中的主要武器。它是将一个庞大、缓慢、单一的蓝图定稿过程分解为许多可以并发进行的微小、独立检查的艺术。

基础:并发数据结构

细粒度锁定最直接的应用是构建软件的基石:数据结构。

想象一个数字邮局,所有进出的邮件都只有一个队列。粗粒度的方法就像只有一个职员,既负责将邮件添加到队列尾部,又负责从队列前端取走邮件。如果有人在投递信件,就没有人能取件,反之亦然。这很简单,但效率极低。细粒度的方法是雇佣两名职员:一名只负责向队尾添加邮件(enqueue),另一名只负责从队首取走邮件(dequeue)。只要队列不为空,他们就可以同时工作而互不干扰。这正是在并发链表队列中通过为 head 和 tail 使用分离的锁所实现的效果。这个简单的改变使得数据生产者和数据消费者能够并行操作,这种模式构成了无数系统的骨干,从 Web 服务器的任务队列到流处理引擎。

但情节变得更加复杂。如果我们的邮局不是一个队列,而是成千上万个邮箱,就像在哈希表中一样呢?我们是为整个房间配一把万能钥匙(全局锁),还是为每个邮箱配一把单独的钥匙(每项锁定),或者为每一排邮箱配一把钥匙(每桶或“分段”锁定)?这个选择是一个美妙的工程权衡。为每个邮箱都配一把钥匙(per-item)提供了最大的并行性——许多人可以同时访问不同的邮箱。然而,管理数百万把钥匙很麻烦,并且有其自身的开销。一把万能钥匙(coarse-grained)很简单,但会使所有人串行化。

最佳点通常在中间:锁分段。通过为每一排邮箱设置一把钥匙,我们在管理锁的开销和并行性的潜力之间取得了平衡。我们甚至可以数学建模来确定创建的最佳锁数量,平衡了因减少争用而减少的等待时间与每个额外锁引入的微小固定开销。这种“锁分段”技术在高性能并发哈希表的设计中至关重要,而这些哈希表又是从编程语言运行时到大规模 Web 缓存等各种事物的关键组件。

对于像树这样更复杂、动态的结构,我们需要一种更优雅的舞蹈。为了在不锁定整个树的情况下遍历到插入点,我们可以使用一种称为​​手递手锁定​​(或锁耦合)的技术。想象一下沿着绳子往下爬:你在用上面的手松开之前,先用下面的手抓住。类似地,一个遍历树的线程在释放其父节点的锁之前,会获取子节点的锁。这确保了线程在穿过树的路径上总有一个稳定的“抓手”,防止另一个线程,比如说,在它上面砍断绳子。这种规范的、自上而下的锁获取方式也巧妙地避免了死锁,使其成为并发指针型数据结构的基石。

社会的引擎:操作系统和数据库

如果说数据结构是砖块,那么操作系统和数据库就是用它们建造的大教堂。在这里,细粒度并发不仅仅关乎速度;它关乎使整个系统能够正常运作。

考虑一下数据库,现代金融、物流和电子商务的心脏。其核心通常是一个 B 树,一种为基于磁盘的存储而优化的特殊树结构。当你在成千上万其他人同时进行同样操作时进行 ATM 取款,数据库必须在不损坏其索引的情况下并发处理这些事务。这是通过一个极其复杂的“手递手锁定”版本来实现的,使用“闩锁”来保护 B 树节点的物理结构。当一个节点变满需要分裂时,一个线程会执行一个精细的、多步骤的手术,但它只锁定父节点和被分裂的节点,允许在树的其他部分进行其他操作继续进行。没有这种细粒度的控制,数据库在并发负载下会陷入停顿。

同样的原则也深深地作用于你计算机的操作系统内部。每当你的计算机用完物理内存需要从磁盘加载数据时,它会触发一个​​缺页中断​​。内核必须随后查询其进程地址空间的内部映射来处理这个中断。一个早期、简单的设计可能会使用一个单一的锁来保护一个进程的整个地址空间。后果是什么?如果你那 32 核数据分析应用中的一个线程触发了一个需要缓慢磁盘 I/O 的缺页中断,所有其他 31 个可能也需要访问内存的线程都被迫停下来等待。多核机器突然表现得像单核机器一样。所有现代操作系统中实现的解决方案是,将这个庞然大物拆开:对地址空间区域使用读写锁,甚至在页表本身上使用更细粒度的锁。这使得在不相交的内存区域中的缺页中断可以并行处理,从而释放硬件的全部威力。

这甚至延伸到系统维护。过去,检查文件系统的错误(fsck)需要将整个系统下线。如今,通过结合使用写时复制快照和细粒度锁定,一个“在线”清理程序可以扫描文件系统的一个一致的历史视图来查找错误。当它发现一个错误时,它会短暂地获取一个只针对受影响的活动元数据对象的细粒度锁来执行修复,而文件系统的其余部分则继续全速运行。这就是高可用性服务器能够运行数年而无需停机的原因。

前沿:超越简单锁定

细粒度控制的哲学在不断演进。有时,锁定的成本不仅仅是等待时间,还有它对数据结构自身逻辑的干扰。例如,像 AVL 树这样的自平衡树通过旋转来维持对数高度。然而,一次旋转可能需要锁定整个子树,重新引入了一个粗粒度的瓶颈,在某些条件下,这可能使其比一个只使用细粒度每节点锁的简单、不平衡的树还要慢。这揭示了算法复杂性与并发性能之间的深刻张力。

这种张力促使计算机科学家发明了更为微妙的机制。对于绝大多数由读取主导的工作负载——比如路由器根据其路由表转发数据包——即使是获取读锁的微小开销也可能在规模扩大时成为瓶颈。这导致了​​读-复制-更新(RCU)​​的开发。在 RCU 中,读取者访问数据时根本不加锁。他们只是读取数据,并假设它是有效的。一个希望更改数据的更新者,不会在原地修改它。相反,它会创建一个副本,修改副本,然后原子地切换一个指针,使新副本成为“官方”版本。然后它等待一个“宽限期”——即所有现有读取者完成对旧数据的使用所需的时间——然后才释放旧副本 [@problem_-id:3639739]。读取路径几乎是瞬时的,而写入路径则付出了复制和等待的代价。对于合适的工作负载,性能提升是惊人的。

从一个简单的队列到操作系统的心脏,细粒度锁定的故事就是并发本身的故事。这是一项持续的、巧妙的努力,旨在拆除串行瓶颈,将粗糙的操作分解为其最精细的组成部分,并指挥一场数百个处理器之间的精妙舞蹈,使它们能够和谐地协同工作。正是这种无形的工程技术,使我们这个快速、并行的世界成为可能。