第17讲 多核与多处理器I

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

第17讲:多核与多处理器 I 学习指南

本讲的主线是:多核不是简单的“核心越多越快”。多核确实带来并行能力,但也引入了缓存一致性、锁竞争、可扩展性断崖、虚假共享和乱序执行等问题。


❗ 本节核心结论:
多核系统的核心矛盾是:核心数增加带来并行能力,但共享数据、私有缓存和锁竞争会引入正确性问题与性能问题。多核优化的关键不是“多加核心”,而是减少核心之间的协调成本。

1. 本章知识地图

这张图要抓住一个核心逻辑:

多核的真正难点不是“多个核心能不能同时跑”,而是“多个核心同时访问共享数据时,如何既正确又高效”。


2. 多核为什么不是免费加速?

2.1 多核解决了什么问题?

单核 CPU 性能提升受到限制:

  • 频率不能无限提高。
  • 功耗和发热会急剧上升。
  • 单核流水线和乱序执行优化也有边界。

因此现代系统通过增加核心数来提升整体吞吐量。

直觉上似乎是:

1
2
3
1 核 = 1 倍性能
4 核 = 4 倍性能
16 核 = 16 倍性能

但现实不是这样。


2.2 多核带来的两类问题

问题类型 核心问题 例子
正确性问题 多个核心看到的数据是否一致 多线程同时 balance++,最后结果错误
性能问题 核心数增加后性能是否真的提升 很多核心抢同一把自旋锁,性能反而下降

多核程序最容易误解的一点是:

正确性和性能是两件事。

锁可以保证正确性,但锁本身可能成为性能瓶颈。缓存一致性可以保证多个核心对同一地址不“各说各话”,但它也会带来通信开销。


3. 加速比与 Amdahl’s Law

3.1 加速比是什么?

加速比 Speedup 衡量的是:

使用更多资源后,同一个任务完成得快了多少倍。

公式是:

1
加速比 = 单核执行时间 / 多核执行时间

例如:

单核时间 多核时间 加速比
100 秒 25 秒 4
100 秒 40 秒 2.5
100 秒 80 秒 1.25

如果用了 4 个核心,但加速比只有 2.5,说明并行化并没有充分利用全部核心。


3.2 Amdahl’s Law 讲什么?

Amdahl 定律说明:

一个程序的串行部分会限制多核加速的理论上限。

S=\frac{1}{(1-p)+\frac{p}{s}}

其中:

符号 含义
S 加速比
p 可以并行的部分占比
1 - p 必须串行的部分占比
s 使用的核心数

3.3 推导过程

假设单核执行整个程序需要 1 单位时间。

程序分成两部分:

1
2
串行部分:1 - p
并行部分:p

单核执行时间:

1
T1 = (1 - p) + p = 1

使用 s 个核心后:

  • 串行部分不能并行,时间仍然是 1 - p
  • 并行部分可以分给 s 个核心,理想时间变为 p / s

所以多核执行时间是:

1
Ts = (1 - p) + p / s

加速比是:

1
S = T1 / Ts

因为 T1 = 1,所以:

1
S = 1 / ((1 - p) + p / s)

3.4 例子

假设一个程序:

1
2
90% 可以并行
10% 必须串行

也就是:

1
2
p = 0.9
1 - p = 0.1

如果用 10 个核心:

1
2
3
S = 1 / (0.1 + 0.9 / 10)
= 1 / 0.19
≈ 5.26

注意:用了 10 个核心,不等于快 10 倍。

如果核心数无限多:

1
2
3
4
p / s → 0
S = 1 / (1 - p)
= 1 / 0.1
= 10

也就是说,即使给无限多个核心,最多也只能快 10 倍。


❗ 本节核心结论:
加速比不是看核心数量,而是看程序中真正能并行的比例。串行部分越大,多核带来的理论收益越低。

4. 多级缓存与缓存一致性

4.1 多核缓存结构

