第17讲 多核与多处理器I

第17讲 多核与多处理器I
agsd以下根据附件《第17讲 多核与多处理器I.md》整理生成。
第17讲:多核与多处理器 I 学习指南
本讲的主线是:多核不是简单的“核心越多越快”。多核确实带来并行能力,但也引入了缓存一致性、锁竞争、可扩展性断崖、虚假共享和乱序执行等问题。
❗ 本节核心结论:
多核系统的核心矛盾是:核心数增加带来并行能力,但共享数据、私有缓存和锁竞争会引入正确性问题与性能问题。多核优化的关键不是“多加核心”,而是减少核心之间的协调成本。
1. 本章知识地图
flowchart TD
A[单核性能遇到瓶颈] --> B[增加 CPU 核心数]
B --> C[多线程并行执行]
C --> D[每个核心有私有缓存]
D --> E[同一地址可能有多个缓存副本]
E --> F[缓存一致性问题]
F --> G[MSI / 目录式一致性]
G --> H[一致性通信开销]
H --> I[锁竞争与可扩展性断崖]
I --> J[Back-off]
I --> K[MCS 锁 / QSpinlock]
I --> L[避免虚假共享]
D --> M[乱序执行]
M --> N[内存一致性模型铺垫]
这张图要抓住一个核心逻辑:
多核的真正难点不是“多个核心能不能同时跑”,而是“多个核心同时访问共享数据时,如何既正确又高效”。
2. 多核为什么不是免费加速?
2.1 多核解决了什么问题?
单核 CPU 性能提升受到限制:
- 频率不能无限提高。
- 功耗和发热会急剧上升。
- 单核流水线和乱序执行优化也有边界。
因此现代系统通过增加核心数来提升整体吞吐量。
直觉上似乎是:
1 | 1 核 = 1 倍性能 |
但现实不是这样。
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 | 串行部分:1 - 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 | 90% 可以并行 |
也就是:
1 | p = 0.9 |
如果用 10 个核心:
1 | S = 1 / (0.1 + 0.9 / 10) |
注意:用了 10 个核心,不等于快 10 倍。
如果核心数无限多:
1 | p / s → 0 |
也就是说,即使给无限多个核心,最多也只能快 10 倍。
❗ 本节核心结论:
加速比不是看核心数量,而是看程序中真正能并行的比例。串行部分越大,多核带来的理论收益越低。
4. 多级缓存与缓存一致性
4.1 多核缓存结构
现代 CPU 通常有多级缓存:
flowchart TD
C0[CPU Core 0] --> L10[L1 Cache]
C1[CPU Core 1] --> L11[L1 Cache]
C2[CPU Core 2] --> L12[L1 Cache]
L10 --> L20[L2 Cache]
L11 --> L21[L2 Cache]
L12 --> L22[L2 Cache]
L20 --> L3[LLC / L3 Cache]
L21 --> L3
L22 --> L3
L3 --> MEM[Memory]
一般来说:
- L1 最快、最小,通常每个核心私有。
- L2 的组织方式取决于具体 CPU,可能私有,也可能部分共享。
- LLC,通常是 L3,往往被多个核心共享。
- 内存最慢,但容量最大。
关键点是:
缓存一致性主要不是 LLC 的问题,而是多个核心的私有缓存可能持有同一缓存行副本的问题。
4.2 为什么需要缓存一致性?
假设同一个地址 X 被多个核心缓存:
1 | CPU0 的 L1:X = 1 |
如果没有一致性协议,不同核心可能对同一个地址读到不同值。
缓存一致性要解决的问题是:
不同核心对同一个内存地址的值必须达成共识。
注意,它解决的是正确性问题,不直接保证性能。
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 | CPU0 想写 X |
所以代码看起来只是:
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 目录式一致性的基本流程
sequenceDiagram
participant CPU0 as CPU0
participant Dir as 目录
participant CPU1 as CPU1
participant CPU2 as CPU2
CPU0->>Dir: 请求写 X
Dir->>Dir: 查询 X 的拥有者/共享者
Dir->>CPU1: 使 X 缓存行失效
Dir->>CPU2: 使 X 缓存行失效
CPU1-->>Dir: 已失效
CPU2-->>Dir: 已失效
Dir-->>CPU0: 获得独占权限
CPU0->>CPU0: 写入 X = 233
目录式不是取消通信,而是让通信更精确。
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 | void lock(int *lock) { |
表面上看,只是在抢一把锁。
但在多核底层:
1 | 所有核心都 CAS 同一个 lock 变量 |
这就是自旋锁危险的地方。
它的问题不只是:
1 | while 循环浪费 CPU |
更严重的是:
1 | 所有核心反复争抢同一个锁变量缓存行 |
❗ 本节核心结论:
普通自旋锁在高竞争多核环境下会把锁变量所在缓存行变成热点,导致一致性通信暴增,引发可扩展性断崖。
9. Back-off 回退策略
9.1 Back-off 是什么?
Back-off 的思想是:
抢锁失败后,不要立刻继续抢,而是先等一会儿。
伪代码:
1 | void lock(int *lock) { |
9.2 为什么有用?
普通自旋锁失败后会立即继续 CAS。
Back-off 让失败线程暂时退让:
1 | 减少抢锁频率 |
9.3 固定等待的问题
如果所有线程等待相同时间:
1 | 大家同时失败 |
所以更好的方式通常是:
- 随机回退
- 指数回退
9.4 Back-off 的局限
Back-off 只是缓解竞争。
它没有改变一个事实:
所有线程最终仍然在竞争同一个锁变量缓存行。
所以它不是根本解决方案。
10. MCS 锁:改变等待方式
10.1 MCS 锁是什么?
MCS 锁是一种更可扩展的锁。
它的核心思想是:
不要让所有等待者都盯着同一个锁变量,而是让每个等待者在自己的节点上等待。
10.2 普通自旋锁 vs MCS 锁
普通自旋锁像这样:
1 | 所有人都抢同一个门把手 |
MCS 锁像这样:
1 | 所有人排队 |
10.3 MCS 锁的流程
flowchart TD
A[线程申请锁] --> B[创建自己的 MCS_node]
B --> C[加入等待队列尾部]
C --> D{前面是否有人?}
D -- 没有 --> E[直接获得锁]
D -- 有 --> F[在自己的节点上自旋等待]
F --> G[前驱释放锁时通知自己]
G --> E
E --> H[进入临界区]
H --> I[释放锁并通知后继]
关键好处:
- 等待者主要访问自己的节点。
- 不再所有核心反复写同一个锁变量。
- 缓存一致性冲突显著减少。
- 高竞争下可扩展性更好。
10.4 MCS 锁不是取消互斥
MCS 锁仍然是锁。
它没有让多个线程同时进入临界区,只是优化了等待方式:
| 锁类型 | 等待方式 |
|---|---|
| 普通自旋锁 | 所有人反复 CAS 同一个变量 |
| Back-off 锁 | 失败后等一会儿再抢 |
| MCS 锁 | 排队,在自己的节点上等待 |
❗ 本节核心结论:
MCS 锁的核心改进不是取消锁,而是改变等待结构,避免所有线程同时竞争同一个缓存行。
11. QSpinlock
QSpinlock 可以理解为结合两类路径:
| 路径 | 适用情况 | 思想 |
|---|---|---|
| 快速路径 | 低竞争 | 类似普通自旋锁,简单快速 |
| 慢速路径 | 高竞争 | 类似 MCS 锁,排队等待 |
它体现了一个工程设计原则:
低竞争时不要为了复杂性付出过多代价;高竞争时必须避免单一缓存行竞争。
12. 虚假共享 False Sharing
12.1 什么是虚假共享?
虚假共享是指:
多个线程访问的是不同变量,但这些变量恰好位于同一个缓存行,于是硬件把它们当成同一个一致性单位处理。
例如:
1 | struct { |
逻辑上:
1 | thread_0 只写 a |
它们没有共享同一个变量。
但如果 a 和 b 位于同一个缓存行,那么硬件会以缓存行为单位维护一致性。
12.2 为什么虚假共享很危险?
因为缓存一致性的单位是缓存行,而不是变量。
如果 a 和 b 在同一个缓存行:
1 | thread_0 写 a |
于是两个线程虽然操作不同变量,却不断互相干扰。
12.3 如何避免虚假共享?
可以使用 padding 或 cache-line alignment:
1 | struct { |
目标是:
1 | 让不同线程高频写入的变量落到不同缓存行 |
这不是为了改变程序逻辑,而是为了改变硬件缓存布局。
❗ 本节核心结论:
不同变量不代表不会互相影响。只要它们落在同一个缓存行,多核写入时就可能触发无谓的一致性通信,这就是虚假共享。
13. 死锁预防回顾
本讲后半部分也回顾了死锁预防。
死锁的四个必要条件:
| 条件 | 含义 |
|---|---|
| 互斥访问 | 一个资源同一时刻只能被一个线程使用 |
| 持有并等待 | 已经持有一些资源,还在等待其他资源 |
| 资源非抢占 | 已经被占有的资源不能被强行抢走 |
| 循环等待 | 线程之间形成首尾相接的等待链 |
死锁预防就是破坏其中至少一个条件。
最常见的方法是:
给资源编号,所有线程按固定顺序申请资源。
例如:
1 | A < B < C |
所有线程都只能按:
1 | A → B → C |
申请锁,不能先拿 B 再拿 A。
这样等待关系只能朝一个方向走,无法形成环。
14. 乱序执行
14.1 乱序执行是什么?
乱序执行指的是:
CPU 实际完成指令的顺序,可能和程序中看到的指令顺序不同。
例如程序写着:
1 | x = 1; |
单线程视角下,CPU 会尽力保证结果看起来像顺序执行。
但在多线程场景中,另一个核心可能观察到复杂情况:
1 | 看起来 y 先变化 |
这会引出后续的内存一致性模型问题。
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. 自测题
- 为什么不能简单认为“CPU 核心数增加,程序性能就一定线性提升”?
- 加速比 Speedup 的定义是什么?
- Amdahl 定律中的
p表示什么? - 为什么串行部分会限制理论最大加速比?
- 多核环境下,为什么每个核心拥有私有 L1 Cache 会带来数据一致性问题?
- 缓存一致性要解决的核心问题是什么?
- 缓存一致性和“程序跑得快”是同一个问题吗?
- MSI 协议中的 Modified、Shared、Invalid 分别表示什么?
Invalid是全局状态,还是某个核心本地缓存副本的状态?- 为什么写共享变量通常比读共享变量更容易触发一致性通信?
- 目录式缓存一致性中的脏位和向量位分别记录什么?
- 为什么目录式一致性可以减少广播成本?
- 什么是一致性通信?
- 普通自旋锁为什么会导致可扩展性断崖?
- 为什么自旋锁的问题不只是“while 循环浪费 CPU”?
- Back-off 策略为什么能缓解自旋锁竞争?
- 为什么固定等待时间的 Back-off 可能效果不好?
- MCS 锁相比普通自旋锁的核心改进是什么?
- 为什么说 MCS 锁优化的是等待方式,而不是取消互斥?
- QSpinlock 为什么要区分快速路径和慢速路径?
- 什么是虚假共享?
- 为什么两个不同变量也可能互相拖慢性能?
- padding 和 cache-line alignment 为什么能缓解虚假共享?
- 死锁产生的四个必要条件是什么?
- 打破循环等待为什么可以预防死锁?
- 乱序执行为什么被设计出来?
- 乱序执行为什么会为后续内存一致性模型埋下问题?
最终总结
本讲可以压缩成一条核心链路:
多核增加并行能力,但私有缓存让同一地址出现多个副本;缓存一致性保证正确性,却带来一致性通信;当多个核心高频争抢同一个缓存行时,性能会出现可扩展性断崖;因此需要从锁设计、数据布局和内存顺序三个层面降低协调成本。
重点记住:
- ❗ 多核不是免费加速:串行部分、锁竞争、缓存通信都会限制加速。
- ❗ Amdahl 定律:串行部分决定理论加速上限。
- ❗ 缓存一致性:解决同一地址的数据共识问题。
- ❗ MSI 协议:读可共享,写要独占,旧副本要失效。
- ❗ 目录式一致性:记录谁拥有缓存行,只通知相关核心。
- ❗ 可扩展性断崖:核心越多,竞争越严重,性能反而下降。
- ❗ MCS 锁:排队等待,避免所有线程争抢同一缓存行。
- ❗ 虚假共享:不同变量落在同一缓存行,也会互相拖慢。
- ❗ 乱序执行:提高单核效率,但让多线程内存可见顺序更复杂。



