
在并发编程的世界里,传统锁是防止数据损坏的既定方法,但其悲观的性质常常造成性能瓶颈。通过强制线程排队等待,锁可能导致 CPU 周期的浪费和全系统范围的降速,即使在实际数据冲突的可能性很低时也是如此。这种低效率凸显了一个根本性的空白:需要一种在无争用的普遍情况下速度快,而在冲突发生时又足够安全的同步机制。
本文探讨了事务性锁省略(TLE),这是一种强大的乐观并发控制技术,正是为了解决这个问题而生。通过利用硬件事务内存(HTM),TLE 允许线程推测性地绕过锁,寄望于一次无冲突的执行来获得显著的速度提升。您将学习到这种在乐观与实用之间优雅的舞蹈是如何实现的,从硬件层面一直到软件设计模式。接下来的章节将引导您了解这一概念,从其核心机制开始,然后探索其广泛的影响。
在我们理解现代计算机如何同时处理多个任务的旅程中,我们经常会遇到不起眼的锁。它就像一个数字交通警察,确保当一个执行线程需要处理一段共享数据时,没有其他线程可以干扰。这对于正确性至关重要,但它是有代价的。让我们层层剥开,去发现一种更 audacious(大胆)且通常快得多的方式来实现同样的目标:事务性锁省略(TLE)。
想象一个受欢迎的咖啡店,里面有一台非常复杂的浓缩咖啡机。为了避免混乱,规定是同一时间只有一个咖啡师能使用这台机器。第一个到达的咖啡师在上面挂一个“使用中”的牌子(获取锁),做一杯咖啡,然后取下牌子(释放锁)。其他咖啡师在等待时做什么呢?他们站在那里,不耐烦地跺着脚,反复检查牌子是否已经拿走。
这正是自旋锁所做的事情。一个想要进入由锁保护的代码“临界区”的线程会运行一个紧凑的循环,反复执行像“测试并设置”(Test-And-Set)这样的原子指令,仅仅为了等待轮到自己而消耗 CPU 周期。这种“忙等待”不仅浪费;在高争用(许多线程想要同一个锁)的情况下,持续的轮询会在处理器的核心之间产生一场通信风暴,进一步拖慢了所有人的速度。
这种方法从根本上是悲观的。它假设冲突很可能发生,所以它总是强制线程串行化——排成单行——即使它们即将执行的是快速、无干扰的任务。难道没有更好的方法吗?
如果一个咖啡师不用排队,而是可以直接走到一台浓缩咖啡机的幻影副本前开始做咖啡,会怎么样?他们会用心记下自己接触过的所有东西。如果他们在没有其他人干扰真实机器的情况下完成,他们就可以神奇地将自己完美制作的咖啡与柜台上的咖啡在一个瞬时步骤中交换。然而,如果另一个咖啡师以冲突的方式开始使用真实机器,我们的英雄只能叹口气,扔掉他的幻影咖啡,然后决定下一步该怎么做。
这就是事务性锁省略背后美丽而乐观的想法。一个线程不再获取锁,而是简单地说:“我将推测性地执行这个临界区,就好像我拥有锁一样。”“省略”部分意味着我们正在跳过或省略实际的锁获取操作。线程不挂上“使用中”的牌子;它直接开始工作,赌注没有其他人会妨碍它。这个赌注通常会带来丰厚的回报,尤其是在冲突很少见的情况下。
这种神奇的幻影咖啡制作,得益于现代处理器中一个卓越的功能,称为硬件事务内存(HTM)。HTM 为线程提供了一种机制,可以将一个代码块定义为一个事务。
可以把事务看作是与硬件签订的一份合同:“亲爱的处理器,请执行以下内存读写序列。请确保这整个序列对系统的其余部分来说,表现为一个单一、不可分割(原子)的操作。如果你能做到,就提交该事务,让我所有的更改一次性永久生效并可见。如果出于任何原因你无法保证这一点——比如另一个线程干扰了——那么请中止该事务,丢弃我所有的更改,就好像它们从未发生过一样,并通知我。”
为了履行这份合同,硬件会为事务维护一个读集和一个写集,通常是通过使用处理器自身的缓存系统来实现。当一个事务从一个内存地址读取时,该地址的缓存行被添加到读集。当它写入时,新数据被保存在缓存内的一个特殊事务性状态中,对其他核心不可见,并且该缓存行被添加到写集。
处理器的缓存一致性协议(如常见的 MESI 协议)成为了冲突检测器。如果另一个核心试图写入我们事务读集中的一个缓存行,或者试图读取或写入我们写集中的一个缓存行,一致性协议就会发出冲突信号。哔! 硬件检测到干扰并自动触发中止。推测性写入会立即从缓存中清除,使系统恢复到事务开始前的状态。
通过锁省略,锁变量本身被简单地添加到事务的读集中。线程并不试图改变锁变量。它只是观察它。如果另一个线程非事务性地前来获取锁(通过写入它),就会产生一个冲突,从而中止推测性事务,确保了正确性。
乐观的路径是美好的,但现实常常介入。事务可能并且确实会因为各种原因而中止。理解这些失败模式是构建稳健系统的关键。
冲突中止(Conflict Aborts): 这是最明显的原因。另一个线程以冲突的方式访问了相同的内存。
容量中止(Capacity Aborts): 硬件跟踪读集和写集的能力是有限的。事务性缓冲区或缓存空间可能会被填满。如果一个事务太长或触及了太多不同的内存位置,它可能超出此容量并被迫中止。我们咖啡师的幻影工作台空间也是有限的!
显式中止和系统中止(Explicit and System Aborts): 有些操作从根本上是“不可撤销”的。如果一个事务包含向打印机发送数据或向磁盘写入文件的指令怎么办?硬件无法回滚这些操作。像I/O或某些系统调用这样的操作通常在事务内部是被禁止的。尝试执行一个会导致立即中止。一个安全的系统必须首先检测到此类指令,并避免在它们周围省略锁。
鉴于事务可能失败,我们不能无限期地重试。一个线程可能会陷入活锁,即它反复尝试并中止,永远无法取得进展。一个稳健的TLE系统必须有一个B计划:一个回退路径。这个回退几乎总是我们试图避免的东西——传统的、悲观的锁。
何时放弃乐观主义并退回到锁的安全地带,这是一个关键的性能权衡。让我们从经济学的角度思考一下。 使用锁的预期成本是获取和释放它的开销,加上因争用而忙等待的时间。我们称之为 。 一次事务性尝试的预期成本取决于它中止的概率 。一个简单的模型可能看起来像 ,其中 是中止和回滚的高昂成本,而 是成功提交的低开销。
当中止率 很低时,事务成本 远低于锁成本 。但随着争用的增加和 的攀升, 这一项开始占主导地位。在某个中止率阈值 处,反复中止的成本变得比一开始就等待锁的成本还要高。对于一个假设的系统,这个盈亏平衡点可能在中止率为 时。如果观察到的中止率超过这个值,那么直接使用锁会更有效率。
一个复杂的系统不会简单地做一次性的决定。一个好的策略可能是:尝试几次事务(比如 )。如果它持续失败,就使用指数退避——在每次失败后等待更长一点时间——来减少争用。如果所有尝试都失败了,那么最终回退并获取重量级锁。更好的是,一个运行时系统可以监控性能计数器,如最近的中止率 和已提交事务吞吐量 ,来动态计算切换策略的最佳时机。
将乐观的事务与悲观的锁相结合,创造了一个强大的混合系统,但它也引入了一些必须小心处理的微妙挑战,以维持正确性。
首先,考虑一个线程中止了它的事务并回退到锁路径。它获取锁,非事务性地执行其写入,然后释放锁。在一个具有弱内存模型的处理器上,对其数据的写入和释放锁的写入可能会以乱序的方式对其他核心可见!另一个线程可能在看到更新后的数据之前就看到锁是“空闲”的,从而导致混乱。解决方案是在释放锁之前放置一个释放栅栏。这就像一个屏障,确保所有先前的内存写入在锁释放可见之前都全局可见,从而保留了锁的预期语义。
其次,这种混合方法可以防止死锁。在经典的死锁中,线程A持有锁 并等待锁 ,而线程B持有 并等待 。如果线程A使用TLE,它不会持有锁 。它只是在推测。当它试图访问 并发现它被B持有时,它的事务会简单地中止。它从未进入“持有并等待”状态,这是死锁的必要条件之一,从而打破了循环依赖。
最后,即使在纯事务性的世界里,也可能出现新问题。考虑事务性优先级反转。一个长时间运行的、低优先级的事务可能会推测性地修改一个热门的缓存行。许多需要访问此行的短时间、高优先级的事务将持续冲突并中止,实际上在长事务慢悠悠进行时被饿死。一个真正先进的硬件实现可以通过“冲突租约”来解决这个问题。当检测到某一行上的第一次冲突时,负责仲裁访问的缓存目录可以启动一个计时器。如果冲突持续了预定的一段时间 ,目录会向长时间运行的事务发送一个强制中止信号,让较短的事务最终能够取得进展。
因此,事务性锁省略不是一个简单的万能灵药。它是从保证的串行化到受控的乐观主义的深刻视角转变。它的美不仅在于它能解锁的原始速度,还在于硬件和软件之间错综复杂的舞蹈——推测、冲突检测、成本分析和恢复的层层机制——所有这些协同工作,构建出一个既快得令人难以置信又可证明正确的系统。
在我们之前的讨论中,我们揭示了事务性锁省略背后的巧妙原理:一种下注的艺术。我们不再是每次都支付传统锁的代价,而是推测性地执行一段临界区代码,赌一把没有其他线程会来干扰。如果我们赌赢了,就能获得丰厚的性能回报。如果我们输了,硬件会优雅地中止我们的尝试,不留任何痕迹,然后我们回退到更安全、更传统的方法。
这个想法,一种乐观与实用主义之间的优美舞蹈,远不止是理论上的好奇。它是一个强大的工具,在整个计算机科学领域掀起了涟漪,改变了我们构建软件基础的方式。在本章中,我们将穿越这些不同的领域——从操作系统的引擎室到数据结构的优雅蓝图,再到现代编译器的自动化工厂——去看看这个单一而强大的概念是如何被应用的。我们将看到,这项技术的真正天才之处不仅在于乐观的快速路径,还在于当我们赌博失败时,那些接住我们的回退机制的深思熟虑的工程设计。
没有什么地方比操作系统(OS)的核心更能体现并发的挑战了。内核是一个熙熙攘攘的线程大都市,所有线程都在争夺共享资源——调度器队列、内存映射、网络缓冲区。在这里,每一纳秒的同步开销都至关重要,这使其成为事务性锁省略的天然试验场。
并发编程中的一个经典难题是读者-写者问题。想象一个共享信息,许多线程需要读取,但只有少数需要写入。传统的解决方案,即读写锁,允许多个读者并发进行,但确保任何写者都拥有独占访问权。虽然这比简单的互斥锁要好,但读者仍然必须执行获取和释放读锁的仪式,这本身就带有开销。
事务性锁省略提供了一个惊人优雅的解决方案。为什么那些不改变任何东西的读者,还要费心去处理锁呢?取而代之的是,每个读者可以开始一个事务,读取数据,然后提交。这个过程快如闪电,并且不需要与其他读者进行显式协调。读者推测失败的唯一情况是当一个写者出现时。为了让这行得通,写者的加锁协议必须与读者的事务集成。标准模式是写者首先获取一个传统的写锁。一个关键细节是,推测执行的读者必须在其事务中读取这个锁变量的状态。这个动作将读者“订阅”到这个锁上;如果一个写者随后修改了这个锁,硬件会检测到读者事务读集上的冲突,并自动中止读者的事务。然后,读者回退到传统的、较慢的获取读锁的路径,该路径会正确地等待写者完成。
但是,在读者泛滥的情况下会发生什么?如果一个写者到达,它可能会被迫等待,因为源源不断的新读者在其回退路径上获取它们的锁。为了防止这种“写者饿死”,一个健壮的系统必须在其回退路径中使用一个公平的、基于队列的锁,以保证一旦有写者在等待,它最终会得到执行的机会。
同样这种“推测并回退”的哲学几乎可以扩展到内核中的任何短临界区。考虑一个需要更新共享计数器或修改小型数据结构的系统调用。内核可以将此更新包装在一个事务中。如果成功,锁就被“省略”了,操作很快。如果由于争用而中止,该怎么办?无限重试?那将是一场灾难。在高争用下,线程可能会陷入“活锁”,永远在中止对方的事务而无法取得任何进展。
解决方案是有界重试策略。系统可能会尝试该事务,比如说,三次。如果所有尝试都失败了,它就放弃推测,回退到获取自旋锁。性能模型显示,少量的重试次数通常是最佳的。它给了系统一个赢得推测赌注的机会,而如果争用真的很高,又不会浪费太多时间。 更先进的实现甚至使用一个共享的“回退中”标志。当一个线程被迫走慢速的、加锁的路径时,它会设置这个标志。推测性事务被设计为读取此标志,如果它被设置,就立即中止,从而防止它们在锁被持有时无用地空转,并帮助系统更快地消除争用风暴。
无论是保护内核路由表还是一个简单的计数器,模式都保持不变,并揭示了一个深刻的真理:不要在事务内部获取锁,因为这有死锁的风险。相反,事务路径和加锁路径必须是两个截然不同的世界,通过让快速路径推测性地读取慢速路径所写入的锁变量来进行协调。
一个聪明的内核程序员手工打造这些事务模式是一回事;而让这个过程自动化则是另一回事。这是编译器和语言运行时的领域,它们扮演着架构师的角色,自动生成和优化我们编写的代码。
一个执行自动并行化的现代编译器可以分析一个程序,找到一个循环,其中每次迭代大部分是独立的,但包含一个由锁保护的小临界区,并自动对其进行转换。它不是生成简单的加锁代码,而是可以生成 TLE 的复杂重试并回退逻辑。编译器甚至可以建立一个性能模型,估算事务中止的概率()以及成功()、中止()和回退()的成本。利用这些,它可以计算并行化循环的预期吞吐量,并决定这种转换是否值得。
这个概念在自适应的、即时(JIT)编译器的世界中达到了顶峰,这些编译器存在于像 Java 虚拟机或 .NET Core 运行时这样的托管运行时中。这些系统不是静态的;它们是活的。它们在程序运行时观察它。它们可以部署轻量级的性能分析钩子来测量特定锁上的真实世界争用率()。利用这些实时数据,运行时可以动态地进行成本效益分析。
TLE 尝试的预期成本是成功和失败场景的混合:,其中 、 和 分别是成功事务、中止事务和回退锁路径的成本。运行时可以求解出 的盈亏平衡点。对于一组实际的时间数据,此计算表明只有当争用率 低于 时,TLE 才是有利可图的。
如果运行时观察到某个“热”锁的争用率已降至此阈值以下,它可以触发动态重编译。它会生成一个使用 TLE 的新的、优化的代码版本,并使用一种称为栈上替换(On-Stack Replacement, OSR)的技术,无缝地将正在运行的线程切换到这个新版本。为防止“颠簸”——过于频繁地来回切换——它使用滞后效应,为去优化设置一个稍高的阈值。如果它检测到某个锁行为不当(例如,导致过多中止或包含非事务安全的操作),它可以立即去优化回安全的加锁版本,甚至将该锁“列入黑名单”,禁止未来的 TLE 尝试。这种动态的、数据驱动的方法是智能系统设计的缩影。
让我们更深入地探讨,研究那些构建我们数据的算法本身。并发数据结构的设计以其正确性的难以保证而臭名昭著。为确保像平衡树这样的复杂结构的正确性所需的加锁协议可能复杂到令人费解。
考虑 B 树,大多数数据库和文件系统背后的主力。一次插入操作可能需要一次“节点分裂”,这是一项复杂的手术,涉及修改节点本身、其父节点以及创建一个新的兄弟节点。以并发方式执行此操作的传统方法涉及在遍历树时采用一种谨慎的“锁耦合”或“手递手”加锁纪律,这既复杂又可能限制并发性。
硬件事务内存提供了一个诱人地简单的替代方案。为什么不把整个操作——向下遍历树、可能的分裂以及对父节点的更新——都放在一个单一的、巨大的事务中呢? 如果事务提交,这个复杂的多步更新对系统的其余部分来说,就表现为一个单一、不可分割的原子事件。这种纯粹的概念上的简单性是一个巨大的胜利。多个细粒度锁的复杂舞蹈被一对简单的 XBEGIN/XEND 所取代。
当然,这个赌注可能并不总是能赢。这样大的事务可能会与另一个事务冲突,或者可能超出硬件跟踪推测状态的能力。因此,一个健壮的设计仍然必须包括一个回退路径。如果宏大的事务策略在几次尝试后失败,线程必须恢复到一种更悲观但保证能工作的策略——比如获取整个树的单个全局锁,并以老式的方式执行插入。这种双管齐下的方法让我们两全其美:在争用低时享有事务的性能和简单性,在争用高时则有全局锁的正确性保证。
对任何技术的深刻理解不仅来自于知道它能做什么,还来自于欣赏它不能做什么。事务内存功能强大,但并非魔法。它的局限性界定了其应用范围,并迫使我们进行更巧妙的设计。
一个硬性限制是容量。跟踪事务推测性读写的硬件是有限的。一个复杂状态机中的状态转换可能需要更新分布在许多缓存行上的数十个内存位置。如果这个内存足迹超过了硬件的容量,事务将确定性地中止,每次都是如此。重试是徒劳的。唯一正确的设计是检测到这种持续的失败,放弃推测,并回退到传统锁。
一个更根本的限制是不可撤销性。事务可以回滚对内存的更改,但它无法回滚与外部世界的交互。你无法“取消发送”一个网络数据包或“取消写入”一个设备寄存器。任何 I/O 操作或系统调用对事务来说都是一剂毒丸。如果被执行,它会使事务的效果不可逆转,从而打破原子性保证。
处理这个问题的正确模式有两种。最简单的是仅在事务成功提交之后执行 I/O。一种更复杂的方法是延迟 I/O。事务不是直接执行 I/O,而是简单地将一个请求写入共享的内存中队列。这是一个纯粹的内存操作,并且是完全事务性的。然后,一个独立的、专门的工作线程可以安全地从队列中取出这些请求并执行实际的 I/O,与事务完全解耦。
最后,我们必须考虑到我们的程序并非在真空中运行。硬件本身还有其他层次,比如硬件虚拟化。当在虚拟机内运行的客户机操作系统试图使用事务性锁省略时会发生什么?虚拟机管理程序(hypervisor)或虚拟机监视器(virtual machine monitor)调节着客户机对物理硬件的访问。在原生机器上可能微不足道的事件,如定时器中断或页错误,可能会导致“VM 退出”——一个从客户机到虚拟机管理程序的重量级转换。至关重要的是,从处理器的角度来看,VM 退出是一个必须处理的不可中断事件。处理器的规则很简单:在处理此类事件之前,任何活动事务都必须中止。其后果是深远的:在虚拟机内运行会引入一个新的、隐藏的事务中止源。例如,频繁的定时器中断会严重破坏事务性工作负载的性能,将一个稳赢的赌注变成一个持续的亏损。 这揭示了虚拟化并非一个完全透明的层;它的存在可能对高级硬件特性产生微妙而显著的性能影响。
我们的旅程表明,事务性锁省略不仅仅是一个单一的技巧;它是一种设计哲学。它认识到在许多并发场景中,冲突是例外,而非规则。通过对这种良好状况进行乐观的赌注,我们可以构建更简单、更快、更具可扩展性的系统。
然而,真正的美在于其二元性。乐观的快速路径的力量与悲观回退的稳健、有原则的工程设计相匹配。正是这种理解,即你必须限定重试次数,你需要一个公平的锁来防止饿死,I/O 必须被延迟,以及你的事务内存足迹不能是无限的。正是这种在推测与保证、乐观与悲观之间的舞蹈,代表了构建高性能并发系统的艺术。这是硬件和软件如何协同进化的完美例证,为并行化的根本挑战提供了越来越优雅的解决方案。