现代 CPU 通常有多级缓存:

一般来说:

  • L1 最快、最小,通常每个核心私有。
  • L2 的组织方式取决于具体 CPU,可能私有,也可能部分共享。
  • LLC,通常是 L3,往往被多个核心共享。
  • 内存最慢,但容量最大。

关键点是:

缓存一致性主要不是 LLC 的问题,而是多个核心的私有缓存可能持有同一缓存行副本的问题。


4.2 为什么需要缓存一致性?

假设同一个地址 X 被多个核心缓存:

1
2
3
CPU0 的 L1:X = 1
CPU1 的 L1:X = ?
CPU2 的 L1:X = 2

如果没有一致性协议,不同核心可能对同一个地址读到不同值。

缓存一致性要解决的问题是:

不同核心对同一个内存地址的值必须达成共识。

注意,它解决的是正确性问题,不直接保证性能。


5. MSI 协议

缓存一致性协议通常以 缓存行 Cache Line 为单位,而不是以单个变量为单位。

MSI 有三种状态:

状态 英文 含义
M Modified 当前核心独占该缓存行,并且内容可能比内存更新
S Shared 多个核心可以共享该缓存行,只能安全读
I Invalid 当前核心里的这份缓存行副本无效

5.1 MSI 的核心规则

可以简化为四句话:

  • 读可以共享。
  • 写必须独占。
  • 别人写了,我这里的副本就要失效。
  • 我写之前,要先让别人手里的副本失效。

5.2 Invalid 是全局状态吗?

不是。

Invalid 是针对:

某个核心本地缓存中的某一条缓存行副本。

例如:

核心 X 所在缓存行状态 含义
CPU0 Modified CPU0 拥有最新副本,可以读写
CPU1 Invalid CPU1 本地副本失效,不能直接使用
CPU2 Invalid CPU2 本地副本失效,不能直接使用

这不是说 X 这个数据“全世界无效”,而是说:

CPU1 / CPU2 手里的旧副本不能再用了。


5.3 写共享变量为什么贵?

假设 CPU0 要写 X

1
2
3
4
5
6
7
8
9
CPU0 想写 X

必须获得 X 所在缓存行的独占修改权限

如果 CPU1、CPU2 也缓存了这个缓存行

就要通知它们失效

CPU0 才能安全写入

所以代码看起来只是:

1
x = 233;

但硬件底层可能发生:

  • 请求独占权限
  • 查询谁拥有副本
  • 发送失效消息
  • 等待确认
  • 更新缓存行状态
  • 最后写入

这些硬件级协调过程就是一致性通信。


❗ 本节核心结论:
缓存一致性保证多个核心对同一地址不出现混乱,但它不是免费的。共享缓存行被频繁写入时,一致性通信会成为巨大性能开销。

6. 目录式缓存一致性

6.1 目录式一致性是什么?

目录式缓存一致性维护一个目录项,用来记录某个缓存行的状态。

目录项通常记录:

信息 含义
脏位 dirty bit 该缓存行是否被修改,是否可能比内存新
向量位 bit vector 哪些 CPU 拥有该缓存行副本
拥有者 owner 谁拥有最新或独占副本

6.2 目录式为什么有用?

如果没有目录信息,一个核心写某个缓存行时,可能需要广播通知所有核心。

目录式协议可以做到:

只通知真正拥有该缓存行副本的核心。

例如有 64 个核心,但只有 CPU1 和 CPU7 缓存了 X

那么 CPU0 要写 X 时,不必通知所有 64 个核心,只需要通知 CPU1 和 CPU7 失效。


6.3 目录式一致性的基本流程

目录式不是取消通信,而是让通信更精确。


6.4 目录式的代价

目录式仍然需要成本:

  • 存储目录项。
  • 查询目录。
  • 更新目录。
  • 发送失效消息。
  • 等待确认。
  • 必要时转发最新数据。

