try ai
科普
编辑
分享
反馈
  • 多核编程

多核编程

SciencePedia玻尔百科
核心要点
  • 高效的多核编程需要管理原子性(atomicity)、可见性(visibility)和顺序性(ordering),以防止竞态条件(race condition)、死锁(deadlock)和撕裂读(torn read)等并发错误。
  • 现代硬件带来了缓存行抖动(伪共享)和指令重排序等复杂性,必须通过填充和内存屏障等特定技术来解决。
  • 同步可以通过锁和条件变量等阻塞机制实现,也可以通过使用比较并交换(Compare-And-Swap)等原子操作的非阻塞、无锁算法实现。
  • 并发原理在不同领域都至关重要,它支撑着响应灵敏的用户界面、高性能的游戏引擎和可扩展的科学模拟。

引言

单核处理器速度指数级增长的时代已经结束。如今,性能的提升来自于并行性——即在多个CPU核心上同时执行多个任务的能力。这一范式转变为现代软件开发带来了最重大的挑战之一。仅仅编写正确的代码已不再足够;代码还必须能够充分利用硬件的全部能力,同时避免因线程交互而产生的微妙且混乱的错误,例如竞态条件、死锁和扼杀性能的争用。本文旨在为读者穿越这片复杂的领域提供一份指南。

为了驾驭这个世界,我们将首先探讨并发的基础“原理与机制”。这段旅程将带领我们从锁的简单概念,走向硬件内存模型和缓存行为的险恶现实,并揭示在混乱中建立秩序所需的工具。随后,“应用与跨学科联系”一章将展示这些抽象原理如何成为现代技术的基石,支撑着从流畅的视频游戏、响应灵敏的用户界面到突破性的科学发现等一切事物。我们首先进入一个多核厨房,在这里,多位厨师必须学会在不让整个操作陷入停顿的情况下共享资源并协调工作。

原理与机制

想象一下,你是一家大厨房的主厨,但厨房里不是一个你,而是几十个。你把菜谱分成了几部分,让每个厨师负责其中一部分。理想的情况是,这顿饭能以几十倍的速度准备好。但是,当两个厨师同时需要同一个盐瓶时会发生什么?或者当一个厨师准备好了蔬菜,需要通知另一个厨师烤架已经准备就绪时,又该怎么办?没有一套系统,厨房就会陷入混乱。一个厨师抓起盐,另一个又把它抢回去,而第三个厨师,因为等着蔬菜而放弃,干脆去睡午觉了。

这个厨房就是一个多核处理器。厨师是核心,共享的餐具和食材是计算机的内存。完美并行的梦想撞上了一堵由后勤噩梦筑成的高墙。问题的核心,也是其巨大魅力之所在,就是​​共享​​。我们如何管理共享状态,才能让我们的厨师们不会互相绊倒,不会覆盖彼此的工作,或者不会进行不可靠的沟通?

发言权杖:锁及其局限

解决盐瓶问题的最直接方法是引入一条规则:一次只能有一个厨师拿着盐瓶。在编程中,这个“发言权杖”被称为​​互斥锁(mutex)​​,是相互排斥(mutual exclusion)的缩写。一个想要访问共享资源(比如银行账户余额)的线程,必须首先acquire(获取)互斥锁。完成操作后,它必须release(释放)互斥锁。当一个线程持有互斥锁时,所有其他试图获取它的线程都必须等待。这是一个简单而强大的思想,可以防止经典的“竞态条件”——即两个线程读取旧的余额,都加上利息,然后一个覆盖了另一个的工作,导致金钱损失。

但如果问题更复杂呢?想象一位厨师,我们称她为线程TTT,她为了拿胡椒粉而锁住了调料架。在持有锁的同时,她意识到她还需要同一架子上的辣椒粉。她再次伸手去拿调料架的锁。会发生什么?

