多核的代价
多核的代价
"我的服务器有 64 核,为什么跑了 64 个线程之后速度只提升了 10 倍?"
这个问题可能是每个接触并发编程的工程师都会遇到的。直觉说"64 核应该快 64 倍",现实往往只有 5-10 倍提升,有时候甚至比单核还慢。为什么?
答案藏在多核的"隐性成本"里:缓存一致性、总线竞争、内存带宽瓶颈、NUMA 效应。每一个都在悄悄吃掉你的并行红利。
Level 1:建立直觉
Amdahl 定律:并行加速的上限
计算机科学家 Gene Amdahl 在 1967 年给出了并行加速的数学上限:
Amdahl 定律:
加速比 = 1 / (S + (1 - S) / N)
S = 串行部分的比例(不能并行化的代码)
N = 处理器核数
例子:如果 20% 的代码无法并行化(S = 0.2):
N = 2: 加速比 = 1 / (0.2 + 0.8/2) = 1.67×
N = 4: 加速比 = 1 / (0.2 + 0.8/4) = 2.5×
N = 8: 加速比 = 1 / (0.2 + 0.8/8) = 3.3×
N = 64: 加速比 = 1 / (0.2 + 0.8/64) ≈ 4.8×
N = ∞: 加速比 = 1 / S = 5×(上限!!)
即使有无限多的核,只要 20% 的代码是串行的,加速比最多 5 倍。这就是为什么"64 核只快了 10 倍"——串行部分限制了上限。
缓存一致性问题
多核 CPU 的每个核有自己的 L1/L2 缓存。当多个核缓存了同一块内存,谁修改了就必须通知其他核——这就是缓存一致性(Cache Coherence)。
2 个核共享变量 x(初始值 = 5):
核0:x 在 L1 缓存中(值 = 5)
核1:x 在 L1 缓存中(值 = 5)
核0 写 x = 10:
→ 核0的 L1: x = 10
→ 核1的 L1: x = 5(过时了!)
没有缓存一致性协议:
核1 读 x → 读到 5(错误!)
这就是为什么多核需要缓存一致性协议(如 MESI)来保证所有核看到一致的内存状态。
伪共享(False Sharing):最隐蔽的性能杀手
缓存一致性的粒度是 cache line(64 字节)。如果两个线程修改同一 cache line 里的不同变量,虽然它们修改的不是同一变量,但 cache 一致性协议会认为有冲突,反复让另一核的缓存失效。
// 伪共享示例:两个计数器在同一 cache line
struct {
int counter_A; // 线程A使用
int counter_B; // 线程B使用
// counter_A 和 counter_B 恰好在同一 64 字节 cache line 里
} shared;
// 线程A:不断递增 counter_A
void thread_a() {
for (int i = 0; i < 1000000; i++) shared.counter_A++;
}
// 线程B:不断递增 counter_B
void thread_b() {
for (int i = 0; i < 1000000; i++) shared.counter_B++;
}
两线程运行时:
核0 修改 counter_A → 使核1 的整个 cache line 失效
核1 修改 counter_B → 使核0 的整个 cache line 失效
→ 反复"踢来踢去"(cache line bouncing)
→ 比单线程还慢(大量额外总线流量)!
解决方案:填充(Padding)到 cache line 边界:
struct {
int counter_A;
char padding_A[60]; // 填充到 64 字节
} thread_a_data;
struct {
int counter_B;
char padding_B[60]; // 另一个 cache line
} thread_b_data;
// 或者用 C11 的 alignas
struct alignas(64) CounterA {
int value;
};
struct alignas(64) CounterB {
int value;
};
实测性能差距:
实验:2个线程,各循环 10^8 次递增自己的计数器
有伪共享:约 2500ms(两核之间 cache line 不断弹跳)
消除伪共享(padding):约 150ms(各自在本核 L1 cache 中独立操作)
差距:约 16 倍
Level 2:原理剖析
MESI 协议
现代 CPU 用 MESI 协议维护缓存一致性:
每个 cache line 有四种状态:
M(Modified):只在一个核的缓存中,已被修改,和内存不同步
E(Exclusive):只在一个核的缓存中,和内存同步
S(Shared):在多个核的缓存中,和内存同步(只读)
I(Invalid):无效,需要从内存或其他核重新加载
状态转换:
初始:core0 读 x → E(独占)
core1 也读 x → 两个都变成 S(共享)
core0 写 x(S状态)→
广播"无效化"给 core1
core1 的 x 变 I
core0 的 x 变 M(修改)
core1 再读 x → core0 需要先把数据"冲刷"到内存(或直接传给 core1)
core0 变 S,core1 也变 S
MESI 的代价:状态转换需要核间通信,通过**互联总线(Interconnect)**传递消息,延迟约 40-100ns(比 L3 cache 慢几倍)。
MOESI 和 MESIF:实际 CPU 常用扩展协议:
- AMD 用 MOESI(增加 O=Owned 状态,允许"脏"数据直接传给其他核,不经过内存)
- Intel 用 MESIF(增加 F=Forward 状态,指定由哪个核负责响应其他核的请求)
总线带宽和内存带宽瓶颈
多核系统中,所有核共享同一条内存总线(和内存带宽):
单核内存带宽峰值:50-100 GB/s(DDR5)
理想情况:16核 → 每核 6.25 GB/s(共享 100GB/s)
现实:每核各需要 80 GB/s 的内存带宽
→ 内存带宽成为瓶颈!
内存密集型程序(流式计算)的实际扩展性:
1核: 80 GB/s → 最快
2核: 100 GB/s(接近上限)
4核: 100 GB/s(上限,不再提升)
8核: 100 GB/s(上限)
对于内存密集型工作负载(streaming),增加核数根本不帮助——瓶颈在内存总线,不在 CPU。
Stream 基准测试:测量内存带宽的标准工具:
# 编译并运行 STREAM 基准
wget https://www.cs.virginia.edu/stream/FTP/Code/stream.c
gcc -O3 -fopenmp -march=native stream.c -o stream
# 测试不同线程数
for N in 1 2 4 8 16; do
OMP_NUM_THREADS=$N ./stream 2>&1 | grep "Triad"
done
# 输出示例:
# 1 核:52000 MB/s
# 2 核:82000 MB/s
# 4 核:95000 MB/s
# 8 核:97000 MB/s ← 几乎不再增加(带宽饱和)
# 16 核:98000 MB/s
NUMA(非均匀内存访问)
服务器通常有 2-8 个 CPU 插槽(socket),每个 socket 有自己的内存。跨 socket 访问内存要通过 QPI/UPI(CPU 互联总线),延迟更高:
NUMA 拓扑(2 socket 系统):
Socket 0 (CPU 0-15) <─ QPI/UPI ─> Socket 1 (CPU 16-31)
│ │
内存 0 (64GB) 内存 1 (64GB)
访问延迟:
本地内存(socket 0 访问内存 0):~70ns
远端内存(socket 0 访问内存 1):~140ns(慢 2 倍)
如果线程在 socket 0 上运行,但它的数据在 socket 1 的内存里(例如 malloc 在 socket 1 上分配的),每次内存访问都要多花 70ns。
# 查看 NUMA 拓扑
numactl --hardware
# available: 2 nodes (0-1)
# node 0 cpus: 0 1 2 3 4 5 6 7
# node 0 size: 65536 MB
# node 1 cpus: 8 9 10 11 12 13 14 15
# node 1 size: 65536 MB
# node distances:
# node 0 1
# 0: 10 20 ← 本地=10,远端=20(相对值)
# NUMA 感知运行
numactl --cpunodebind=0 --membind=0 ./my_program
锁竞争的代价
多线程使用 Mutex 时,锁竞争会造成额外开销:
无竞争 mutex(锁没有被其他人持有时):
mutex_lock: ~10-20ns(原子 CAS + 内存屏障)
mutex_unlock: ~5ns
有竞争 mutex(锁被别人持有,需要等待):
系统调用(futex):~1-10μs(睡眠+唤醒)
还包括:
- 上下文切换开销
- cache 失效(锁数据结构)
- 调度延迟
**锁扩展性(Scalability)**实验:N 个线程竞争同一把锁,各做 N 次 lock/unlock:
理想:N个线程总时间 = 单线程时间(并行执行)
现实:
1 线程:1×
2 线程:2×(因为轮流等锁)
4 线程:6×(等待时间随竞争指数增长)
8 线程:15×
16 线程:40×
锁竞争会让多线程比单线程还慢!
原子操作的代价
原子操作(CAS、fetch-add)比普通内存操作慢多少?
// 非原子加(单线程可用)
int x = 0;
x++; // 约 1 周期
// 原子加(多线程安全)
#include <stdatomic.h>
atomic_int y = 0;
atomic_fetch_add(&y, 1); // 无竞争时约 5-10 周期;有竞争时可达 100+ 周期
// 原子 CAS(Compare And Swap)
int expected = 0;
atomic_compare_exchange_strong(&y, &expected, 1);
// 用于实现无锁数据结构
原子操作延迟(i7-12700K,核心数不同时):
1 核:约 5 周期(只需要 L1 原子操作)
2 核竞争:约 15 周期(需要 cache line 弹跳)
4 核竞争:约 40 周期
16 核竞争:约 200 周期(严重争抢)
Level 3 · 规范怎么定义的(资深)
并行计算的理论极限
Amdahl 定律(1967 年)给出了并行加速的理论上限:如果程序中串行部分占比为 s,则无论使用多少个处理器,加速比上限为 1/s。这意味着即使只有 5% 的串行代码,最大加速比也只有 20 倍——增加到 1000 个核心也无法突破。Gustafson 定律(1988 年)提供了另一个视角:随着处理器增多,人们倾向于增大问题规模而非缩短固定问题的求解时间,因此实际加速比可以更乐观。
缓存一致性的形式化由 Censier 和 Feautrier 在 1978 年首次定义,随后 MESI 协议(1984 年,Illinois Protocol)成为工业标准。MESI 的正确性可以用状态机的不变量来证明:在任意时刻,如果某条缓存行在某核心处于 Modified 或 Exclusive 状态,则其他所有核心该行必须为 Invalid。Intel 的 QPI/UPI(Ultra Path Interconnect)和 AMD 的 Infinity Fabric 是实现缓存一致性的物理互连协议,它们在片上网络(NoC)上传递一致性消息(snoop request/response)。
NUMA 架构在 AMD Opteron(2003 年)上首次大规模商用。Linux 内核的 NUMA 支持由 numactl 工具和 libnuma 库提供接口,底层通过 mbind() 和 set_mempolicy() 系统调用实现。内核的 NUMA 平衡器(Automatic NUMA Balancing,内核 3.8+)会在运行时检测线程的内存访问模式,自动将页面迁移到访问最频繁的 NUMA 节点——但这种迁移本身有开销,在某些工作负载下反而有害。
Level 4 · 边界与陷阱(所有人)
陷阱 1:锁竞争导致多核性能不升反降
在极端情况下,增加核心数反而让程序变慢。经典例子:多线程共享一个受锁保护的计数器,每个线程在紧密循环中递增计数器。线程数从 1 增加到 2 时,由于锁竞争和缓存行在核心间弹跳,性能可能下降到单线程的 1/3。继续增加线程只会更慢——因为每次锁获取/释放都需要跨核心的缓存一致性消息(约 50-100ns),而递增操作本身只需 1ns。解决方案是减少共享:使用线程本地计数器,最后再汇总(如 per_cpu 变量在 Linux 内核中的广泛使用)。
陷阱 2:伪共享的性能影响可能达到 10 倍以上
两个线程分别更新各自的独立变量 thread_data[0].counter 和 thread_data[1].counter,如果 thread_data 数组元素大小小于 64 字节(一条缓存行),两个变量会位于同一缓存行。每次写入都会触发另一个核心的缓存行无效化,性能下降可达 10-50 倍。Java 的 JMH(Java Microbenchmark Harness)和 Linux 的 perf c2c(cache-to-cache)工具可以检测伪共享。修复方式是在变量之间添加填充,确保每个线程的数据独占一条缓存行。Linux 内核源码中 ____cacheline_aligned_in_smp 宏正是为此设计的。
陷阱 3:NUMA 远端内存访问的延迟可能被忽视
在双路服务器上,每个 CPU 有自己的本地内存。当一个核心访问另一个 CPU 的内存(远端访问)时,延迟通常是本地访问的 1.5-2 倍(约 150ns vs 100ns)。如果你的程序在 CPU 0 上分配了大量内存,然后在 CPU 1 的核心上运行线程来处理这些数据,所有内存访问都是远端的——性能下降 30-50%。MySQL、PostgreSQL 和 Redis 的性能调优指南都建议在 NUMA 系统上使用 numactl --interleave=all 或 --membind 来控制内存分配策略。