第18讲 多核与多处理器II

以下根据附件《第18讲 多核与多处理器II.md》整理生成。

第18讲:多核与多处理器 II 学习指南

本讲承接上一讲“多核不是免费加速”的主线,进一步讨论两个问题:

  1. 正确性问题:程序员写下来的代码顺序,为什么不一定等于其他 CPU 核心观察到的顺序?
  2. 性能问题:即使用了 MCS 锁这类可扩展锁,为什么在多核、NUMA 环境下仍然可能性能很差?

❗ 本节核心结论:
多核程序不能只按“代码书写顺序”理解共享内存访问,而必须考虑内存一致性模型。同步原语不仅提供互斥,也提供访存顺序保证;而在高核数和 NUMA 环境下,锁本身可扩展不代表临界区数据访问也可扩展。

1. 本章知识地图

这张图的核心是:

第17讲主要讲“缓存一致性和锁竞争”,第18讲进一步讲“内存操作顺序”和“NUMA 下的局部性”。


2. 为什么 LockOne / Peterson 要重新审视?

2.1 LockOne 的基本想法

LockOne 是 Peterson 算法的前身,思想很直观:

1
2
3
4
5
flag[0] = TRUE;
while (flag[1] == TRUE)
;
/* critical section */
flag[0] = FALSE;

意思是:

  1. 我先声明自己想进入临界区。
  2. 然后检查对方是否也想进入。
  3. 如果对方不想进入,我就进入。

2.2 它隐含了什么假设?

这段代码隐含了一个很强的假设:

我写在前面的语句,会先执行,并且会先被其他核心看到。

也就是程序员默认:

1
2
3
flag[0] = TRUE
一定早于
读取 flag[1]

但现代处理器为了性能,可能会让某些访存操作乱序执行。


2.3 可能怎么出问题?

程序员写的是:

1
2
3
flag[0] = TRUE;
while (flag[1] == TRUE)
;

但硬件可能让“读取 flag[1]”先于“写入 flag[0]”对外发生。

于是两个线程都可能先读到对方的 flag 还是 FALSE

1
2
CPU0 读到 flag[1] == FALSE
CPU1 读到 flag[0] == FALSE

结果两个线程都认为:

1
对方不想进临界区,所以我可以进。

于是同时进入临界区,互斥失败。


❗ 本节核心结论:
LockOne 这类算法的问题不是简单“代码写错了”,而是它依赖了较强的访存顺序假设。现代硬件为了性能可能不会默认提供这种顺序。

3. 内存一致性模型

3.1 它是什么?

内存一致性模型 Memory Consistency Model 是硬件和软件之间的约定:

多个 CPU 核心同时读写共享内存时,哪些读写顺序必须被保证,哪些顺序可以被硬件重排。

它关注的不是单个地址最终值是否一致,而是:

多个地址上的读写操作,在不同 CPU 看来顺序是否合法。


4. 严格一致性 Strict Consistency

4.1 定义

严格一致性要求:

任意读操作都必须读到全局时间上最近一次写入的值。

它像是系统中有一个绝对全局时钟:

1
谁先发生,所有 CPU 都必须按这个顺序看到。

4.2 为什么它符合直觉?

因为它最接近程序员的朴素理解:

1
2
data = 666;
flag = READY;

程序员自然认为:

1
别人看到 flag = READY 时,就应该已经能看到 data = 666。

严格一致性会保证这种直觉。


4.3 为什么现实中很难高效实现?

因为现代处理器有:

  • 私有缓存
  • 写缓冲区
  • 乱序执行
  • 多级缓存
  • 跨核心互联
  • NUMA 远程访问

如果每次读写都必须全局同步,CPU 流水线会被严重阻塞,性能代价极高。


❗ 本节核心结论:
严格一致性最符合直觉,但实现成本太高,所以现实硬件通常不会提供这么强的默认保证。

5. 顺序一致性 Sequential Consistency

5.1 定义

顺序一致性比严格一致性弱一些。

它不要求操作按照真实时间顺序对所有核心可见,但要求:

  1. 所有操作的结果必须等价于某个全局顺序。
  2. 在这个全局顺序中,每个 CPU 自己的程序顺序不能被打破。

可以理解为:

不要求全局真实时间一致,但要求能编出一个合法的全局执行顺序。


5.2 经典例子

