为现代多核处理器设计软件面临一个困境。传统的软件设计,其中线程操作共享数据,其可扩展性有限,因为对共享数据更新的同步会串行化线程并限制并行性。另一种分布式软件设计,其中线程不共享可变数据,消除了同步并提供了更好的可扩展性。但是,分布式设计使得实现共享数据结构自然提供的功能(例如动态负载均衡和强一致性保证)变得具有挑战性,并且并非适用于每个程序。
然而,通常情况下,共享可变数据结构的性能受到当今使用的同步方法的限制,无论是基于锁的还是无锁的。为了帮助读者做出明智的设计决策,本文介绍了先进(且实用)的同步方法,这些方法可以将使用共享可变数据的设计的性能提升到许多应用程序可以接受的水平。
为了了解设计多核软件所涉及的困境,让我们考虑一个具体的问题:实现一个工作队列,它允许线程排队和出队工作项——要处理的事件、要处理的数据包等等。此处讨论的类似问题通常适用于多核软件设计。14
一种自然的工作队列设计(如图 1a 所示)是实现熟悉的 FIFO(先进先出)队列数据结构的集中式共享(线程安全)版本——例如,基于链表。此数据结构支持以恒定数量的内存操作进行入队和出队。它还易于促进动态负载均衡:由于所有待处理的工作都存储在数据结构中,因此空闲线程可以轻松获取要执行的工作。然而,为了使数据结构线程安全,必须同步对队列头尾的更新,这不可避免地限制了可扩展性。
使用锁来保护队列会串行化其操作:一次只能有一个核心更新队列,其他核心必须等待轮到它们。这最终会创建一个顺序瓶颈并迅速破坏性能。提高可扩展性的一种可能性是用无锁同步替换锁,它使用原子指令直接操作队列,1,11 从而减少串行化量。(串行化仍然是一个问题,因为硬件缓存一致性机制1 串行化更新同一内存位置的原子指令。)然而,在实践中,无锁同步通常不如基于锁的同步,原因将在稍后讨论。
替代的工作队列设计通过分布数据结构来寻求可扩展性,这允许更多的并行性,但放弃了集中式共享队列的一些属性。例如,图 1b 显示了一种设计,该设计为每个核心使用一个 SPMC(单生产者/多消费者)队列。每个核心将其工作项入队到其队列中。出队操作可以通过多种方式实现——例如,通过迭代所有队列(起始点随机选择),直到找到包含工作的队列。
这种设计应该比集中式共享队列具有更好的可扩展性:不同核心的入队操作并行运行,因为它们更新不同的队列,并且(假设所有队列都包含工作)不同核心的出队操作预计会选择不同的队列进行出队,因此它们也将并行运行。
然而,这种设计牺牲的是数据结构的一致性保证。特别是,与集中式共享队列不同,分布式设计不维护程序中的因果关系。即使核心 P1 在核心 P0 将 x0 入队到其队列后将 x1 入队到其队列,x1 也可能在 x0 之前出队。该设计削弱了数据结构提供的一致性保证。
这种削弱的根本原因是,在分布式设计中,很难(也很慢)将每个核心的数据组合成数据结构的一致视图——这种视图本应由简单的集中式实现产生。相反,就像在本例中一样,分布式设计通常会削弱数据结构的一致性保证。5,8,14 较弱的保证是否可以接受取决于应用程序,但弄清楚这一点——推理可接受的行为——使使用数据结构的任务变得复杂。
尽管这种每个核心 SPMC 队列设计更具分布式特性,但当负载不均衡时,仍然可能产生瓶颈。例如,如果只有一个核心生成工作,那么出队核心都会争抢其队列,并且它们的操作变得串行化。
为了完全消除多线程同步,您可以转向如图 1c 所示的设计,每个核心为系统中其他每个核心维护一个 SPSC(单生产者/单消费者)队列,它将希望其对等核心出队的项入队到这些队列中。与之前一样,这种设计削弱了队列的一致性保证。它也使动态负载均衡更加困难,因为它预先选择哪个核心将出队一个项。
本次讨论的关键在于,通过分布队列数据结构获得可扩展性会牺牲集中式共享队列提供的一些有用属性。然而,通常情况下,这些权衡被集中式数据结构不可接受的性能所掩盖,这抵消了它们可能提供的任何好处。本文的观点是,这种糟糕的性能很大程度上是低效同步方法的结果。它调查了先进的同步方法,这些方法可以提高集中式设计的性能,并使其对更多应用程序可接受。有了这些方法,设计人员在构建系统架构时可以做出更明智的选择。
锁定本质上串行化了它保护的临界区的执行。因此,锁限制了扩展:核心越多,每个核心必须等待轮到它执行临界区的时间就越长,因此,超过一定数量的核心,这些等待时间将主导计算。然而,通过更有效地串行化,可以将此可扩展性限制推得相当高——在某些情况下,甚至超出当前系统的规模。更有效地串行化的锁支持每秒更多的操作,因此可以处理具有更多核心的工作负载。
更准确地说,目标是最小化计算的临界路径:由于数据依赖性而必须按顺序执行的最长操作序列的长度。当使用锁时,临界路径包含成功的锁获取、临界区的执行和锁释放。
作为限制可扩展性的低效串行化的一个例子,请考虑简单自旋锁中臭名昭著的锁争用。当许多核心同时尝试获取自旋锁时,它们会导致其缓存行在它们之间跳动,从而减慢锁的获取/释放速度。这增加了临界路径的长度,并导致性能“熔毁”,因为核心数量增加。2
锁争用有一个已知的解决方案,即基于可扩展队列的锁。2,10 基于队列的锁不是让所有等待线程竞争成为下一个获取锁的线程,而是将等待线程排队,使锁释放能够将锁移交给下一个等待线程。这些移交,每次获取/释放只需要恒定数量的缓存未命中,加快了锁的获取/释放速度,并减少了临界路径的长度,如图 2a 所示:基于队列的锁的临界路径仅包含锁的缓存行的单次传输(虚线箭头)。
基于锁的串行化可以变得更有效吗?本节中描述的委托同步方法正是如此:它从临界路径中消除了大多数锁获取和释放,并加快了临界区本身的执行速度。
委托。 在委托锁中,持有锁的核心充当服务器,并执行等待获取锁的核心希望执行的操作。委托通过多种方式提高可扩展性(图 2b)。首先,它消除了等待线程原本会执行的锁获取和释放。其次,它加快了操作(临界区)的执行速度,因为数据结构在服务器的缓存中是热的,不必从远程缓存或内存中传输。委托还支持新的优化,这些优化利用数据结构的语义来进一步加快临界区执行速度,这将在稍后介绍。
实现委托。 通过让单个线程执行等待线程的操作来更快地串行化的想法可以追溯到 Oyama 等人在 1999 年的工作,13 但他们实现的开销掩盖了其好处。Hendler 等人,6 在他们的扁平合并工作中,首次有效地实现了这个想法,并观察到它促进了基于执行操作语义的优化。
在扁平合并算法中,每个即将获取锁的线程都会将其打算执行的操作(例如,dequeue
或 enqueue(x)
)发布在共享的发布列表中。获取锁的线程成为服务器;其余线程自旋,等待它们的操作被应用。服务器扫描发布列表,应用待处理的操作,并在完成后释放锁。为了分摊将记录添加到发布列表的同步成本,线程将其发布记录留在列表中,并在未来的操作中重复使用它。后来的工作探索了将发布列表背负在队列锁的队列之上的方法,4 以及通过为服务器角色分配一个核心而不是让线程机会主义地成为服务器来提高操作的缓存局部性。9
基于语义的优化。 服务器线程具有并发待处理操作的全局视图,它可以利用该视图通过两种方式优化其执行
• 合并。服务器可以将多个操作合并为一个操作,从而节省重复访问数据结构的次数。例如,多个计数器递增操作可以转换为一个加法。
• 消除。相互抵消的操作,例如计数器递增和递减,或从集合中插入和删除同一项,可以在完全不修改数据结构的情况下执行。
延迟委托。 对于仅更新数据结构但不返回值(例如 enqueue()
)的操作,委托促进了一种优化,有时可以完全消除串行化。由于这些操作不向调用核心返回响应,因此核心不必等待服务器执行它们;它可以只将请求的操作记录在发布列表中并继续运行。如果核心稍后调用一个其返回值依赖于数据结构状态的操作,例如 dequeue()
,那么它必须等待服务器应用其所有先前的操作。但在那之前——在更新密集型工作负载中可能很少见——它的所有操作都是异步执行的。
此优化的原始实现仍然要求核心在执行这些延迟操作时进行同步,因为它将操作记录在集中式(无锁)队列中。7 然而,Boyd-Wickizer 等人,3 通过利用全系统同步时钟,实现了无需任何更新同步的延迟委托。他们的 OpLog 库将无响应更新操作的调用连同其调用时间一起记录在每个核心的日志中。读取数据结构的操作成为服务器:它们获取所有每个核心日志的锁,按时间戳顺序应用操作,然后读取更新后的数据结构状态。因此,OpLog 创建了数据结构的可扩展实现,这些数据结构更新频繁但读取很少,例如 LRU(最近最少使用)缓存。
为了演示委托的好处,让我们将基于锁的工作队列与使用委托实现的队列进行比较。基于锁的算法是 Michael 和 Scott 的双锁队列。11 该算法使用不同的锁保护对队列头尾的更新,串行化相同类型的操作,但允许入队和出队并行运行。基于队列的 CLH(Craig、Landin 和 Hagerstein)锁用于基于锁的算法的评估实现中。基于委托的队列是 Fatourou 和 Kallimanis 的 CC-Queue,4 它向基于锁的算法中的每个锁添加了委托。(因此它有两个服务器在运行:一个用于出队,一个用于入队。)
图 3 显示了基于锁的队列及其基于委托的版本的入队/出队吞吐量比较(越高越好)。该基准测试模拟了一个通用应用程序。每个核心重复访问数据结构,执行成对的入队和出队操作,报告队列操作的吞吐量(即,每秒完成的队列操作总数)。为了模拟真实应用程序中完成的工作,在每个队列操作后插入一段“思考时间”。思考时间在 1 到 100 纳秒之间均匀随机选择,以模拟队列使用量大的挑战性工作负载。使用了来自 Fatourou 和 Kallimanis 基准测试框架的算法的 C 实现(https://github.com/nkallima/sim-universal-construction),以及一个可扩展的内存分配库,以避免 malloc
瓶颈。未实现基于语义的优化。
此基准测试(以及本文中报告的所有其他实验)在 Intel Xeon E7-4870 (Westmere EX) 处理器上运行。该处理器有十个 2.40 GHz 核心,每个核心多路复用两个硬件线程,总共 20 个硬件线程。
图 3 显示了基准测试吞吐量结果,取十次运行的平均值。基于锁的算法扩展到两个线程,因为它使用了两个锁,但由于串行化,无法扩展到超过该并发量。相比之下,基于委托的算法可以扩展,最终每秒执行近 3000 万次操作,是基于锁的算法吞吐量的 3.5 倍以上。
无锁同步(也称为非阻塞同步)使用原子指令而不是锁直接操作共享数据。大多数无锁算法使用所有多核处理器上可用的 CAS(比较并交换)指令(或等效指令)。CAS 接受三个操作数:内存地址 addr
、old
值和 new
值。它原子地将存储在 addr
中的值从 old
更新为 new
;如果存储在 addr
中的值不是 old
,则 CAS 失败,而不更新内存。
基于 CAS 的无锁算法使用 CAS 循环模式进行同步:核心读取共享状态,计算新值,并使用 CAS 将共享变量更新为新值。如果 CAS 成功,则此读取-计算-更新序列似乎是原子的;否则,核心必须重试。图 4 显示了将节点链接到链表头的此类 CAS 循环的示例,该示例取自 Treiber 经典的 LIFO(后进先出)堆栈算法。15 类似的想法是许多基本数据结构(例如队列、堆栈和优先级队列)的无锁实现的基础,所有这些实现基本上都使用单个原子指令执行整个数据结构更新。
当没有(或只有轻微)争用时,使用(有时是多个)原子指令可能会使无锁同步比基于锁的解决方案慢。然而,在高争用下,无锁同步有可能比基于锁的同步更有效,因为它从临界路径中消除了锁获取和释放操作,只留下数据结构操作(图 5a)。
此外,无锁算法保证某些操作始终可以完成,因此在高负载下表现得更优雅,而如果操作系统抢占了持有锁的线程,则基于锁的算法可能会陷入停顿。
然而,在实践中,无锁算法可能无法达到这些性能期望。例如,考虑 Michael 和 Scott 的无锁队列算法。11 该算法使用链表实现队列,使用 CAS 循环将项入队到尾部并从头部删除。(确切的细节不如基本思想重要,基本思想与图 4 中的示例类似。)尽管如此,如图 6a 所示,无锁算法无法扩展到超过四个线程,并且最终性能比双锁队列算法更差。
这种糟糕性能的原因是 CAS 失败:随着并发量的增加,冲突的 CAS 在核心的读取-计算-更新 CAS 区域中间交错的机会也随之增加,导致其 CAS 失败。以这种方式失败的 CAS 操作在临界路径上堆积了无用的工作。尽管这些失败的 CAS 不会修改内存,但执行它们仍然需要获得对变量缓存行的独占访问权。这延迟了稍后操作获得缓存行并成功完成的时间(参见图 5b,其中在图 5a 中完成三个操作的相同时间内仅完成两个操作)。
为了估计由于 CAS 失败而浪费的性能量,图 6b 将在 CAS 循环(如图 4 所示)中执行的成功 CAS 的吞吐量与总 CAS 吞吐量(包括失败的 CAS)进行了比较。观察到系统以几乎是在数据结构中最终观察到的速率三倍的速度执行争用的原子指令。如果有一种方法可以使每个原子指令都对完成操作有用,那么您将显着提高性能。但是,鉴于 CAS 失败是固有的,如何才能实现这一点呢?
要做的关键观察是,x86 架构支持多个始终成功的原子指令。其中一个指令是 FAA(取并加),它原子地将一个整数加到一个变量,并返回存储在该变量中的先前值。以下部分介绍了基于 FAA 而不是 CAS 的无锁队列的设计。该算法名为 LCRQ(链接并发环形队列),12 使用 FAA 指令在队列中的项之间分散线程,使它们能够快速并行地入队和出队。LCRQ 操作通常执行一个 FAA 以获得它们在队列中的位置,从而提供完全期望的行为。
本节概述 LCRQ 算法;有关详细描述和评估,请参阅论文。12 从概念上讲,LCRQ 可以被视为以下简单但不切实际的队列算法的实际实现(图 7)。不切实际的算法使用无限数组 Q
实现队列,其中包含(无界)head
和 tail
索引,用于标识 Q
中可能包含项的部分。最初,每个单元格 Q[i]
都是空的,并且包含一个保留值 ⊥,该值可能不会入队。head
和 tail
索引使用 FAA 操作,用于在数组的单元格周围分散线程,线程在这些单元格中使用(无争用)CAS 进行同步。
enqueue(x)
操作通过对 tail
执行 FAA 操作来获得单元格索引 t
。然后,入队操作使用 CAS 原子地将 x
放置在 Q[t]
中,以将 Q[t]
从 ⊥ 更新为 x
。如果 CAS 成功,则入队操作完成;否则,它会重复此过程。
出队操作 D
使用对 head
的 FAA 操作获得单元格索引 h
。它尝试原子地将 Q[h]
的内容从 ⊥ CAS 为另一个保留值 ⊤。如果 Q[h]
包含一些 x
≠ ⊥,则此 CAS 失败,在这种情况下,D
返回 x
。否则,D
在单元格中存储 ⊤ 的事实保证了稍后尝试在 Q[h]
中存储项的入队操作将不会成功。如果 tail ≤ h + 1 (
head
在 D
的 FAA 之后的值为 h + 1
),则 D
然后返回 NULL(指示队列为空)。如果 D
无法返回 NULL,则它会重复此过程。
可以证明此算法正确地实现了 FIFO 队列,但它有两个主要缺陷,使其在实践中不相关:使用无限数组和容易受到活锁的影响(当出队器连续将 ⊤ 写入入队器即将访问的单元格时)。实用的 LCRQ 算法解决了这些缺陷。
无限数组首先折叠成一个 R
个单元格的并发环形(循环数组)队列——简称 CRQ。head
和 tail
索引仍然严格递增,但现在索引模 R
的值指定了它指向的环形单元格。因为现在多个入队器和出队器可以并发访问一个单元格,所以 CRQ 使用更复杂的基于 CAS 的协议在每个单元格内进行同步。此协议使操作能够避免等待 FAA 返回较小索引的操作完成,这些索引也指向相同的环形单元格。
CRQ 的关键性能特性是,在常见的快速路径中,操作仅执行一个 FAA 指令。然后,LCRQ 算法建立在 CRQ 的基础上,以防止活锁问题并处理 CRQ 填满的情况。LCRQ 本质上是一个 Michael 和 Scott 链表队列11,其中每个节点都是一个 CRQ。填满或遇到活锁的 CRQ 将关闭以进行进一步的入队操作,而是将一个新的 CRQ 附加到列表中并开始在其中工作。因此,LCRQ 中的大多数活动都发生在各个 CRQ 中,使得列表头尾的争用(和 CAS 失败)不成问题。
本节将 LCRQ 与 Michael 和 Scott 的经典无锁队列11 以及上一节中介绍的基于委托的变体进行比较。通过测试 LCRQ-CAS(其中 FAA 是使用 CAS 循环实现的 LCRQ 版本)来探索 CAS 失败的影响。
图 8a 显示了结果。LCRQ 在两个线程之后优于所有其他队列,达到约每秒 4000 万次操作的峰值吞吐量,或每个队列操作约 1000 个周期。从八个线程开始,LCRQ 的性能比基于委托的队列高 1.4 到 1.5 倍,比 MS(Michael 和 Scott)队列高三倍以上。LCRQ-CAS 在四个线程之前与 LCRQ 的性能相匹配,但在那时其性能趋于平稳。随后,LCRQ-CAS 表现出与 CAS 失败相关的吞吐量“熔毁”。同样,MS 队列的性能在两个线程时达到峰值,并随着并发性的增加而降低。
过载的工作负载可以演示无锁算法在高负载下的优雅行为。在这些工作负载中,软件线程的数量超过了硬件支持的级别,迫使操作系统在线程之间进行上下文切换。如果持有锁的线程被抢占,则基于锁的算法在再次运行之前无法取得进展。事实上,如图 8b 所示,当线程数超过 20 时,基于锁的委托算法的吞吐量骤降 15 倍,而 LCRQ 和 MS 队列都保持了其峰值吞吐量。
先进的同步方法可以提高共享可变数据结构的性能。同步仍然有其代价,当性能需求极端时(或者如果不需要集中式数据结构的属性),那么分布式数据结构可能是正确的选择。然而,对于许多剩余的情况,本文中描述的方法可以帮助构建高性能软件。了解这些方法可以帮助那些为多核机器设计软件的人。
1. Al Bahra, S. 2013. 非阻塞算法和可扩展多核编程。 通讯 56(7): 50-61.
2. Boyd-Wickizer, S., Frans Kaashoek, M., Morris, R., Zeldovich, N. 2012. 不可扩展的锁是危险的。在渥太华 Linux 研讨会论文集中: 121-132.
3. Boyd-Wickizer, S., Frans Kaashoek, M., Morris, R., Zeldovich, N. 2014. OpLog:用于扩展更新密集型数据结构的库。技术报告 MIT-CSAIL-TR2014-019。
4. Fatourou, P., Kallimanis, N. D. 2012. 重新审视合并同步技术。在第 17 届 SIGPLAN 并行编程原理与实践研讨会论文集中: 257- 266.
5. Haas, A., Lippautz, M., Henzinger, T. A., Payer, H., Sokolova, A., Kirsch, C. M., Sezgin, A. 2013. 共享内存中的分布式队列:通过定量松弛实现多核性能和可扩展性。在 国际计算前沿会议论文集中: 17:1-17:9.
6. Hendler, D., Incze, I., Shavit, N., Tzafrir, M. 2010. 扁平合并和同步-并行权衡。在第 22 届 并行算法与体系结构研讨会论文集中: 355-364.
7. Klaftenegger, D., Sagonas, K., Winblad, K. 2014. 用于改进多线程程序性能的委托锁定库。在第 20 届国际欧洲并行和分布式计算会议论文集中: 572-583.
8. Kulkarni, M., Pingali, K., Walter, B., Ramanarayanan, G., Bala, K., Chew, L. P. 2007. 乐观并行性需要抽象。在2007 年 SIGPLAN 编程语言设计与实现会议论文集中: 211-222.
9. Lozi, J.-P., David, F., Thomas, G., Lawall, J., Muller, G. 2012. 远程核心锁定:迁移临界区执行以提高多线程应用程序的性能。在2012 年 USENIX 年度技术会议论文集中: 65-76.
10. Mellor-Crummey, J. M., Scott, M. L. 1991. 共享内存多处理器上的可扩展同步算法。 计算机系统事务 9(1): 21-65 .
11. Michael, M. M., Scott, M. L. 1996. 简单、快速且实用的非阻塞和阻塞并发队列算法。在第 15 届 分布式计算原理年度研讨会论文集中: 267-275.
12. Morrison, A., Afek, Y. 2013. x86 处理器的快速并发队列。在第 18 届 SIGPLAN 并行编程原理与实践研讨会论文集中: 103-112.
13. Oyama, Y., Taura, K., Yonezawa, A. 1999. 有效执行具有同步瓶颈的并行程序。在国际符号和不规则应用并行和分布式计算研讨会论文集中: 182-204.
14. Shavit, N. 多核时代的数据结构。2011. 通讯 54(3): 76-84.
15. Treiber, R. K. 2006. 系统编程:应对并行性。技术报告 RJ5118,IBM 阿尔马登研究中心。
Adam Morrison 致力于使并行和分布式系统更易于使用,同时又不损害其性能;他的研究兴趣涵盖计算机体系结构、系统软件和分布式计算理论。他是以色列特拉维夫大学 Blavatnik 计算机科学学院的助理教授。他获得了特拉维夫大学计算机科学博士学位,然后在以色列理工学院 Technion 担任了三年博士后研究员。他曾获得 IBM 博士奖学金以及英特尔和德意志奖。
版权所有 © 2016,由所有者/作者持有。出版权已许可给 。
相关文章
非阻塞算法和可扩展多核编程
- Samy Al Bahra
探索基于锁的同步的一些替代方案
使用非对称多核系统最大化功率效率
- Alexandra Fedorova 等人
非对称多核系统有望比传统的对称处理器使用更少的能量。我们如何开发能够最大限度地发挥这种潜力的软件?
软件和并发革命
- Herb Sutter 和 James Larus
利用多核处理器的全部能力需要软件行业的新工具和新思维。
最初发表于 Queue 杂志,第 14 卷,第 4 期—
在 数字图书馆 中评论本文
Fabien Gaud, Baptiste Lepers, Justin Funston, Mohammad Dashti, Alexandra Fedorova, Vivien Quéma, Renaud Lachaize, Mark Roth - 现代 NUMA 系统上内存管理的挑战
现代服务器级系统通常由多个多核芯片组合在一个系统中构成。每个芯片都有一个本地 DRAM(动态随机存取存储器)模块;它们一起被称为一个节点。节点通过高速互连连接,系统完全一致。这意味着,程序员可以透明地向其节点的本地内存以及其他节点的内存发出请求。关键的区别在于,远程请求将花费更长的时间,因为它们受到更长的线路延迟的影响,并且可能必须跳转多个跃点才能遍历互连。
Spencer Rathbun - 使用 Promise 进行并行处理
在当今世界,有很多理由编写并发软件。提高性能和增加吞吐量的愿望导致了许多不同的异步技术。然而,所涉及的技术通常很复杂,并且是许多微妙错误的原因,特别是当它们需要共享可变状态时。如果不需要共享状态,那么这些问题可以通过称为 Promise 的更好抽象来解决。这些允许程序员将异步函数调用连接在一起,等待每个函数返回成功或失败,然后再运行链中的下一个适当函数。
Davidlohr Bueso - 实用同步原语的可扩展性技术
在理想的世界中,应用程序有望在越来越大的系统上执行时自动扩展。然而,在实践中,不仅这种扩展没有发生,而且在更大的系统上看到性能实际恶化是很常见的。
John T. Richards, Jonathan Brezin, Calvin B. Swart, Christine A. Halverson - 并行编程的生产力:十年的进步
2002 年,DARPA(国防高级研究计划局)启动了一项 HPCS(高生产力计算系统)的重大倡议。该计划的动机是相信,即将到来的并行机器的利用受到以 peta 级编写、调试、调整和维护软件的难度的限制。