第18讲 多核与多处理器II

第18讲 多核与多处理器II
agsd以下根据附件《第18讲 多核与多处理器II.md》整理生成。
第18讲:多核与多处理器 II 学习指南
本讲承接上一讲“多核不是免费加速”的主线,进一步讨论两个问题:
- 正确性问题:程序员写下来的代码顺序,为什么不一定等于其他 CPU 核心观察到的顺序?
- 性能问题:即使用了 MCS 锁这类可扩展锁,为什么在多核、NUMA 环境下仍然可能性能很差?
❗ 本节核心结论:
多核程序不能只按“代码书写顺序”理解共享内存访问,而必须考虑内存一致性模型。同步原语不仅提供互斥,也提供访存顺序保证;而在高核数和 NUMA 环境下,锁本身可扩展不代表临界区数据访问也可扩展。
1. 本章知识地图
flowchart TD
A[多核共享内存程序] --> B[代码顺序不等于硬件可见顺序]
B --> C[乱序执行]
C --> D[内存一致性模型]
D --> E[严格一致性]
D --> F[顺序一致性]
D --> G[TSO]
D --> H[弱序一致性]
H --> I[barrier / fence]
I --> J[同步原语封装顺序语义]
J --> K[锁保证正确性]
K --> L[锁可扩展性问题]
L --> M[MCS 锁]
M --> N[锁元数据竞争减少]
M --> O[临界区共享数据仍会迁移]
O --> P[NUMA]
P --> Q[本地内存 / 远程内存]
Q --> R[NUMA 感知设计]
R --> S[cohort 锁]
这张图的核心是:
第17讲主要讲“缓存一致性和锁竞争”,第18讲进一步讲“内存操作顺序”和“NUMA 下的局部性”。
2. 为什么 LockOne / Peterson 要重新审视?
2.1 LockOne 的基本想法
LockOne 是 Peterson 算法的前身,思想很直观:
1 | flag[0] = TRUE; |
意思是:
- 我先声明自己想进入临界区。
- 然后检查对方是否也想进入。
- 如果对方不想进入,我就进入。
2.2 它隐含了什么假设?
这段代码隐含了一个很强的假设:
我写在前面的语句,会先执行,并且会先被其他核心看到。
也就是程序员默认:
1 | flag[0] = TRUE |
但现代处理器为了性能,可能会让某些访存操作乱序执行。
2.3 可能怎么出问题?
程序员写的是:
1 | flag[0] = TRUE; |
但硬件可能让“读取 flag[1]”先于“写入 flag[0]”对外发生。
于是两个线程都可能先读到对方的 flag 还是 FALSE:
1 | CPU0 读到 flag[1] == FALSE |
结果两个线程都认为:
1 | 对方不想进临界区,所以我可以进。 |
于是同时进入临界区,互斥失败。
❗ 本节核心结论:
LockOne 这类算法的问题不是简单“代码写错了”,而是它依赖了较强的访存顺序假设。现代硬件为了性能可能不会默认提供这种顺序。
3. 内存一致性模型
3.1 它是什么?
内存一致性模型 Memory Consistency Model 是硬件和软件之间的约定:
多个 CPU 核心同时读写共享内存时,哪些读写顺序必须被保证,哪些顺序可以被硬件重排。
它关注的不是单个地址最终值是否一致,而是:
多个地址上的读写操作,在不同 CPU 看来顺序是否合法。
4. 严格一致性 Strict Consistency
4.1 定义
严格一致性要求:
任意读操作都必须读到全局时间上最近一次写入的值。
它像是系统中有一个绝对全局时钟:
1 | 谁先发生,所有 CPU 都必须按这个顺序看到。 |
4.2 为什么它符合直觉?
因为它最接近程序员的朴素理解:
1 | data = 666; |
程序员自然认为:
1 | 别人看到 flag = READY 时,就应该已经能看到 data = 666。 |
严格一致性会保证这种直觉。
4.3 为什么现实中很难高效实现?
因为现代处理器有:
- 私有缓存
- 写缓冲区
- 乱序执行
- 多级缓存
- 跨核心互联
- NUMA 远程访问
如果每次读写都必须全局同步,CPU 流水线会被严重阻塞,性能代价极高。
❗ 本节核心结论:
严格一致性最符合直觉,但实现成本太高,所以现实硬件通常不会提供这么强的默认保证。
5. 顺序一致性 Sequential Consistency
5.1 定义
顺序一致性比严格一致性弱一些。
它不要求操作按照真实时间顺序对所有核心可见,但要求:
- 所有操作的结果必须等价于某个全局顺序。
- 在这个全局顺序中,每个 CPU 自己的程序顺序不能被打破。
可以理解为:
不要求全局真实时间一致,但要求能编出一个合法的全局执行顺序。
5.2 经典例子
1 | // CPU0 |
在顺序一致性下,允许出现:
| 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 | CPU0 的读 < CPU1 的写 |
同时又必须保持程序顺序:
1 | CPU0 的写 < CPU0 的读 |
这会形成无法成立的循环顺序。
❗ 本节核心结论:
顺序一致性不要求真实时间全局一致,但要求每个线程自己的程序顺序被保留,并且整体结果能对应某个合法全局顺序。
6. TSO:Total Store Ordering
6.1 TSO 是什么?
TSO 是一种比顺序一致性更弱、但比弱序模型更强的内存一致性模型。
它大致保证:
| 顺序类型 | TSO 是否保证 |
|---|---|
| 读 → 读 | 保证 |
| 读 → 写 | 保证 |
| 写 → 写 | 保证 |
| 写 → 读 | 不一定保证 |
TSO 最关键的放松是:
写之后的读,可能先于这个写对其他核心可见之前执行。
6.2 为什么 TSO 下可能出现 (A, B) = (0, 0)?
还是这个例子:
1 | // CPU0 |
在 TSO 下可能发生:
- CPU0 把
flag[0] = 1放进本地写缓冲区。 - CPU1 把
flag[1] = 1放进本地写缓冲区。 - 这两个写还没有对对方可见。
- CPU0 先执行读取
flag[1],读到旧值 0。 - CPU1 先执行读取
flag[0],读到旧值 0。
于是得到:
1 | A = 0, B = 0 |
6.3 TSO 为什么要这样设计?
因为它允许写操作先进入写缓冲区,不必每次都等全系统可见后再继续执行后面的读。
这样可以:
- 减少流水线阻塞。
- 提高处理器吞吐。
- 降低每次写共享变量的等待成本。
但代价是:
程序员不能再简单认为“代码中写在前面的 store,一定先被其他 CPU 看到”。
❗ 本节核心结论:
TSO 保留了大部分访存顺序,但放松了“写 → 读”顺序。它通过写缓冲提高性能,但也让并发程序可能读到旧值。
7. 弱序一致性 Weak Ordering
7.1 弱序模型是什么?
弱序一致性进一步放松访存顺序。
它通常不保证不同地址之间的普通读写顺序。
例如:
1 | data = 666; |
程序员想表达:
1 | 先写 data,再设置 flag。 |
但弱序模型下,其他核心可能先看到:
1 | flag = READY |
然后去读 data,却读到旧值。
7.2 为什么硬件要采用弱序模型?
弱序模型可以:
- 降低硬件复杂度。
- 降低功耗。
- 提高流水线和缓存系统自由度。
- 给处理器更多重排空间。
但代价是:
软件必须显式说明哪些地方需要保证顺序。
也就是需要 barrier / fence 或正确实现的同步原语。
8. barrier / fence:访存顺序边界
8.1 barrier 是什么?
barrier,也叫 fence,是内存屏障。
它的作用是:
屏障前面的访存操作不能越过屏障跑到后面;屏障后面的访存操作不能越过屏障跑到前面。
可以把它理解成硬件访存顺序中的一道闸门。
8.2 data / flag 例子
错误写法:
1 | // sender |
在弱序模型下,receiver 可能看到 flag = READY,但还没看到新的 data。
正确思路:
1 | // sender |
发送者侧 barrier 保证:
1 | data = 666 |
接收者侧 barrier 保证:
1 | 看到 flag = READY 后 |
8.3 barrier 为什么不能乱加?
barrier 会限制 CPU 的优化空间,可能导致:
- 流水线停顿
- 写缓冲区刷新
- 乱序执行受限
- 缓存系统协调成本增加
所以 barrier 不是越多越好。
正确做法通常是:
使用同步原语,让同步原语内部用合适的 barrier 实现顺序保证。
❗ 本节核心结论:
barrier 的本质是访存顺序边界。它可以保证关键读写顺序,但代价较高,因此应优先通过锁、原子操作等同步原语表达同步关系。
9. 同步原语的访存顺序语义
9.1 锁不只是互斥
很多人初学锁时只理解为:
同一时刻只有一个线程能进入临界区。
但在多核内存模型中,锁还有另一个关键作用:
提供访存顺序保证。
例如:
1 | lock(&mutex); |
通常意味着:
- 临界区内的读写不能跑到
lock之前。 - 临界区内的读写不能跑到
unlock之后。 - 其他线程在获得同一把锁后,应该能看到前一个线程释放锁前的修改。
9.2 acquire / release 语义
锁常对应两类语义:
| 语义 | 含义 |
|---|---|
| acquire | 获取锁之后,后续读写不能跑到获取锁之前 |
| release | 释放锁之前,临界区读写不能跑到释放锁之后 |
也就是说:
1 | lock(&mutex); // acquire |
同步原语内部会用:
- 原子指令
- 内存屏障
- 架构相关的顺序约束
来实现这些语义。
9.3 为什么要封装进同步原语?
因为普通程序员不应该每次都手写:
1 | barrier(); |
更合理的是:
- 系统软件开发者正确实现
lock/unlock。 - 应用程序员按规范使用同步原语。
- 内存模型复杂性被封装起来。
❗ 本节核心结论:
同步原语不仅解决“谁能进入临界区”,还解决“临界区内的内存操作如何对其他核心可见”的问题。
10. MCS 锁为什么还不够?
10.1 MCS 锁优化了什么?
MCS 锁的核心思想是:
每个等待线程在自己的节点上自旋,而不是所有线程都反复读写同一个锁变量。
它优化的是:
1 | 锁元数据的缓存行竞争 |
也就是减少所有核心对同一个锁变量缓存行的争抢。
10.2 它没有优化什么?
MCS 锁不能消除临界区内部共享数据的缓存行迁移。
例如:
1 | lock(&mcs_lock); |
即使 MCS 锁本身很可扩展,临界区里的共享数据仍然可能:
- 被不同核心轮流修改。
- 所在缓存行在核心之间迁移。
- 触发大量缓存一致性通信。
10.3 类比理解
MCS 锁像是优化了教室门口排队:
1 | 以前大家挤门口; |
但如果进教室之后,所有人都要搬同一张桌子,那么瓶颈就变成:
1 | 教室里的共享桌子 |
对应到系统里:
1 | 锁变量不是瓶颈了; |
❗ 本节核心结论:
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 | 线程运行在 NUMA node 0 |
这会导致远程访存,代价更高。
如果多个 NUMA 结点频繁竞争同一份共享数据,还会产生跨结点缓存一致性通信,开销非常大。
12. NUMA 感知设计与 cohort 锁
12.1 NUMA 感知设计是什么?
NUMA 感知设计的目标是:
让线程尽量访问本地数据,减少远程访存和跨结点缓存行迁移。
它关注两个问题:
| 问题 | 目标 |
|---|---|
| 线程在哪里运行 | 尽量固定在合适 NUMA 结点 |
| 数据在哪里分配 | 尽量分配到使用它的 NUMA 结点附近 |
12.2 cohort 锁的基本思想
cohort 锁是一种 NUMA 感知锁。
它的大致思想是:
先在本 NUMA 结点内部排队,再竞争全局锁;释放时优先把锁传给本地等待者。
流程:
flowchart TD
A[线程申请锁] --> B[先获取本 NUMA 结点本地锁]
B --> C[再竞争全局锁]
C --> D[进入临界区]
D --> E{本地队列还有等待者吗?}
E -- 有 --> F[优先传给本地等待者]
E -- 没有 --> G[释放全局锁给其他 NUMA 结点]
12.3 为什么 cohort 锁有效?
普通全局锁可能这样传递:
1 | node 0 的线程 |
这样每次交接都可能造成:
- 锁缓存行跨 NUMA 结点迁移。
- 临界区共享数据跨结点迁移。
- 远程缓存一致性通信。
cohort 锁尝试让锁在一段时间内留在本地:
1 | node 0 内部连续传几次 |
这样可以提升局部性,减少跨结点开销。
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=666 和 flag=READY 谁先对外可见 |
| 解决重点 | 缓存行状态、失效、更新、目录项 | 顺序模型、barrier、同步原语语义 |
| 易错理解 | 以为一个地址一致就够了 | 即使单地址一致,不同地址顺序仍可能出问题 |
一句话区分:
缓存一致性解决“同一个地址的值对不对”;内存一致性解决“多个地址的读写顺序对不对”。
13.3 MCS 锁 vs cohort 锁
| 对比点 | MCS 锁 | cohort 锁 |
|---|---|---|
| 主要目标 | 减少锁变量竞争 | 减少跨 NUMA 结点传递 |
| 关注层次 | 多核共享缓存 | NUMA 多结点 |
| 等待方式 | 每个线程在自己的节点上等待 | 先本地队列,再全局锁 |
| 优点 | 锁元数据可扩展性好 | 保持 NUMA 局部性 |
| 局限 | 不知道临界区访问了哪些共享数据 | 设计复杂,可能牺牲部分公平性 |
14. 最容易混淆的点
14.1 代码顺序不是硬件可见顺序
你写:
1 | data = 666; |
不代表其他 CPU 一定先看到 data,再看到 flag。
在弱内存模型下,如果没有 barrier 或同步原语,其他核心可能先看到 flag。
14.2 乱序执行不是 CPU 乱来
乱序执行不是 CPU 随便改程序。
它通常要保证:
- 单线程结果看起来正确。
- 数据依赖不能被随意破坏。
- 异常语义不能被随意破坏。
但多线程共享内存程序中,其他 CPU 看到的顺序可能和本线程代码顺序不同。
14.3 TSO 不是顺序一致性
TSO 已经比较强,但它仍然允许:
1 | 写 → 读 |
顺序被放松。
所以不能说:
1 | x86 是 TSO,所以并发代码可以随便写。 |
TSO 只是让一部分问题不容易暴露,不代表没有内存顺序问题。
14.4 barrier 不是越多越好
barrier 可以保证顺序,但会牺牲性能。
正确思路是:
1 | 用同步原语表达同步关系; |
14.5 MCS 锁好,不代表临界区整体性能好
MCS 锁优化的是:
1 | 锁变量缓存行竞争 |
如果临界区访问大量共享缓存行,瓶颈仍然会存在。
14.6 NUMA 不是“多个 CPU”这么简单
NUMA 的关键不是 CPU 多,而是:
1 | 不同 CPU 访问不同内存位置的代价不同。 |
同一条 load 指令,访问本地内存和远程内存,代价可能差很多。
15. 本讲一句话总结
多核系统中,不能只用代码书写顺序推理共享内存程序,因为硬件可能乱序执行,不同 CPU 对内存操作的可见顺序也可能不同。内存一致性模型规定了哪些顺序必须被保证;弱内存模型下需要 barrier 或正确实现的同步原语来建立顺序。同时,在多核和 NUMA 环境中,即使 MCS 锁减少了锁变量竞争,临界区共享数据和远程访存仍然可能成为性能瓶颈。
16. 自测题
- 为什么 LockOne 这类算法会暴露“代码顺序不等于硬件可见顺序”的问题?
- Peterson 算法为什么也需要在现实硬件上考虑内存一致性模型?
- 什么是内存一致性模型?
- 内存一致性和缓存一致性有什么区别?
- 严格一致性为什么符合程序员直觉?
- 严格一致性为什么很难高效实现?
- 顺序一致性为什么不要求真实时间顺序,却仍然能限制并发结果?
- 在顺序一致性下,为什么
(A, B) = (0, 0)不应该出现? - TSO 为什么主要放松“写 → 读”顺序?
- TSO 下为什么两个线程都可能读到旧值?
- 弱序一致性为什么能降低硬件复杂度?
- 弱序一致性为什么增加软件编写难度?
data = 666; flag = READY;在弱序模型下为什么危险?- barrier / fence 的本质作用是什么?
- 为什么说 barrier 是一条“访存顺序边界”?
- 为什么普通应用程序员不应该到处手写 barrier?
- 锁除了互斥之外,还提供什么访存顺序语义?
- acquire / release 分别表示什么?
- MCS 锁相比普通自旋锁主要优化了什么?
- MCS 锁没有优化什么?
- 为什么临界区访问更多共享缓存行,会影响 MCS 锁可扩展性?
- NUMA 中本地内存访问和远程内存访问有什么区别?
- NUMA 为什么能提升总内存带宽?
- NUMA 为什么又会带来新的性能问题?
- cohort 锁为什么能改善 NUMA 场景下的锁性能?
- cohort 锁可能牺牲什么?
最终总结
本讲可以压缩成一条主线:
多核程序的难点不只是“多个线程会不会同时进入临界区”,还包括“不同核心按什么顺序看到共享内存操作”,以及“核心和内存位置不均匀时,锁和数据如何保持局部性”。
重点记住:
- ❗ 内存一致性模型:规定多个核心观察内存操作顺序的规则。
- ❗ 顺序一致性:要求存在一个保持各线程程序顺序的全局顺序。
- ❗ TSO:大部分顺序保留,但写→读可能被放松。
- ❗ 弱序模型:更多顺序交给软件用 barrier 或同步原语保证。
- ❗ barrier / fence:限制访存重排,建立顺序边界。
- ❗ 同步原语:不仅提供互斥,也提供内存顺序语义。
- ❗ MCS 锁局限:优化锁变量竞争,但不消除临界区共享数据迁移。
- ❗ NUMA:本地访问快,远程访问慢。
- ❗ cohort 锁:让锁尽量在本 NUMA 结点内传递,减少跨结点开销。




