try ai
科普
编辑
分享
反馈
  • 原子操作:并发的基石

原子操作:并发的基石

SciencePedia玻尔百科
核心要点
  • 原子操作通过确保像read-modify-write这样的多步序列作为单个不可分割的单元执行,对于防止并行计算中的竞争条件至关重要。
  • 除了简单的不可分割性,内存排序语义(如release-acquire)对于控制编译器和处理器如何重排指令至关重要,这对于线程间的正确同步是必不可少的。
  • 原子操作是构建更高级别同步工具(如锁和信号量)的基础构建块,并使得先进、高性能的无锁数据结构成为可能。
  • 原子操作的影响从底层硬件和操作系统延伸到科学计算和GPU计算等领域的高级应用,在这些领域中,它们与缓存一致性和浮点运算等现实情况相互作用。

引言

在多核处理器时代,我们的计算机就像是繁忙的作坊,多个线程在共享的内存画布上同时工作。这种并行性带来了惊人的性能,但也引入了一个根本性的挑战:我们如何协调这些线程,以防止它们相互干扰并破坏共享数据?像增量这样简单的操作都可能成为一种被称为竞争条件的、微妙且灾难性的错误的根源。解决这种混乱的方案在于原子操作的概念——这是确保并发编程中秩序和正确性的基础机制。

本文将深入探讨原子操作的世界,从底层开始探索它们。在“原理与机制”部分,我们将剖析是什么让一个操作成为原子的,探索提供程序员所依赖的保证的硬件指令和内存一致性模型。之后,“应用与跨学科联系”部分将揭示这些底层原语如何成为从操作系统锁和可扩展算法到科学研究和现代GPU上执行的复杂并行计算等一切事物的基石。读完本文,您将不仅理解原子操作是什么,还将明白为什么它们是现代计算不可或缺的支柱。

原理与机制

想象一下,两位艺术大师被委以修复一幅精致的壁画。他们同时工作。一位正在给天空添上一抹蓝色,而另一位则在光环上轻点一些金色。如果他们的动作没有完美协调,一个人可能会在另一个人刚画好的地方上色,或者更糟的是,他们可能会互相碰撞,弄脏整个部分。最终的结果可能是一片混乱,并非任何一方的本意。这本质上就是现代计算的核心挑战。我们的计算机不再是孤独的艺术家;它们是拥有多个处理器核心的繁忙作坊,所有核心都在共享的内存画布上工作。我们如何防止它们制造混乱?我们如何确保一个操作以完美、不可分割的优雅姿态完成?答案就在于​​原子操作​​这个优美而微妙的概念中。

简单性的幻觉

乍一看,像x = x + 1这样的操作似乎再简单不过了。它感觉就像一个单一的、瞬间的想法。但对计算机来说,这是一出三幕剧:

  1. ​​读取 (Read):​​ 处理器从内存中读取x的当前值。
  2. ​​修改 (Modify):​​ 它在其内部的一个寄存器中将该值加1。
  3. ​​写入 (Write):​​ 它将新值写回内存中的x。

如果只有一个处理器核心在执行这个操作,那就没有问题。但如果两个核心,我们称之为核心A和核心B,试图在完全相同的时间做这件事呢?假设x的初始值是50。

  • 核心A读取x(得到50)。
  • 就在那时,在A完成之前,核心B也读取x(它也得到50)。
  • 核心A将其值加1(现在有51)并将其写回x。内存现在保存着51。
  • 核心B,对刚刚发生的事情一无所知,也将其值加1(它也得到51)并将其写回x。内存仍然保存着51。

我们执行了两次增量操作,但x的值只增加了一。我们丢失了一次更新。这是一个经典的​​竞争条件​​,它是并行编程的根本性难题。为了防止这种情况,我们需要整个读-改-写序列是​​原子的​​——即对系统的其余部分来说,它表现为一个单一、不可分割、瞬时的事件。

“不可分割”的真正含义:撕裂的幽灵

问题比仅仅丢失一次更新要深刻得多。“不可分割”对一个操作来说意味着什么?假设我们正在一个64位系统上工作,但由于某些历史原因,我们的处理器只能处理32位大小的内存块。要写入一个64位的数字,它必须执行两次独立的32位写入。

现在,想象一下线程T0T_0T0​想要将64位值0xAAAAAAAA00000000写入一个初始全为零的变量x,而线程T1T_1T1​想要写入0xBBBBBBBBFFFFFFFF。一个淘气的调度器可能会像这样交错它们的操作:

  1. T1T_1T1​写入其高32位:x变为0xBBBBBBBB00000000。
  2. T0T_0T0​写入其高32位:x变为0xAAAAAAAA00000000。
  3. T1T_1T1​写入其低32位:x变为0xAAAAAAAAFFFFFFFF。
  4. T0T_0T0​写入其低32位:x变为0xAAAAAAAA00000000。

