高级计算机架构(八):多处理器到底难在哪

发布于 作者: Ethan

多处理器最难的不是多加几个核

今天几乎所有计算机都在谈多核,但多处理器真正困难的地方,从来不只是把核心数量堆上去。难点在于:怎样让多个执行单元共享同一份内存,又让它们看到的结果既正确、又足够快。只要这个问题没有处理好,核心再多,也可能跑不出理想性能。

从这个角度看,多处理器的核心主题其实可以归结为三件事:第一,程序里到底有多少真正能并行的工作;第二,共享内存怎样组织才跑得动;第三,多个缓存同时读写同一份数据时,系统怎样维持“大家看到的是同一个世界”。

共享内存为什么会成为主流

共享内存多处理器的基本想法很直观:多个处理器或核心共享一个全局内存空间,进程或线程之间通过普通的 load 和 store 来交换数据,而不是每次通信都依赖操作系统介入。多个程序可以同时运行,也可以让同一个程序复制出多个执行上下文并行推进。

gao-ji-ji-suan-ji-jia-gou-ba-duo-chu-li-qi-dao-di-nan-zai-na-0

这种模型之所以流行,是因为它对软件很友好。对应用来说,它看起来很像一个“会同时跑很多线程的单处理器”;对操作系统来说,也更像是在原有机制上做扩展,而不是彻底换一种机器模型。程序员可以先关心正确性,再逐步优化性能。

但共享内存的代价也很明显。同步本身很复杂,线程之间的通信是隐式发生的,因此不容易像显式消息传递那样直接优化;更重要的是,它必须依赖硬件支持。为了让每个处理器既有自己的私有地址空间,又能访问全局共享的数据,系统还要维护本地地址和全局物理地址之间的映射,这通常离不开操作系统配合。

这也是为什么,多处理器设计到最后,讨论的重点往往不是算术单元,而是内存系统。处理器的每一次计算几乎都从寄存器开始、也回到寄存器结束,而整台机器的大量晶体管和大量复杂性,最终都花在了“怎么存、怎么取、怎么共享、怎么保持一致”上。

真正能用上的并行度通常没有想象中高

多处理器不等于程序天然会并行。衡量这一点时,一个很实用的指标叫线程级并行性,也就是 Thread-Level Parallelism,简称 TLP。可以把它理解为:机器不空闲的时候,平均同时有多少个线程在运行。

数据库和 Web 服务器这类系统通常很容易理解这个概念:每个请求都可以看成一个线程,线程之间松散共享数据,彼此异步推进。但桌面应用、交互式程序和很多日常负载,并没有想象中那么“满并行”。很多时候,程序的大部分时间都在等待用户输入、等待 I/O,或者被一小段关键的串行路径卡住。

gao-ji-ji-suan-ji-jia-gou-ba-duo-chu-li-qi-dao-di-nan-zai-na-1

一个很说明问题的现象是,哪怕在双处理器机器上,许多交互式桌面应用的平均 TLP 也只在 1.3 左右。这意味着它们不是完全没有并行,而是“有一点,但远远达不到把两个核心都吃满”。因此响应时间会改善,但改善幅度也有限。

并行性能为什么总是不如核心数那样线性增长,最经典的解释就是 Amdahl 定律。假设一个程序里有比例为 $f$ 的部分可以被加速,加速倍数是 $S$,那么整体加速比是

$$ \text{Speedup} = \frac{1}{(1-f) + \frac{f}{S}} $$

这里的 $(1-f)$ 是无论如何都得串行执行的部分,它会直接限制最终收益。比如一个程序有 $80%$ 的时间可并行,如果这部分速度翻倍,那么总加速比是

$$ \frac{1}{0.2 + 0.8/2} = 1.66 $$

就算把可并行部分“无限加速”,理论上也只能达到

$$ \frac{1}{1-0.8} = 5 $$

这就是多处理器里最常见的现实:决定上限的,不是最强的并行段,而是剩下那一小截去不掉的串行路径。

另一个容易被忽略的问题是负载均衡。高 TLP 不只是“线程数多”,还要求这些线程的运行时间和处理器利用率比较接近。假设 5% 的时间有 4 个线程在跑,15% 的时间有 3 个线程在跑,剩下 80% 的时间只有 1 个线程在跑,那么平均 TLP 是

$$ 0.05 \times 4 + 0.15 \times 3 + 0.8 \times 1 = 1.45 $$

看起来线程不少,但真正主导总时间的,是那段只有一个线程在运行的长尾部分。归根到底,这还是 Amdahl 定律在系统层面的另一种表达。

内存组织一变 难题就开始出现

当处理器数量不多时,最容易理解的组织方式是 UMA,也就是 Uniform Memory Access。每个处理器访问任意内存的延迟都差不多,软件不用太在意数据放在哪里,编程体验更简单。但它的峰值性能有限,因为大家最终会争同一个共享带宽。

