Post

多处理器编程

多处理器编程

REVIEW

image-20260416194842183

问题动机:从零开始的系统

一台刚上电的计算机,什么软件都没运行。谁来决定第一条指令是什么?谁来把操作系统加载进内存? 这就是启动链(boot chain)要解决的问题。

关键约束:CPU 上电后不知道”系统在哪”,只知道跳转到一个固定的物理地址,剩下的事交给那里的代码。


启动链(Boot Chain)

整个过程是一条接力棒,每一级负责把下一级加载进来并移交控制权。

1. CPU Reset — 硬件初始状态

  • 所有寄存器归零(或置为规定初值),PC 指向固定的物理地址(如 x86 上的 0xFFFFFFF0)。
  • 此时内存里只有固件(Firmware),没有任何 OS。

2. Firmware(BIOS / UEFI)— 硬件的“管家”

  • 上电自检(POST):检测 CPU、内存、外设是否正常。
  • 确定启动设备(磁盘、USB、网络…),从中读取操作系统引导程序。
  • 把控制权移交给 OS bootloader(如 GRUB)。

工程备注:UEFI 相比 BIOS 更现代,支持 GPT 分区、安全启动(Secure Boot)等,本质仍是完成同一件事。

3. OS 内核加载 — initramfs → init

  • OS 内核被加载进内存后,需要挂载根文件系统才能继续运行。
  • 问题:驱动程序(如磁盘驱动)存在文件系统里,但读文件系统又需要驱动 —— 鸡蛋问题。
  • 解法:内核解压一个内存中的临时文件系统 initramfs(Initial RAM Filesystem),里面包含最小化的驱动和 init 程序,用来完成早期初始化。

4. pivot_root → systemd(pid=1)— 接管用户空间

  • init 程序加载真正的磁盘驱动,挂载真实根文件系统。
  • 调用 pivot_root 切换根目录,抛弃 initramfs,把根指向真实文件系统。
  • 然后执行真正的 init,现代 Linux 上通常是 systemd(pid=1)
  • 从此,所有进程都是 systemd 的后代,OS 持续提供 syscall:forkexecvemountmmapopen……

syscall:应用与硬件之间的唯一接口

核心洞察:应用程序永远不能直接操作硬件,它只能通过 syscall 向内核“请求服务”。

如果没有 syscall 隔离有了 syscall 隔离
应用程序直接写硬件寄存器应用程序调用 write(fd, buf, n)
换一块网卡/磁盘就要重写应用驱动细节完全封装在内核里
任何 bug 都能破坏整个系统用户态崩溃不影响内核
无法移植到不同 CPU 架构x86/ARM 上同一份代码可运行

这就是为什么 syscall API 被称为“指令集无关”的:同一个 C 程序在 x86 和 ARM 上的二进制不同,但源码行为相同。


基于 OS API 的软件生态(分层结构)

从 syscall 往上,是一个层层叠加的生态:

1
2
3
4
5
6
7
8
9
10
11
海量应用程序(shell、浏览器、数据库…)
        ↑
语言供应链(PyPI / npm / crates.io…)
        ↑
底层库(curses / readline / openssl…)
        ↑
libc — 可移植的标准 C 基座(ISO 标准)
        ↑
syscall API ← 隔离边界
        ↑
OS 内核
  • libc 是关键的”翻译层”:把平台无关的 printfmalloc 等标准函数,翻译成具体的 syscall。它是 POSIX/ISO 标准,让同一份源码跨平台编译。
  • 底层库(如 readline)在 libc 之上封装更高级的功能(行编辑、历史记录),供语言运行时或应用直接使用。
  • 语言供应链(npm、PyPI)则是更高层的模块化复用机制,与具体 OS 几乎无关,只依赖语言运行时的抽象。

###

概念本节关联
链接与加载OS 用 execve 触发加载器,把 ELF 文件映射进地址空间
进程模型fork 是 systemd 繁殖进程树的基础 syscall
虚拟内存mmap 是 OS 为进程提供虚拟地址空间的手段
文件系统mount 允许把任意文件系统挂载到目录树上
可移植性libc + syscall 共同构成跨架构可移植的基础