如果很多核心反复写同一个缓存行,仍然会出现缓存行所有权在核心之间反复迁移。


❗ 本节核心结论:
目录式一致性的优点是减少无目标广播,只通知相关核心;但它仍然需要一致性通信,不能消除共享缓存行竞争。

7. 性能可扩展性与可扩展性断崖

7.1 什么是性能可扩展性?

性能可扩展性指的是:

当硬件资源增加时,系统性能是否能随之有效增长。

在多核中,“规模变大”通常指:

1
CPU 核心数增加

希望看到的是:

1
核心数增加 → 吞吐量上升

如果出现:

1
核心数增加 → 吞吐量不升反降

就说明性能可扩展性很差。


7.2 可扩展性不是“能不能加规模”

更准确地说:

可扩展性 = 扩大规模以后,收益是否还能增长,代价是否仍可控。

例如:

场景 规模变大 可扩展表现 不可扩展表现
多核处理器 核心数增加 吞吐量继续上升 锁竞争导致性能下降
P2P 网络 节点数增加 节点也贡献上传能力 中心节点或维护开销爆炸
Web 服务 请求数增加 加机器后能处理更多请求 数据库成为瓶颈
软件架构 功能增加 模块化扩展 改一个功能牵动全局

所以“可扩展”不是简单的“能扩”,而是:

  • 扩了之后撑得住。
  • 扩了之后还划算。
  • 扩了之后不会被某个瓶颈拖垮。

7.3 什么是可扩展性断崖?

可扩展性断崖指的是:

核心数增加到某个程度后,程序性能不仅不再提升,反而突然大幅下降。

例如:

核心数 吞吐量
1 100
2 180
4 300
8 280
16 80
32 20

这不是“收益递减”那么简单,而是性能直接塌下去。


8. 自旋锁为什么会导致可扩展性断崖?

普通自旋锁大致如下:

1
2
3
4
5
6
7
8
void lock(int *lock) {
while (atomic_CAS(lock, 0, 1) != 0)
; // busy-looping
}

void unlock(int *lock) {
*lock = 0;
}

表面上看,只是在抢一把锁。

但在多核底层:

1
2
3
4
5
6
7
8
9
10
11
12
13
所有核心都 CAS 同一个 lock 变量

lock 变量位于某个缓存行

CAS 是写操作,需要独占该缓存行

多个核心反复抢这个缓存行的所有权

缓存行在核心之间来回迁移

一致性通信暴增

性能断崖式下降

这就是自旋锁危险的地方。

它的问题不只是:

1
while 循环浪费 CPU

更严重的是:

1
所有核心反复争抢同一个锁变量缓存行

❗ 本节核心结论:
普通自旋锁在高竞争多核环境下会把锁变量所在缓存行变成热点,导致一致性通信暴增,引发可扩展性断崖。

9. Back-off 回退策略

9.1 Back-off 是什么?

Back-off 的思想是:

抢锁失败后,不要立刻继续抢,而是先等一会儿。

伪代码:

1
2
3
4
5
void lock(int *lock) {
while (atomic_CAS(lock, 0, 1) != 0) {
back_off(DEFAULT_TIME);
}
}

9.2 为什么有用?

普通自旋锁失败后会立即继续 CAS。

Back-off 让失败线程暂时退让:

1
2
3
4
5
减少抢锁频率

减少同一缓存行被反复争抢

降低一致性通信压力

9.3 固定等待的问题

如果所有线程等待相同时间:

1
2
3
4
5
6
7
8
9
大家同时失败

大家同时等待

大家同时醒来

大家同时继续抢

再次冲突

所以更好的方式通常是:

  • 随机回退
  • 指数回退

9.4 Back-off 的局限

Back-off 只是缓解竞争。

它没有改变一个事实:

所有线程最终仍然在竞争同一个锁变量缓存行。

所以它不是根本解决方案。


10. MCS 锁:改变等待方式

10.1 MCS 锁是什么?