一旦规模继续扩大,系统常常转向 NUMA,也就是 Non-Uniform Memory Access。每个处理器或节点附近都有“本地内存”,访问本地更快,访问远端更慢。这样能换来更高的总体带宽和更好的扩展性,但代价是软件开始需要关心数据布局:线程跑在哪个节点上、数据又放在哪个节点上,这些都开始影响性能。

配套的互连结构也会一起变化。小规模系统常见的是总线式嗅探结构,通常适合 2 到 8 个处理器;更大规模的系统则会使用点到点网络,比如树、环、网格等拓扑。网络里如果路由器是独立芯片,就是间接网络;如果把处理器、内存和路由功能集成进节点内部,就是直接网络。前者更容易理解,后者组件更少、扩展更自然。

但无论是 UMA 还是 NUMA,只要每个核心前面有缓存,接下来就一定会遇到同一个问题:如果两个处理器都缓存了变量 $A$,其中一个刚把它改成 1,另一个随后去读,读到的到底是 1,还是自己缓存里的旧值 0?

这就是 cache coherence,也就是缓存相干性问题。

相干性解决的是同一个地址到底该看到哪个值

相干性只关心一件事:针对同一个内存位置,所有处理器最终必须能够看到一个一致、可解释的读写顺序。它不要求整个系统里所有地址都同时有一个全球统一的时间线,但至少要保证“同一个地址不会各说各话”。

最简单的办法,是让缓存行只有 Valid 和 Invalid 两种状态。多个处理器可以同时读,但一旦有人写,就把写操作直接送到总线上,并让其他缓存里对应的副本全部失效。这种方法实现简单,但通常要求 write-through、no-write-allocate 之类的保守缓存策略,而且所有缓存都得不停地嗅探总线,扩展性很差。

所以更常见的教学起点是 MSI 协议。它给每个缓存块定义三个状态:

  • $I$:Invalid,说明本地没有有效副本
  • $S$:Shared,说明本地有只读且干净的副本,内存仍然是最新的
  • $M$:Modified,说明本地持有唯一可写副本,而且数据已经比内存更新

它背后的思想很重要:不仅要知道“有没有这份数据”,还要知道“谁拥有写权限”。多个读者可以同时存在,但写必须互斥。处理器会发起诸如 BusRd、BusRdX、BusInv、BusWB、BusReply 这样的总线消息,用来请求数据、获得独占权限、使别人失效、回写脏数据,以及返回数据。

比如一个缓存块当前在 $S$ 状态时,本地想执行写入,就不能直接改。它必须先发出升级请求,让其他缓存里的这份数据无效,自己再进入 $M$ 状态。这个过程本质上是在说:从“多人可读”切换到“我独占可写”。

gao-ji-ji-suan-ji-jia-gou-ba-duo-chu-li-qi-dao-di-nan-zai-na-2

MSI 已经能正确工作,但它还有一个明显浪费:如果某个块其实只有一个缓存持有,第一次读进来后它会落在 $S$,接着第一次写又要再做一次升级,白白多了一次总线事务。为了解决这个问题,MESI 加了一个 $E$,也就是 Exclusive 状态。它表示“只有我这一份副本、可写、但还是干净的”。这样,读到一个私有块之后,第一次写就能直接从 $E$ 变成 $M$,不必再广播升级。

再往前一步,MOESI 又加了一个 $O$,也就是 Owned 状态。它特别适合生产者—消费者模式:某个缓存里有脏数据,但这份数据已经被其他缓存共享了。此时没必要立刻把它写回内存,只要保留一个 owner,等将来驱逐时再回写即可。这样能省掉不少带宽。

这些状态机在课本上看起来像几颗圆点加几条箭头,但真实实现要复杂得多。真正的硬件协议里会有大量瞬态状态、竞争条件、重试、确认、转发和超时处理。很多时候,真正难写的不是“稳态规则”,而是“两个看起来都合法的消息刚好撞在一起时怎么办”。

gao-ji-ji-suan-ji-jia-gou-ba-duo-chu-li-qi-dao-di-nan-zai-na-3

规模一大 就不能再靠所有人一直偷听总线

总线式嗅探协议适合小系统,因为它的前提很直接:任何缓存事件都广播给所有处理器,谁听到了、谁需要动作,自己决定。但处理器一多,这种办法会同时撞上两个瓶颈。

第一个瓶颈是总线带宽。广播本身不具备好的扩展性,节点一多,所有人共用的一条通信通道很快就会拥塞。第二个瓶颈是嗅探带宽。大多数广播其实和大多数处理器都无关,结果却让所有缓存都白白检查一遍。这相当于不停地给全系统群发消息,只为了让极少数节点响应。

目录协议就是为了解决这两个问题而出现的。它利用一个关键事实:物理地址空间通常是静态划分的,所以给定一个 cache line,很容易知道它的 home 在哪块内存上;真正难知道的是,当前哪些处理器缓存了它。于是系统干脆在 home 处维护一本“目录”,记录两类信息:谁是 owner,谁是 sharer。

gao-ji-ji-suan-ji-jia-gou-ba-duo-chu-li-qi-dao-di-nan-zai-na-4