如果我们的锁是一个简单的机制,比如​​二进制信号量​​,它只知道两种状态:可用(值为1)或已占用(值为0)。当线程TTT第一次获取锁时,其值变为0。当她再次尝试获取时,她看到值是0,于是按照规则让自己进入睡眠状态,等待锁被释放。但谁能释放锁呢?只有线程TTT!她现在正在等待自己完成一个她无法完成的任务,因为她正在等待。这就是​​自我死锁​​。这个本应防止混乱的机制,反而让一个线程陷入了逻辑悖论。

这不是锁概念的失败,而是一个对于任务来说过于简单的工具的失败。我们需要一个更智能的锁。​​递归互斥锁​​正是如此。它不仅知道“已占用”或“空闲”,它还知道谁占用了它以及占用了多少次。当所有者线程再次请求锁时,递归互斥锁会说:“啊,又是你!不用等。我只需增加你的锁计数。”当线程释放锁时,它会递减计数。只有当计数回到零时,锁才真正对其他线程开放。这个简单的状态补充——所有权——解决了可重入问题。这是我们对一个深刻原理的初次领悟:我们的同步工具必须与我们打算构建的交互模式一样复杂。

超越保护:信号与等待

锁是为了防止线程互相干扰。但通常,我们需要它们进行合作。一个“生产者”线程准备数据,一个“消费者”线程处理数据。生产者如何告诉消费者:“数据准备好了!”?

一个天真的方法是使用一个共享标志。生产者写入数据,然后设置 ready = true。消费者在一个循环中疯狂地空转,检查 while (!ready) {}。这能行,但效率极低。消费者在除了问“到了吗?”之外什么都不做的情况下,白白消耗CPU周期。

一种更文明的方法是​​条件变量​​。它允许一个线程进入睡眠,直到某个条件变为真。消费者可以在一个条件变量上wait(等待),而生产者可以在数据准备好时signal(发信号)通知它。但这里存在另一个微妙而美丽的陷阱。想象一下这个事件序列:

  1. 消费者检查标志。它是false。
  2. 消费者即将调用wait进入睡眠。但就在它这么做之前,操作系统暂停了消费者,并运行了生产者。
  3. 生产者写入数据,设置ready = true,并调用signal。信号发出去了,但没有人在听!消费者还没有睡着。这个信号就像在空房间里的一声呐喊,丢失了。
  4. 消费者线程恢复。它现在调用wait并进入睡眠……永远地。数据已经准备好了,但唤醒的呼叫被错过了。

解决方案是一条不可破坏的规则:​​共享状态(ready标志)和信号机制(条件变量)必须由同一个互斥锁保护。​​ 线程必须持有互斥锁才能检查标志。如果它需要等待,cond_wait函数会执行一个神奇的原子操作:它同时释放互斥锁并让线程进入睡眠。这样就不会有信号丢失的间隙。当生产者想要发信号时,它必须首先获取互斥锁。这确保了它不能在消费者处于检查和等待之间的微妙状态时修改标志和发信号。这是一个优美的逻辑契约,是互斥锁、条件和谓词这三个组件的舞蹈,保证了可靠的通信。

现代硬件的险恶环境

到目前为止,我们都把内存想象成一块所有线程都能看到的、单一且有序的黑板。这是一个方便的虚构。现实要奇怪和迷人得多。为了追求速度,每个CPU核心都有自己的私有笔记本,即​​缓存​​,它在其中保存主内存的副本。而这正是多核编程中一些最深层问题的根源。

爱管闲事的邻居问题:伪共享

缓存不会只从内存中取一个字节;它会取一整块,称为​​缓存行​​,通常是64字节长。如果你的数据和你邻居的数据恰好在同一个缓存行上,会发生什么?

考虑一个有八个线程的音频处理流水线,每个线程在自己的核心上运行,更新各自声道的进度指针。如果我们把这八个8字节的指针存储在一个简单的数组中,它们将完美地装入一个64字节的缓存行。线程1写入它的指针。线程2写入它自己的指针。从逻辑上讲,这些是完全独立的操作。但对硬件来说,它们都在写入同一个缓存行。