共享内存的并发编程:动机

image-20260416201240321

核心问题

为什么要引入“多个执行流共享内存”这种复杂的编程模型?

两个独立但相互呼应的动机共同驱动了这一需求。


动机 1:syscall 可能执行很长时间

问题所在

1
2
3
4
5
6
7
void http_server(int fd) {
    while (1) {
        buf = alloc_buf();
        nread = read(fd, buf, 1024);  // ← 这里可能阻塞几毫秒到几秒
        handle_request(buf, nread);   // ← 在此之前什么都做不了
    }
}

read(fd, ...) 是一个 syscall。当数据尚未到达时,OS 会把这个进程挂起(放入等待队列),CPU 去执行别的进程。等数据来了再唤醒它。

结果:整个 http_server 在等待期间完全停摆,无法并发处理其他请求。

朴素解法为何不够

  • 多进程(fork):可以解决并发,但进程间内存隔离,通信成本高。
  • 单进程事件循环(select/epoll):可以避免阻塞,但编程模型复杂,且无法利用多核。

关键思路

如果能有“多个执行流共享同一地址空间”,则:在一个执行流等待 read 时,另一个执行流可以继续执行 handle_request,CPU 不闲置。

这是并发的 I/O 利用率动机:不让 CPU 在 syscall 等待期间空转。


动机 2:多处理器系统天然是共享内存的

硬件现实

现代服务器通常有多颗 CPU(每颗多个 core),它们通过总线/互联网络访问同一块物理内存。这是 SMP(Symmetric Multiprocessing)的基本设计。

  • 这意味着:如果两个线程运行在不同 core 上,它们可以直接读写同一个变量,通信代价极低(相比进程间通信)。

fork 的局限

fork 之后,子进程得到的是父进程地址空间的完整副本(Copy-on-Write)。之后两个进程的内存写操作互相不可见——“形同陌路”。

要让两个 fork 出来的进程通信,必须借助 pipe、socket、共享内存段等 IPC 机制,绕了一大圈。

先有鸡先有蛋:历史锁定效应

这里有一个深刻的系统设计规律:

  • 早期设计了 SMP → 操作系统内核、编译器、语言运行时全都假设共享内存 → 软件生态繁荣 → 换掉 SMP 的代价无穷大。
  • 即便后来出现了 NUMA(Non-Uniform Memory Access,不同 socket 的内存访问延迟不同),硬件和 OS 依然要维持统一共享内存的幻象,否则现有的所有软件都要重写。

这是工程史上典型的“路径依赖”:一个早期的架构决定,因为与上层系统深度耦合,即便后来发现它有代价,也再也下不了车。

###

 动机 1(I/O)动机 2(多核)
问题syscall 让 CPU 空转多核算力用不上
单进程解法无法利用多核进程间通信太重
共同答案多线程:共享地址空间的多个执行流 

线程(thread)正是为了同时解决这两个问题而生的:同一进程的多个线程共享内存,可以在不同 core 上并行运行,也可以在某个线程阻塞时让其他线程继续工作。

代价:共享内存意味着竞态条件(race condition)、数据竞争(data race),这正是后续课程要解决的核心问题。

共享内存并发 = 把”程序的确定性”这个我们赖以生存的假设打破了。

线程

1. 起点:什么是线程?

1.1 问题动机

单线程程序的执行模型很干净:一个 PC(程序计数器)+ 一个栈 + 一份内存,CPU 不断从 PC 取指令、执行、更新状态。

但现实需求是:

  • 我想让程序的多个部分逻辑上同时推进(比如一边渲染 UI 一边下载文件)
  • 我想利用多核 CPU 真正加速

最朴素的想法:开多个进程。但进程之间内存隔离,通信代价高。我们想要更轻的东西。

1.2 线程的本质定义

