
在多核处理器的时代,并发编程是释放真正性能的关键。然而,当我们增加更多线程来并行解决问题时,常常会遇到一个令人沮丧的悖论:应用程序不再变快,甚至可能变慢。这个瓶颈通常是由锁竞争引起的,这是并发编程中的一个根本性挑战,线程被迫排队等待对共享资源的访问。这种现象削弱了并行计算的初衷,是软件工程师必须克服的关键障碍。
本文剖析了锁竞争问题,深入解读其原因、影响和解决方案。通过从核心理论到实际应用的过渡,本文阐明了如何编写更具可扩展性和效率的并发软件。接下来的章节将引导您穿越这个复杂的领域。首先,“原理与机制”将分解基本概念,从阿姆达尔定律和等待策略,到复杂锁算法的演进。随后,“应用与跨学科联系”将揭示竞争在实践中潜藏于何处——从您自己的代码到操作系统的深层,再到大规模分布式系统——从而提供对这一普遍挑战的全面视角。
想象一条繁忙的多车道高速公路,代表一个强大的多核处理器。所有车道都畅通无阻,直到它们汇入一座单车道桥梁。这座桥就是一个临界区——一段共享数据或一个资源,一次只能由一个线程修改。为了管理交通,桥的入口处有一个交通信号灯。这个信号灯就是我们的锁。当一个线程(一辆车)需要过桥时,它必须等待信号灯变绿。一旦它上了桥,其他所有人的信号灯都会变红。当它离开时,信号灯为下一辆排队的车变绿。这个简单的画面就是并发编程的核心。
但当交通变得拥挤时会发生什么?一长队汽车排起长龙,引擎空转,等待轮到自己。这场交通堵塞就是锁竞争。尽管我们有一条宏伟的多车道高速公路,但整体吞吐量——每小时通过的汽车数量——却受限于这个单车道瓶颈。这是对一个基本原理——阿姆达尔定律(Amdahl's Law)——的优美而直观的诠释。它告诉我们,通过并行处理可以获得的总加速比,最终受限于必须串行完成的那部分工作。在我们的例子中,无论我们有多少个核心,它们都必须排队逐一过桥。
更糟糕的是,竞争行为本身会使串行部分感觉更长。想象一下汽车在队首争抢位置时的混乱场面。这种额外的开销,我们可称之为竞争放大因子,实际上增加了通过串行瓶颈所需的时间,进一步限制了我们从并行化中获得的收益。线性加速的梦想——核心加倍速度加倍——在这唯一的序列化点面前被无情地击碎了。
当一个线程到达一个已被锁定的资源时,它必须等待。但它应该如何等待?这个问题引出了两种根本不同的策略,每种策略都有其自身的特点和权衡。
第一种策略是忙等待,或称自旋。想象一下,在红灯前,司机一直踩着油门,引擎轰鸣,准备在信号灯变绿的瞬间冲出去。这就是自旋锁。线程在一个紧凑的循环中不断检查锁的状态,消耗 CPU 周期却不做任何“实际”工作。这看起来很浪费,但如果锁被占用的时间非常非常短——比如说,比关闭再启动引擎的时间还短——那么自旋就是最高效的选择。它避免了进入睡眠和再次唤醒的开销。这在像操作系统中断处理程序这样的上下文中尤其重要,因为在这些情境中,让线程睡眠甚至是不可能的选项。
第二种策略是阻塞,或称睡眠。在这里,司机熄火并决定小睡一会儿。操作系统将该线程移出调度队列,标记其为“等待”锁的状态。CPU 现在可以自由地去做其他有用的工作——为另一个线程服务。当锁被释放时,操作系统收到一个信号并“唤醒”睡眠的线程。这是一个标准互斥锁(mutual exclusion lock)的行为。如果预期的等待时间很长,阻塞的效率要高得多。它节省了电力,并通过不浪费 CPU 时间无休止地问“我们到了吗?”来提高整个系统的生产力。
选择自旋还是睡眠,是一个根本性的设计决策。但有一条规则永远、永远不能被打破:永远不要在持有锁的同时进入睡眠。想象一个司机,他拿到绿灯,开车上桥,然后决定睡个长觉。整条高速公路都陷入停滞。没有人能过桥。这被称为队头阻塞。在程序中,如果一个线程持有锁,然后执行一个缓慢的、阻塞式的 I/O 操作(如写入磁盘),或者自愿进入深度省电睡眠状态,它可能会对性能造成灾难性的影响,甚至可能冻结整个系统。仅仅因为一个线程在休息前未能释放其锁,吞吐量就可能骤降几个数量级。
鉴于锁是竞争点,计算机科学家们长期以来一直在探索设计更好、更智能、更公平的锁机制。这段历程揭示了算法与底层硬件架构之间美妙的相互作用。
我们的起点是使用 Test-and-Set 指令构建的最原始的自旋锁。这是一个原子性的硬件操作,它在一个不可分割的步骤中同时检查锁的值并进行设置。你可以把它想象成一个冲撞舞池:每个等待的线程都不断地挤向前方,试图抢夺锁。这在处理器的内部通信网络,即互连(interconnect)上造成了混乱。每次尝试写入锁变量都会使该内存位置在其他所有核心的缓存中失效,引发一场昂贵的缓存一致性流量“风暴”。性能极差,并且这个过程根本不公平——无法保证等待已久的线程会比新来的线程先获得锁。这可能导致饿死,即某些线程永远无法取得进展。
为了给这场混乱带来秩序,我们可以引入公平性。票据锁(ticket lock) 就像你在熟食店看到的“取号”系统。一个线程原子地增加一个“票据”计数器来获取自己的号码,然后等待“当前服务”计数器与它的票据匹配。这是一个巨大的进步。它是公平的(先进先出),并且由于线程现在只是读取“当前服务”计数器,等待期间的互连流量大大降低。然而,问题依然存在。当锁被释放时,“当前服务”计数器被更新,这会同时使每个等待核心上的缓存行失效。所有核心随后都会蜂拥而至重新读取新值,造成“惊群效应”,仍然会淹没互连。随着核心数量的增加,这种设计的扩展性很差。
迄今为止最优雅的解决方案是 Mellor-Crummey 和 Scott (MCS) 锁。它不是一个公开的“当前服务”标志,而是创建了一个私有的、有序的队列——一条人链。当一个线程想要锁时,它把自己添加到链的末尾,然后只观察它正前方的人。当锁被释放时,持有者只需“拍拍”队列中下一个人的肩膀。所有的通信都是局部的。一个线程在其自己的内存空间中的一个标志上自旋,产生的互连流量为零。释放是一次从一个核心到另一个核心的、有针对性的单一写操作。流量是恒定的(),无论有多少线程在等待。这种设计精妙地考虑到了现代硬件,特别是非统一内存访问(NUMA)架构,在这种架构中,远距离处理器插槽之间的通信非常昂贵。MCS 锁明白,对邻居耳语比在拥挤的房间里大喊要便宜得多。
有时,最巧妙的解决方案不是构建一个更好的锁,而是从一开始就避免竞争。这需要我们跳出思维定势,重新思考程序本身的结构。
一个强大的策略是缩小锁定的范围。如果你的“临界区”实际上是两个独立的数据结构——比如说,一个用户表和一个配置表——为什么要用一个巨大的锁来保护两者呢?通过将粗粒度锁拆分为两个更细粒度的锁,每个表一个,你可以立即减少竞争。处理用户表的线程将不再干扰处理配置表的线程。这种简单的分解行为可以显著提高并行性。当然,这也带来了一个新的挑战:如果一个线程需要两个锁,它必须按照一个一致的、全局定义的锁顺序来获取它们,以避免产生死锁——一种两个或多个线程陷入循环等待对方的致命拥抱。
最激进的策略是完全摒弃锁。这就是无锁编程的世界。你不是用锁来预防冲突,而是检测它们并重试。这得益于强大的原子硬件指令,如比较并交换(Compare-And-Swap, CAS)。一个 CAS 操作就像是说:“我相信 X 的当前值是 5。如果是,请将其更新为 6。如果不再是 5,就告诉我失败了。”如果操作失败,意味着在你工作期间有另一个线程修改了该值。没问题——你只需读取新值,然后再次尝试你的计算。
在合适的条件下,这种方法可以具有极高的可扩展性。因为没有单一的序列化锁,多个线程可以并行尝试它们的更新。理论上,整个系统的吞吐量可以随线程数量线性扩展,避免了困扰基于锁的设计的阿姆达尔定律的僵硬瓶颈。虽然正确设计起来要复杂得多,但无锁算法代表了追求真正并行性的前沿,从一个“停止并等待”的世界走向一个“乐观、并行进展”的世界。
掌握了锁竞争的基本原理后,我们现在可以开始一段旅程,看看这种迷人的现象在实际应用中出现在哪里。你会发现它并非教科书中某个抽象的麻烦;相反,它是交织在现代计算结构中的一个根本性挑战。就像物理世界中的摩擦力一样,竞争是一种无处不在的力量,工程师必须不断地理解、测量和围绕它进行设计。我们将在我们自己编写的代码中,在操作系统的最深层,以及在驱动云的庞大分布式系统中看到它的身影。
通常,我们与竞争的首次相遇源于一个乍一看似乎完全合乎逻辑的设计。想象一下,你正在编写一个多线程的科学模拟程序,每个工作线程都需要生成随机数。简单的方法是创建一个单一的、共享的随机数生成器(RNG),并用一个互斥锁来保护它,以防止其内部状态被破坏。这会有什么问题呢?
在低负载下,没有问题。但是当你增加线程数量,或者每个线程更频繁地请求随机数时,程序神秘地停止变快了。它撞上了一堵墙。这是最纯粹形式的锁竞争。你所有强大的 CPU 核心都在花费时间排成单列,等待轮流使用那个共享的 RNG。我们甚至可以惊人地精确地描述这场交通堵塞。锁的“利用率”——它处于繁忙状态的时间比例——大约是线程数()、每个线程发出请求的速率()以及服务一个请求所需时间()的乘积。当这个乘积 接近 1 时,系统就饱和了。等待线程的队列不断增长,性能急剧下降。
在这种情况下,解决方案既优雅又有效:消除共享。与其使用一个共享的 RNG,不如给每个线程一个它自己的私有 RNG。这样就不再有中央资源需要争夺,瓶颈也随之消失。线程可以自由地并行生成随机数,程序的性能也能够随核心数量的增加而扩展。这个简单的故事教会了我们对抗竞争最有力的一课:最好的锁就是没有锁。当然,这本身也带来了一些微妙之处。为了确保随机数流在统计上是独立的,每个线程私有的 RNG 都必须用一个唯一的种子来初始化。对于可复现的模拟,这些种子必须是确定性生成的,以确保即使线程在每次运行时可能被不同地调度,最终的总体结果保持不变。
但我们不能总是消除锁。有时,线程必须通过一个共享数据结构进行协调。考虑并发编程的主力:共享队列。一种粗粒度的方法是用一个单一的锁来保护整个队列。这是安全的,但它意味着在队列尾部的 enqueue 操作必须等待队列头部的 dequeue 操作完成。这就像一栋只有一个门供进出的建筑。
一个更精细的策略是使用细粒度锁定。我们可以使用两个独立的锁:一个用于队列头部(head_lock),一个用于队列尾部(tail_lock)。现在,添加元素的生产者可以获取 tail_lock,而移除元素的消费者可以同时获取 head_lock。这两个操作可以并行进行,极大地提高了吞吐量。然而,这种设计揭示了并发编程中既美妙又危险的微妙之处。当一个消费者移除了最后一个元素,使队列变空时会发生什么?在许多链表实现中,这需要更新 tail 指针以指回哨兵头节点。但 tail 指针受 tail_lock 保护!所以,已经持有 head_lock 的消费者现在还必须获取 tail_lock。为了避免致命拥抱——即死锁,其中一个生产者持有尾锁并想要头锁,而我们的消费者则相反——必须严格执行一个全局的锁获取顺序。例如,规则可能是:必须总是在获取 tail_lock 之前获取 head_lock。这确保了依赖循环永远不会形成,从而在保证前进的同时,仍然允许高度的并行性。
如果我们作为应用程序员必须与竞争搏斗,你可以想象操作系统设计者所进行的斗争。操作系统是一个庞大的、并发的系统,为我们管理着成千上万的线程和数不清的资源。竞争不是一个偶然的问题;它是一个核心的设计约束。
一个完美的例子就在于操作系统调度器。调度器必须维护一个所有准备运行的任务列表——runqueue。一个幼稚的设计可能会为所有 CPU 核心使用一个单一的、全局的 runqueue,由一个单一的锁保护。现在,想象一下一次活动爆发:几十个新任务同时变为就绪状态。所有这些任务都需要入队,所以它们都争相获取全局 runqueue 锁。与此同时,任何空闲的 CPU 核心也在试图获取同一个锁来出队一个任务运行。结果是大规模的拥堵。这个单一锁上的竞争成为主要瓶颈,限制了整个系统的可扩展性。
现代的解决方案与我们每个线程私有的 RNG 的例子直接对应:分区。我们不再使用一个全局的 runqueue,而是使用每 CPU 的 runqueue,每个都有自己的锁。当一个新任务就绪时,它被分配到其中一个 runqueue,也许是随机的。竞争现在被分散了。在一个 核机器上,一次 个新任务的爆发不再造成一个 个竞争者争夺一个锁的交通堵塞。相反, 个锁中的每一个现在只面对一个出队的核心和预期 个入队的任务。这种优雅的分区将每个锁的竞争减少了大约 倍,使得调度器能够随着核心数量的增加而优雅地扩展。
我们在文件系统中也看到了类似的模式。当一个文件变得非常流行,许多线程试图同时读取它时会发生什么?即使该文件的数据已经在内存中(在页缓存中),每个 read() 系统调用仍然必须遍历文件系统的元数据结构来获取权限和定位数据。这条路径通常涉及获取文件 inode 或 dentry(目录项)上的锁。如果许多同时被唤醒的线程都试图读取同一个“热点”文件,它们可能会造成一场惊群效应,蜂拥冲向这个单一的元数据锁。它们被序列化,一个接一个地通过临界区。服务这群线程的总时间不是一次读取的时间,而是一次读取的时间乘以群体中线程的数量。为了诊断这样的问题,工程师必须使用检测工具,这些工具不关注磁盘 I/O(因为它没有发生),而是关注在虚拟文件系统(VFS)层内等待和持有锁所花费的时间。
操作系统中充满了这样隐藏的序列化点。其中最令人惊讶的一个是动态链接器。当你的程序启动时,或者当它通过像 dlopen() 这样的函数加载共享库或插件时,操作系统必须对进程的内存映射进行复杂的修改。为了确保这些操作安全地进行,它们通常由一个单一的、全局的加载器锁保护。对于大多数应用程序来说,这是不明显的。但是对于一个动态加载和卸载代码模块的大型多线程服务器来说,这个单一的锁可能成为一个严重的瓶颈。使用排队论的工具,我们可以将这个锁建模为一个单服务台队列。如果 dlopen() 请求的速率超过了链接器能够服务的速率,系统就会变得不稳定。等待线程的队列无限增长,应用程序的性能陷入停滞。这告诉我们,竞争可能潜伏在系统软件最意想不到的角落。
为了战胜竞争,工程师们开发出了越来越复杂和微妙的技术。也许在操作系统的内存管理子系统中最能体现这一点,该子系统负责将虚拟内存地址转换为物理地址。
这种转换是使用页表完成的,为了速度,结果被缓存在每个核心的转译后备缓冲器(TLB)中。当操作系统在主页表中更改一个映射时——例如,将一个虚拟页面从一个物理帧重新映射到另一个物理帧——它必须确保没有核心继续使用其私有 TLB 中旧的、过时的映射。这是通过向其他核心发送处理器间中断(IPI)来实现的,指示它们使过时的 TLB 条目无效——这个过程称为 TLB 击落(TLB shootdown)。
现在,考虑一下竞争的影响。一个针对所有页表的单一全局锁将慢得无法想象。现代系统使用粒度细得多的锁,甚至可能每个页表项(PTE)一个锁。但这引入了一个可怕的竞争条件。假设核心 A 更改了一个 PTE,然后向核心 B 发送了一个击落 IPI。如果在核心 A 写入新 PTE 之后但在核心 B 处理中断之前的微小时间间隔内,核心 B 的硬件执行了一次页表遍历并缓存了新的 PTE,然后核心 B 处理了针对旧 PTE 的击落请求,会发生什么?可能会导致混乱。
正确的解决方案是一个优美的两阶段协议。为了更改一个映射,操作系统首先获取特定 PTE 上的锁,然后在其中写入一个临时的无效条目(清除“存在”位)。这充当了一个屏障,确保任何从此点开始尝试访问该页面的核心都会触发故障,而不是缓存一个转换。只有在那之后,它才发出 TLB 击落。一旦所有核心都确认清除了旧条目,操作系统就可以安全地写入最终的、正确的 PTE。这种复杂的舞蹈仅在更改映射或权限时才需要。对于信息性的更改,比如操作系统为了其页面替换算法清除页面的“已访问”位,则根本不需要击落。锁仍然是必需的,以防止在读-改-写操作上出现竞争,但昂贵的跨核心失效操作被避免了。这种语义更新和非语义更新之间的区别,以及相应的同步复杂性,展示了操作系统设计者为了平衡正确性和性能所必须付出的极端努力。
这些原则超越了单台计算机,延伸到广阔的分布式系统世界。在分布式文件系统中,一个集中的元数据服务器(MDS)通常管理文件系统命名空间。一个“热点”目录——一个正在进行大量文件创建或删除的目录——可能成为整个集群的竞争点。MDS 应该为整个目录使用一个单一的锁,还是为其中的每个文件使用更细粒度的锁?事实证明,答案取决于工作负载。如果操作分散在许多不同的文件上,文件级锁因允许更大的并行性而胜出。然而,如果竞争是由于所有操作都针对少数几个“热点文件”造成的,那么细粒度锁定的好处就减少了。这催生了强大的自适应策略。一种方法是可以将热点目录拆分为多个子目录来分散负载。更好的是,一个倾斜感知的拆分策略可以识别出最热的文件并将它们隔离在自己的目录中,从而隔离竞争,让命名空间的其余部分不受影响。同样的原则也适用于数据库,其中对整个 B 树索引的粗粒度锁远不如只锁定更新路径上节点的细粒度方法。一个被倾斜工作负载针对的树中的“热点”叶子节点仍然会看到竞争,但索引的其余部分仍然可用于并发访问。
最后,我们来到了前沿领域:无锁数据结构。如果我们能用一个单一、极快、由硬件提供的原子指令来替换一个保护整个操作序列的锁,会怎么样?考虑一个多生产者网络数据包队列。生产者可以使用一个原子 fetch-and-add 指令来操作共享的写索引,从而立即在环形缓冲区中声明一个槽位,而不是通过加锁来将数据包入队。序列化点并没有消失——硬件确保原子操作一次只发生一个——但其持续时间已从执行多指令临界区的时间()缩短到单个内存操作所需的纳秒级时间()。这可以带来巨大的加速。但这种能力伴随着巨大的复杂性代价。为了确保消费者不会在生产者完成写入包描述符之前读取它,程序员必须使用显式的内存排序屏障(例如,释放-获取语义)来防止 CPU 和编译器以有害的方式对内存操作进行重排序。无锁编程是一个充满惊人性能和可怕微妙之处的世界,代表了我们与竞争斗争的终极体现。
从一个简单的编程错误到 TLB 击落的复杂舞蹈,锁竞争是一条贯穿始终的线索。通过研究它,我们学会了不把我们的计算机系统看作一组静态的组件,而是一个由相互作用的代理组成的动态、活生生的生态系统,其集体性能受排队、同步和通信的微妙法则所支配。