缓存一致性协议(如​​MESI​​)要求,在一个核心可以写入一个缓存行之前,它必须拥有该行的独占所有权。于是,核心1获得了该行。然后核心2需要写入,所以它大喊:“我需要那一行!”核心1必须使其副本失效,并将该行发送给核心2。接着核心3又为它大喊,依此类推。单个缓存行在所有八个核心之间疯狂地来回传递——这种现象被称为​​缓存行乒乓​​。尽管线程没有共享数据,但它们共享了缓存行,导致了一种“伪”共享场景,严重损害了性能。

解决方案既奇怪又有效:​​填充(padding)​​。我们故意浪费内存。我们不是把指针紧密地打包在一起,而是把每个指针放在它自己的64字节块的开头。其他56个字节是空的。现在,每个指针都位于一个单独的缓存行上。当线程1写入它的指针时,它不会打扰任何其他线程。我们用空间换取了速度,这是对硬件物理现实的必要妥协。

重排序的无政府状态

怪事还不止于此。不仅内存不是统一的,它的顺序也得不到保证。为了榨干最后一滴性能,现代CPU是一位欺骗大师。它维持着一个“程序顺序”——你编写的指令序列——但它可能会以完全不同的顺序执行它们,只要对于单个线程而言结果看起来是正确的。CPU可能会将一个缓慢的写操作延迟在一个​​存储缓冲区(store buffer)​​中,然后继续执行后面更快的读操作。

这对于一个线程来说没问题,但对于多个线程来说,就是一片混乱。考虑这个简单的程序:两个线程,两个共享变量x和y,初始值都为0。

  • ​​线程1:​​ x = 1; 然后 r1 = y;
  • ​​线程2:​​ y = 1; 然后 r2 = x;

寄存器r1和r2的最终可能值是什么?你可能会认为至少有一个写操作必须在另一个线程的读操作之前完成,所以我们不可能得到r1=0和r2=0的结果。但在许多现代处理器(所谓的弱序系统)上,这个结果是可能的!。每个线程都可以将其写操作放入其存储缓冲区,然后从主存中执行其读操作(看到旧值0),直到后来,缓冲的写操作才对另一个核心可见。

处理器对操作的重排序创造了一个似乎违反逻辑和因果关系的结果。这就是​​内存一致性模型​​的狂野世界。内存模型是程序员与硬件之间的正式契约,定义了你可以——以及不可以——依赖哪些顺序保证。多年来,程序员试图使用volatile关键字来驯服这一点,但这是一个错误。volatile主要告诉编译器不要优化掉读写操作,但它通常并不能阻止硬件对它们进行重排序。

驯服野兽:栅栏与内存顺序

为了恢复理智,我们需要给硬件下达明确的指令。​​内存栅栏​​(或内存屏障)就是这样一种指令。它是一条不可逾越的界线。一个完整的栅栏会说:“确保此栅栏之前的所有内存操作在全局可见之后,你才能考虑开始此栅栏之后的任何内存操作。”

不同的架构有不同的契约。在常见的x86处理器上,内存模型(称为完全存储定序,或TSO)相对较强。它保证来自单个线程的写操作不会相互重排序。对于我们的生产者-消费者例子(data=v; flag=1;),这意味着在x86上,你通常不需要栅栏。硬件已经尊重了写顺序。但在像ARM(几乎每部智能手机里都有)这样的弱序架构上,硬件可以自由地重排写操作。对flag的写入可能会在对data的写入之前变得可见,从而破坏逻辑。在ARM上,屏障是必不可少的。