线程 = 独立的执行流 + 独立的栈 + 共享的全局内存(堆 + 全局变量 + 代码段)

为什么栈必须独立?因为执行流的本质就是“PC 在栈上推进函数调用”。如果两个执行流共享栈,函数调用帧就会互相覆盖,根本无法独立推进。

为什么其他东西可以共享?因为这正是线程“轻”的来源——共享地址空间意味着创建快、通信快(直接读写共享变量)。

关键洞察:“程序是独立执行的,但内存是共享的”——这一句话同时定义了线程的优势(高效通信)和所有麻烦的根源(下面所有问题都来自”共享”二字)。

1.3 状态机视角

把整个多线程程序看成一个状态机:

  • 状态 = 所有线程的 PC + 所有线程的栈 + 共享内存
  • 迁移 = 选择某个线程,执行它的下一条指令

这个视角后面会反复使用,因为它能精确刻画”非确定性”从何而来。


2. 并发 vs 并行

维度并发 (Concurrency)并行 (Parallelism)
含义逻辑上同时执行物理上同时执行
是否需要多核不需要需要
实现方式OS 调度轮转多个 CPU 核心同时取指
关心的问题程序结构、正确性性能、加速比

关键点:并行是并发的一种实现方式,不是对立概念。一个并发程序在单核上是”轮流执行”,在多核上才变成真正的并行。

对应到状态机模型

  • 并发模型:每一步从所有线程中选一个,执行它的一条语句
  • 并行模型:每一步多个线程同时各执行一条语句

常见误解:以为“我写了多线程程序就一定用上了多核”。实际上还要看 OS 调度、线程数量、以及程序里有没有锁导致串行化。


3. 最小线程 API:spawn 与 join

1
2
spawn(fn);   // 创建线程,入口为 fn(int tid),立即开始执行
join();      // 等待所有线程结束(main 返回时默认 join)

这是把 pthread 简化到极致的版本——只够用来理解原理,没有任何实用性。真实 pthread 还有线程属性、取消、分离、TLS、信号处理……

一个实证”共享内存”的小程序

1
2
3
4
5
6
7
8
9
int x = 0, y = 0;
void inc_x() { while (1) { x++; sleep(1); } }
void inc_y() { while (1) { y++; sleep(2); } }

int main() {
    spawn(inc_x);
    spawn(inc_y);
    while (1) { printf("x=%d, y=%d\n", x, y); }
}

主线程能看到 xy 在变——这就证明了三个线程访问的是同一份 xy,即共享内存


4. 核心问题:确定性的崩塌

这是整份笔记最重要的一节。

4.1 什么是“确定性”

数学的确定性:函数 $f: X \to Y$,相同输入产生唯一输出。

程序的确定性:可以把程序看作状态迁移函数 $s’ = f(s)$。给定相同的初始状态,程序总是产生相同的指令序列和最终状态。

确定性为什么重要? 它是我们能“理解”和“调试”程序的基础——bug 是 reproducible 的,所以可以定位。这也是函数式编程吸引人的根本原因(SICP)。

4.2 单线程程序“几乎”是确定的

严格来说,单线程程序的不确定性来自外部

  • 初始状态:argvenvpauxv、ASLR
  • 系统调用:getpid()read()、时间相关调用
  • 极少数指令:rdrand(硬件随机)、rdtsc(时间戳)

程序内部的计算——所有内存读写、算术指令——是完全确定的。给定相同输入,结果一致。

4.3 共享内存并发打破了一切

关键事实:线程执行的相对速度没有任何保证

这意味着什么?回到状态机模型:每一步“选哪个线程执行”是任意的。一个 load 指令可能:

  • 读到自己之前的 store
  • 读到另一个线程刚写入的值
  • 读到另一个线程即将覆盖的值

结果:相同的程序、相同的输入,不同次运行可能产生不同结果。确定性没了,可复现性没了,调试地狱开始了。