1
2
3
4
5
6
7
// CPU0
flag[0] = 1;
A = flag[1];

// CPU1
flag[1] = 1;
B = flag[0];

在顺序一致性下,允许出现:

A B 是否可能
0 1 可能
1 0 可能
1 1 可能
0 0 不可能

为什么 (A, B) = (0, 0) 不可能?

因为如果:

1
A = 0

说明 CPU0 读 flag[1] 时,CPU1 的写 flag[1] = 1 还没发生。

如果:

1
B = 0

说明 CPU1 读 flag[0] 时,CPU0 的写 flag[0] = 1 还没发生。

那就会要求:

1
2
CPU0 的读 < CPU1 的写
CPU1 的读 < CPU0 的写

同时又必须保持程序顺序:

1
2
CPU0 的写 < CPU0 的读
CPU1 的写 < CPU1 的读

这会形成无法成立的循环顺序。


❗ 本节核心结论:
顺序一致性不要求真实时间全局一致,但要求每个线程自己的程序顺序被保留,并且整体结果能对应某个合法全局顺序。

6. TSO:Total Store Ordering

6.1 TSO 是什么?

TSO 是一种比顺序一致性更弱、但比弱序模型更强的内存一致性模型。

它大致保证:

顺序类型 TSO 是否保证
读 → 读 保证
读 → 写 保证
写 → 写 保证
写 → 读 不一定保证

TSO 最关键的放松是:

写之后的读,可能先于这个写对其他核心可见之前执行。


6.2 为什么 TSO 下可能出现 (A, B) = (0, 0)

还是这个例子:

1
2
3
4
5
6
7
// CPU0
flag[0] = 1;
A = flag[1];

// CPU1
flag[1] = 1;
B = flag[0];

在 TSO 下可能发生:

  1. CPU0 把 flag[0] = 1 放进本地写缓冲区。
  2. CPU1 把 flag[1] = 1 放进本地写缓冲区。
  3. 这两个写还没有对对方可见。
  4. CPU0 先执行读取 flag[1],读到旧值 0。
  5. CPU1 先执行读取 flag[0],读到旧值 0。

于是得到:

1
A = 0, B = 0

6.3 TSO 为什么要这样设计?

因为它允许写操作先进入写缓冲区,不必每次都等全系统可见后再继续执行后面的读。

这样可以:

  • 减少流水线阻塞。
  • 提高处理器吞吐。
  • 降低每次写共享变量的等待成本。

但代价是:

程序员不能再简单认为“代码中写在前面的 store,一定先被其他 CPU 看到”。


❗ 本节核心结论:
TSO 保留了大部分访存顺序,但放松了“写 → 读”顺序。它通过写缓冲提高性能,但也让并发程序可能读到旧值。

7. 弱序一致性 Weak Ordering

7.1 弱序模型是什么?

弱序一致性进一步放松访存顺序。

它通常不保证不同地址之间的普通读写顺序。

例如:

1
2
data = 666;
flag = READY;

程序员想表达:

1
先写 data,再设置 flag。

但弱序模型下,其他核心可能先看到:

1
flag = READY

然后去读 data,却读到旧值。


7.2 为什么硬件要采用弱序模型?

弱序模型可以:

  • 降低硬件复杂度。
  • 降低功耗。
  • 提高流水线和缓存系统自由度。
  • 给处理器更多重排空间。

但代价是:

软件必须显式说明哪些地方需要保证顺序。

也就是需要 barrier / fence 或正确实现的同步原语。


8. barrier / fence:访存顺序边界

8.1 barrier 是什么?

barrier,也叫 fence,是内存屏障。

它的作用是:

屏障前面的访存操作不能越过屏障跑到后面;屏障后面的访存操作不能越过屏障跑到前面。

可以把它理解成硬件访存顺序中的一道闸门。


8.2 data / flag 例子

错误写法:

1
2
3
4
5
6
7
8
// sender
data = 666;
flag = READY;

// receiver
while (flag != READY)
;
handle(data);

在弱序模型下,receiver 可能看到 flag = READY,但还没看到新的 data

正确思路:

1
2
3
4
5
6
7
8
9
10
// sender
data = 666;
barrier();
flag = READY;

// receiver
while (flag != READY)
;
barrier();
handle(data);

发送者侧 barrier 保证:

1
2
3
data = 666
先于
flag = READY