栅栏是一种粗暴的工具。一个更优雅的解决方案,由C++11等现代编程语言提供,是将顺序语义直接附加到原子操作上。这把我们带到了​​释放-获取语义​​这个优美的概念。

  • 向一个标志写入的生产者可以执行一个​​释放存储(release store)​​。这告诉CPU:“在我使这次存储可见之前,让我之前的所有内存写入对其他核心可见。”
  • 读取该标志的消费者可以执行一个​​获取加载(acquire load)​​。这告诉CPU:“在执行我后续的任何内存操作之前,确保这次加载完成,并确保我能看到来自匹配的释放操作的写入。”

当一个获取加载读取了由一个释放存储所写入的值时,它们形成了一个​​同步于(synchronizes-with)​​关系。这创建了一条​​先行发生(happens-before)​​边,一座从生产者到消费者的因果桥梁。生产者在释放操作之前所做的所有工作,都保证在消费者进行获取操作之后对它可见。这个强大而精细的工具是修复臭名昭著的困难错误的钥匙,比如著名的​​双重检查锁定模式(Double-Checked Locking Pattern)​​,在这种模式下,一个线程可能看到初始化标志为true,但由于内存重排序,仍然看到一个空指针。最后,如果一个写操作根本不是一个单一的操作怎么办?一个以两次8位写操作执行的16位写操作可能会被中断,导致​​撕裂读(torn read)​​。这激发了对硬件保证的、真正不可分割的​​原子操作​​的需求。

前沿:无锁生活

锁是安全的,但可能会很慢。当许多线程争夺同一个锁时,它们会排成一个队列,上下文切换的开销可能会很高。这催生了一个大胆的前沿领域:​​无锁编程​​。核心工具是一种原子指令,如​​比较并交换(Compare-And-Swap, CAS)​​。一个CAS操作会说:“查看这个内存位置。如果它包含值A,就原子地用值B替换它。否则,什么都不做,并告诉我失败了。”

你可以用它来构建复杂的数据结构,比如栈。要推入一个项目,你创建一个新节点,将其next指向当前的栈顶,然后使用CAS尝试将top指针从其旧值摆动到你的新节点。如果另一个线程抢先了,你的CAS会失败,你只需重试即可。

这非常巧妙,但它有代价。一个无锁算法保证整个系统总是在取得进展。但它不保证你特定的线程会。在激烈争用下,一个线程可能会永远倒霉:每次它尝试CAS top指针时,都发现某个其他线程刚刚成功了。它可能会被无限期地饿死。这意味着该算法虽然是​​无锁的​​,但违反了​​有界等待​​的公平性条件。我们用死锁的风险换来了饥饿的风险。

如何应对这个问题?不是用另一个锁,而是用礼貌。当一个线程的CAS失败时,它会退避一小段随机的时间再重试。一个强大的策略是​​随机指数退避​​,其中等待窗口随着每次连续失败而增长。这使得争用的线程能够去同步化,并分散它们的尝试,从而极大地降低了碰撞的概率。这是一个去中心化的、概率性的解决方案,允许系统从交通堵塞中自我组织出来,是一个涌现秩序的美丽例子。

统一的视角

我们从简单的厨房到CPU混乱的量子领域的旅程揭示了并发的三个相互关联的支柱:

  1. ​​原子性(Atomicity):​​ 确保操作是不可分割的,不能被中断。这是互斥锁和像CAS这样的原子指令的领域。
  2. ​​可见性(Visibility):​​ 确保一个线程所做的更改对其他线程可见。这由硬件的缓存一致性系统来管理。
  3. ​​顺序性(Ordering):​​ 确保操作以正确的顺序被观察到。这是内存模型、栅栏和释放-获取语义的领域。

掌握多核编程就是要理解这三个概念之间深刻的相互作用。它是要学习一个奇异新宇宙的规则,一个非顺序的、时间是相对的、你必须在混乱的基础上建立秩序的宇宙。这是现代计算机科学中最具挑战性,但也是最有价值的智力旅程之一。

核心的交响曲:从硅逻辑到科学发现