第三个线程T2T_2T2​在任何时候读取x的值,都可能会看到一个弗兰肯斯坦怪物般的值——一个T0T_0T0​或T1T_1T1​都从未打算作为一个整体写入的值。这被称为​​撕裂读 (torn read)​​。在这种情况下,你看到了一部分旧值和一部分新值,因为写入操作本身不是原子的。

有时候,值的宇宙可能会合谋掩盖这种丑陋。如果你正在将一个16位变量从0更新到2,这涉及到将0x02写入低位字节,将0x00写入高位字节,任何中间的读取都只会看到初始值(0)或最终值(2)。你可能会因此认为你的代码是安全的。但这是一个危险的错觉。撕裂的机制仍然存在;只是特定的数据模式阻止了一个怪异值的出现。改变初始值,这个怪物就会再次出现。

真正的原子性意味着一个操作是全有或全无的。对外部世界而言,它要么根本没有发生,要么已经完全发生。没有“中间状态”。

铸造不可分割性:硬件的契约

处理器究竟是如何铸造这种不可分割性的?它不能只是请求其他核心“请礼貌地等待”。它需要一个可强制执行的机制,一种方法来临时、独占地支配一块内存。这是通过特殊的硬件指令和协调协议来完成的。

其核心是​​读-改-写 (Read-Modify-Write, RMW)​​ 指令,这是一类特殊的操作,包括像下面这样的主力军:

  • ​​比较并交换 (Compare-and-Swap, CAS):​​ 原子地将内存中的值与一个给定的期望值进行比较。如果它们匹配,它就用一个新值替换内存中的值;否则,它什么也不做。
  • ​​取值并加 (Fetch-and-Add):​​ 原子地将一个值加到内存位置上,并返回旧值。
  • ​​交换 (Exchange, XCHG):​​ 原子地交换寄存器中的值和内存中的值。

当一个核心,比如C0C_0C0​,想要对一个共享变量执行这些RMW指令之一时,它不能只是读取数据,思考一下,然后再写回去。在它“思考”的时间里,另一个核心C1C_1C1​可能已经改变了数据。整个RMW序列必须受到保护。

在许多系统上,这种保护是通过基本上锁定一个共享通信通道,即​​总线​​来实现的。当C0C_0C0​开始其原子操作时,它会获取一个​​总线锁​​。在它持有这个锁的期间,没有其他核心可以使用总线来访问内存。C0C_0C0​然后可以安全地执行其读取、修改和写入,而不会有任何干扰。一旦序列完成,它就释放锁,允许其他核心继续。这有效地串行化了对内存的访问,确保一次只有一个原子操作发生。这是一个强有力的保证,但它是有代价的。强迫所有其他内存流量等待会造成严重的性能瓶颈,这是一种降低内存系统总吞吐量的流水线冒险。事实证明,原子性不是免费的。

重排序的无序状态:原子性之外

所以,我们有了这些奇妙的原子指令。我们可以用它们来构建同步工具,比如锁。一个简单的自旋锁可能看起来是这样的:一个共享变量lock_var在未锁定时为0,锁定时为1。为了获取锁,一个线程使用原子比较并交换来尝试将lock_var从0变为1。它在一个循环中不断尝试,直到成功。为了释放锁,它只需将0写回。

loading

如果写入者先运行,然后是读取者,那么读取者是否保证能看到val为123?令人震惊的答案是​​不​​,不一定!

为什么?因为我们只解决了问题的一半。我们已经使锁操作本身是原子的,但我们没有对它们与周围其他内存操作的关系做出任何规定,比如对shared_data的访问。

现代处理器和编译器是无情的优化者。它们就像匆忙的棋手,意识到两步棋互不依赖,可以按任意顺序下。对它们来说,写入shared_data和写入以释放锁是两个独立的内存操作。为了提高速度,处理器可能会对它们进行重排!它可能在对shared_data的写入实际对其他核心可见之前就释放了锁。然后,读取者线程可能获取锁,进入“受保护的”区域,并读取shared_data的旧的、过时的值。尽管我们使用了锁,但我们还是遇到了数据竞争!

控制的光谱:内存排序规则

这种表面的混乱并非缺陷;它是高性能计算的一个特性。但是要编写正确的并行程序,我们需要施加一些规则。我们需要告诉编译器和处理器,“不,这些特定的操作必须按顺序执行。”这就是​​内存一致性模型​​和你可以为原子操作指定的​​内存排序​​所扮演的角色。

