5.1 Go 内存模型正式定义
卷一讲过 -race 怎么用、Mutex 和 channel 怎么选。但那些都是工具与用法,没有回答一个更底层的问题:凭什么说一个并发程序是对的?「我加锁了」「我用了 channel」是直觉,不是定义。要判断一段代码到底有没有保证,得回到 Go 内存模型——一份用 happens-before 形式化的规范。
本节要回答:Go 内存模型到底定义了什么,「happens-before」是怎么被构造出来的,以及什么才算一个 data race。结论是:内存模型定义内存操作的顺序与可见性;无竞争程序享有 DRF-SC,有竞争程序仍有最低实现约束,但不能按顺序一致性推理;理解这一点,5.3 节的种种「看不见的 bug」才有统一解释。
5.1.1 官方文档在哪里
Go 内存模型的权威原文有两份等价载体:在线版在 https://go.dev/ref/mem,本机副本是 GOROOT 下的 doc/go_mem.html:
$ ls /usr/local/go/doc/ | grep mem
go_mem.html
$ python3 -c '
import re, html
t = open("/usr/local/go/doc/go_mem.html").read()
t = re.sub(r"<script.*?</script>", "", t, flags=re.S)
print(html.unescape(re.sub(r"<[^>]+>", "", t))[:180])'
本节所有引用都来自这份文件,逐字核对过。文档开头就有一句很值得抄下来的话:
If you must read the rest of this document to understand the behavior of your program,
you are being too clever.
Don't be clever.
这句话的工程含义是:**内存模型的正式定义是给「判断对错」用的,不是给「设计并发」用的。**正常写并发应该用锁和 channel 把数据串行化,形式定义只在你要论证「这个无锁结构真的安全」时才需要翻开。
5.1.2 内存操作:四个细节
正式定义从最底层的单位讲起。一次**内存操作(memory operation)**由四件事刻画:
A memory operation is modeled by four details:
its kind, indicating whether it is an ordinary data read, an ordinary data write,
or a synchronizing operation such as an atomic data access,
a mutex operation, or a channel operation,
its location in the program,
the memory location or variable being accessed, and
the values read or written by the operation.
四件事分别是:种类(普通读、普通写、还是同步操作)、在程序里的位置、被访问的变量、读写的值。这里最关键的是第一种分类——同步操作与普通操作不是一回事。
文档进一步把操作按「读样」与「写样」归类:
Some memory operations are read-like, including read, atomic read, mutex lock, and channel receive.
Other memory operations are write-like, including write, atomic write, mutex unlock, channel send, and channel close.
Some, such as atomic compare-and-swap, are both read-like and write-like.
注意几个反直觉的归类:Lock 是读样的、Unlock 是写样的;channel 接收是读样、发送是写样;CompareAndSwap 两者皆是。这个归类不是文字游戏——后面 happens-before 的传递链正是靠它接起来的。
5.1.3 三个关系:从 sequenced before 到 happens before
定义建立在三个偏序关系上,层层叠加:
**第一层:sequenced before(同 goroutine 内)。**它是单个 goroutine 内部的求值顺序,来自语言规范:
Requirement 1:
The memory operations in each goroutine must correspond to a correct sequential execution of that goroutine,
given the values read from and written to memory.
That execution must be consistent with the sequenced before relation,
defined as the partial order requirements set out by the Go language specification
for Go's control flow constructs as well as the order of evaluation for expressions.
**第二层:synchronized before(跨 goroutine 的同步对)。**它来自「某个读样同步操作观察到了某个写样同步操作」:
The synchronized before relation is a partial order on synchronizing memory operations,
derived from W.
If a synchronizing read-like memory operation r
observes a synchronizing write-like memory operation w
(that is, if W(r) = w),
then w is synchronized before r.
**第三层:happens before = 前两者的传递闭包。**这一句是整份文档的地基:
The happens before relation is defined as the transitive closure of the
union of the sequenced before and synchronized before relations.
用一张表把三者摆开:
| 关系 | 作用域 | 来源 | 推理方式 |
|---|---|---|---|
| sequenced before | 单 goroutine 内 | 语言规范的控制流与求值顺序 | 依照规范中的顺序要求 |
| synchronized before | 同步操作之间,常用于跨 goroutine | 由同步操作的观察关系及具体同步规则确定 | 不能仅凭墙钟先后建立同步边 |
| happens before | 全局 | 前两者并集的传递闭包 | 可沿上述两类边连续推导 |
「传递闭包」是全部魔力的来源:如果 A sequenced before B(同 goroutine),B synchronized before C(跨 goroutine),C sequenced before D(同 goroutine),那么 A happens before D——即使 A 和 D 在两个不同的 goroutine 里、中间隔着一把锁或一个 channel。这就是「锁能保护数据」的数学依据。
5.1.4 可见性:Requirement 3
有了 happens-before,就能定义「一次普通读能看到哪个写」。文档用 W(r) 表示「读 r 读到了哪个写」:
Requirement 3:
For an ordinary (non-synchronizing) data read r on a memory location x,
W(r) must be a write w that is visible to r,
where visible means that both of the following hold:
w happens before r.
w does not happen before any other write w' (to x) that happens before r.
翻译成人话:**一次普通读只能看到「在它之前发生、且没有被更晚的写覆盖掉」的那个写。**两个条件缺一不可——既要 w happens before r(不能读到「未来」的值),又要 w 之后没有别的写也 happens before r(不能读到「已被覆盖」的旧值)。
这里埋着 5.3 节要展开的坑:Requirement 3 只对普通读规定了「必须读到什么」,没有规定「必须及时读到」。如果两次访问之间没有任何同步,r 和 w 就可能 unordered by happens-before——若同一位置的访问至少包含一次写、至少一次操作是非同步操作,且在 happens-before 下无序,就有 data race;失去 DRF-SC 保证,但仍受后文最低实现约束。
5.1.5 data race 的精确定义
data race 不是「同时访问」,而是「无序的并发访问」。文档给的定义分两种:
A read-write data race on memory location x
consists of a read-like memory operation r on x
and a write-like memory operation w on x,
at least one of which is non-synchronizing,
which are unordered by happens before
(that is, neither r happens before w
nor w happens before r).
A write-write data race on memory location x
consists of two write-like memory operations w and w' on x,
at least one of which is non-synchronizing,
which are unordered by happens before.
拆成三个必要条件:
- 至少一次访问是普通(非同步)操作——两边都是原子操作就不算 race;
- 访问同一内存位置,且其中至少一个是写(读读不算);
- 两个操作在 happens-before 下无序——既不是
r → w,也不是w → r。
第 3 条是精髓:**两个访问就算在时间上真的并行,只要它们被 happens-before 连起来,就不算 race。**反过来说,两个访问就算「看起来」先后执行,只要 happens-before 没连上,照样是 race——因为编译器和 CPU 可以重排。文档给过一个极简反例:
var a, b int
func f() { a = 1; b = 2 }
func g() { print(b); print(a) }
func main() {
go f()
g()
}
文档对它的结论是:it can happen that g prints 2 and then 0.——g 看到 b=2 却不保证看到 a=1。这正是「观察到一个写不蕴含观察到它之前的写」。
5.1.6 DRF-SC:无竞争程序是顺序一致的
内存模型给无竞争程序的最大承诺叫 DRF-SC(Data-Race-Free ⇒ Sequentially Consistent):
In the absence of data races, Go programs behave as if all the goroutines
were multiplexed onto a single processor.
This property is sometimes referred to as DRF-SC: data-race-free programs
execute in a sequentially consistent manner.
它的实用价值极高:**只要你的程序没有 data race,就可以按「所有 goroutine 在一个 CPU 上轮流跑」来推理。**不必考虑内存重排、缓存可见性、指令乱序——这些都被 happens-before 兜住了。
反过来,DRF-SC 的边界也同样重要:它对有竞争的程序不作任何顺序一致保证。文档明确说,对于含竞争的程序,实现可以「report the race and halt execution」(-race 就是这么干的),也可以只保证「每次读看到一个实际被写过的值」。这解释了为什么 -race 干净不等于逻辑正确,也解释了为什么带竞争的程序会给出「时好时坏」的结果——它的结果不能按 DRF-SC 推断,但也不等同于 C/C++ 数据竞争导致的完全未定义行为。
| 程序状态 | 内存模型承诺 | 推理方式 |
|---|---|---|
| 无 data race | DRF-SC:顺序一致 | 可当作单 CPU 轮流执行 |
| 有 data race | 无顺序一致保证 | 只能靠「读看到某次写」的最低约束 |
5.1.7 含竞争程序的实现约束
DRF-SC 只管无竞争程序。那有竞争的程序呢?文档专门给了一节「Implementation Restrictions for Programs Containing Data Races」,规定实现至少要满足什么。核心是这条关于单字读取的约束:
A read r of a memory location x
holding a value
that is not larger than a machine word must observe
some write w such that r does not happen before w
and there is no write w' such that w happens before w'
and w' happens before r.
That is, each read must observe a value written by a preceding or concurrent write.
Additionally, observation of acausal and “out of thin air” writes is disallowed.
意思是:不大于一个机器字(64 位平台是 8 字节)的读,必须看到某个「之前或并发发生的写」实际写下的值,不允许凭空造值(out-of-thin-air)。这是 Go 比 C/C++ 温和的地方——C/C++ 里带竞争的程序完全未定义,编译器可以「为所欲为」;Go 则承诺大多数竞争只有有限几种结果。
但这条约束只对单字及以下成立。对多字数据结构,文档明确说实现「may instead treat larger operations as a set of individual machine-word-sized operations」:
This means that races on multiword data structures
can lead to inconsistent values not corresponding to a single write.
When the values depend on the consistency
of internal (pointer, length) or (pointer, type) pairs,
as can be the case for interface values, maps,
slices, and strings in most Go implementations,
such races can in turn lead to arbitrary memory corruption.
这一段解释了为什么「接口值 / map / slice / string 上的竞争」特别致命:接口、字符串及切片等具有多个相关字段,常见表示分别包含类型与数据、指针与长度、指针与长度及容量。具体布局是实现细节,map 本身也不能按一个固定的“双字对”统一解释。竞争可能让程序读到「新指针 + 旧长度」这种撕裂组合,进而任意内存损坏——不是值错,是崩。
| 访问宽度 | 带竞争时的保证 |
|---|---|
| ≤ 1 个机器字 | 必须看到某次实际写过的值,禁止凭空造值 |
| > 1 个机器字 | 只保证「按机器字拆开各自成立」,可能读到撕裂值 |
这条边界是「别在共享可变结构上偷懒」的硬理由:多字对象的竞争后果不是「读到旧值」,而是「读到不存在的值」。
5.1.8 这一节与后续的关系
本节是第 5 章的地基,后面两节各铺一条路:
- 5.2 happens-before 的建立:把官方文档「Synchronization」小节里列出的每一条规则(goroutine 创建、channel、锁、
Once、原子操作、init)逐条配上可运行的例子,回答「怎么把 happens-before 连起来」。 - 5.3 可见性陷阱与
-race实测:把「连不起来」会怎样做成真实的反例,并用-race打出竞争报告。
一句话收束:**Go 内存模型不是「关于内存的模型」,而是「关于什么算同步的模型」。**它用 sequenced before 与 synchronized before 的传递闭包定义 happens-before,再用 happens-before 定义 data race 与可见性。凡是没被 happens-before 连起来的并发访问,程序就跌出了 DRF-SC 的保护范围。
阅读导航:上一节:4.3 Go 1.26/1.27 变更逐项与迁移检查表 · 下一节:5.2 happens-before 的建立 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。