在经历了多核编程的基本原理和机制——原子操作、内存模型和同步原语的世界——之后,我们可能会有一种在雷区中航行的感觉。这些规则可能看起来很抽象,像是一套旨在防止我们的程序崩溃的约束。但这只是故事的一半。这些原则不仅仅是为了避免灾难;它们是并行创造的语法本身。它们是支配信息在处理器内部多车道高速公路上流动的物理定律,理解它们使我们能够指挥一场跨越多个核心的计算交响乐。

在本章中,我们将从“如何做”转向“为何做”。我们将看到这些基础概念如何开花结果,为横跨一系列令人惊叹的学科的实际、现实世界问题提供解决方案。从视频游戏的流畅、沉浸式世界到科学模拟的宏大挑战,并发原则是现代技术奇迹的无声推动者。

引擎室:系统编程与性能

在软件的最底层,在操作系统和高性能库的引擎室中,多核编程是一场精妙优化的游戏。在这里,每一纳秒都至关重要,机器的架构不是一个抽象概念,而是一个需要掌握的物理现实。

考虑一下计算机执行的最基本任务之一:内存分配。当多个核心都需要同时请求和释放内存时,它们从哪里获取内存?一种常见的方法是共享位图,即一张每个比特位代表一个内存块的地图,若在使用中则设为1,若空闲则设为0。一个核心找到一个0,用原子操作将其翻转为1,然后取走该块。还有比这更简单的吗?

然而,这种简单性隐藏了一个陷阱。如果所有核心都从位图的开头开始搜索,它们都会汇集到第一个可用的块上。它们都将试图对位于单个缓存行上的同一内存区域进行原子操作。在现代缓存一致性机器上,一次只有一个核心可以拥有一个缓存行的独占所有权。结果是一种被称为​​缓存行弹跳​​或​​抖动​​的现象,即这个单个缓存行在各个核心的缓存之间疯狂传递。核心们花费在争夺缓存行上的时间比做有用功的时间还多,从而创建了一个序列化点,无论你增加多少核心,都会扼杀整个系统的性能。

我们如何解决这个问题?并发的原则指引着我们。我们可以创建多个空闲列表,而不是一个全局的空闲列表,这种技术称为​​分片(sharding)​​。我们将位图划分为几个区域,并为每个核心(或核心组)分配其自己的分片。现在,核心只与使用同一分片的少数其他核心竞争,极大地减少了任何单个缓存行上的“交通堵塞”。总吞吐量得到了优美的扩展。这是通过划分共享资源来减少争用的直接应用。

当我们通过现代硬件的视角重新审视经典的并发难题时,同样的可扩展设计原则也会出现。著名的​​哲学家就餐问题​​通常被当作关于死锁的抽象课程来教授。但在一个真实的多核芯片上,它变成了一个关于性能的课程。如果“叉子”由简单的测试并设置自旋锁保护,等待的线程会反复尝试获取锁,我们就会重现同样的缓存行抖动噩梦。所有等待的核心都猛击同一个内存位置,导致互连网络被流量淹没。一个更复杂的锁,比如MCS锁,将这种混乱转化为秩序。它为等待的线程构建了一个明确的队列。每个线程耐心地在其私有标志上自旋,不产生全系统范围的流量。当一个锁被释放时,它被直接传递给队列中的下一个线程。这是一个拥挤的人群大声要求服务与一个有序队列之间的区别。性能的提升不是渐进的;它是根本性的,使系统能够扩展到几十甚至几百个核心。

幻象的艺术:实时图形学与用户界面

从引擎室转向我们日常交互的应用程序,挑战从原始吞吐量转变为感知的响应能力和视觉一致性。在视频游戏和用户界面的世界里,多核编程是创造无缝幻象的艺术。