可以把它想象成一个从无序到极权的控制光谱。

  • ​​memory_order_relaxed:​​ 这是最宽松的。它表示,“只让这个单一操作是原子的。我不对其与任何其他内存访问的排序做任何保证。”这对于简单的、独立的统计计数器来说是完美的,你只需要防止更新丢失,而不需要同步任何其他数据。但正是这一点破坏了我们的自旋锁。

  • ​​memory_order_release 和 memory_order_acquire:​​ 这是生产者-消费者模式(包括锁)的优雅解决方案。

    • 一个带有​​release​​语义的存储操作(如释放一个锁)充当一个屏障。它告诉处理器:“确保我在此之前所做的所有内存写入都已完成并可见,然后才能执行此release操作。”
    • 一个带有​​acquire​​语义的加载操作(如获取一个锁)是与之匹配的屏障。它表示:“确保我在此之后所做的任何内存读取都在此acquire操作之后发生。此外,如果我读取了一个由release操作写入的值,我保证能看到在该release之前发生的所有内存写入。”
    • 这种release-acquire配对创建了一个​​synchronizes-with(同步于)​​关系。这是一个正式的契约,它在线程之间建立了“happens-before(先行发生)”排序,确保在临界区内写入的数据对下一个获取锁的线程是可见的。
  • ​​memory_order_seq_cst (Sequentially Consistent,顺序一致性):​​ 这是最严格的排序。它保证整个程序中所有的seq_cst操作看起来都像是在一个所有核心都同意的单一全局时间线上发生的。这是最容易推理的,但也可能是性能成本最高的,因为它严重限制了重排序。

这些排序之间的差异并非学术性的。考虑一个程序,其中两个线程各自原子地增加一个计数器x,然后设置一个标志a或b。第三个线程等待a和b两个标志都被设置,然后读取x。使用relaxed原子操作,第三个线程完全有可能看到两个标志都已设置,但读取到的x的初始值为0!这是因为对标志的写入可以抢先执行,并在对x的增量操作完成之前就全局可见。在顺序一致性下,这种结果是不可能的;如果你看到了标志写入的效果,你也必须看到在全局顺序中发生于它们之前的x增量的效果。

宏伟设计

因此,原子操作不仅仅是单一的指令。它们是硬件架构、编译器设计和编程逻辑之间宏大协议的连接点。它们是驯服并行执行的混乱物理,使其变为可预测行为的关键所在。

理解这一点使我们能够构建出异常复杂且可扩展的系统。当我们发现对单个“热”变量的原子操作正在造成瓶颈时,我们可以设计出巧妙的替代方案。我们可以使用每核计数器来消除争用,甚至可以设计带有​​组合树 (combining trees)​​的硬件,它可以在传输过程中合并更新,从而显著提高性能。我们甚至可以设计协议,在多个物理上分离的内存位置上执行原子操作,将一个复杂的分布式系统问题转变为一个可管理的工程壮举。

即使在单个乱序处理器的复杂舞蹈中,原子性规则也得到精心遵守。处理器可以巧妙地在原子存储变得全局可见之前,从内部缓冲区“读取自己的写入”,这是一种称为​​store-to-load转发​​的技巧,可以保持执行流水线的流畅。这种局部优化尊重了“读自己写”的原则,而没有违反其他核心所依赖的全局原子性保证。

从卑微的x = x + 1到超级计算机的庞大架构,原子操作都是并发的基石。它们是使计算交响乐成为可能,而不是陷入混乱嘈杂之声的纪律严明的机制。它们是我们共享的数字世界得以运行的美丽、分层复杂性的证明。

应用与跨学科联系

在理解了原子操作的“是什么”和“如何做”——这些构成并发代码量子力学的不可分割、闪电般快速的指令——之后,我们现在要问最重要的问题:“所以呢?”这些看似底层的硬件技巧究竟在何处显现?正如我们将要看到的,答案是无处不在。从启动你电脑的操作系统到你最爱游戏中的图形,甚至在科学发现的微妙结构中,原子操作都是使我们的并行世界成为可能的沉默的无名英雄。这不仅仅是一次计算机科学之旅;它是一次关于一个强大思想如何向外辐射,连接不同工程和科学领域的巡礼。

秩序的基石:构建同步原语

从本质上讲,现代操作系统是一个 masterful 的协调者,同时处理无数任务。为防止混乱,它依赖于规则和结构来管理对共享资源的访问。其中最基本的是锁。但如何构建一个锁呢?你可能会想到一个简单的标志:如果标志是“放下”的(资源空闲),一个进程就举起它并进入临界区。

