你有没有遇到过这种场景:两个线程配合干活,一个负责“准备数据”,另一个负责“等准备好就开干”。单元测试里一切正常,上了线却偶尔读到一半更新的配置、莫名其妙崩溃。直觉上你会想:既然这些 Bug 都和乱序、缓存之类的细节有关,那为什么 CPU 和编译器不干脆规定——所有线程的内存访问都必须严格按代码顺序来,不就省心了?
问题就在这里:如果一切都按顺序来,程序员确实舒服了,但机器会非常难受。现代 CPU 为了跑快,拼命利用流水线、缓存、乱序执行;编译器为了榨性能,也会大胆地重排指令。如果在语言层面一刀切地说“禁止一切重排”,那等于把这些优化全关掉,性能会掉得惨不忍睹。于是,语言设计者只好在“程序员的心智负担”和“机器的性能潜力”之间做权衡,这份折中选择就叫作内存模型。
接下来,我们先把几个核心概念讲清楚,再看看早期 C/C++ 是怎么“放养”的,然后再看 Java、Go、C++ 这几门主流语言,分别踩在了这条光谱上的哪几个点。
核心概念:到底什么是“松弛(Relaxed)”?
先说说“松弛”这个词到底在松什么。
在很多人想象的“理想世界”里,所有线程对内存的读写,都可以被排成一条时间线:每个线程内部按源码顺序执行,多线程只是把这些操作交叉起来。这种“大家都在一条时间线上排队”的模型,就是经典的顺序一致性(Sequential Consistency, SC)。
但在真实的机器上,事情远没这么简单。为了少等几次内存、少刷几次缓存,编译器会在不改变单线程结果的前提下悄悄调换指令顺序;CPU 也会利用写缓冲、乱序执行,让后面的指令先跑起来。这就导致:
源码里看上去是“先 A 后 B”的两次内存访问,在另外一个线程眼里,很可能变成了“先 B 后 A”。
只要单线程语义不变,这种“调换顺序”的行为在硬件和编译器看来就是合法的优化。语言如果选择接受这类优化,就被称为有一个比较“松弛”的内存模型;如果语言禁止这类行为,强制所有线程的访问都老老实实在一条时间线上排队,那就是比较“强”的模型。
所以,粗略可以这样记:松弛 = 接受更多种“奇怪”的执行顺序,换取性能;强模型 = 禁掉这些奇怪顺序,换取易于理解的语义。
选项一:早期 C/C++——完全不管,交给硬件
在很长一段时间里,C 和 C++ 其实是“躺平”的:标准里几乎不谈并发,更别说什么内存模型了。多线程完全是库层面的事情,你用 pthreads 也好、Windows 线程 API 也罢,语言规范本身保持沉默。
这看上去给了实现者很大的自由度:每个平台可以根据自己的硬件特性,决定怎么实现线程、锁和原子操作。问题在于,程序员完全失去了一个统一的“参考坐标系”。
想象你写了一段多线程 C++ 代码,在 x86 服务器上跑得好好的,移植到 ARM 手机上却开始偶尔读到旧值;在 GCC 某个版本加上 -O3 也能跑,再换成新版本就时不时挂掉。你去查标准,发现它压根就没告诉你:在有数据竞争的情况下,程序到底允许出现哪些行为、哪些是不允许的。
换句话说,程序的语义被“外包”给了具体组合:编译器版本 + 优化选项 + 硬件架构。只要你想写出真正可靠的并发程序,就得去翻每一种 CPU 的手册,还要反复试验每一种编译器的优化策略。这也就是 Hans Boehm 在那篇著名论文《Threads Cannot be Implemented as a Library》里抱怨的:
只靠库是没法把多线程说清楚的,语言自己必须给一个说法。
选项二:强顺序模型——一切都排队,大家都省心
在光谱的另一端,是那种“理想但昂贵”的强模型。还记得前面那条“所有线程的读写交错在一条时间线上”的假想吗?如果语言直接规定:
所有线程的内存访问,都必须像在这条时间线上那样,严格按照某个全局顺序执行;每个线程内部看到的顺序,也必须和源码完全一致。
那我们就得到了一个近乎完美的世界:
- 你可以把多线程程序想象成“几段单线程代码按某种顺序轮流执行”,推理起来非常直观;
- 很多诡异的执行结果,在这个模型下天生就不可能发生,连担心都不用担心。
这就是**顺序一致性(SC)**的魅力所在。很多人第一次接触并发时脑子里想的,其实就是这么一个世界。
但对编译器和 CPU 来说,这个世界太“理想化”了。为了营造出“好像大家都在一条队伍里”的错觉,它们必须:
- 更谨慎地做指令重排,许多本来安全的优化都不能用了;
- 在多核之间频繁同步缓存状态,插入额外的屏障指令,牺牲一部分并行度。
这就好比把一条本来可以自由超车的高速公路,强行改成大家只能按顺序排队慢慢开。事故少了,但车流量也下来了。
选项三:主流语言的折中路线
现实世界里的语言,最终都没有选这两个极端:既不能像早期 C/C++ 那样完全不管,也很难在现代硬件上贯彻“所有东西都强顺序”。于是,不同语言各自找了一条折中的路线。
有的语言(比如 Java)会比较“啰嗦”地写下一长串规则,告诉你在什么条件下,一个线程对内存的修改必须对另一个线程可见,这些规则合在一起就构成了所谓的 Happens-Before 关系;
也有语言(比如 Go)干脆不鼓励你直接在共享内存上做文章,而是鼓励你把并发写成“消息在管道里流动”的样子,让语言运行时在管道内部帮你兜住细节;
再有就是 C++ 这种“给你一整套精细仪表盘”的语言:既提供了锁、条件变量这些高层抽象,也暴露了原子操作和内存序这样的低层开关。
为了更具体地感受这些差异,我们先从 Java 这个“规则写得最细”的例子开始。
1. Java:先驱者的尝试(Java 5+)
Java 很早就意识到:“如果语言自己不把并发语义说清楚,库作者和编译器作者就会互相猜来猜去,最后谁也说不清 bug 到底是谁的锅。” 于是,在 Java 5 的 JSR-133 规范里,它正式引入了自己的内存模型(Java Memory Model, JMM)。
JMM 做了几件关键的事:
首先,它保证了基本的类型安全——比如不会出现“读到一个写了一半的对象引用”这种恐怖场景。其次,它给 volatile 赋予了比较强的语义:对一个 volatile 变量的写,在效果上不仅是“别合并、别缓存”,而且会在其他线程里建立起明显的“先后顺序”。再往后,它通过一堆看上去有点抽象的规则,定义了所谓的 Happens-Before 关系。
可以用一个常见的“配置加载”小故事来理解 Happens-Before:
线程 A 在启动时加载配置,把结果放进某个 config 对象里,然后把一个标志位 initialized 设为 true;线程 B 在运行过程中,会先检查这个标志位,如果发现已经是 true,就放心地去读 config。在一个天真的想象里,只要 B 看到 initialized == true,就一定能看到最新的配置。但在松弛的世界里,如果语言不管这件事,硬件和编译器完全可能把 A 里的两句赋值重排一下,让“写标志位”这一步先对外可见,而“写配置”的那一步晚一点才被别的核看到。结果就是:B 看到 initialized 已经变真了,却读到了旧配置。
Happens-Before 的作用,就是在这种场景里帮我们“拉一条线”:如果规范说“操作 A happens-before 操作 B”,那就意味着 A 对内存的所有修改,在 B 看来都必须已经完成了。Java 规定:对某个 volatile 变量的写,在内存模型里 happens-before 于之后任何线程对同一个变量的读。于是,只要把 initialized 声明成 volatile,一旦线程 B 看到它变成 true,就可以确信自己看到的是线程 A 已经写完 config 之后的世界。
从程序员的角度看,有了这些规则,你不需要理解每一种 CPU 的乱序细节,只要围绕 Happens-Before 去设计“谁先、谁后”的关系,就能在绝大多数情况下写出行为可预测的并发代码。
2. Go:大道至简
说完 Java,我们来看一个风格几乎相反的例子:Go。Go 从一开始就打着“简单、工程化”的旗号,它在并发上的基本态度可以概括成一句话:不要把普通工程师推到内存模型的细节里去打滚。Go 的官方口号你可能听过:
“不要通过共享内存来通信,而要通过通信来共享内存。”
这句话有点抽象,我们还是从一个具体的小场景讲起。假设你有两个 goroutine,生产者负责生成任务,消费者负责处理任务。如果按照传统 C/C++ 的习惯,你可能会写一个全局的 Task task 再加一个 bool ready。生产者在准备好任务后,把 task 填满,然后把 ready 设为 true;消费者则在一个循环里盯着 ready,一旦发现它变成了 true,就去读取 task 并开始处理。
乍一看这没什么问题,但细想一下就会发现很多隐患。你需要自己保证“写 task 再写 ready”这个顺序,在别的 CPU 核上看起来也是这个顺序;你得担心消费者会不会在任务还没完全写好之前,就已经看到 ready == true;你还要考虑要不要给这两个变量加锁、锁应该锁多大粒度、会不会造成性能瓶颈。所有这些顾虑,其实都是在跟“松弛内存模型”抢地盘。
在 Go 里,推荐的做法是换一种思路:直接建一个 chan Task。生产者只管在准备好任务之后,执行 ch <- task;消费者只需要在自己的 goroutine 里写 t := <-ch。表面上看,我们只是把“写全局变量 + 改标志位”这两个步骤,换成了“往 Channel 里发一条消息”,但底层的故事完全不一样。发送之前,生产者先在自己的栈或堆里把 task 填好;发送时,Go 运行时把任务的内容拷贝进 Channel 内部维护的队列;接收时,再把这份数据从队列里拷贝出来交给消费者。整个“发送 → 接收”的过程,自带一条强有力的保证:只要消费者成功从 Channel 里拿到一个 Task,它看到的一定是发送方在发送之前已经完全写好的那一份,而不是某个中间状态。
那如果你坚持用共享变量呢?Go 的态度就非常直接了:只要有两个 goroutine 同时访问同一个变量,且至少有一个是写操作,你就必须用 sync.Mutex 或者其他同步原语来保护;如果你不这么做,那就是数据竞争,行为未定义,跑起来算你运气,调试工具也会把这视为彻头彻尾的 Bug。Go 不像 Java 那样还去精细地讨论“在没同步的前提下,我还能给你哪些最低限度的保证”,它选择的是把这些危险用法一棍子打死——要么走 Channel 这条安全的大路,要么老老实实加锁,别指望在共享内存上玩花样。
再回头看 Channel 本身,其实并没有什么“魔法物理学”。在实现层面,它依然是共享内存加锁和条件等待,只不过被封装成了一个看上去很干净的管道。你可以把它想象成一个带锁的环形缓冲区:发送时拿锁,把数据写进环形队列;接收时再拿锁,从队列里把下一条数据取出来。在这个过程中,Go 运行时会在合适的地方插入内存屏障,建立起清晰的“先写数据、再让别人看到”的关系。对你来说,只需要记得一件事:要么从 Channel 里拿到一份完整的数据,要么什么也拿不到,不会出现“一半是旧数据、一半是新数据”的诡异情况。
站在工程实践的角度看,Go 做的是三件事的组合:把正确的并发写法(Channel / Mutex)做得尽量顺手;把那些“看起来能跑、其实很危险”的写法(裸共享变量 + 侥幸心理)直接判为错误;把大部分内存模型的复杂度埋进运行时和工具(比如 go vet、-race 检测)里,而不是摊给每一个业务工程师。这才是“大道至简”的真正含义——不是说它的底层实现多么简单,而是它尽量不让你直面那些复杂的细节。
3. C/C++:极致的控制权(C++11+)
再把视角拉回到 C/C++,这里的气质就明显不一样了:它本来就是用来写操作系统、数据库、浏览器内核的语言,目标用户是“要和硬件正面刚”的那群人。
C++ 的一个核心口号是 zero-cost abstraction——抽象可以给你,但一旦不用就绝不能多花你一条指令。这也意味着:它没法像 Go 那样在运行时帮你兜太多底,相反更倾向于把所有控制旋钮都摆到你面前,让你自己决定要不要拧。
在 C++11 之前,标准里其实也没有正式的“内存模型”概念,线程也只是库层面的东西。Hans Boehm 在 2005 年那篇著名论文《Threads Cannot be Implemented as a Library》里直接指出:如果语言自己不定义并发语义,只靠库是没法说清楚多线程程序到底在干什么的。 这篇文章后来基本把 C/C++ 标准委员会“按在桌子上”,逼着他们必须把内存模型写进语言规范。
于是,在 C++11 里,你第一次看到了 std::atomic 和 std::memory_order 这些家伙。可以这么理解它们的设计思路:
- 如果你什么都不管,只是老老实实用默认的
std::atomic<int>,不写内存序,编译器就按最安全、最直观的那套来对待你(memory_order_seq_cst),尽可能接近我们前面讲的“强顺序世界观”。大部分业务代码只要配合互斥量、条件变量,其实到这里就够了。 - 当你开始在乎性能,觉得“默认有点笨重”时,可以先学会
memory_order_acquire/memory_order_release这一对。它们就像在两个线程之间拉了一根绳子:一端是release写,一端是acquire读,只要这根绳子搭好了,“先写数据,再打标志”这样的模式就能被安全表达出来,而不用付出完整顺序一致性的成本。 - 再往下,还有
memory_order_relaxed这样的“专家模式”。这里 C++ 基本把底牌全翻给你看:除了保证单次操作是原子的之外,其他顺序几乎都不管了,换来的是和普通读写几乎一样的性能。这种东西典型用在“只需要大致统计”的计数器、性能监控指标上,一旦用错,就会掉进非常微妙的 Bug 里。
你可以把这种设计看成是:C++ 不替你做决定,但提供了从“完全省心”到“完全手动”的一整条梯子。
同样一段并发逻辑,你可以先用默认原子和锁写出一个正确版,再在明确知道自己在做什么的前提下,逐步换成 acquire/release,甚至个别地方用 relaxed 去挖掘性能空间。
从历史上看,C++11 这套内存模型虽然被很多人吐槽“太难懂”,但它后来几乎成了系统级语言的模板:Rust、Swift 等在设计底层原子操作时,都不同程度地向它看齐。某种意义上,它帮整个业界把“硬件级并发”这件事的术语和规则梳理了一遍。
C++ 的“规约”:DRF-SC
说了这么多细节,C++ 其实也不指望每个人都去画 happens-before 图、研究每一种 CPU 的乱序规则。它给你准备了一根可以牢牢抓住的“安全绳”,这就是常被提起的 DRF-SC(Data Race Free implies Sequential Consistency) 原则。名字虽然有点吓人,但翻成白话其实就一句话:
只要你的程序里没有数据竞争,它看起来就像在一个强顺序的理想世界里执行。
这里的“没有数据竞争”,可以想成是给自己立下的一条纪律:要么某个共享变量始终只在一个线程里读写;要么一旦有多个线程要同时动它,就必须把所有访问都放进锁里,或者通过默认的顺序一致原子操作(seq_cst)来保护。只要你守住这条纪律,C++ 标准就站在你这边,保证你可以用“像单线程那样按顺序执行”的直觉来思考程序,不必被编译器的重排和 CPU 的乱序吓到。
从这个角度看,DRF-SC 像是在你和实现之间签了一份君子协定:你承诺不写出有数据竞争的代码,语言和编译器就承诺帮你屏蔽掉大部分底层细节,让程序的行为与强顺序模型尽量一致。这极大减轻了普通 C++ 开发者的心智负担——大多数时候,你只需要注意哪里该加锁、哪些地方应该用原子,就能写出行为可预期的并发代码。
当然,C++ 也很诚实地告诉你:如果有一天你为了极致性能,主动跑去用 memory_order_relaxed 之类更松弛的内存序,刻意绕开这层保护,那你就是走出了 DRF-SC 的舒适区。从那一刻起,语言不再保证你看到的是强顺序的效果,很多“离谱”的执行结果也都变得合法起来。你获得了更多的自由度,同时也必须自己理解底层内存模型的细节,并为可能出现的各种微妙 Bug 负全责。
总结
回过头来看,Java、Go、C++ 在内存模型上的路线,其实刚好勾勒出了一条从“强保护”到“强控制”的光谱:
- Java 更愿意当那个细心的监护人:帮你把大部分危险情况都想清楚,用 Happens-Before 等规则把该禁止的行为先禁掉。
- Go 则选择做一个工程经理:制定简单直接的用法规范(Channel / Mutex),把复杂度藏在 Runtime 和工具里,对危险用法零容忍。
- C++ 更像是把实验室的钥匙交给你:从安全的默认原子,到精细的内存序,再到完全松弛的模式,所有工具都摆在桌上,由你自己决定怎么用。
理解了这些背后的取舍,再看 C++ 的 std::atomic 和各种 memory_order,就不太会把它们当成“故意刁难人的语法怪物”,而更像是一组精细的控制旋钮——
- 如果你只需要“写对代码”,就握着 DRF-SC 这根安全绳,用锁和默认原子就好;
- 如果你真的需要挖掘极限性能,才去考虑那些更松弛的选项,并为此承担相应的心智成本。