这就是多线程编程困难的根本原因。不是 API 复杂,不是概念抽象,而是非确定性让人类的”顺序推理”直觉彻底失效。


5. 案例一:支付系统的竞态条件

1
2
3
4
5
6
7
8
9
unsigned int balance = 100;

int T_alipay_withdraw(int amount) {
    if (balance >= amount) {     // (1) 检查
        balance -= amount;        // (2) 扣款
        return SUCCESS;
    }
    return FAIL;
}

两个线程同时取 ¥100,余额只有 ¥100,会发生什么?

问题不在代码逻辑,而在 (1) 和 (2) 之间存在时间窗口。可能的执行交错:

1
2
3
4
5
线程A: 检查 balance >= 100 ✓
线程B: 检查 balance >= 100 ✓     ← 此时 A 还没扣!
线程A: balance -= 100  → balance = 0
线程B: balance -= 100  → balance = -100
                                  ↑ unsigned 下溢成天文数字!

这就是 Mt. Gox 损失 650,000 BTC 的类型(按当时算约 $280 亿)。

核心洞察:这种 bug 单线程测试永远测不出来。它依赖特定的指令交错,而这种交错可能 100 万次只出现 1 次——但对手会专门构造请求触发它。


6. 案例二:连 1+1 都不会算了

1
2
3
4
5
6
7
8
9
10
11
12
13
#define N 100000000
long sum = 0;

void T_sum() {
    for (int i = 0; i < N; i++) sum++;
}

int main() {
    spawn(T_sum);
    spawn(T_sum);
    join();
    printf("sum = %ld\n", sum);   // 期望 2*N,实际呢?
}

直觉上 sum 应该是 $2N$。实际跑出来通常远小于 $2N$

为什么?因为 sum++ 不是一条指令,而是三条:

1
2
3
load  r, [sum]      // 把 sum 读到寄存器
add   r, 1
store [sum], r      // 写回

经典的丢失更新交错:

1
2
3
4
5
6
线程A: load r=5
线程B: load r=5      ← A 还没写回
线程A: add  → r=6
线程A: store sum=6
线程B: add  → r=6
线程B: store sum=6   ← A 的更新被覆盖!本应是 7

关键概念:这种“看起来是一步、实际是多步”的操作叫非原子操作x++x += y、甚至某些架构下的 64 位整数读写,都不是原子的。

思考:三个线程并发跑 T_sum(每个循环 3 次),sum 最小可能是多少?

假设 load/store 单行原子。每个线程做 3 轮 load → +1 → store

1
2
3
4
5
6
7
8
9
10
11
12
A1.load → 0
B1.load → 0                  (B 也提前 load)
A1.store → 1
A2.load → 1; A2.store → 2
B1.store → 1                 (B 覆盖回 1)
A3.load → 1                  (A 记住 1!)
B2.load → 1; B2.store → 2
B3.load → 2; B3.store → 3
C1.load → 3; ... C 完成 ...  C3.store → 6 或类似
A3.store → 2                 (A 用 1+1=2 覆盖)

最终 sum = 2 ✓

如果 9 次操作完全不重叠,sum = 9。让两个线程先完成全部,最后一个线程用很早 load 的值覆盖,即最后一次load到1, store 写入 2,sum 最小可以是 2


7. 解药:互斥 (Mutex)

笔记最后提到 mutex_lock / mutex_unlock。它们的核心作用:

把一段代码标记为临界区,保证任意时刻最多一个线程在临界区内。这就把非原子操作“打包”成了逻辑上的原子操作。

1
2
3
4
5
6
7
8
mutex_t lk;
void T_sum() {
    for (int i = 0; i < N; i++) {
        mutex_lock(&lk);
        sum++;                    // 现在整个 ++ 是原子的
        mutex_unlock(&lk);
    }
}

但这只是开始,会带来新问题:

  • 性能:锁让并发退化成串行,可能比单线程还慢
  • 死锁:多个锁的获取顺序错了会互相等待
  • 公平性:某些线程可能长期拿不到锁