这里我们遇到了第一个问题,一个经典的被称为“检查时-使用时”(Time-Of-Check-to-Time-Of-Use, TOCTOU)错误的竞争条件。想象两个进程,P1P_1P1​和P2P_2P2​,想要获取一个锁。P1P_1P1​检查标志,看到它是放下的。在它能举起标志之前,系统调度器暂停了P1P_1P1​,让P2P_2P2​运行。P2P_2P2​也检查标志,看到它仍然是放下的,于是举起它,并进入临界区。当P1P_1P1​恢复时,它继续举起它已经看到是放下的标志,也进入了临界区。混乱随之而来。

检查和动作必须是同一个、不可分割的步骤。这正是原子操作所提供的。例如,一个原子的Compare-And-Swap (CAS) 指令可以被告知:“查看这个内存位置。如果它包含一个0(空闲),就把它改成1(锁定),并告诉我你成功了。如果不是0,什么也别做,并告诉我你失败了。”因为这是原子性地发生的,所以只有一个进程能在这场竞赛中获胜。这个简单而优雅的解决方案是无数现实世界系统中互斥的基础,例如允许多个并发读取者但只有一个写入者的读写锁。原子操作是构建同步“分子”——锁、信号量和管程——的“原子”。

为可扩展性而设计:从交通拥堵到超级高速公路

让一个并发程序变得正确是一回事;让它变得快速是另一回事。随着我们增加越来越多的处理器核心,一个简单的锁可能会成为一个主要的瓶颈。考虑一个简单的“测试并设置”自旋锁,其中等待的线程在一个单一的共享锁变量上重复执行原子指令。当一个线程释放锁时,所有其他等待的线程都会蜂拥而上试图获取它。在现代硬件上,这是一场灾难。一个核心的每次原子写入都会使所有其他核心的缓存失效,在系统互连上造成一场一致性流量风暴。性能不仅不会随着核心增多而提高;它甚至可能灾难性地变得更糟。

这催生了更复杂的“可扩展”锁的设计。例如,优美的MCS锁,它使用原子操作不是为了争夺一个单一变量,而是优雅地形成一个队列。每个到达的线程原子地将自己添加到队列的尾部,然后在其自己的私有标志上自旋——就像在自己的邮箱里等待一封信。当一个线程释放锁时,它只需通过写入下一个人的私有标志来“轻拍”队列中下一个人的肩膀。结果呢?全系统的交通拥堵消失了,取而代之的是一个安静、有序的行列。每次获取锁的一致性流量从与等待线程数成正比的O(N)O(N)O(N),降至一个常数O(1)O(1)O(1)。

软件算法和硬件现实之间的这种舞蹈甚至延伸到内存中数据的布局。如果一个生产者线程正在更新队列的head指针,而一个消费者线程正在更新tail指针,将这两个变量在内存中并排放置可能是一个性能陷阱。如果它们落在同一个缓存行上,每次对head的写入都会使消费者的缓存失效,而每次对tail的写入都会使生产者的缓存失效,尽管它们接触的是不同的数据。这种“伪共享”就像两个人在同一个小笔记本的相邻页面上书写——他们没有写在对方的字上,但他们在不断地来回抢夺笔记本。简单的解决方法是什么?添加填充以确保它们位于不同的缓存行上,这实际上是给每个人自己的笔记本。这表明,深刻的理解不仅需要原子地思考操作,还需要思考内存的布局本身。

大逃逸:无锁的生活

如果我们能完全摆脱锁呢?这就是“无锁”编程的承诺,它完全依赖原子操作来协调对共享数据的访问。线程不再等待,而是重试它们的操作直到成功。

一个简单的单生产者单消费者(SPSC)队列可以通过谨慎使用内存排序语义来实现无锁。生产者首先将项目放入队列的数组中,然后才——通过一个release存储——更新head指针。这个release操作就像一个屏障,确保在指针更新之前,数据写入对所有其他核心都是可见的。消费者使用一个acquire加载来读取head指针,这与release存储配对。这保证了如果消费者看到了更新后的指针,它也保证能看到之前写入的数据。这就像先寄出包裹,只有在包裹投递后才发送跟踪号码。

