Skip to content

内存序:为什么多核下,你的代码不一定按写下的顺序执行

上一篇咱们把那个 ctx 写花的 bug 钉死在了根因上:旧核在存一份任务的上下文,新核同时已经在取同一份上下文,两头并发读写同一个字段。要修它,得让「旧核存完了」这件事,能被新核靠得住地看见——这就撞上一件比调度器更深的事:多核之间,一个核写下的数据,另一个核到底什么时候才看得见?你以为代码是按写下的顺序一条条跑的,可在多核世界里,这件事并不天然成立。这一篇咱们就把「内存序」这玩意儿讲透,不然下一篇看到 release 两个字,你会完全不明白它在防什么。

先破一个错觉:代码不是一条一条顺序执行的

聊内存序之前,得先打破一个根深蒂固的直觉——你以为 CPU 是老老实实按你写下的顺序,一条指令一条指令地执行。实际情况不是这样,有两个家伙都在偷偷重排你的指令。

第一个是编译器。编译器在优化你的代码时,只要它判断「两条指令之间没有依赖关系」,就可能把它们的前后顺序打乱——目的是让 CPU 的流水线喂得更满、跑得更快。比如你先写 a = 1 再写 b = 2,编译器看这俩互不影响,完全可能输出成先写 b 再写 a 的机器码。对你这个单线程程序的结果来说,毫无区别。

第二个是 CPU 自己。现代 CPU 为了快,早就不是「取一条、执行一条、再取下一条」那种老实做法了。它有流水线、有乱序执行(out-of-order execution):只要两条指令没有依赖,后面的指令完全可以提前跑到前面去执行;写到内存里的数据,也可能先在 CPU 自己的缓存或写缓冲里憋一会儿,再统一提交到主存。这一切,都是硬件为了榨性能偷偷干的。

那为什么咱们平时写代码感觉不到?

你可能要慌了:既然编译器和 CPU 都在乱排我的指令,那程序还能跑对?

答案是:在单核视角下,它们遵守着一种叫「as-if 串行」的契约——不管怎么重排,对这个核自己看来,程序的执行结果和「老老实实按顺序执行」一模一样。能重排的,只有那些「重排了也不影响本核观察到的结果」的指令,也就是互相没有依赖的。所以单线程程序,你怎么写就怎么跑,感觉不到任何异样。

麻烦出在多核

多核之下,重排就露馅了

一旦有了两个以上的核,「as-if 串行」就只对每个核自己成立了。一个核的写,什么时候、以什么顺序对另一个核变得可见,中间没有免费的保证。

这里头有两层麻烦。第一层,每个核都有自己的写缓冲(store buffer)和私有的 L1 缓存——你写一个值,它可能先停在核自己的缓冲里,过一会儿才真正抵达别的核能看见的地方;这个「过一会儿」有多长、几次写之间谁先到,不保证。第二层,编译器和 CPU 的重排,在多核下就直接暴露成了「别的核观察到的顺序」和「你写代码的顺序」不一致。

来看一个经典的坑。假设核 A 往一块共享内存里填数据,填完把一个标志位置位,通知核 B 来读:

text
核 A:                          核 B:
  data = 42;        // 写①       while (flag != 1);   // 等 flag 置位
  flag  = 1;        // 写②       print(data);         // 期望读到 42

你的直觉是:核 A 先写 data、再写 flag,核 B 看到 flag 变 1 了,data 肯定早就写好了,读出来必然是 42。可在一个会重排的机器上,核 A 的「写② flag」可能被重排到「写① data」之前——于是 flag 先变成 1 对核 B 可见,核 B 兴冲冲跑去读 data,读到的却是 0(旧的值)。程序就错了,而且错得极其难复现,因为它依赖具体某次重排到底有没有发生。

这就是为什么咱们需要一种手段,在某些关键位置禁止重排、保证「之前的写,一定先于之后的写,对别的核可见」。这个手段,就是内存屏障。

内存屏障:在关键位置钉一根桩