想象一个高速运行的视频游戏。一个线程,即​​物理线程​​,正忙于计算下一帧中所有对象的位置和方向。第二个线程,即​​渲染线程​​,负责将上一个完成的帧绘制到屏幕上。它们通常使用一种称为双缓冲的技术处理不同的帧。物理线程写入缓冲区A,而渲染器读取缓冲区B,然后它们交换。但渲染器如何精确地知道物理线程何时完成了一个新帧?如果它看得太早,它可能会看到一个“撕裂帧”——一辆车在其新位置,但其车轮仍在其旧位置。这正是弱内存模型所允许的数据竞争。

使用重锁可以解决问题,但可能会引入卡顿和延迟,这在实时图形中是不可接受的。优雅的解决方案在于​​获取-释放语义​​的精妙舞蹈。在物理线程写完新帧的最后一个字节后,它对一个共享指针或索引执行一次原子性的​​释放存储(release store)​​,从而有效地发布该帧。渲染线程执行相应的原子性​​获取加载(acquire load)​​来检查是否有新帧。这对释放-获取操作就像一个内存屏障,一个“秘密握手”,建立了一个先行发生(happens-before)关系。它保证了对帧数据的所有写入在渲染器看到更新的指针之前都是可见的。这是一种轻量级的、无锁的一致性保证,可以在不牺牲性能的情况下消除撕裂。同样的模式在机器人技术中至关重要,其中传感器线程必须读取机器人规划轨迹的一致快照,而绝不能阻塞。

同样的主题——响应性和效率——也回响在我们手机和桌面上的图形用户界面(GUI)设计中。一个渲染线程负责绘制UI,但它只应该在某些东西实际发生变化时才这样做。多个其他“生产者”线程——处理用户输入、网络更新、动画——都可以触发更改。一个天真的方法是让每个生产者都唤醒渲染线程。如果在一毫秒内发生十次更新,渲染线程可能会被浪费地信号通知十次。

一个更精巧的设计使用了一个​​条件变量​​和一个简单的布尔值dirty标志。当一个生产者有更新时,它获取一个锁,检查该标志。如果已经是true,这意味着渲染线程已经被安排唤醒或正在工作。生产者只需更新其数据然后离开;它的工作与待处理的渲染​​合并​​了。如果标志是false,生产者将其设置为true,然后——且仅当此时——才向条件变量发信号以唤醒渲染线程。渲染线程醒来后,在一个循环中重新检查dirty标志(以防范虚假唤醒),将其设置回false,并执行一次高效的渲染,该渲染捕获了所有已发生的更改。这是一个用于事件驱动系统的极其简单的模式,它在响应性和效率之间取得了平衡。

宏伟的挑战:科学与工程模拟

也许多核编程最令人敬畏的应用是在科学和工程模拟中,我们在机器内部构建整个宇宙,以解决人类面临的一些最大挑战。从模拟蛋白质的折叠到星系的碰撞,这些问题对于单个核心,甚至单台计算机来说都太大了。

在这里,我们必须在遍布超级计算集群的数千甚至数百万个核心上协同进行计算。主要策略是​​区域分解​​:一个大的物理问题,比如计算电磁学模拟中的一个三维空间体积,被分解成更小的子域。每个子域被分配给一个进程。问题在于,一个子域边缘的物理特性取决于相邻子域的“光环(halo)”或“幽灵(ghost)”区域中的值。这需要通信。

这导致了与硬件结构相呼应的并行编程模型层次结构:

  • ​​分布式内存(MPI):​​ 集群计算节点之间的通信由像消息传递接口(Message Passing Interface, MPI)这样的库来处理。每个MPI进程都有自己私有的地址空间。为了交换光环数据,一个进程必须将数据显式地打包成消息,并通过网络发送给它的邻居,邻居必须显式地接收它。这里没有共享内存;所有通信都是显式的。

  • ​​共享内存(线程/OpenMP):​​ 在单个计算节点内部,该节点本身包含多个核心,我们可以使用共享内存并行。一个MPI进程可以产生多个线程(例如,使用OpenMP),这些线程都共享同一个地址空间。这些线程可以共同处理分配给其父进程的子域,通过读写共享数组进行隐式通信。这需要使用屏障和锁等熟悉的同步机制来协调它们的工作。

  • ​​混合模型(MPI+X):​​ 最先进的是一种混合模型。MPI处理粗粒度的、节点间的通信,而像OpenMP(用于多核CPU)或CUDA(用于GPU)这样的技术处理细粒度的、节点内的并行。这种模型完美地映射到硬件上,利用快速的、节点上的共享内存进行本地协作,利用较慢的网络进行全局协调。

