第 24 章

多核的代价

多核的代价

"我的服务器有 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 常用扩展协议:

总线带宽和内存带宽瓶颈

多核系统中,所有核共享同一条内存总线(和内存带宽):

单核内存带宽峰值: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].counterthread_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 来控制内存分配策略。

本章评分
4.6  / 5  (5 评分)

💬 留言讨论