内存屏障(memory barrier,也叫内存栅栏 memory fence)是一条给 CPU 和编译器的硬性指令,意思是:这条线之前的所有读写,必须在它之后的所有读写之前完成——不仅在自己这个核上完成,而且要对其他核「可见」的顺序也保持如此。

它的作用就是切一刀:屏障上方的读写,不许重排到屏障下方;屏障下方的读写,不许重排到屏障上方。于是你可以刻意地,在「写 data」和「写 flag」之间立一道屏障,强制 flag 一定在 data 之后才对别的核可见,上面那个坑就被堵上了。

不过,直接手写屏障指令既容易出错又不直观。于是人们把它包成了两种更顺手的语义,叫 acquirerelease——这也是 Cinux 代码里 __ATOMIC_ACQUIRE__ATOMIC_RELEASE 那两个怪名字的来历。

acquire(获取)语义,通常用在「读」上:它保证——屏障之后的读写,不许重排到这次 acquire 读的之前。它像一扇进临界区的门:你进门(acquire)之后干的任何事,都不会被偷偷挪到门外去。典型的例子是拿锁——test_and_set(acquire) 拿到锁后,临界区里那些对共享数据的读写,保证不会跑到「拿锁」之前去。

release(释放)语义,通常用在「写」上:它保证——屏障之前的读写,不许重排到这次 release 写的之后。它是出临界区的门:你出门(release)之前干的所有事(临界区里那些写),保证都已经完成、对别的核可见了,然后才放出去这条 release 写。

一对 acquire 和 release 合起来,正好把一个临界区从两头夹住:里面的读写既跑不出去(acquire 挡前)、外面的读写也插不进来(release 挡后),而且临界区里所有改动,在 release 的那一刻,保证对接下来拿到锁的核全部可见。

为什么不直接依赖「x86 很强」

读到这儿你可能会想:Cinux 跑在 x86-64 上,而 x86 的内存模型是出了名的强(大致是一种叫 TSO 的、全序写模型),普通写之间基本不会重排,上面那个 data/flag 的坑在 x86 上其实不会发生,那咱们是不是可以不写屏障、直接裸写?

能这么想说明你抓到了点子上,但这条路不能走。编译器那一层依然在重排——就算 CPU 不重排,编译器在没看到屏障或原子操作约束时,照样会把你的普通读写打乱,x86 的强模型管不到编译器。而且代码写出来是要讲可移植和可维护的:今天跑在 x86 上,明天换个架构(比如 ARM,它的内存模型比 x86 弱得多,真会触发上面的坑),裸写就会炸。用 acquire / release 这层抽象,等于把「到底要不要插硬件屏障指令」交给编译器去按当前 CPU 决定——在 x86 上它多半什么都不插(因为本来就不需要),在 ARM 上它会老老实实插上。你写的代码,两处都对。

所以记住这条:写跨核的共享数据,该用 acquire / release 就用,别赌当前 CPU 够不够强。

这一篇留下了什么

把内存序讲到这儿,是为下一篇那个 on_cpu 修法铺一块能站稳的地。一句话收住:编译器和 CPU 都会为了性能重排你的读写指令,单核下靠 as-if 串行保平安,多核下一个核的写何时对别的核可见却没有免费保证——而内存屏障就是在关键位置钉一根桩,强制之前的读写先于之后的读写对别的核可见;acquire / release 把这根桩包成了顺手的语义,acquire 挡住临界区入口、release 挡住出口,合起来保证临界区里的改动在 release 时对别的核全部可见。

带着这些再回头看那个 ctx bug 的修法,就顺理成章了:旧核把上下文一条条存进 task 结构体之后,用一次 release 写on_cpu 标记成「存完了」——这条 release 保证,「存上下文的那些写」全部先于「写 on_cpu」对别的核可见;于是新核只要看到 on_cpu 变成了「存完了」,就敢放心去取这份上下文,因为它知道该看见的它都看得见了。下一篇咱们就看这套标记在代码里怎么落下去。

035_multi_terminal-45-gf25de18 · f25de18 · 2026-08-04