
长期以来,追求更简单、更高效的并行编程一直是计算机科学领域的核心挑战,开发者们常常与传统锁的繁琐和易错性作斗争。硬件事务内存 (HTM) 作为一种有前景的解决方案应运而生,它提供了一种新的范式:开发者只需声明一个代码块是原子的,然后让处理器来处理并发的复杂性。本文旨在弥合事务内存的优雅概念与其在芯片上的实际实现之间的知识鸿沟。本文将深入探讨这项强大的技术,探索其基础设计和固有局限性。读者将了解使 HTM 成为可能的基本原理,从其推测执行模型到事务失败的原因。随后,讨论将转向其多样化的应用和跨学科的联系,揭示 HTM 如何重塑从操作系统到网络安全等多个领域。
几个世纪以来,物理学家们梦想着一个统一的理论,一个能描述自然界所有作用力的优雅方程。在计算世界里,程序员们也有一个类似的梦想:简化编写正确并行程序的这一极其复杂的任务。这项工作的传统工具——锁,是一种笨拙的工具。它是悲观的,迫使线程排成有序队列,逐一通过,即使它们实际上可能不会相互干扰。这可能导致性能瓶颈、死锁以及一系列其他问题。人们梦想的是一种远为优雅的方式:只需标记一段代码,然后告诉处理器,“无论发生什么,让这个代码块看起来像是一次性、不可分割地完成的。”这就是事务内存的梦想。
这个梦想建立在两个优美的支柱之上:原子性和隔离性。原子性意味着事务是“全有或全无”的;要么它的所有更改对系统可见,要么全部不可见。隔离性意味着从任何其他线程的角度来看,事务似乎是在某个单一时间点瞬时执行的,没有任何中间状态可见。硬件事务内存 (HTM) 就是将这一梦想变为物理现实的尝试,它将这种逻辑直接嵌入到处理器的硅片中。
一块硅片如何能完成如此神奇的壮举?其机制是现代处理器中已有功能的美妙结合,这些功能被重新用于一个全新的、雄心勃勃的目标。它依赖于推测执行,并将处理器自身的缓存用作私有的草稿纸。
想象一个线程开始执行一个事务。在底层会发生以下情况:
开始事务:处理器进入推测模式。这就像一位物理学家在黑板上草草记下计算过程,他知道这些内容可以轻易擦除。
跟踪读写:当事务执行时,处理器会 meticulously 记录。当它从一个内存地址读取时,会将该地址的缓存行添加到一个读集中。当它向一个内存地址写入时,它不会将数据发送到主内存。相反,它会将新值缓冲在其私有缓存中,并将该缓存行添加到一个写集中。这些推测性写入对系统中的所有其他线程都是不可见的。处理器实际上是在构建一个私有的、另类的现实。
冲突检测:这部分非常巧妙。处理器利用现有的缓存一致性协议——确保所有核心对内存有统一视图的系统——作为一个内置的“间谍网络”。如果另一个核心试图写入我们线程读集中的某个缓存行,或者试图访问(读取或写入)我们线程写集中的某个缓存行,一致性协议就会发出冲突信号。“间谍网络”侦测到了对隔离性的潜在违反。
提交或中止:如果事务在没有任何冲突的情况下到达终点,处理器将执行提交操作。在一个单一的、原子的瞬间,它将缓存中缓冲的所有推测性写入对系统的其余部分可见。黑板上的计算被宣布正确并永久化。然而,如果在任何时刻检测到冲突,处理器将触发中止。它会简单地丢弃其缓存中所有的推测性更改。黑板被擦干净了。对外界来说,就好像这个事务从未开始过一样。
这种机制直接提供了原子性的保证。考虑两个共享变量 和 ,初始值都为 。一个处理器 上的事务写入 ,然后写入 。另一个处理器 读取 ,然后读取 。由于事务的写入是缓冲并原子性提交的, 只能看到事务之前的状态 ()、事务之后的状态 (),或者其读取操作跨越提交点的状态(它在提交前读取 ,在提交后读取 ,观察到 )。但它永远不会看到 。观察到 意味着事务已经提交,此时 也必须是 。这与在弱内存模型的机器上使用非事务性写入有着根本的不同,在那种情况下,写入的可见性可能被重排序,使得 的结果成为可能。HTM 通过其原子性提交,对其自身的操作强制执行了强大的局部顺序。
HTM 机制很优雅,但现实世界是复杂的。事务可能因多种原因失败,并非所有原因都像直接的数据冲突那样直截了当。理解这些失败模式,即中止,是有效使用 HTM 的关键。
中止最明显的原因是真正的数据冲突,即两个线程试图以不兼容的方式访问同一块数据。这是机制按预期工作的情况。然而,由于 HTM 以缓存行(通常为 字节)的粒度跟踪冲突,一个更隐蔽的问题可能会出现:伪共享。想象两个线程更新两个完全独立的变量,而这两个变量恰好位于同一个缓存行中。硬件对应用程序的逻辑一无所知,它看到两个线程在修改同一个缓存行,于是宣告冲突,强制中止。这种“假中止”是实现方式的产物,而非真正的数据依赖。这种情况发生的概率很大程度上取决于数据结构在内存中的布局方式,而使用简单锁的程序员通常可以忽略这个细节。
处理器跟踪读集和写集的能力并非无限。硬件有一个有限的缓冲区——无论是缓存中的空间还是专门的结构——来存储这些推测信息。一个过长或触及过多不同缓存行的事务可能会耗尽这个缓冲区,导致容量中止。
这个限制是真实存在的,并且是固化在硅片中的。例如,为了跟踪读写,处理器 L1 缓存中的每个缓存行可能需要额外的元数据位:一个‘读’位、一个‘写’位,以及一个‘写掩码’来跟踪行内哪些具体的字被修改了。对于一个拥有 个缓存行的缓存,每行仅增加 个这样的位,就相当于增加了一百多万个晶体管,占用了可观的芯片面积。如果一个工作负载的内存足迹,不仅包括其数据 (),还包括其查询的任何元数据 () 和内部跟踪开销 (),超过了硬件的容量 (),那么该事务将总是因容量中止而失败。这种失败是确定性的,与其他线程无关;即使是单个线程隔离运行也无法提交。
事务是一种脆弱的、推测性的状态。它可能被与程序逻辑无关的事件所打破。一个操作系统的定时器中断、一个页错误、一个进入内核的调用,甚至来自另一个核心的一致性消息都可能强制立即中止。处理器不能简单地暂停一个事务,处理一个中断,然后再无缝地恢复。这种中断的代价是三重的:回滚推测状态的硬件成本 ()、处理已中止状态的额外操作系统簿记成本 (),以及最痛苦的,在中止前已完成但现在必须重新执行的应用程序工作的损失 ()。这些异步中止意味着事务的成功永远无法得到保证,即使它没有数据冲突并且在硬件容量之内。
鉴于事务可能因为像容量限制这样的持续性原因而失败,无限重试并非一个可行的策略。一个线程可能会陷入无休止的中止循环,即活锁状态,无法取得任何进展。解决方案是采用一种混合执行模型:保持乐观,但要有一个悲观的备用计划。
标准模式是尝试在有限次数内以事务方式执行一个临界区。如果成功,我们就能获得乐观并发的性能优势。如果它反复失败,我们就切换到一个使用传统、健壮锁的回退路径。这保证了操作最终会完成。锁的选择很重要;一个简单的自旋锁可能无法避免饥饿,但一个公平的队列锁(如 MCS 锁)可以保证每个线程最终都能获得执行机会,从而确保整个系统的强前进保障。
为了使这个混合模型正确,事务路径和基于锁的路径必须正确地序列化。这通常通过锁省略来完成,其中事务路径“省略”了锁的获取,但仍然监控锁的状态。如果回退锁被获取,这个动作会导致任何并发的事务检测到冲突并中止,从而确保在任何时候只有一个线程——要么是锁的持有者,要么是一个成功的事务——处于临界区内。
HTM 很强大,但它的力量局限于一个特定的领域:可缓存内存。它无法处理具有不可逆转的外部副作用的操作。典型的例子是输入/输出 (I/O)。当一个程序写入内存映射的 I/O 寄存器以发送网络数据包或启动磁盘写入时,该内存访问通常被标记为非缓存的。该写入会绕过处理器的缓存,直接到达设备。
这与 HTM 的推测性质产生了根本冲突。如果一个非缓存的 I/O 写入在事务内部执行,该动作会立即且不可逆地发生。如果该事务后来中止,硬件将无法“撤销发送”该网络数据包。为了防止这种对原子性的违反,HTM 硬件明确禁止 I/O。任何在事务内部访问非缓存内存区域的尝试都会导致立即中止。解决方案再次是健壮的回退路径:软件检测到这种特定类型的中止,并在传统锁的保护下重新执行整个操作——包括内存更新和 I/O 写入。
硬件事务内存不是银弹;它是一个具有特定权衡的工具。其性能优势完全取决于工作负载。
HTM vs. 细粒度锁:想象一个需要更新 个不同内存位置的临界区。使用 个细粒度锁会为每次锁获取带来开销。相比之下,HTM 为启动事务支付一个单一的、较大的设置成本。如果事务成功,对于较大的 来说,这是一个巨大的胜利。然而,这必须与中止的概率以及失败时付出的代价相平衡。只有当访问的位置数量足够大,以摊销其较高的进入成本和潜在的中止惩罚时,HTM 才比细粒度锁更具吸引力。
HTM vs. 软件事务内存 (STM):STM 实现了同样的原子性梦想,但完全在软件中实现,通过在每次内存访问时添加插桩(额外代码)。这赋予了它极大的灵活性(例如,无限的容量),但也带来了很高的单次访问开销。HTM 通过将这种跟踪移入硬件,具有更高的设置成本,但单次访问开销几乎为零。这就产生了一个明显的交叉点:对于非常小的事务,HTM 的设置成本占主导地位,STM 通常更好;而对于较大的事务,STM 的单次访问惩罚变得难以承受,HTM 则表现出色。
最终,HTM 在复杂数据结构上存在中等程度竞争的场景中大放异彩,此时事务足够大,可以从硬件加速中受益,但又足够小,可以容纳在容量限制内并避免频繁中止。它可以成为构建复杂并发系统的强大基石,例如为增量式垃圾回收器实现低开销的写屏障。简单、原子区域的梦想如今已成为现实,但就像任何强大的工具一样,要有效地使用它,需要理解其深刻的美妙之处和其实际的局限性。
在我们之前的讨论中,我们揭示了硬件事务内存的精妙机制。我们看到了硬件如何凭借其对内存的敏锐观察,有望解开锁和互斥锁这个棘手的难题。但原理是一回事,实践是另一回事。我们能用这种新获得的力量做些什么?它将我们引向何方?在本章中,我们将踏上一段旅程,去看看 HTM 在实际应用中的表现。我们将看到它如何改变编程的艺术,重塑我们操作系统和编译器的基础,并且在一个引人入胜的转折中,甚至在网络安全世界开辟了一条新的战线。这不仅是一个关于应用的故事,也是一个关于硬件、软件以及计算逻辑本身之间美妙而常常出人意料的相互作用的故事。
任何与并发编程搏斗过的人都知道竞争条件的恐怖和无锁算法令人费解的复杂性。使用像比较并交换 (CAS) 这样的原语编写正确的代码就像走钢丝,常常导致复杂的循环,这些循环难以编写、更难阅读,而且几乎不可能证明其正确性。HTM 带来了一股清新的空气。它允许我们用一个简单的声明性语句来取代那些繁复的结构:“这个代码块应该是原子的。” 硬件负责处理跟踪内存访问和确保原子性的复杂舞蹈。虽然一个经过精细调优的 CAS 循环在特定的低竞争场景下可能仍然能挤出更多性能,但使用 HTM 在程序员生产力和代码正确性方面的提升通常是不可估量的。它让我们能够专注于做什么,而不是怎么做。
这种优雅的一个美妙例证是一种称为事务性锁省略 (TLE) 的技术,它可以显著加速一种常见的同步模式:读写锁。想象一个共享数据结构,它被非常频繁地读取,但很少被写入。为每次读取获取锁的开销似乎很浪费,尤其是在没有写入者的情况下。通过 TLE,读取线程可以“省略”锁的获取。它们推测性地开始一个硬件事务,并直接继续读取数据,但有一个关键的补充:在事务内部,它们也读取写入者自己的锁变量。这次读取就像一个“绊网”。如果一个写入线程到达,它的第一个动作是获取锁,这涉及到对那个锁变量的写入。铛! 绊网被触发了。HTM 硬件检测到写-读冲突,并立即中止所有活动的读取者事务。 这些被中止的读取者随后会回退到更慢、更安全的路径,即获取一个正常的读锁。结果是神奇的:在常见的、无写入者的情况下,读取者以全速进行,同步开销为零。
然而,这个故事有一个关键的后记:公平性。HTM 事务可能因多种原因中止,理论上,一个线程可能会陷入无休止的重试循环中,即活锁状态。为了防止线程饿死,一个健壮的系统必须有一个后备计划。在几次事务尝试失败后,线程应该放弃推测,回退到一个传统的、公平的锁,该锁使用队列来保证所有等待的线程最终都能获得访问权。 HTM 并非包治百病的灵丹妙药,它不能免除我们进行仔细设计的需要;它是设计者工具箱中一个强大的新工具。
HTM 的性能看似神奇,但它的根基牢牢地扎在内存系统的硅片中。事务的生死存亡取决于缓存一致性协议的“恩典”。要编写可扩展的 HTM 代码,必须像硬件一样思考。
想象两个程序员,在不同的房间里,更新一个共享日志本中的两个不同条目。这应该没问题。但如果他们不知道,他们的两个条目在同一个物理页面上呢?每当一个人写入时,图书管理员(一致性协议)就会从另一个人手中抢走该页面,大喊“冲突!”。这就是*伪共享*的本质。在 HTM 中,这表现为令人抓狂的频繁中止。如果我们有一个包含许多小计数器(比如 8 字节整数)的数组,并将它们紧密地打包在内存中,我们可能会无意中将其中八个放在一个 64 字节的缓存行上。当八个不同的线程试图更新它们各自的“私有”计数器时,硬件看到的是八个线程在争夺一个缓存行,事务便会接二连三地中止。解决方案是反直觉但深刻的:我们有时必须浪费空间来换取速度。通过对每个计数器进行填充,使其各自占据一整个缓存行,我们确保了对不同计数器的更新发生在不同的缓存行上。伪冲突就消失了。
这个原则——最小化事务足迹并避免真实和虚假的竞争——是实现可扩展性的关键。我们可以在大型并发数据结构(如多生产者、多消费者队列)的设计中看到这一点。一个幼稚的设计可能会将整个‘入队’或‘出队’操作包装在一个大事务中。这会造成一个瓶颈,因为每个操作都会在队列的头指针和尾指针上产生竞争。一个更好的设计是将数据结构划分为更小的、独立的块,并使用短小的、局部的事务,这些事务每次只触及一个块,从而极大地降低了冲突的概率。
HTM 不仅适用于应用程序开发者。它深刻地影响着我们构建的最复杂的软件:操作系统和编译器。
对正确、高效并发的需求,在操作系统的心脏地带——内核中,比任何地方都更为关键。考虑将一个正在运行的进程从一个 CPU 核心迁移到另一个核心的任务。这是一个出奇精细的操作。调度器必须更新进程的状态,将其从旧 CPU 的运行队列中移除,并添加到新 CPU 的运行队列中——所有这些操作都要确保系统的另一部分不会,例如,改变进程的 CPU 亲和性,从而使迁移变得非法。传统上,这需要会损害可扩展性的粗粒度锁,或者容易引入错误的复杂细粒度锁。HTM 提供了一个惊人简单的解决方案:将整个迁移逻辑包装在一个单一的事务中。 所有相关的更新——对任务结构、对两个运行队列的更新——都作为一个单一的、原子的单元提交。调度器的不变量被毫不费力地保持了。当然,在一个永远不能真正阻塞的操作系统内核中,这个事务路径必须与一个健壮的、非阻塞的回退路径(可能使用 CAS)配对,以保证即使在重度竞争下也能取得进展。
HTM 的出现给编译器世界带来了涟漪,改变了旧规则,创造了新机会。一个经典的编译器优化,如循环不变代码外提 (LICM)——它将循环内恒定的计算提升到循环之前——突然变得危险。如果这个“不变量”是对一个共享变量的读取,而循环体是一个事务,那么将读取操作外提意味着事务现在对该变量的并发写入是“盲目”的。编译器破坏了事务本应提供的隔离性! 为了安全地执行此优化,编译器必须变得更聪明:它要么必须证明该变量是真正不可变的,要么必须在事务内部重新插入一个验证检查,以确保外提的值仍然是最新的。
同时,HTM 也使编译器能够更加激进。一个预先 (AOT) 编译器可以为一个临界区生成两个版本的代码——一个使用锁,一个使用 HTM——并使用运行时的 CPUID 检查来为它所在的硬件选择最佳路径,从而允许程序根据其环境进行自我优化。 在最先进的场景中,HTM 成为构建全新并行化方式的基石。考虑一个操作顺序很重要的循环(它们是非交换的)。编译器可以推测性地将所有迭代作为独立的事务并行执行,但使用一个共享的“票号计数器”来确保它们以正确的顺序提交。[@problem_g_id:3622680] 这允许了大规模的并行性,同时严格保持了原始程序的逻辑——这是仅使用传统锁无法想象的壮举。
每项强大的技术都会投下阴影,HTM 也不例外。它确保正确性的机制——内存冲突的检测——可以被用于一个更黑暗的目的:窃取秘密。
想象一个攻击者程序在一个核心上运行,而一个受害者程序在另一个核心上处理敏感数据。攻击者可以启动一个只读事务,该事务仅仅读取与特定数据(比如一个哈希表桶)相关联的内存地址。与此同时,受害者的操作(依赖于一个密钥)可能会也可能不会访问同一个桶。如果受害者依赖于密钥的操作写入了该桶,攻击者的事务将因冲突而中止。如果没写入,攻击者的事务则很可能会成功。通过重复这个过程数千次并测量事务中止率,攻击者可以构建一个关于受害者内存访问模式的统计图像,并从中推断出密钥。 中止本身变成了一个侧信道,一种微妙的信息泄露。硬件在勤勉地执行正确性规则时,无意中成了一个告密者。这揭示了一个深刻的真理:在计算机系统中,每一个可观察到的效应,无论多么微小,都是一个潜在的信息通道。
我们对硬件事务内存应用的巡礼揭示了它远不止是锁的一个简单替代品。它是程序员与处理器之间一个新的对话层。它简化了并发编程的险恶地貌,但作为回报,要求我们对底层硬件有更深入、更周到的理解。我们看到它催生了更优雅的操作系统和更智能的编译器,但也开启了新的安全漏洞。它没有解决并发问题,但它改变了问题的性质,推动了可能性的前沿,并提醒我们,在硬件与软件的复杂舞蹈中,每一步都有其后果,无论是预期的还是意料之外的。