##

层面收获
理论共享内存并发的本质是状态机的非确定性迁移
抽象线程 = 独立执行流 + 共享地址空间
陷阱非原子操作 + 任意交错 = 难以复现的 bug
工具互斥锁是最基本的同步原语,但有代价
思维转变从”按顺序读代码”升级到”考虑所有可能的交错”

几个值得记住的“反直觉”事实

  1. 多线程程序未必用上了多核——取决于 OS 调度和锁竞争
  2. 每个线程有独立的栈,但栈的大小有限(通常几 MB),递归过深会栈溢出
  3. x++ 不是原子的——这是新手最常踩的坑
  4. bug 的不可复现性本身就是症状——意味着你写出了竞态条件
  5. 加锁只能恢复正确性,不能恢复确定性——多次运行结果可能仍不同(线程调度顺序不同),但每次都是正确的
关于 GDB 调试多线程

简单关键命令:

  • info threads — 查看所有线程
  • thread N — 切换到线程 N
  • set scheduler-locking on — 单步时锁住其他线程,方便复现
  • break ... thread N — 只在某个线程触发的断点

“虽然把数学视角砍掉了,但状态机视角在理解并发程序时还是很有用。”

传统的“霍尔逻辑”(前置条件 → 语句 → 后置条件)这种顺序推理框架,在并发下会失效——因为任意位置都可能被其他线程“插一脚”修改共享状态。状态机 + 所有可能的交错(即“调度树”),是我们能形式化分析并发程序的最底层模型。后面学到的内存模型(happens-before、acquire/release)、模型检查(如 TLA+),本质上都是在这个状态机模型上做近似和约束。

内存一致性模型(为什么我自认为的状态机模型在真实硬件上还不够准确——CPU 会重排你的指令)

9. 一个看起来无害的例子

1
while (!flag);

直观语义:“等,直到另一个线程把 flag 设成 1,我就继续。”

这是一种最朴素的“自己造同步原语”的尝试——程序员希望用一个普通变量充当线程间的信号。

问题:这段代码在单线程语义下完全合法,编译器和 CPU 都把它当顺序程序优化。它们不知道也不在乎你的“线程间约定”。

关键洞察:你脑子里的“flag 表示同步”是约定,但编译器看到的是代码。它只对代码本身做等价变换,不理解你的意图。


10. 编译器在做什么:顺序语义下的”合法”破坏

10.1 案例 A:循环里的 flag 永远不会被读

1
while (!flag);

编译器视角的“合法推理”:

  • 这是单线程程序(编译器假设)
  • 循环体是空的,循环条件 !flag 在循环内没有任何东西能改变 flag
  • 所以 flag 的值在整个循环过程中不变
  • 优化:把 load 提到循环外,只读一次
1
2
3
// 编译器优化后等价于:
register int t = flag;
while (!t);   // 如果 t=0,死循环;如果 t≠0,跳过

结果:如果第一次读到 flag=0,永远死循环——即使另一个线程后来把 flag 设成 1,这段代码也看不到了。

10.2 案例 B:sum++ 在优化下的诡异结果

回到求和例子:

1
2
3
void T_sum() {
    for (int i = 0; i < N; i++) sum++;
}

不开优化时,sum++ 老老实实展开成 load → +1 → store,在每次循环都访问内存。两个线程并发跑,丢失更新,sum < 2N——这是我们预期的。

但开了优化呢?

优化级别实测结果编译器做了什么
-O0远小于 2Nload/store 每次都执行,但非原子
-O1恰好 N把 sum 提到寄存器:reg = sum; for(...) reg++; sum = reg;
-O2恰好 2N进一步识别出循环本质是 sum += N,直接计算,恰好等于2N

-O1 为什么正好是 N? 编译器把循环优化成:

1
2
3
long reg = sum;       // 进入循环前 load 一次
for (int i = 0; i < N; i++) reg++;
sum = reg;            // 循环结束后 store 一次