MCS 锁是一种更可扩展的锁。

它的核心思想是:

不要让所有等待者都盯着同一个锁变量,而是让每个等待者在自己的节点上等待。


10.2 普通自旋锁 vs MCS 锁

普通自旋锁像这样:

1
所有人都抢同一个门把手

MCS 锁像这样:

1
2
所有人排队
前一个人出来后通知后一个人

10.3 MCS 锁的流程

关键好处:

  • 等待者主要访问自己的节点。
  • 不再所有核心反复写同一个锁变量。
  • 缓存一致性冲突显著减少。
  • 高竞争下可扩展性更好。

10.4 MCS 锁不是取消互斥

MCS 锁仍然是锁。

它没有让多个线程同时进入临界区,只是优化了等待方式:

锁类型 等待方式
普通自旋锁 所有人反复 CAS 同一个变量
Back-off 锁 失败后等一会儿再抢
MCS 锁 排队,在自己的节点上等待

❗ 本节核心结论:
MCS 锁的核心改进不是取消锁,而是改变等待结构,避免所有线程同时竞争同一个缓存行。

11. QSpinlock

QSpinlock 可以理解为结合两类路径:

路径 适用情况 思想
快速路径 低竞争 类似普通自旋锁,简单快速
慢速路径 高竞争 类似 MCS 锁,排队等待

它体现了一个工程设计原则:

低竞争时不要为了复杂性付出过多代价;高竞争时必须避免单一缓存行竞争。


12. 虚假共享 False Sharing

12.1 什么是虚假共享?

虚假共享是指:

多个线程访问的是不同变量,但这些变量恰好位于同一个缓存行,于是硬件把它们当成同一个一致性单位处理。

例如:

1
2
3
4
struct {
int a; // thread_0 写
int b; // thread_1 写
} data;

逻辑上:

1
2
thread_0 只写 a
thread_1 只写 b

它们没有共享同一个变量。

但如果 ab 位于同一个缓存行,那么硬件会以缓存行为单位维护一致性。


12.2 为什么虚假共享很危险?

因为缓存一致性的单位是缓存行,而不是变量。

如果 ab 在同一个缓存行:

1
2
3
4
5
6
7
8
9
10
11
thread_0 写 a

获得该缓存行独占权限

thread_1 的缓存行副本失效

thread_1 写 b

又获得该缓存行独占权限

thread_0 的缓存行副本失效

于是两个线程虽然操作不同变量,却不断互相干扰。


12.3 如何避免虚假共享?

可以使用 padding 或 cache-line alignment:

1
2
3
4
5
struct {
int a;
char padding[64];
int b;
} data;

目标是:

1
让不同线程高频写入的变量落到不同缓存行

这不是为了改变程序逻辑,而是为了改变硬件缓存布局。


❗ 本节核心结论:
不同变量不代表不会互相影响。只要它们落在同一个缓存行,多核写入时就可能触发无谓的一致性通信,这就是虚假共享。

13. 死锁预防回顾

本讲后半部分也回顾了死锁预防。

死锁的四个必要条件:

条件 含义
互斥访问 一个资源同一时刻只能被一个线程使用
持有并等待 已经持有一些资源,还在等待其他资源
资源非抢占 已经被占有的资源不能被强行抢走
循环等待 线程之间形成首尾相接的等待链

死锁预防就是破坏其中至少一个条件。

最常见的方法是:

给资源编号,所有线程按固定顺序申请资源。

例如:

1
A < B < C

所有线程都只能按:

1
A → B → C

申请锁,不能先拿 B 再拿 A。

这样等待关系只能朝一个方向走,无法形成环。


14. 乱序执行

14.1 乱序执行是什么?

乱序执行指的是:

CPU 实际完成指令的顺序,可能和程序中看到的指令顺序不同。

例如程序写着:

1
2
x = 1;
y = 1;

单线程视角下,CPU 会尽力保证结果看起来像顺序执行。