接收者侧 barrier 保证:

1
2
看到 flag = READY 后
再读取 data

8.3 barrier 为什么不能乱加?

barrier 会限制 CPU 的优化空间,可能导致:

  • 流水线停顿
  • 写缓冲区刷新
  • 乱序执行受限
  • 缓存系统协调成本增加

所以 barrier 不是越多越好。

正确做法通常是:

使用同步原语,让同步原语内部用合适的 barrier 实现顺序保证。


❗ 本节核心结论:
barrier 的本质是访存顺序边界。它可以保证关键读写顺序,但代价较高,因此应优先通过锁、原子操作等同步原语表达同步关系。

9. 同步原语的访存顺序语义

9.1 锁不只是互斥

很多人初学锁时只理解为:

同一时刻只有一个线程能进入临界区。

但在多核内存模型中,锁还有另一个关键作用:

提供访存顺序保证。

例如:

1
2
3
4
5
lock(&mutex);

/* critical section */

unlock(&mutex);

通常意味着:

  • 临界区内的读写不能跑到 lock 之前。
  • 临界区内的读写不能跑到 unlock 之后。
  • 其他线程在获得同一把锁后,应该能看到前一个线程释放锁前的修改。

9.2 acquire / release 语义

锁常对应两类语义:

语义 含义
acquire 获取锁之后,后续读写不能跑到获取锁之前
release 释放锁之前,临界区读写不能跑到释放锁之后

也就是说:

1
2
3
lock(&mutex);    // acquire
/* 共享数据访问 */
unlock(&mutex); // release

同步原语内部会用:

  • 原子指令
  • 内存屏障
  • 架构相关的顺序约束

来实现这些语义。


9.3 为什么要封装进同步原语?

因为普通程序员不应该每次都手写:

1
barrier();

更合理的是:

  • 系统软件开发者正确实现 lock/unlock
  • 应用程序员按规范使用同步原语。
  • 内存模型复杂性被封装起来。

❗ 本节核心结论:
同步原语不仅解决“谁能进入临界区”,还解决“临界区内的内存操作如何对其他核心可见”的问题。

10. MCS 锁为什么还不够?

10.1 MCS 锁优化了什么?

MCS 锁的核心思想是:

每个等待线程在自己的节点上自旋,而不是所有线程都反复读写同一个锁变量。

它优化的是:

1
锁元数据的缓存行竞争

也就是减少所有核心对同一个锁变量缓存行的争抢。


10.2 它没有优化什么?

MCS 锁不能消除临界区内部共享数据的缓存行迁移。

例如:

1
2
3
4
5
6
7
lock(&mcs_lock);

shared_counter++;
shared_array[i] = ...;
shared_state = ...;

unlock(&mcs_lock);

即使 MCS 锁本身很可扩展,临界区里的共享数据仍然可能:

  • 被不同核心轮流修改。
  • 所在缓存行在核心之间迁移。
  • 触发大量缓存一致性通信。

10.3 类比理解

MCS 锁像是优化了教室门口排队:

1
2
以前大家挤门口;
现在大家排队,一个一个进。

但如果进教室之后,所有人都要搬同一张桌子,那么瓶颈就变成:

1
教室里的共享桌子

对应到系统里:

1
2
锁变量不是瓶颈了;
临界区共享数据可能变成瓶颈。

❗ 本节核心结论:
MCS 锁提升的是锁本身的可扩展性,但不能自动解决临界区共享数据的缓存一致性开销。

11. NUMA:非一致内存访问

11.1 NUMA 是什么?

NUMA 全称是:

1
Non-Uniform Memory Access

即非一致内存访问。

它的核心含义是:

不同 CPU 访问不同位置的内存,代价不一样。

在 NUMA 系统中:

  • 每个处理器 / NUMA 结点附近有本地内存。
  • 访问本地内存更快。
  • 访问其他结点的远程内存更慢。

11.2 为什么需要 NUMA?

如果所有 CPU 都通过同一个内存控制器访问内存,那么核心数量增加后,内存控制器会成为瓶颈。

NUMA 的思路是:

1
把内存控制器和内存分散到不同处理器附近

这样可以:

  • 提高总内存带宽。
  • 降低本地访问延迟。
  • 避免单个内存控制器成为全局瓶颈。

11.3 NUMA 的代价

NUMA 的代价是:

内存访问不再均匀。

例如:

1
2
线程运行在 NUMA node 0
但频繁访问 NUMA node 2 的内存

这会导致远程访存,代价更高。

如果多个 NUMA 结点频繁竞争同一份共享数据,还会产生跨结点缓存一致性通信,开销非常大。


12. NUMA 感知设计与 cohort 锁

12.1 NUMA 感知设计是什么?

NUMA 感知设计的目标是:

让线程尽量访问本地数据,减少远程访存和跨结点缓存行迁移。

它关注两个问题:

问题 目标
线程在哪里运行 尽量固定在合适 NUMA 结点
数据在哪里分配 尽量分配到使用它的 NUMA 结点附近

12.2 cohort 锁的基本思想

cohort 锁是一种 NUMA 感知锁。

它的大致思想是:

先在本 NUMA 结点内部排队,再竞争全局锁;释放时优先把锁传给本地等待者。

流程:


12.3 为什么 cohort 锁有效?

普通全局锁可能这样传递:

1
2
3
4
node 0 的线程
→ node 2 的线程
→ node 1 的线程
→ node 3 的线程

这样每次交接都可能造成:

  • 锁缓存行跨 NUMA 结点迁移。
  • 临界区共享数据跨结点迁移。
  • 远程缓存一致性通信。

cohort 锁尝试让锁在一段时间内留在本地:

1
2
node 0 内部连续传几次
→ 再交给其他 node

这样可以提升局部性,减少跨结点开销。


12.4 cohort 锁的取舍

cohort 锁不是绝对更优,它是在做取舍:

目标 影响
提高 NUMA 局部性 减少远程访问
减少跨结点锁传递 降低缓存行迁移
优先本地等待者 可能牺牲部分全局公平性
设计更复杂 实现和调优成本更高

❗ 本节核心结论:
NUMA 优化的核心不是“所有线程公平轮流”,而是尽量让访问保持局部性。cohort 锁通过本地优先传递锁,减少跨 NUMA 结点通信。

13. 重要对比

13.1 四种内存一致性模型

对比点 严格一致性 顺序一致性 TSO 弱序一致性
保证强度 最强 较强 中等 较弱
是否按真实时间全局可见 不要求 不要求 不要求
是否保持每个核心程序顺序 基本保持 保持 大多数保持 不一定保持
典型放松 几乎不放松 不按真实时间 写 → 读 可放松 不同地址读写都可能放松
硬件实现成本 极高 中等 较低
程序员负担 较低 中等
代表场景 理论模型 教学 / 理论推理 x86/64 常见 ARM / PowerPC 常见

13.2 缓存一致性 vs 内存一致性

对比点 缓存一致性 Cache Coherence 内存一致性 Memory Consistency
关注对象 同一个内存地址 多个地址上的操作顺序
核心问题 一个地址的多个缓存副本怎么保持一致 不同 CPU 观察到的读写顺序是否合法
典型例子 flag 的新值什么时候被其他核心看到 data=666flag=READY 谁先对外可见
解决重点 缓存行状态、失效、更新、目录项 顺序模型、barrier、同步原语语义
易错理解 以为一个地址一致就够了 即使单地址一致,不同地址顺序仍可能出问题

一句话区分:

缓存一致性解决“同一个地址的值对不对”;内存一致性解决“多个地址的读写顺序对不对”。


13.3 MCS 锁 vs cohort 锁

对比点 MCS 锁 cohort 锁
主要目标 减少锁变量竞争 减少跨 NUMA 结点传递
关注层次 多核共享缓存 NUMA 多结点
等待方式 每个线程在自己的节点上等待 先本地队列,再全局锁
优点 锁元数据可扩展性好 保持 NUMA 局部性
局限 不知道临界区访问了哪些共享数据 设计复杂,可能牺牲部分公平性

14. 最容易混淆的点

14.1 代码顺序不是硬件可见顺序

你写:

1
2
data = 666;
flag = READY;

不代表其他 CPU 一定先看到 data,再看到 flag

在弱内存模型下,如果没有 barrier 或同步原语,其他核心可能先看到 flag


14.2 乱序执行不是 CPU 乱来

乱序执行不是 CPU 随便改程序。

它通常要保证:

  • 单线程结果看起来正确。
  • 数据依赖不能被随意破坏。
  • 异常语义不能被随意破坏。