两个线程都 reg = 0,各自加到 N,然后几乎同时写回。最后一个写的覆盖前一个,sum = N。

-O2 为什么是 2N? 编译器进一步识别“循环本质是加 N”,直接生成:

1
sum += N;   // 编译成 add 指令

在 x86 上,add [sum], N 是一条指令——而 x86 保证对齐 word 的单条 read-modify-write 指令在单核内是不可分的(虽然不是真原子,但在很多调度下表现得像原子)。所以两个线程各 +N,得到 2N。


11. 控制编译器的两种武器

方法 1:编译器屏障(compiler barrier)

1
while (!flag) { asm volatile ("" ::: "memory"); }

作用:告诉编译器“在这一点,假设所有内存都可能被改了,不要跨越这点做任何缓存/重排”。

机制拆解

  • asm volatile:嵌入一段汇编,且这段汇编本身不可被优化掉
  • "":空汇编,不生成任何机器指令
  • ::: "memory":clobber list 告诉编译器“这条指令可能会破坏所有内存”,编译器必须把所有缓存在寄存器里的变量丢弃,下次重新 load

注意:这只阻止编译器的优化,不阻止 CPU 的运行时重排

方法 2:volatile 关键字

1
2
volatile int flag;
while (!flag);

作用:标记这个变量的每次 load/store 都必须真实发生,不能优化掉。

volatile 的真正含义

  • ✅ 每次访问都生成实际的 load/store 指令
  • ✅ 不会把多次访问合并成一次
  • ✅ 不会把访问顺序与其他 volatile 访问交换
  • 不保证原子性
  • 不阻止 CPU 重排
  • 不能用作线程同步(在 C/C++ 中。Java 的 volatile 语义不同!)

常见误解:很多人以为 volatile 是“线程安全”的关键字。在 C/C++ 中根本不是。volatile 的设计初衷是处理“内存映射 I/O”——告诉编译器某个地址背后是硬件寄存器,每次读都可能不同。

操作系统课的态度

Don’t play with shared memory.

意思是:别自己用 volatile / 编译器屏障搞同步。这些都是知道自己在做什么的人才该碰的工具。正确做法是用封装好的同步原语:mutex、condition variable、atomic。这些原语的实现者已经替你处理了编译器和 CPU 的所有“小动作”。


12. 两层“编译”:问题不止在编译器

12.1 .c → .s(编译器编译)

编译器做的事我们已经看过:

  • 把变量缓存到寄存器
  • 调换语句顺序(如果它认为等价)
  • 删除“死代码”
  • 把循环展开 / 强度削减
  • 把多次访问合并

12.2 .s → CPU 内部状态(处理器“编译”)

关键事实:现代 CPU 本身也是一个编译器

它内部维护一个“指令窗口”(reorder buffer),可能同时有几十甚至上百条指令在流水线里。它会:

  • 乱序执行(out-of-order execution):先执行已经准备好操作数的指令
  • 推测执行(speculative execution):猜测分支结果,提前执行
  • store buffer:写操作先放到 store buffer,慢慢刷到 cache
  • 读到 store buffer 的”未来”值(store-to-load forwarding)

CPU 这么做的前提假设:单线程下结果不变。它做的所有重排都保证“如果只有一个线程在跑,结果和顺序执行一样”。

问题:这个假设和编译器一模一样——它们都不知道有别的线程在看

关键洞察:处理器和编译器在“为单线程优化”这件事上是共谋。它们都假设你的程序是顺序的,都认为可以自由重排“等价”操作。一旦你引入共享内存并发,它们俩都成了你的对手。


13. 宽松内存模型(Relaxed Memory Model)

wmo

13.1 为什么会出现

一切为了性能

  • Cache 是必需的:内存比 CPU 慢两个数量级。每条 load/store 都直接打内存,CPU 大部分时间在等。
  • 每个 CPU 核有自己的 L1 cache:A 核的 store 不会立刻被 B 核看到,要先经过 cache coherence 协议同步。
  • Store buffer 是必需的:写内存比读内存还慢。CPU 把 store 先扔到 store buffer,继续往下执行,过会儿再刷出去。
  • Load 不等 store buffer:B 核 load 时,A 核的 store 可能还在 A 的 store buffer 里没出来。