一个简单而具体的比喻可以在一个简单的交通模拟中找到。想象一下模拟一个城市网格,其中交叉路口是任务,道路是数据。一个交叉路口的状态取决于从相邻道路流入的交通。一个天真的并行化尝试,即每个交叉路口任务锁定其传入道路以供读取,并锁定其传出道路以供写入,可能会导致字面意义上的​​网格锁(gridlock)​​——一种循环依赖,每个任务都在等待另一个任务,导致整个模拟冻结。

在科学计算中广泛使用的解决方案是​​双缓冲​​。在每个时间步,每个任务只从“当前状态”缓冲区读取,只向“下一状态”缓冲区写入。由于读集合和写集合是完全分开的,因此没有资源冲突,也没有死锁的可能性。一个全局​​同步屏障​​确保只有在所有任务都完成其工作之后,“下一状态”才成为下一个时间步的“当前状态”。这种方法完美地保留了模拟的离散时间步,同时实现了大规模并行。

机器中的幽灵:统一的原则

我们旅程的最后一站揭示了最深刻的洞见:多核编程的原则不仅仅适用于软件。它们是几十年来一直处于计算机体系结构核心的思想的回响。从某种意义上说,我们今天在软件中面临的挑战,硬件设计师在试图让单个核心更快时首先遇到了。

考虑一下Tomasulo算法,这是20世纪60年代开发的一种用于动态指令调度的杰出硬件方案。它的目标是乱序执行指令,即使面对数据依赖,也要让处理器的功能单元(加法器、乘法器)尽可能保持繁忙。它是如何工作的?

  • 当一条指令被分派时,它被放置在一个​​保留站(Reservation Station)​​(一个任务队列)中。
  • 如果它的操作数因为正在被更早的指令计算而尚不可用,它不会等待值。相反,它会订阅将产生该值的指令的​​标签(tag)​​。
  • 当一条指令完成时,它会在​​公共数据总线(Common Data Bus, CDB)​​上广播其结果和标签。
  • 所有等待该标签的保留站都会获取该值。

这是一个微型的并发模型!标签是​​future​​或​​promise​​。CDB是一个单一的、经过仲裁的通信通道——就像一个被争用的锁一样的瓶颈。该算法是数据流图的硬件实现,动态解决依赖关系。这揭示了一个惊人的统一性:future和promise这些感觉像是现代软件抽象的概念,其实是半个多世纪以来一直被刻在硅片上的并行执行深层原理的反映。

硬件、编译器和软件之间的这种深刻联系无处不在。当编译器试图自动并行化一个循环时,它就像一个自动化的并发程序员。它分析依赖关系,以确定哪些迭代可以并行运行。但它的能力受到语义的限制。如果一个循环包含一个print语句,编译器必须认识到这不仅仅是一个函数调用;它是一个具有可观察的、必须保留的顺序性的I/O操作。一个聪明的编译器仍然可以并行化纯粹的计算,缓冲结果,然后在最后有序的步骤中执行打印——将可并行的工作与固有的顺序副作用分开。

多核编程的旅程将我们从硬件的最深层次带到科学抽象的最高层次。同步、内存排序和通信的规则并非任意。它们是并行的通用语法,出现在单个CPU核心的逻辑中,用户界面的框架中,以及遍布全球的超级计算机的架构中。掌握它们,就是学习如何指挥一场交响乐,将硅片的静默嗡鸣转化为洞见、发现和幻象。