但多线程共享内存程序中,其他 CPU 看到的顺序可能和本线程代码顺序不同。


14.3 TSO 不是顺序一致性

TSO 已经比较强,但它仍然允许:

1
写 → 读

顺序被放松。

所以不能说:

1
x86 是 TSO,所以并发代码可以随便写。

TSO 只是让一部分问题不容易暴露,不代表没有内存顺序问题。


14.4 barrier 不是越多越好

barrier 可以保证顺序,但会牺牲性能。

正确思路是:

1
2
用同步原语表达同步关系;
让同步原语内部使用合适的 barrier。

14.5 MCS 锁好,不代表临界区整体性能好

MCS 锁优化的是:

1
锁变量缓存行竞争

如果临界区访问大量共享缓存行,瓶颈仍然会存在。


14.6 NUMA 不是“多个 CPU”这么简单

NUMA 的关键不是 CPU 多,而是:

1
不同 CPU 访问不同内存位置的代价不同。

同一条 load 指令,访问本地内存和远程内存,代价可能差很多。


15. 本讲一句话总结

多核系统中,不能只用代码书写顺序推理共享内存程序,因为硬件可能乱序执行,不同 CPU 对内存操作的可见顺序也可能不同。内存一致性模型规定了哪些顺序必须被保证;弱内存模型下需要 barrier 或正确实现的同步原语来建立顺序。同时,在多核和 NUMA 环境中,即使 MCS 锁减少了锁变量竞争,临界区共享数据和远程访存仍然可能成为性能瓶颈。


16. 自测题

  1. 为什么 LockOne 这类算法会暴露“代码顺序不等于硬件可见顺序”的问题?
  2. Peterson 算法为什么也需要在现实硬件上考虑内存一致性模型?
  3. 什么是内存一致性模型?
  4. 内存一致性和缓存一致性有什么区别?
  5. 严格一致性为什么符合程序员直觉?
  6. 严格一致性为什么很难高效实现?
  7. 顺序一致性为什么不要求真实时间顺序,却仍然能限制并发结果?
  8. 在顺序一致性下,为什么 (A, B) = (0, 0) 不应该出现?
  9. TSO 为什么主要放松“写 → 读”顺序?
  10. TSO 下为什么两个线程都可能读到旧值?
  11. 弱序一致性为什么能降低硬件复杂度?
  12. 弱序一致性为什么增加软件编写难度?
  13. data = 666; flag = READY; 在弱序模型下为什么危险?
  14. barrier / fence 的本质作用是什么?
  15. 为什么说 barrier 是一条“访存顺序边界”?
  16. 为什么普通应用程序员不应该到处手写 barrier?
  17. 锁除了互斥之外,还提供什么访存顺序语义?
  18. acquire / release 分别表示什么?
  19. MCS 锁相比普通自旋锁主要优化了什么?
  20. MCS 锁没有优化什么?
  21. 为什么临界区访问更多共享缓存行,会影响 MCS 锁可扩展性?
  22. NUMA 中本地内存访问和远程内存访问有什么区别?
  23. NUMA 为什么能提升总内存带宽?
  24. NUMA 为什么又会带来新的性能问题?
  25. cohort 锁为什么能改善 NUMA 场景下的锁性能?
  26. cohort 锁可能牺牲什么?

最终总结

本讲可以压缩成一条主线:

多核程序的难点不只是“多个线程会不会同时进入临界区”,还包括“不同核心按什么顺序看到共享内存操作”,以及“核心和内存位置不均匀时,锁和数据如何保持局部性”。

重点记住:

  • ❗ 内存一致性模型:规定多个核心观察内存操作顺序的规则。
  • ❗ 顺序一致性:要求存在一个保持各线程程序顺序的全局顺序。
  • ❗ TSO:大部分顺序保留,但写→读可能被放松。
  • ❗ 弱序模型:更多顺序交给软件用 barrier 或同步原语保证。
  • ❗ barrier / fence:限制访存重排,建立顺序边界。
  • ❗ 同步原语:不仅提供互斥,也提供内存顺序语义。
  • ❗ MCS 锁局限:优化锁变量竞争,但不消除临界区共享数据迁移。
  • ❗ NUMA:本地访问快,远程访问慢。
  • ❗ cohort 锁:让锁尽量在本 NUMA 结点内传递,减少跨结点开销。