
在多核处理器的世界里,并发编程不再是一门小众的专业技术,而是一项基本要求。然而,管理并发的传统工具——锁——是出了名的难以正确使用,常常导致细微的错误、死锁和性能瓶颈。这种复杂性为充分利用现代硬件的全部能力制造了巨大的障碍。事务内存(TM)作为一种引人注目的替代方案应运而生,它提供了一种范式转变,通过借鉴数据库领域一个精妙而简单的概念——原子事务,有望简化并发代码。
本文对事务内存进行了全面探讨,揭示其工作原理并展示其最有效的应用场景。我们将从核心原理走向实际应用,清晰地描绘出其强大之处与局限性。第一章 “原理与机制” 深入探讨了 TM 的机制,解释了其基于乐观执行、冲突检测和回滚的基础。它对比了两种主要的实现方法——快速但有限的硬件事务内存(HTM)和灵活但较慢的软件事务内存(STM)——并概述了程序员必须遵循的一套新规则。随后,关于 “应用与跨学科联系” 的章节将展示 TM 的实际应用,探讨其在操作系统和编译器中对复杂问题(从调度器设计到自动代码并行化)带来的变革性影响。读完本文,您将不仅理解 TM 的理论,还能领会其作为构建下一代健壮、并行软件的强大工具的实际意义。
想象一下,你走进银行,想把钱从储蓄账户转到支票账户。这个简单的操作包含两个步骤:从储蓄账户借记和向支票账户贷记。如果在钱离开储蓄账户后,银行的电脑在钱到达支票账户前崩溃了,会怎么样?钱就会凭空消失!为了防止这类灾难,银行保证交易是 原子性 的。整个两步过程要么完全成功,要么失败得好像从未发生过一样。没有中间状态。
几十年来,程序员一直在努力将这种精妙的简单性带入并发编程的世界。当一个程序的多个线程试图同时访问和修改共享数据时——这相当于我们的数字世界里多个职员访问同一本账本——它们可能会相互干扰,导致数据损坏。传统的解决方案是使用“锁”,即数字看门人,确保一次只有一个线程可以访问某块数据。但管理锁就像在一个有百万个十字路口的城市里当交警。它极其复杂,一个错误就可能导致死锁(线程在永久的对峙中相互等待)或难以发现和修复的细微错误。
事务内存(TM)提供了一条截然不同的路径。它邀请程序员简单地将一个代码块声明为一个 事务。然后,系统承诺原子地执行该代码块,就像银行转账一样。理论上,所有管理并发访问的繁琐细节都由系统自动处理。这是一个诱人的承诺,但系统是如何实现这一魔法的呢?
事务内存背后的核心原则并非魔法,而是一种强大的策略:乐观执行。执行事务的线程不会在接触任何数据前小心翼翼地获取锁,而是直接开始操作。它 推测 不会有其他线程来干扰。
当线程执行其事务时,它不会直接修改主内存。相反,它在一个私有的沙箱中工作。它会记录下它读取的所有内存位置(称为其 读集),以及它打算做的所有更改(称为其 写集)。可以把这看作是一封邮件草稿;在你点击“发送”之前,这些更改不是最终的,别人也看不到。从编译器的角度看,这要求在程序的公共、已提交状态和这种仅在事务内部可见的私有、推测状态之间维持一种谨慎的区分。
如果线程是独自运行,这种乐观主义会运作得很好。但当另一个线程出现时会发生什么呢?系统必须持续监视 冲突。当一个事务试图更改另一个并发事务正在读取或写入的数据时,就会发生冲突。例如,如果事务 读取了变量 ,而另一个事务 在 仍在运行时向 写入了一个新值,那么 的世界观就变得不一致了。它正在基于过时的数据进行操作。
当检测到冲突时,系统必须采取行动以维持原子性的假象。它通过强制其中一个冲突的事务 中止 来做到这一点。一个被中止的事务就像揉掉邮件草稿;其写集中的所有推测性更改都被丢弃,内存保持原样,仿佛该事务从未开始过。
接下来会发生什么?被中止的事务通常必须 重试。它回到起点,重新尝试执行其代码块,希望这一次不会遇到冲突。这个“中止-重试”循环是 TM 的基本机制。这种尝试、失败、重试的循环不仅仅是一个概念模型;它是实际的控制流。从编程语言的角度来看,这种重试机制等同于一个简单的 while 循环,直到成功为止,甚至可以使用尾调用优化等技术高效实现,以避免在重复重试期间消耗系统资源。成功的预期时间直接取决于在任何给定尝试中发生冲突的概率。
跟踪读写集、检测冲突和处理中止的机制可以通过两种根本不同的方式构建:在硬件本身中,或纯粹在软件中。这一选择代表了原始速度和灵活能力之间的经典工程权衡。
硬件事务内存 (HTM) 将事务逻辑直接集成到处理器中。CPU 利用其缓存一致性协议——正是这个系统保持不同处理器核心的内存视图同步——来跟踪读集和写集。当一个事务读取一个内存位置时,相应的缓存行在其读集中被标记。当它写入时,新数据被临时存储在缓存中,并且该行在其写集中被标记。冲突检测成为缓存协议的自然延伸:如果一个核心试图写入一个被另一个核心的事务性读集包含的缓存行,硬件会立即检测到冲突并可以触发中止。
软件事务内存 (STM) 则相反,它不需要特殊硬件。它是一个库或一种语言特性,编译器在事务内部的每次内存访问周围插入特殊代码——插桩。每次读取都变成一个像 tx_load() 这样的调用,每次写入都变成 tx_store()。这些函数在内存中维护数据结构,以记录读写集并检查冲突。
那么,哪个更好呢?就像工程中的大多数事情一样,这要视情况而定。定量分析揭示了一个清晰的交叉点。对于短小且只触及少量数据的事务,HTM 的低开销使其成为明显的赢家。对于会溢出 HTM 硬件缓冲区的非常大的事务,STM 是唯一的选择。选择取决于工作负载,中止概率在最终性能计算中也扮演着关键角色。
与事务内存共存意味着要学习一套新规则。事务块不仅仅是语法糖;它是一个语义边界,对从编译器到操作系统的整个系统都有深远的影响。
首先,不可逆操作 在事务内部是被禁止的。如果一个事务向屏幕打印了一条消息或通过网络发送了一个数据包,然后中止了怎么办?消息无法被“取消打印”;数据包无法被“取消发送”。这类操作破坏了全有或全无的承诺。这是一个根本性的限制。如果操作系统在事务执行中途向一个线程传递了一个异步信号,而信号处理程序需要执行 I/O,唯一安全的做法是中止该事务,处理信号,然后再重试该事务。TM 是用于 内存 的,而不是用于改变外部世界的。
其次,编译器必须尊重事务边界。一个聪明的编译器可能会注意到一个循环内的事务在反复读取同一个内存位置。一项标准的优化,循环不变量代码外提 (Loop-Invariant Code Motion, LICM),会建议将该读取操作提升到循环之外,只执行一次。但如果对一个 共享 变量的事务性读取执行此操作,就会破坏模型!通过将读取移出事务,该变量将从事务的读集中移除,系统也就失去了检测其他线程是否修改它的能力。这可能导致事务使用过时的数据执行,从而违反正确性。为了安全地进行此操作,编译器必须要么证明数据是不可变的,要么在事务中加回一个“验证”读取,以确保其视图仍然一致。
最后,事务内存不必是锁的全盘替代品。事实上,一些最强大的设计使用 TM 作为构建更好、更快同步机制的工具。考虑经典的读者-写者问题,其中许多“读者”线程应该能够并发地访问数据,但“写者”线程需要独占访问权。使用 HTM,读者可以在事务中推测性地执行其临界区。如果一个写者到来,它只需写入一个指定的“锁字”。任何活跃的读者事务的读集中都会有这个词,从而检测到冲突并中止。这种被称为 事务锁省略 的优雅技术提供了高度并发的读取。但如果存在高争用,事务不断中止怎么办?一个健壮的系统必须包含一个 回退路径。在一定次数的中止后,线程放弃推测,转而获取一个传统的、公平的锁,以保证它最终能够取得进展。这种混合方法结合了 TM 的乐观速度和锁的保证进展,集两家之长。
因此,事务内存并非万能药,而是一种深刻的观念转变。它用系统的自动化、乐观的推测和回滚工作,换取了程序员手动、易错地管理锁的努力。虽然这也引入了其自身的一套规则和隐藏成本——从伪中止 到因推测而浪费的内存带宽——但它为实现更简单、更健壮的并发程序提供了一条路径,这条路径由原子性这一精妙而简单的原则所指引。
在我们之前的讨论中,我们打开了事务内存的“黑匣子”,审视了硬件和软件实现的齿轮与杠杆。我们看到了“事务”——一组全有或全无的操作——这个简单而强大的理念是如何被实现的。但是,只有当我们看到一台精美的机器在运转时,才能真正欣赏它。我们为什么要费这么大劲呢?这个新工具能让我们解决哪些宏大的问题?
答案是,事务内存不仅仅是一个巧妙的技巧;它是一种思考现代计算中最困难挑战之一——并发——的新方式。当许多独立的行动者试图在一个共享的世界中协同工作时,会出现狂野的复杂性,而 TM 提供了一条驯服这种复杂性的路径。现在,让我们开启一段穿越计算机科学多个领域的旅程,见证这单一原则如何为一系列棘手问题带来惊人的优雅与清晰。
操作系统是计算机的总指挥,是一场由并发任务组成的复杂芭蕾。正是在这里,在机器的心脏地带,并行的混乱最为剧烈,也正是事务内存找到其最引人注目的应用之处。
想象一下操作系统调度器试图将一个正在运行的进程(我们称之为任务 )从一个处理器核心 迁移到另一个核心 的任务。这并不像简单地“移动”它那么简单。一整张相互关联的数据网络必须被原子地更新,仿佛在瞬间完成。任务的状态必须改变,它的亲和性掩码 (允许它运行的 CPU 集合)必须允许 ,它必须从 的运行队列 中出队,并入队到 的队列中。
传统的方法是一种蛮力方法:锁。调度器会锁定任务,锁定队列 ,再锁定队列 。在持有这些锁期间,其他任何人都不能触碰这些结构。这虽然可行,但却是一种笨拙的解决方案。它会造成交通堵塞,其他需要访问这些队列中任何一个的 CPU 都必须停下来等待。
事务内存提供了一种远为优雅、乐观的替代方案。整个迁移过程——检查亲和性、更新任务当前所在的 CPU、以及将其从一个队列剪接到另一个队列——都被包装在一个单一的事务中。调度器实际上是在 排练 这次移动。如果没有系统的其他部分干扰它接触到的任何数据(亲和性掩码、两个队列的头部),事务就会提交,这些变更对世界其他部分来说是瞬时发生的。然而,如果另一个 CPU 同时试图更改任务 的亲和性以禁止它在 上运行,硬件内置的冲突检测会感知到这种重叠。其中一个事务将被迫中止并重试。
这自动且正确地防止了系统进入一种无效状态,即任务在一个它不被允许的 CPU 上运行。事务的全有或全无保证,由底层硬件强制执行,优雅地维护了调度器的复杂不变量。当然,我们必须务实。如果由于高争用导致事务反复中止,我们需要一个后备计划。一个健壮的设计会重试几次事务,然后切换到更传统的、使用诸如比较并交换(Compare-And-Swap, CAS)等原语的非阻塞算法,以确保总能取得进展。这种混合方法让我们兼得两者的优点:在普遍情况下,享有事务的低开销乐观主义;在争用情况下,享有无锁代码的保证进展。
另一个经典的操作系统难题是内存回收。想象一个许多线程正在读取的共享链表。一个写者前来,从链表中移除了一个节点。何时可以安全地释放该节点的内存?一个读者线程可能在它被断开链接前的一瞬间刚刚获取了指向该节点的指针。如果我们过早释放内存,读者将持有一个“悬垂指针”,试图跟随时会导致程序崩溃。
一个著名的解决方案是读-复制-更新(Read-Copy-Update, RCU)。它的工作原理是强制执行一个“宽限期”。写者断开节点链接,但必须等待才能释放它。它要等到每一个可能在读取旧列表的线程都宣告完成之后。这就像一个图书管理员想要丢弃一本旧版书,但必须等到当时在图书馆里的每一位读者都离开,以防其中有人还在读它。这虽然有效,但需要对每个读者的状态进行仔细、显式的跟踪。
事务内存提供了一个引人入胜的替代方案。假设每个读者都在一个只读事务内访问列表。当一个写者事务断开一个节点的链接时,它实际上已经将其“私有化”了——使其从共享列表中无法访问。现在,哪些读者可能持有悬垂指针?只有那些其事务在写者事务提交 之前 开始的读者。事务内存系统本身通常会跟踪哪些事务正在进行中以及它们何时开始!因此,写者可以简单地查询 TM 系统,并等待所有那些“旧的”读者事务完成(无论是提交还是中止)。
这利用了 TM 系统自身的内部记账功能,以一种更集成的方式实现了与 RCU 宽限期相同的安全性。 不透明性(opacity)这一属性确保了即使是最终中止的事务也永远不会跟随一个悬垂指针进入未定义领域。一旦一个事务被中止,它就消失了,我们不再需要担心它。我们只需要等待那些可能已经看到旧数据的 存活 事务。
许多系统服务,从性能监视器到调试器,都需要获取系统状态的“快照”——例如,所有正在运行的进程及其当前状态的列表。这听起来很简单,但就像试图为一群蜜蜂拍一张清晰不模糊的照片。如果你从头到尾扫描进程列表,当你扫描到列表末尾时,那里的进程状态早已改变。最终得到的画面是不一致的,是不同时间点的混杂。
在这里,一种特定风格的事务内存——基于多版本并发控制(MVCC)的事务内存——大放异彩。这个想法非常简单。写者不覆盖数据,而是创建数据的一个 新版本,并用一个全局递增计数器的时间戳为其打上标签。旧版本会保留一段时间。
一个希望获取快照的读者,通过首先读取全局时间戳计数器来开始一个事务;假设值为 。这个时间戳 现在定义了读者的“现实”。当读者遍历进程列表时,它只同意看到每个进程时间戳小于或等于 的最新版本。在它读取计数器之后发生的任何更新,其时间戳都会大于 ,因此对读者完全不可见。
这种方法的美妙之处在于读者从不与写者冲突。写者忙于创造未来的新版本,而读者则平静地观察过去的一个一致性时刻。这意味着读者的事务保证永远不会因为冲突而中止。它可以在不被写者活动停止或强制重试的情况下完成扫描,这是一种被称为无等待(wait-freedom)的强大属性。这将获取动态世界一致性快照的艰巨任务,转变为一个简单、非阻塞且高效的操作。
如果说操作系统使用 TM 来管理固有的并发性,那么编译器则用它来创造原本不存在的并发性。自动并行化的圣杯是获取程序员编写的普通串行代码,并神奇地将其转换为能在多个核心上同时运行的代码。
考虑一个简单的循环,它更新一个大数组 的每个元素。一种天真的并行化方法是给每个线程分配一部分工作。例如,线程 0 更新元素 ,线程 1 更新 ,以此类推,其中 是线程数。我们可以将每个微小的更新,即 ,包装在它自己的事务中。
但这可能导致性能灾难。问题深藏于硬件之中。内存不是逐字节获取的,而是以称为“缓存行”的较大块获取的。很可能两个元素,比如 和 ,由两个不同的线程更新,恰好位于同一个缓存行上。事务内存系统在缓存行级别监视冲突,看到两个线程写入同一行时,就会大喊“冲突!”。它中止其中一个事务,尽管这些线程接触的是完全独立的数据。这就是“伪冲突”或“伪共享”。这就像两个在相邻办公室的工人共享一堵墙;一个在墙上钉钉子导致另一个的画晃动,即使他们在不同的房间里,也造成了干扰。
这表明 TM 并非一种可以无视硬件现实的魔法抽象。另一种策略,如循环分块(loop tiling),可能更好。在这里,我们给每个线程一个大的、连续的数组块,让其私下处理。这确保了这些块不共享缓存行。由于工人们现在在不同的“大楼”里,伪冲突被消除了。比较这些策略表明,选择正确的并行化模式是一门微妙的艺术,而 TM 是几种强大工具之一,其有效性关键取决于与底层硬件架构的相互作用。
编译器能做的最大胆的举动是,获取一个操作顺序很重要的循环,并尝试无论如何都并行运行它。这被称为推测并行化。想象一下,循环的每次迭代 $i$ 将函数 应用于共享状态 。关键是,这些函数是非交换的:先应用 再应用 与先应用 再应用 的结果不同。串行代码有一个明确的含义:。
我们可以在这里使用 TM 吗?如果我们只是将所有迭代作为事务启动,TM 系统将保证它们以 某个 串行顺序执行,但不一定是 正确 的从 $1$ 到 $n$ 的顺序。我们会得到一个有效但错误的答案。
这正是算法设计真正艺术性的体现。我们可以通过增加一点额外的逻辑来增强事务,以强制执行原始顺序。我们引入一个共享的“票号计数器” ,初始化为 $0$。现在,迭代 $i$ 的事务执行以下操作:
$c$。$c = i-1$。如果不是,则中止。所有这些都发生在一个单一的事务中。原子性是关键。它确保了检查、 的更新以及 $c$ 的递增作为一个不可分割的步骤发生。这个巧妙的策略利用了事务内存的原子性来强制对提交施加严格的顺序。迭代 $i$ 在迭代 $i-1$ 提交并通过将 $c$ 设置为 $i-1$ 传递接力棒之前,无法提交。这就像告诉一组短跑运动员他们可以同时起跑,但必须按预定顺序冲过终点线。这种“有序提交”协议将 TM 的推测并行性与原始代码的严格顺序要求相结合,提供了正确的结果,并且在推测频繁失败时,还有一个健壮的回退方案,即回退到使用简单的锁。
从操作系统的核心到编译器优化的前沿,事务内存提供了一个统一的抽象。它允许我们提升我们的思维层次,专注于本质上的“什么”——什么 需要是原子的——而不是关于锁、信号量和内存屏障的极其复杂的“如何”。它并不能解决所有问题,其性能也与硬件的现实息息相关,但它代表了我们在使并行计算机的力量对所有程序员来说都易于使用且安全这一探索中的一次深刻飞跃。它证明了一个单一、强大的理念向外辐射,为计算世界的混乱带来秩序之美。