但在多线程场景中,另一个核心可能观察到复杂情况:

1
2
看起来 y 先变化
x 后变化

这会引出后续的内存一致性模型问题。


14.2 CPU 为什么要乱序执行?

因为不同指令耗时不同:

  • 有的指令要等内存。
  • 有的指令只做寄存器运算。
  • 有的指令依赖前一条结果。
  • 有的指令互不依赖。

如果 CPU 严格按程序顺序等待,流水线资源会被浪费。

所以 CPU 会:

  • 能先执行的先执行。
  • 不依赖前面结果的指令先执行。
  • 尽量让流水线保持忙碌。

乱序执行的目的不是“乱来”,而是提升指令级并行性。


14.3 乱序执行为什么影响多核?

单线程中,CPU 通常保证程序表现得像顺序执行。

但多线程中,问题变成:

一个核心的写入顺序,其他核心是否也按同样顺序看到?

这就从“缓存一致性”进一步走向“内存一致性”。

概念 关注点
缓存一致性 Cache Coherence 同一个地址的值是否一致
内存一致性 Memory Consistency 多个内存操作的可见顺序是否一致

❗ 本节核心结论:
乱序执行不是 CPU 随便改程序,而是在不破坏单线程语义的前提下提高效率。但在多线程中,不同核心观察内存操作的顺序会变复杂,因此需要内存一致性模型约束。

15. 重要对比

15.1 缓存一致性 vs 内存一致性

对比点 缓存一致性 内存一致性
英文 Cache Coherence Memory Consistency
关注对象 同一个地址的数据值 多个内存操作的可见顺序
典型问题 CPU0 和 CPU1 读同一个地址是否一致 CPU0 先写 x 再写 y,CPU1 是否也按这个顺序看到
核心单位 缓存行 内存操作顺序
关键词 Modified / Shared / Invalid 乱序执行、重排、内存屏障

15.2 自旋锁 vs Back-off 锁 vs MCS 锁

对比点 普通自旋锁 Back-off 锁 MCS 锁
等待方式 失败后立刻继续 CAS 失败后等待一段时间 排队等待
竞争位置 同一个锁变量缓存行 仍是同一个缓存行,但频率降低 各自在本地节点上等待
实现复杂度
低竞争性能 可能略有额外等待 不一定最优
高竞争性能 差,容易断崖 有缓解 更可扩展
核心思想 忙等 降低抢锁频率 避免单一缓存行高度竞争

15.3 真实共享 vs 虚假共享

对比点 真实共享 虚假共享
是否访问同一变量
是否在同一缓存行 通常是
逻辑上是否共享数据
硬件上是否触发一致性通信 也会
例子 多线程都写 counter 线程 A 写 a,线程 B 写 b,但 a/b 在同一缓存行
解决方向 减少共享、分片、换锁设计 padding、cache-line alignment

16. 最容易混淆的点

16.1 缓存一致性不是没有性能代价

缓存一致性保证正确性,但会带来:

  • 状态迁移
  • 失效通知
  • 目录查询
  • 数据转发
  • 权限确认

所以不能理解成:

有了缓存一致性,多核共享数据就和单核一样便宜。


16.2 缓存一致性不是只在 LLC 中考虑

真正麻烦的是:

多个核心的私有缓存中可能有同一个缓存行的副本。

LLC 共享不代表问题消失。只要 L1 / L2 中有私有副本,就需要一致性协议协调。


16.3 不同变量也可能互相拖慢

如果两个变量位于同一个缓存行,那么硬件会把它们作为一个一致性单位处理。

变量级别看是独立的,缓存行级别看可能是共享的。


16.4 自旋锁的问题不只是浪费 CPU

自旋锁忙等会浪费 CPU,但更严重的是:

多个核心反复争抢锁变量所在缓存行,导致一致性通信暴增。


16.5 Back-off 不是根本解决方案

Back-off 只是让线程“别抢得太频繁”。

