bigywater.tech
Go · 71

Go 内存模型:happens-before 在 channel、锁和 atomic 之间怎么建立

“写了 x,另一个 goroutine 一定能读到吗?”靠的是 happens-before。这篇用竞态检测器逐个检验 channel、锁、原子操作等是否真的建立了它,在 arm64 上跑 litmus 测试证明弱内存序不是纸面理论,并对着本机的内存模型文档逐条读规则。

9 + 3
12 种同步写法里,建立了 happens-before 的 9 种,没有建立的 3 种(Sleep、Gosched、容量 1 channel 只 send 一次)
27591 次
SB 测试 200 万轮里普通变量出现 r1=r2=0 的次数,sync/atomic 为 0
k+C
容量 C 的 channel:第 k 次 receive 在第 k+C 次 send 完成之前同步

哪些同步有用

实测 · 本机执行输出
  [chan-send] 缓冲 channel:写 x 后 send,另一边 receive 后读 x → 无竞争(退出码 0)
  [chan-close] close 之后另一边 receive 到零值,再读 x → 无竞争(退出码 0)
  [unbuf-recv] 无缓冲 channel:对方先 receive,send 完成之后读 x → 无竞争(退出码 0)
  [buf1-recv-single] 容量 1 的 channel:只 send 一次,不等 receive 就读 x(睡 20 ms 后读) → 报告 DATA RACE(退出码 66)
  [buf1-recv-second] 容量 1 的 channel:第 2 次 send 完成之后读 x(第 1 次 receive 在 goroutine 里) → 无竞争(退出码 0)
  [mutex] goroutine 里写 x 再 Unlock,main 的第二次 Lock 之后读 x → 无竞争(退出码 0)
  [atomic-flag] atomic.Bool 的 Store(true)/Load() 当作发布标志,之后读普通变量 x → 无竞争(退出码 0)
  [once] sync.Once 里写 x,4 个 goroutine 在 Do 之后读 x → 无竞争(退出码 0)
  [waitgroup] goroutine 里写 x 再 Done,Wait 之后读 x → 无竞争(退出码 0)
  [go-stmt] go 语句之前写 x,新 goroutine 里读 x → 无竞争(退出码 0)
  [sleep-only] 只靠 time.Sleep(50ms) → 报告 DATA RACE(退出码 66)
  [gosched-only] 只靠 runtime.Gosched() 与 Sleep → 报告 DATA RACE(退出码 66)

channel 的 send→receive、close→receive、无缓冲 channel 的 receive→send 完成、mutex 的 Unlock→Lock、atomic 的 Store/Load 作发布标志、sync.Once、WaitGroup、go 语句,用 -race 检验都没有竞争。睡眠和 Gosched 不是同步,会被报告。

容量为 C 的 channel 要看规则

实测 · 本机执行输出
  SB(store buffering):线程 A: x=1; r1=y  线程 B: y=1; r2=x。顺序一致时不可能 r1=0 且 r2=0
    普通变量:r1=0 且 r2=0 出现 27591 次
    sync/atomic:r1=0 且 r2=0 出现 0 次
  MP(message passing):线程 A: data=1; flag=1  线程 B: f=flag; d=data。看到 flag=1 却看到 data=0 就是重排
    普通变量:f=1 且 d=0 出现 0 次
    flag 用 atomic:f=1 且 d=0 出现 0 次

容量 1 的 channel 只 send 一次就读 x,即使 goroutine 稍后才 receive 也是竞争,因为 send 到缓冲区不等 receive。第 2 次 send 才需要第 1 次 receive 先发生,这时读 x 就安全。这正是文档里 “第 k 次 receive 在第 k+C 次 send 完成之前”的含义。

弱内存序真的会出现,文档的结论是别耍聪明

实测 · 本机执行输出
  A send on a channel is synchronized before the completion of the corresponding receive from that channel.
  The closing of a channel is synchronized before a receive that returns a zero value because the channel is closed.
  A receive from an unbuffered channel is synchronized before the completion of the corresponding send on that channel.
  The kth receive from a channel with capacity C is synchronized before the completion of the k+Cth send on that channel.
  call n of l.Unlock() is synchronized before call m of l.Lock() returns.
  The go statement that starts a new goroutine is synchronized before the start of the goroutine's execution.
  If the effect of an atomic operation A is observed by atomic operation B, then A is synchronized before B.
  A data race is defined as a write to a memory location happening concurrently with another read or write to that same location, unless all the accesses involved are atomic data accesses as provided by the sync/atomic package.
  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.

SB 测试里,普通变量在 200 万轮中有 27591 次出现“两边都没看到对方的写”;换成 sync/atomic 是 0 次。我另外试了 MP 测试,普通变量和 atomic 都是 0 次,这次没观察到重排——没观察到不等于不会发生,文档对有数据竞争的程序没有给任何保证。本机文档把规则写成一条条 “synchronized before”,最后劝你别耍聪明:先找到一条规则把读写连起来,找不到就加同步。

分步演示

Go 内存模型
本步的对象

    本文用到的工具与边界

    • 本机 macOS 27 arm64 / Go 1.27.1(brew);用 go run / go build / go test 直接编译运行,需要第三方库时经代理从 proxy.golang.org 下载;计时用 time.Now / testing.B,数字每次运行会有小幅抖动。第一节每种同步是一个小程序,用 go build -race 编译后运行一次看是否报告 DATA RACE;litmus 测试在 arm64(GOMAXPROCS=4)上各 200 万轮,每轮用原子计数器做屏障,测试变量本身是普通 int32 或 sync/atomic;文档引用来自本机 $GOROOT/doc/go_mem.html,逐句核对过原文存在
    • 没有验证:x86 上的结果、MP 测试在更多轮次/更多线程下是否出现重排、编译器重排(只看了硬件+编译器的联合表现)、Go 1.19 之后内存模型对原子操作顺序一致的完整表述、unsafe 与 cgo 场景
    京ICP备2026067502号-1京公网安备11010502063487号