无锁带来的好处可能是深远的。在操作系统中,资源分配通常用图来跟踪。一个等待另一个进程持有的锁的进程会创建一个依赖关系。如果这些依赖关系形成一个环——P1P_1P1​等待P2P_2P2​持有的资源,而P2P_2P2​又等待P1P_1P1​持有的资源——我们就遇到了死锁。系统会陷入停顿。通过用非阻塞的无锁算法替换阻塞锁,进程不再进入“等待”状态。它可能忙于重试一个CAS循环,但它没有被阻塞。这从资源图中移除了“等待”边,打破了产生循环的可能性,并消除了死锁的可能性。虽然这可能会引入一个较小的新问题,称为“活锁”(线程在活动但没有取得进展),但它展示了底层原子原语与高层系统可靠性之间的美妙联系。

实践中的原子操作:一张连接之网

原子操作的影响远远超出了操作系统的核心,贯穿于众多学科。

​​硬件与设备驱动:​​ 原子操作是与物理硬件通信的关键。例如,一个网卡的设备驱动程序必须将数据描述符写入主内存,然后告诉网卡开始处理。从设备的角度来看,这两个步骤必须按正确的顺序发生。使用原子指令更新共享的软件状态标志可以防止管理该设备的多个CPU核心之间的竞争。但是为了确保描述符的写入在“开始”信号发送到设备的内存映射I/O(MMIO)寄存器之前完成,需要一个内存屏障。这个屏障对CPU来说是一个严格的命令:“在继续之前,确保所有先前的内存写入都已完成。”这是一种软件逻辑、原子保证和硬件行为之间微妙而必要的舞蹈。

​​编译器技术:​​ 使用原子操作的责任不总是落在程序员身上。现代编译器在自动并行化代码方面变得越来越熟练。当编译器看到像计算直方图的循环hist[A[i]]++时,它会进行数据依赖性分析。它认识到,如果输入数组A包含重复值,多个循环迭代将尝试更新hist中的同一个计数器,从而产生竞争条件。编译器知道这是一个“归约”模式,可以自动以两种方式之一转换代码:要么它将简单的增量替换为atomicAdd指令,要么它将为每个线程生成代码来计算一个私有的、本地的直方图,然后在循环完成后合并结果。原子操作是编译器安全释放并行性武库中的一个基本工具。

​​GPU与大规模并行计算:​​ 在图形处理单元(GPU)上,成千上万的线程可能协同运行,争用是性能的头号大敌。再来看直方图问题,简单地让每个线程发出一个全局原子加法会在对应于热门箱子的少数内存位置上造成巨大的瓶颈。一个更具可扩展性的策略是两阶段方法:首先,一个本地组(一个“线程块”)内的线程使用GPU极快的片上共享内存协同构建一个私有直方图。这个阶段也使用原子操作,但争用被限制在一小群线程内。然后,在第二阶段,每个块执行少量的原子加法,将其私有结果合并到最终的全局直方图中。这种“私有化-合并”模式是高性能并行算法设计的基石,极大地减少了全局争用并最大化了吞吐量。

​​科学计算与数字的本质:​​ 也许最微妙和深刻的联系在于科学计算的世界。在大型模拟中,例如地球力学中的有限元法(FEM)分析,成千上万的线程可能会计算力并将它们的贡献原子地加到一个全局力向量上。人们可能会期望,因为加法是可交换的(a+b=b+aa+b = b+aa+b=b+a),无论线程以何种顺序执行,最终结果都应该是相同的。然而,这忽略了计算机表示数字的一个关键细节:浮点运算​​不满足结合律​​。由于每一步都有舍入,(a+b)+c(a+b)+c(a+b)+c并不总是与a+(b+c)a+(b+c)a+(b+c)按位相同。

因为GPU上的原子操作是以非确定性的顺序串行化的,所以求和的有效顺序可能在每次运行时都不同。将一个非常小的数加到一个非常大的数上,可能导致小数被“淹没”并因舍入而丢失。但是,如果许多小数先加在一起,它们的总和可能足够大,以至于加到大数上时能够被记录下来。因此,对完全相同的输入进行的两次完全相同的模拟可能会产生略有不同的数值结果。这不是原子操作的错误;它是并发执行与计算机算术有限精度之间根本性的相互作用。对于依赖按位可复现性的科学家来说,这是一个关键问题,通常通过用强制固定操作顺序的确定性归约算法替换非确定性原子操作来解决。

从确保一个简单的标志被正确设置,到协调庞大的处理器军团,再到揭示计算机算术的微妙怪癖,原子操作远不止是硬件上的奇特之物。它们是一个基本概念,一条贯穿现代计算几乎每一层的统一线索,使我们现在习以为常的并行世界成为可能。

// Writer Thread acquire_lock(); shared_data = 123; release_lock(); // Reader Thread acquire_lock(); int val = shared_data; release_lock();