这样一来,请求方不必再广播给所有处理器,而是先把相干事件发给 home。home 查看目录后,只通知真正相关的那些节点。比如某个块被远端节点以修改态持有,本地节点这时发起读缺失,home 不需要自己返回旧内存数据,而是可以把请求转发给那个 owner,让 owner 直接把最新数据送过来。

gao-ji-ji-suan-ji-jia-gou-ba-duo-chu-li-qi-dao-di-nan-zai-na-5

目录还可以有不同组织方式。集中式目录把所有节点的缓存标签集中管理,优点是容易序列化、也容易推导一致性;缺点是根本不适合大规模系统。分布式目录则把目录分散到各个内存模块上,目录容量随内存容量自然扩展,更适合大机器,但它也带来新的责任:不同地址之间不再有天然的全局串行点,一致性约束就更多落在处理器接口和内存模型上。

更麻烦的是,目录协议里竞争情况非常常见。一个典型例子是:某节点正准备把脏块写回 home,另一个节点几乎同时发来读请求。此时 home 既要处理写回,又要处理读,还要确保最后状态正确,不能把新值弄丢,也不能让目录记录错乱。

gao-ji-ji-suan-ji-jia-gou-ba-duo-chu-li-qi-dao-di-nan-zai-na-6

这类竞争说明了一个事实:相干协议不是简单的“状态转换图”,而是一个分布式并发控制系统。只要节点多、网络深、消息可重排,协议设计就会迅速从“看懂”变成“难证正确”。

相干性不等于一致性

理解多处理器时,最容易混淆的两个词就是 coherence 和 consistency。它们都和“看到的数据对不对”有关,但管的不是同一件事。

相干性只讨论单个内存位置。对于同一个地址,系统要能把所有操作序列化,并保证每个处理器自己的程序顺序不被破坏,读到的值等于这个序列里最后一次写入该地址的值。

一致性讨论的则是多个地址之间的观察顺序。也就是说,当程序同时读写 $A$、$B$、flag 等多个变量时,其他处理器到底会以什么顺序看到这些更新。

这两个概念为什么不能混为一谈,可以看一个典型场景。假设两个处理器共享三个变量:$A$、$B$ 和 flag,初始值都是 0。一个处理器先写 $A=1$、再写 $B=1$、最后写 flag。另一个处理器一直等到看到 flag 变成 1,再去读 $A$ 和 $B$。从直觉上看,既然 flag 都已经看到了,$A$ 和 $B$ 好像也应该已经是 1 了。

但仅靠相干性,这个结论并不成立。原因是相干性只保证“每个地址各自正确”,却不保证“不同地址之间的可见顺序一致”。$A$ 可以先被看到,$B$ 可以后被看到,flag 甚至还可能先于它们被观察到。单处理器里那些依赖 load/store queue 的顺序直觉,在多处理器跨地址观察时并不自动成立。

顺序一致性,也就是 Sequential Consistency,试图给程序员一个最直观的模型:所有处理器的内存操作,好像被放进了同一条全局顺序里;同时,每个处理器自己的操作,在这条全局顺序里仍然保持程序里的先后关系。

更形式化一点说,顺序一致性要求:任何一次执行的结果,都等价于“所有处理器的内存操作按某个单一顺序依次发生”,并且每个处理器内部的操作顺序与程序顺序一致。

这个模型很好理解,也最符合直觉,但硬件实现起来很贵。它要求每个处理器严格按程序顺序发出内存操作,还要求一次内存操作在逻辑上足够原子,很多简单实现甚至意味着下一次访存必须等上一次完全结束后才能开始。这样做会严重限制并发访存、限制乱序执行,也和很多隐藏延迟的优化手段直接冲突。

所以现实机器通常不会对所有普通读写都维持最强的一致性,而是采用更宽松的内存模型:允许一定程度的重排,把真正必须维持顺序的地方集中在同步操作上,例如锁、屏障、原子指令等。换句话说,系统不是要求“所有时刻都绝对有序”,而是要求“程序在同步点上看到的结果仍然正确”。

结尾

多处理器之所以难,不是因为“多几个核心”这件事本身有多神秘,而是因为它必须把很多本来局部、简单的问题,变成一个全局、并发、分布式的问题。

程序层面,真正可利用的线程级并行性常常低于直觉,Amdahl 定律几乎总会站出来提醒你:串行部分才决定上限。硬件层面,共享内存要在 UMA、NUMA、总线和点到点网络之间做权衡。缓存层面,相干协议要保证同一地址的值不冲突,从最简单的 Valid/Invalid,一路发展到 MSI、MESI、MOESI。系统规模再往上走,广播式嗅探撑不住,就必须改用目录协议,把“谁拥有、谁共享”这件事显式记录下来。再往上一层,一致性问题又会提醒我们:同一个地址正确,并不代表整个程序的观察顺序就一定正确。

所以,多处理器真正提供的不是“更多核”,而是一种精心制造出来的假象:很多核心在同时工作,但程序仍然像面对一个可以理解、可以推理、可以编程的共享内存世界。这个假象越自然,机器就越强大;而让这个假象成立,正是现代计算机体系结构里最硬核的工程之一。