Appearance
内存一致性模型
概念
上一章讲到"乱序执行",那还只是一个核内部的事——硬件重排了指令,但对外仍然保持"像是顺序执行"。
一旦有多个核,性质就变了。因为每个核都有自己的私有 Cache,变量在物理上有不止一份副本。于是冒出两个必须分开回答的问题:
| 问题 | 名字 | 关心什么 |
|---|---|---|
| A 核写了 x,B 核什么时候能看见新值? | 缓存一致性(cache coherence) | 写传播——最终要传过去 |
| A 核先写 x 再写 y,B 核会不会先看到 y 后看到 x? | 内存序(memory ordering) | 写的先后顺序——不一定要保持一致 |
这两个问题经常被混为一谈,而它们其实是两件事。 一句话概括区别:
一致性保证"每个写最终会被看见",内存序决定"看见的顺序"。
内存一致性模型(memory consistency model,简称内存模型)就是后面这半个问题的答案——一套规则,规定多核之间"看到"的读写顺序允许长成什么样。
原理
一、先解决一致性:MESI 协议
每个 Cache 行在每个核里都有一个状态。MESI 是最常用的四状态协议:
| 状态 | 含义 | 本核有权做什么 | 别人有权做什么 |
|---|---|---|---|
| M(Modified) | 我改了,全局只有我这一份,和主存不一样 | 读写自由 | 什么都不许(别人要读得先从我这儿拿) |
| E(Exclusive) | 全局只有我这一份,和主存一致(干净) | 读写自由 | 什么都不许 |
| S(Shared) | 我和别人都有,都干净 | 只能读 | 只能读 |
| I(Invalid) | 我这份作废了 | 无 | 无 |
为什么需要 E 这个状态? 这是 MESI 里最精妙的一点:
- 如果只有 M/S/I,那么"独占的干净副本"要么记成 S(其实没别人有),要么记成 M(其实不脏)。
- 有了 E,一个"独占且干净"的行要改成写时,可以当场静默升级成 M,一次总线都不用走。
这就是 E 态存在的全部意义:它把"我接下来很可能要写"这个信息留了下来,省掉了未来的一次总线事务。
"一致性"到底保证什么? 说得技术一点,通常归纳成两条性质:
- 写传播:一个写最终会被所有核看到。
- 写串行化:所有核看到的、对同一个地址的多个写,顺序是同一个。
注意第 2 条只约束"同一个地址"。它并没有说"我对 x 的写和对我对 y 的写,别人也会按这个顺序看到"。那个问题被留给了内存序。
二、硬件为什么会重排:Store Buffer 与 Invalidate Queue
乱序执行那一章讲的是"指令重排"。在访存这一侧,还有两个专门的硬件造成重排。它们的存在都是为了不让 CPU 等内存:
| 部件 | 位置 | 作用 | 造成的重排 |
|---|---|---|---|
| 写缓冲(Store Buffer) | 核与 Cache 之间 | 写先塞进缓冲,CPU 不等它写完就继续跑 | Store → Load 重排(后面的读跑到了前面) |
| 失效队列(Invalidate Queue) | 核接收作废消息 | 收到"作废"消息先记账、晚处理 | Load → Load 重排(后面的读先看到新值) |
看写缓冲为什么能重排 Store→Load:核 A 执行 x = 1,这条写进了写缓冲就返回了,CPU 接着执行下一条 r1 = y。从别的核的角度看,就是"A 读 y 的时候,A 的写 x 还没发生"。
这不是 bug,是用"别人晚点才知道"换"自己不必等"——这是一笔刻意做的性能交易。
三、四种重排
把两个访存操作两两组合,一共四种重排:
| 重排 | 名字 | 直观危害 |
|---|---|---|
| Load → Load | 读读重排 | 后面的读跑到了前面的读前面 |
| Load → Store | 读写重排 | 读在前、写在后,却先看到写的效果 |
| Store → Store | 写写重排 | 两个写被别核按相反顺序看到 |
| Store → Load | 写读重排 | 写还没出门,读就已经走了 |
关键点:这四种里,第 4 种(Store→Load)是最普遍、也最难避免的一种。因为写缓冲的存在就是为了"不等写完成",而"不等"就意味着后面的读会跑到前面。
各类 CPU 允许哪些重排,就是"内存序谱系"。
四、内存序谱系
| 模型 | 代表 | 允许的重排 | 说明 |
|---|---|---|---|
| 顺序一致 SC | 理论模型(1969 年 Lamport 提出) | 全禁 | 最直观,但性能代价大,实际硬件很少做到 |
| TSO(全存储序) | x86 / x86-64 | 只允许 Store→Load | "写缓冲"的代价,其余四种次序都守 |
| PSO(部分存储序) | SPARC、IBM Power 早期 | 加允许 Store→Store | 写缓冲还能合并 |
| 弱序 / 松散序 | ARM、POWER、RISC-V(默认) | 四种都允许 | 最激进,性能最高,也最需要程序员自己加屏障 |
读这张表的正确姿势:从左到右,性能递增、程序员负担递增。
- x86 上写代码很省心,因为只有一种重排要防;
- ARM 上"看起来对"的无锁代码经常出问题,因为它允许全部四种;
- RISC-V 默认是弱序,但提供了
Ztso扩展能让它表现成 x86 那样。
历史上这段还有个教训:早年用 x86 的开发者写的无锁代码,移植到 ARM 上大批失效——不是 ARM 有 bug,是那段代码本来就依赖了 x86 才有的保证。
五、屏障:把想要的那一种拦回来
硬件允许重排,但你若真的需要顺序,可以显式要求:
| 屏障 | 作用 |
|---|---|
全屏障(MFENCE / DMB SY / fence) | 挡住所有重排 |
写屏障(SFENCE / DMB ST) | 只挡 Store 与 Store 之间 |
读屏障(LFENCE / DMB LD) | 只挡 Load 与 Load 之间 |
代价很直白:屏障会掏空写缓冲(或让队列停下等),也就是强制 CPU 去等内存——把前面省下来的时间又还回去。所以屏障要"按需"加,不是越多越安全。
六、C++ 内存序:把选择权交给程序员
C++11 之后,语言层面直接提供了这套选择(通过 std::atomic):
| 内存序 | 含义 | 对应硬件动作 |
|---|---|---|
relaxed | 只有原子性,没有顺序保证 | 无屏障(最快) |
release(存) | 我前面的访存不许排到这条之后 | 写屏障 |
acquire(读) | 我后面的访存不许排到这条之前 | 读屏障 |
acq_rel | 读用 acquire、写用 release | 两个方向 |
seq_cst | 全序,最直观 | 全屏障(默认值) |
最常用的搭配是 release 存 + acquire 读,它建立一条先行发生(happens-before)关系:
如果读到了某条
release写进去的值,那么该写之前的所有访存,都对做这个读的线程可见。
这句话是无锁编程的地基。relaxed 相减的计数器、acquire/release 相配的发布-订阅,是两套完全不同的用法——用错就出事。
七、一个真实的翻车现场:双重检查锁定
单例模式里那个"看起来很聪明"的写法:
c
if (ptr == NULL) { /* 第一次检查,不加锁,快 */
lock();
if (ptr == NULL) { /* 第二次检查,加锁 */
ptr = malloc(sizeof(Obj)); /* 先分配 */
init(ptr); /* 再初始化 */
}
unlock();
}
use(ptr); /* 别的线程可能在这里看到"非空但没初始化完"的对象 */问题出在写 ptr 和 init(ptr) 之间的顺序上。 硬件(或编译器)完全可能把这两步调换:先把地址写进 ptr,再往那块内存里填内容。
而 ptr 的写是在 unlock() 之前完成的——别的线程一旦看到 ptr != NULL,就认为对象可用,可实际上它还没初始化完。
这正是"允许 Store→Store 重排"的代价。修法只有两条:
| 修法 | 做法 |
|---|---|
| 加屏障 | 在 init(ptr) 之后、写 ptr 之前插一条写屏障/发布屏障 |
| 用语言保证 | 用 release 存(atomic_store_explicit(p, ptr, memory_order_release))+ acquire 读 |
示例
例 1:MESI 的状态组合,合法的一共几种(C)
两个核、同一行数据,每个核各持一个状态,一共有 4 × 4 = 16 种组合。但其中很多在物理上不可能——因为协议有三条不变量。
#include <stdio.h>
/* 四态编号:0 = M(我改了,只有我有),1 = E(干净且只有我有),2 = S(共享),3 = I(作废) */
static const char *NAME[4] = {"M", "E", "S", "I"};
/* 两条不变量:*/
/* ① M 至多一个,且另一核必须是 I(脏数据只有一份,别人要读得先从它那儿拿) */
/* ② E 至多一个,且另一核必须是 I("只有我有"与"我也有一份"互相矛盾) */
/* ③ 只剩 S 与 I 时,怎么组合都行 */
static int legal(int a, int b) {
if (a == 0 || b == 0) { /* 出现 M */
if (a == 0 && b == 0) return 0; /* 两个 M:脏数据有了两份 -> 不可能 */
return (a == 0 ? b : a) == 3; /* 另一个只能是 I */
}
if (a == 1 || b == 1) { /* 出现 E 但没有 M */
if (a == 1 && b == 1) return 0; /* 两个 E:都说"只有我有" -> 不可能 */
return (a == 1 ? b : a) == 3; /* 另一个只能是 I */
}
return 1; /* 只有 S / I,随意 */
}
int main(void) {
int ok = 0, tot = 0;
printf("%-8s %-6s %-6s %s\n", "combo", "core1", "core2", "legal");
for (int a = 0; a < 4; a++)
for (int b = 0; b < 4; b++) {
tot++;
if (legal(a, b)) ok++;
printf("%-8d %-6s %-6s %s\n", a * 4 + b, NAME[a], NAME[b],
legal(a, b) ? "OK" : "BAD");
}
printf("共 %d 种组合:合法 %d 种,非法 %d 种(合法率 %.1f%%)\n",
tot, ok, tot - ok, 100.0 * ok / tot);
return 0;
}
c 本站为静态站,不提供在线运行;可复制到本地用 gcc / python 执行
预期输出:
combo core1 core2 legal
0 M M BAD
1 M E BAD
2 M S BAD
3 M I OK
4 E M BAD
5 E E BAD
6 E S BAD
7 E I OK
8 S M BAD
9 S E BAD
10 S S OK
11 S I OK
12 I M OK
13 I E OK
14 I S OK
15 I I OK
共 16 种组合:合法 8 种,非法 8 种(合法率 50.0%)看那 8 条 BAD:每一条都在描述一个自相矛盾的世界。比如 (M, S) 说的是"A 核手里的数据是脏的、而且只有它这一份;同时 B 核也有一份",这两句话不可能同时成立。
"非法组合"的好处是它把协议变成了可检查的——硬件每做一个状态迁移,只需确认结果落在 8 个合法格子里。
例 2:MESI 轨迹 + Store Buffering 测试(Python)
C 段只检查了静态组合。这里跑两件动态的事:一行数据来回抢时的完整轨迹,以及那个著名的顺序一致 vs 重排的对照。
def dw(s):
return sum(2 if ord(c) > 0x2000 else 1 for c in str(s))
def pad(s, w):
return str(s) + " " * max(0, w - dw(str(s)))
def table(head, rows, gap=2):
data = [[str(c) for c in r] for r in rows]
w = [max([dw(head[i])] + [dw(r[i]) for r in data]) + gap for i in range(len(head))]
print(" " + "".join(pad(head[i], w[i]) for i in range(len(head))))
for r in data:
print(" " + "".join(pad(r[i], w[i]) for i in range(len(head))))
def mesi(accesses, cores=("P1", "P2")):
"""MESI:一行数据、若干核各有私有 Cache;返回轨迹与总线事务数"""
st = {c: "I" for c in cores}
rows, bus = [], 0
for core, op in accesses:
others = [c for c in cores if c != core]
if op == "R":
if st[core] in ("E", "S", "M"):
act = "本地命中"
else:
if any(st[o] == "M" for o in others):
act = "BusRd + 让脏持有者 Flush 出来"
for o in others:
if st[o] == "M":
st[o] = "S"
st[core] = "S"
elif any(st[o] in ("S", "E") for o in others):
act = "BusRd(看到别人也有 -> 共享)"
for o in others:
if st[o] == "E":
st[o] = "S"
st[core] = "S"
else:
act = "BusRd(只有我有 -> 独占,干净)"
st[core] = "E"
bus += 1
else:
if st[core] == "M":
act = "本地命中(本核独占且已脏)"
elif st[core] == "E":
act = "静默升级 E→M(独占了就不用上总线)"
st[core] = "M"
else:
act = "BusRdX(要写 -> 作废别人的副本)"
for o in others:
if st[o] != "I":
st[o] = "I"
st[core] = "M"
bus += 1
rows.append(["%s %s" % (core, "读" if op == "R" else "写"), act,
" ".join("%s=%s" % (c, st[c]) for c in cores)])
return rows, bus
SEQ_A = [("P1", "R"), ("P1", "R"), ("P2", "R"), ("P1", "R"),
("P2", "W"), ("P2", "W"), ("P1", "R"), ("P2", "W")]
SEQ_B = [("P1", "R"), ("P1", "R"), ("P1", "R"), ("P1", "W"),
("P1", "W"), ("P1", "R"), ("P1", "R"), ("P1", "W")]
print("=== 1. 两核来回抢同一行:MESI 的完整轨迹 ===")
rows, bus = mesi(SEQ_A)
table(["动作", "总线上的事", "各核状态(M 脏/独占 E/共享 S/无效 I)"], rows)
print(" %d 次访问,%d 次占用总线,本地命中 %d 次 -> 命中率 %.1f%%"
% (len(SEQ_A), bus, len(SEQ_A) - bus, (len(SEQ_A) - bus) / len(SEQ_A) * 100))
print()
print("=== 2. 只有一核反复访问:命中率立刻上去 ===")
rows, bus2 = mesi(SEQ_B)
table(["动作", "总线上的事", "各核状态(M 脏/独占 E/共享 S/无效 I)"], rows)
print(" %d 次访问,只有 %d 次占用总线 -> 命中率 %.1f%%"
% (len(SEQ_B), bus2, (len(SEQ_B) - bus2) / len(SEQ_B) * 100))
print(" 注意第 4 步:从 E 变 M 是'静默升级',不产生总线事务 —— 这是 E 态存在的全部意义。")
print(" 也注意:一旦某核把它写成 M,别的核再读就得等它 Flush,成本立刻回来了。")
print()
print("=== 3. Store Buffering 测试:顺序一致 vs 允许 Store→Load 重排 ===")
def interleavings(progs):
names = list(progs)
out = []
def rec(pc, path):
if all(pc[n] == len(progs[n]) for n in names):
out.append(list(path))
return
for n in names:
if pc[n] < len(progs[n]):
pc[n] += 1
path.append(progs[n][pc[n] - 1])
rec(pc, path)
path.pop()
pc[n] -= 1
rec({n: 0 for n in names}, [])
return out
def run_sc(seq):
mem, reg = {"x": 0, "y": 0}, {}
for core, op, obj in seq:
if op == "S":
mem[obj] = 1
else:
reg[core] = mem[obj]
return reg["P1"], reg["P2"]
PLAIN = {"P1": [("P1", "S", "x"), ("P1", "L", "y")],
"P2": [("P2", "S", "y"), ("P2", "L", "x")]}
SWAPPED = {"P1": [("P1", "L", "y"), ("P1", "S", "x")],
"P2": [("P2", "L", "x"), ("P2", "S", "y")]}
for title, combos in [
("不允许任何重排(两核都按程序序,相当于顺序一致)",
[(PLAIN, PLAIN)]),
("允许 Store→Load 重排(每核可自行决定先写还是先读)",
[(PLAIN, PLAIN), (PLAIN, SWAPPED), (SWAPPED, PLAIN), (SWAPPED, SWAPPED)])]:
got, total = {}, 0
for p1, p2 in combos:
for s in interleavings({"P1": p1["P1"], "P2": p2["P2"]}):
r1, r2 = run_sc(s)
got[(r1, r2)] = got.get((r1, r2), 0) + 1
total += 1
print(" %s" % title)
print(" 一共 %d 个执行,观测到 %d 种结果:" % (total, len(got)))
for k in sorted(got):
print(" (r1, r2) = (%d, %d) 出现 %2d 次" % (k[0], k[1], got[k]))
print(" 其中 (0, 0) 出现 %d 次%s"
% (got.get((0, 0), 0), " <-- 顺序一致下绝不可能" if got.get((0, 0), 0) else ""))
print()
print(" r1 是 P1 读到的 y,r2 是 P2 读到的 x。两核各自:先把'自己的标志'置 1,再读对方的。")
print(" 顺序一致下 (0,0) 一次都不出现;一旦允许 Store→Load 重排,它就出现了。")
print(" 这就是 x86 上那个著名的 Store Buffering 测试:")
print(" 硬件保证'单个地址的写最终会传播',却不保证'两次写被别的核按同一顺序看到'。")
python 本站为静态站,不提供在线运行;可复制到本地用 gcc / python 执行
预期输出:
=== 1. 两核来回抢同一行:MESI 的完整轨迹 ===
动作 总线上的事 各核状态(M 脏/独占 E/共享 S/无效 I)
P1 读 BusRd(只有我有 -> 独占,干净) P1=E P2=I
P1 读 本地命中 P1=E P2=I
P2 读 BusRd(看到别人也有 -> 共享) P1=S P2=S
P1 读 本地命中 P1=S P2=S
P2 写 BusRdX(要写 -> 作废别人的副本) P1=I P2=M
P2 写 本地命中(本核独占且已脏) P1=I P2=M
P1 读 BusRd + 让脏持有者 Flush 出来 P1=S P2=S
P2 写 BusRdX(要写 -> 作废别人的副本) P1=I P2=M
8 次访问,5 次占用总线,本地命中 3 次 -> 命中率 37.5%
=== 2. 只有一核反复访问:命中率立刻上去 ===
动作 总线上的事 各核状态(M 脏/独占 E/共享 S/无效 I)
P1 读 BusRd(只有我有 -> 独占,干净) P1=E P2=I
P1 读 本地命中 P1=E P2=I
P1 读 本地命中 P1=E P2=I
P1 写 静默升级 E→M(独占了就不用上总线) P1=M P2=I
P1 写 本地命中(本核独占且已脏) P1=M P2=I
P1 读 本地命中 P1=M P2=I
P1 读 本地命中 P1=M P2=I
P1 写 本地命中(本核独占且已脏) P1=M P2=I
8 次访问,只有 1 次占用总线 -> 命中率 87.5%
注意第 4 步:从 E 变 M 是'静默升级',不产生总线事务 —— 这是 E 态存在的全部意义。
也注意:一旦某核把它写成 M,别的核再读就得等它 Flush,成本立刻回来了。
=== 3. Store Buffering 测试:顺序一致 vs 允许 Store→Load 重排 ===
不允许任何重排(两核都按程序序,相当于顺序一致)
一共 6 个执行,观测到 3 种结果:
(r1, r2) = (0, 1) 出现 1 次
(r1, r2) = (1, 0) 出现 1 次
(r1, r2) = (1, 1) 出现 4 次
其中 (0, 0) 出现 0 次
允许 Store→Load 重排(每核可自行决定先写还是先读)
一共 24 个执行,观测到 4 种结果:
(r1, r2) = (0, 0) 出现 6 次
(r1, r2) = (0, 1) 出现 6 次
(r1, r2) = (1, 0) 出现 6 次
(r1, r2) = (1, 1) 出现 6 次
其中 (0, 0) 出现 6 次 <-- 顺序一致下绝不可能
r1 是 P1 读到的 y,r2 是 P2 读到的 x。两核各自:先把'自己的标志'置 1,再读对方的。
顺序一致下 (0,0) 一次都不出现;一旦允许 Store→Load 重排,它就出现了。
这就是 x86 上那个著名的 Store Buffering 测试:
硬件保证'单个地址的写最终会传播',却不保证'两次写被别的核按同一顺序看到'。四条结论:
- 两核来回抢同一行,命中率只有 37.5%(8 次访问有 5 次上总线);一核独占时是 87.5%(只有 1 次上总线)。多核性能的第一杀手不是算得慢,是数据在核之间来回搬。
P1=E那一步的"静默升级"不占总线——这就是 E 态的价值。可一旦升成 M,别的核再想读就得等 Flush,成本立刻回来。- 顺序一致下,(0,0) 出现 0 次:6 个执行里只有 3 种结果。这不是巧合,是逻辑必然——把两核的程序序串起来会得到一个环(
x=1<r1=y<y=1<r2=x<x=1),环不可能成立。 - 一旦允许 Store→Load 重排,(0,0) 出现了 6 次。同一段代码、同一台机器、两种执行模型,结果天差地别。
考点
考点
1. 两个问题必须分开答
| 问题 | 名字 | 保证什么 |
|---|---|---|
| 写什么时候被看见 | 缓存一致性 | 写传播 + 同一地址的写顺序一致 |
| 不同地址的写按什么顺序被看见 | 内存序 | 由一致性模型规定 |
一致性只约束"同一个地址"——"A 先写 x 再写 y,别人会不会先看到 y"这件事,一致性问题根本没回答。
2. MESI 四态与不变量
- M:我改了、只有我有;E:干净、只有我有;S:别人也有一份;I:作废。
- 两条不变量:M 至多一个、E 至多一个,且它们的同伴只能是 I。
- 两核 4 × 4 = 16 种组合里合法 8 种、非法 8 种(合法率 50%)。
- E 态的意义:独占干净的行要写时静默升级成 M,不占总线。
3. 两种"看似无关的写法"的命中率差
| 访问模式 | 总线事务 | 命中率 |
|---|---|---|
| 两核来回抢同一行(8 次) | 5 | 37.5% |
| 一核独占反复访问(8 次) | 1 | 87.5% |
结论:数据布局要按核划分,别让多核反复抢同一行(这也是"伪共享 padding"的由来)。
4. 四种重排与各架构
| 重排 | x86 (TSO) | ARM / POWER / RISC-V |
|---|---|---|
| Load → Load | 禁 | 允许 |
| Load → Store | 禁 | 允许 |
| Store → Store | 禁 | 允许 |
| Store → Load | 允许 | 允许 |
x86 是 TSO,只允许 Store→Load 一种重排;ARM 是弱序,四种全允许。反过来记:"x86 上能跑对" ≠ "代码正确",可能只是依赖了 x86 的额外保证。
5. 屏障与 C++ 内存序
- 硬件:全屏障
MFENCE/ 写屏障SFENCE/ 读屏障LFENCE(ARM 对应DMB SY/DMB ST/DMB LD)。 - 语言:
relaxed(无屏障)→release存 /acquire读(发布-订阅)→seq_cst(全序,默认值)。 release存 +acquire读建立 "先行发生":读到release写的值,就能看到该写之前的所有访存。
6. 双重检查锁定为什么错
ptr = malloc(...) 与 init(ptr) 之间允许 Store→Store 重排:别的线程可能看到 ptr != NULL,但对象内容还没填好。修法:在发布指针前加写屏障,或用 release 存 + acquire 读。
7. 易错点清单
- 把"缓存一致性"当成"内存一致性":前者只管写传播与同地址次序,后者才管不同地址的可见顺序。
- 认为"顺序一致"是所有硬件的事实:它是理论模型,实际硬件都是更弱的模型。
- 认为 x86 什么顺序都保证:x86 允许 Store→Load 重排。
- 把
volatile当同步手段:volatile只管"别优化掉",不管重排,也不保证原子性。 - 屏障加得越多越"安全":屏障会掏空写缓冲,代价是真的,要按需加。
- 用一核的直觉推多核:"我这行代码之后内存一定改了"这个直觉,在多核上不成立。
- 忽略伪共享:两个核写同一 Cache 行里的不同变量,也会来回抢行。
小结
- 一致性 vs 内存序是两件事:一致性保证"写最终被看见、同一地址的写次序一致";内存序才规定"不同地址的写按什么顺序被看见"。
- MESI 用四个状态把一致性做成可检查的规则:M/E 各至多一个,16 种组合只有 8 种合法;E 态的意义是省掉一次未来的总线事务。
- 硬件重排不是为了出错,是为了不等人:写缓冲让 Store→Load 重排,失效队列让 Load→Load 重排。
- 内存序谱系从左到右性能递增、程序员负担递增:SC → TSO(x86)→ 弱序(ARM / RISC-V)。
- 屏障与
acquire/release是程序员唯一能拿回顺序的工具,而它是要花钱的——加到刚好够用为止。
回到主线:这一章说的"多核看到的东西可能不一致",本质上是分布式系统的一个小规模版本——没有全局时钟、通信有延迟、消息可能乱序到达。区别只在于这里的"节点"是核而不是机器。下一章把同一套问题放到网络尺度上重讲一遍。
下一篇:分布式系统基础
评论(0)
当前浏览器不允许本地存储,评论无法保存。
还没有评论,来说两句。