结果:站在程序员角度,写出去的东西,别的线程不一定立刻看到;看到的东西,可能是别人早就改过的旧值

13.2 经典的 Litmus Test

1
2
3
4
5
6
7
8
9
10
11
int x = 0, y = 0;

void T_1() {
    x = 1;        // Store(x, 1)
    int t1 = y;   // Load(y)
}

void T_2() {
    y = 1;        // Store(y, 1)
    int t2 = x;   // Load(x)
}

问题:执行完后,(t1, t2) 可能是哪些值?

按”顺序一致”(每次任选一个线程执行一条)模型,可能的结果:

交错t1t2
T1 全跑完,T2 全跑完01
T2 全跑完,T1 全跑完10
交错执行11

不可能出现 (0, 0)——因为如果 t1=0,说明 T1 的 load(y) 在 T2 的 store(y) 之前;同理 t2=0 说明 T2 的 load(x) 在 T1 的 store(x) 之前。但 T1 是先 store(x) 再 load(y),T2 是先 store(y) 再 load(x),逻辑上推不出。

实际结果:在 x86 和 ARM 上都能观察到 (0, 0)

为什么? 两种可能(取决于硬件):

  1. Store buffer:T1 的 x=1 进了 T1 的 store buffer,T2 的 load(x) 直接打 cache,看到旧值 0。同理对称。
  2. 乱序执行:CPU 觉得 store 和 load 没有数据依赖(不同地址),可以先做 load 再做 store

这就叫 store-load 重排。在大多数架构上都允许。

13.3 内存模型的强弱

不同硬件的“宽松程度”不同:

架构内存模型允许的重排
顺序一致(理论)最强不允许任何重排
x86/x86-64TSO(Total Store Order)仅允许 store-load 重排
ARM/POWER弱内存模型允许 load-load、load-store、store-store、store-load 几乎所有重排
Alpha(历史)极弱连有依赖的 load 都可能重排(恶名昭著)

Intel 的决定为何被吐槽

  • x86 的 TSO 比 ARM 强很多
  • “强”意味着硬件要做更多保证(store buffer 排空、cache 同步),性能开销大
  • 但又“不够强”——还是允许 store-load 重排,程序员写跨线程代码时仍然要小心
  • 两头不讨好:既没强到让程序员不用动脑子,又拖了性能后腿

Rosetta 2 的“作弊”:Apple 的 M1 在跑 x86 程序时,需要模拟 x86 的 TSO 内存模型。但 ARM 默认是弱模型——硬模拟会非常慢。Apple 在 M1 里加了一个特殊模式:执行 x86 翻译代码时,CPU 切换到 TSO 模式。这是硬件级别的支持,普通 ARM 芯片做不到。

13.4 怎么”逆向”出一个架构的内存模型

像上面这样的小测试程序就叫 litmus test(石蕊测试)。研究者写出大量这样的小程序,每个测试一种特定的重排可能性,跑成千上万次,统计哪些”理论上不可能”的结果实际出现了——出现的就说明硬件允许这种重排

这是逆向工程内存模型的标准方法。Russ Cox 的 《Memory Models》 系列是这个领域最好的入门读物。


14. 一个统一的世界观

到这里我们应该重新理解多线程程序的执行模型。

14.1 SimpleC 并发模型只是一个幻想

我们最初的并发模型是“每次选一个线程,执行一条语句”。这个模型有两层假设都不成立:

  1. 语句是原子的:错。sum++ 是三条指令;甚至 64 位写在 32 位机上也不是原子的。
  2. 指令按程序顺序执行:错。编译器和 CPU 都会重排。

14.2 真实的执行模型