它减少竞争频率,但没有改变所有线程仍然抢同一个锁变量的事实。


16.6 MCS 锁不是不用锁

MCS 锁仍然保证互斥。

它优化的是等待方式,不是取消临界区互斥。


16.7 乱序执行不是 CPU 乱来

乱序执行是在不破坏单线程可观察语义的前提下提高效率。

问题出现在多线程中:不同核心之间观察内存操作顺序会变复杂。


17. 本讲一句话总结

多核与多处理器的核心矛盾是:核心数增加带来并行能力,但共享数据、私有缓存和锁竞争会引入正确性与性能问题。缓存一致性协议通过 MSI 状态和目录项维护同一地址的数据共识,但高频竞争同一缓存行会导致一致性通信暴增,进而出现可扩展性断崖。因此系统软件需要用 Back-off、MCS 锁、QSpinlock、padding、cache-line alignment 等方法降低核心之间的协调成本。


18. 自测题

  1. 为什么不能简单认为“CPU 核心数增加,程序性能就一定线性提升”?
  2. 加速比 Speedup 的定义是什么?
  3. Amdahl 定律中的 p 表示什么?
  4. 为什么串行部分会限制理论最大加速比?
  5. 多核环境下,为什么每个核心拥有私有 L1 Cache 会带来数据一致性问题?
  6. 缓存一致性要解决的核心问题是什么?
  7. 缓存一致性和“程序跑得快”是同一个问题吗?
  8. MSI 协议中的 Modified、Shared、Invalid 分别表示什么?
  9. Invalid 是全局状态,还是某个核心本地缓存副本的状态?
  10. 为什么写共享变量通常比读共享变量更容易触发一致性通信?
  11. 目录式缓存一致性中的脏位和向量位分别记录什么?
  12. 为什么目录式一致性可以减少广播成本?
  13. 什么是一致性通信?
  14. 普通自旋锁为什么会导致可扩展性断崖?
  15. 为什么自旋锁的问题不只是“while 循环浪费 CPU”?
  16. Back-off 策略为什么能缓解自旋锁竞争?
  17. 为什么固定等待时间的 Back-off 可能效果不好?
  18. MCS 锁相比普通自旋锁的核心改进是什么?
  19. 为什么说 MCS 锁优化的是等待方式,而不是取消互斥?
  20. QSpinlock 为什么要区分快速路径和慢速路径?
  21. 什么是虚假共享?
  22. 为什么两个不同变量也可能互相拖慢性能?
  23. padding 和 cache-line alignment 为什么能缓解虚假共享?
  24. 死锁产生的四个必要条件是什么?
  25. 打破循环等待为什么可以预防死锁?
  26. 乱序执行为什么被设计出来?
  27. 乱序执行为什么会为后续内存一致性模型埋下问题?

最终总结

本讲可以压缩成一条核心链路:

多核增加并行能力,但私有缓存让同一地址出现多个副本;缓存一致性保证正确性,却带来一致性通信;当多个核心高频争抢同一个缓存行时,性能会出现可扩展性断崖;因此需要从锁设计、数据布局和内存顺序三个层面降低协调成本。

重点记住:

  • ❗ 多核不是免费加速:串行部分、锁竞争、缓存通信都会限制加速。
  • ❗ Amdahl 定律:串行部分决定理论加速上限。
  • ❗ 缓存一致性:解决同一地址的数据共识问题。
  • ❗ MSI 协议:读可共享,写要独占,旧副本要失效。
  • ❗ 目录式一致性:记录谁拥有缓存行,只通知相关核心。
  • ❗ 可扩展性断崖:核心越多,竞争越严重,性能反而下降。
  • ❗ MCS 锁:排队等待,避免所有线程争抢同一缓存行。
  • ❗ 虚假共享:不同变量落在同一缓存行,也会互相拖慢。
  • ❗ 乱序执行:提高单核效率,但让多线程内存可见顺序更复杂。