真实的多线程程序执行可以这样描述:

  • 每个线程的指令序列经过编译器重排(保持单线程语义)
  • 这些指令再经过 CPU 重排(保持单线程语义)
  • 不同线程的最终指令流以几乎任意的顺序交错
  • 每次 load 读到的值,由内存模型规定的规则决定(不是简单的“最近一次 store 的值”)

14.3 这是一把递给程序员的刀

整个共享内存并发的故事可以总结为:性能和正确性之间的赤裸交易,刀柄交给了程序员

机制给程序员的”方便”程序员要付出的代价
共享内存多线程通信高效1+1 都算不对(lost update)
宽松内存模型性能起飞1, 2, 3 都数不清(重排可见)
malloc/free灵活控制内存Use After Free / Double Free
open/close灵活控制资源资源泄漏 / 文件句柄耗尽

这些都是“高效但危险”的接口设计哲学。它们假设程序员”知道自己在做什么”。

这恰恰是为什么 Rust、Go、Java 这些”现代”语言要把刀子没收

  • Rust 的 ownership:编译期消灭 Use After Free 和数据竞争
  • Java 的 synchronized + 内存模型规范:把内存模型从”硬件细节”上升为”语言保证”
  • Go 的 channel:鼓励”通过通信共享内存”而不是”通过共享内存通信”

15. 总结

15.1 反直觉事实

  1. while(!flag); 在优化下是死循环——不是 bug,是编译器“对的”
  2. 优化级别越高,多线程程序结果可能“越正常”——这是巧合,不是修复
  3. volatile 在 C/C++ 中不能用作线程同步——它解决的是另一个问题
  4. x86 看似“很强”的内存模型,仍然允许 store-load 重排
  5. 加锁不只是为了互斥,还为了内存可见性(mutex 实现里包含内存屏障)
  6. CPU 是个编译器——它在运行时做的事和编译器在编译时做的事,本质相同

15.2 实用准则

  • 不要自己造同步原语。用 mutex、condvar、atomic。
  • 不要用普通变量做线程间信号。用 atomic_int 或锁保护。
  • 不要假设语句是原子的。即使是单个变量的读写。
  • 不要假设 A 线程的写 B 线程立刻能看到
  • 如果你必须用 atomic / volatile / barrier,先读三遍 C++ memory model 文档,再读 Russ Cox 的文章

15.3 留给下一步的问题

笔记到这里铺垫了一个完整的问题图景:

  • 我们知道问题是什么(共享内存 + 编译器/CPU 重排 = 不确定性灾难)
  • 我们知道朴素方案不行(自己用变量同步、volatile 都不靠谱)

下一步自然要问:正确的同步机制是什么?怎么实现?性能代价多大?

主线包括:

  • 原子操作(atomic)和硬件支持(CAS、LL/SC)
  • 内存屏障(memory fence)的硬件指令
  • 互斥锁的实现(从忙等到操作系统调度)
  • 条件变量、信号量等高级原语
  • Lock-free / Wait-free 数据结构
  • C++/Java 的 memory model 规范

一个值得反复回味的视角

“顺序”这个概念,在系统的不同层次有不同的含义。

  • 程序员写的 C 代码:一种顺序
  • 编译器生成的汇编:另一种顺序(按单线程等价重排)
  • CPU 实际执行的指令:又一种顺序(按动态依赖重排)
  • 不同 CPU 核观察到的内存事件:还是不同的顺序(按 cache 同步规则)

这些顺序在单线程下被强制等价,所以单线程程序员永远不需要意识到差异。但一旦多个执行流共享状态,所有这些层次的差异都会“泄漏”出来,砸到程序员脸上。

理解多处理器编程的本质,就是理解这些“顺序”的层次结构,以及在每一层用什么工具能强制对齐它们。这就是内存屏障、原子操作、锁的意义——它们都是在某个层次上重新建立“我以为的顺序”和“实际的顺序”之间的对应关系。

This post is licensed under CC BY 4.0 by the author.

Trending Tags