《Go 语言高级编程》5.2 happens-before 的建立

上一节给出了 happens-before 的定义,本节回答它由哪些操作建立。逐条拆解官方文档的 Synchronization 小节:初始化、goroutine 创建、channel 收发与关闭、锁、Once、原子操作、终结器,每条规则配一段本机 -race 实测,并汇总成一张「建立 happens-before 的操作」对照表。

5.2 happens-before 的建立

5.1 节把 happens-before 定义成「sequenced before 与 synchronized before 的传递闭包」。前一半(同 goroutine 的求值顺序)由语言规范给定,不用你操心;真正要你负责的是后一半——用什么操作把两个 goroutine 接起来。接不上,程序就有 data race。

本节要回答:Go 里到底哪些操作会建立 synchronized-before 边。结论是:官方文档列了七类——初始化、goroutine 创建、channel 收发与关闭、锁、Once、原子操作、终结器;唯独「goroutine 结束」不建立任何顺序,这是最反直觉的一条。每条规则下面都配了本机 -race 实测。

5.2.1 只有同步操作才建立边

回忆 5.1 节的归类:Lock 是读样同步操作、Unlock 是写样同步操作,channel 接收读样、发送写样。synchronized-before 边只在同步操作之间产生:当某个读样同步操作「观察到」某个写样同步操作时,就画一条 w → r 的边。普通读写不产生边。

所以「建立 happens-before」这件事,本质是用同步操作把普通读写夹在中间:普通写 → (sequenced) → 同步写样操作 → (synchronized) → 同步读样操作 → (sequenced) → 普通读。这样普通读就能看到普通写。下面逐条看官方给了哪些「同步操作」。

5.2.2 初始化

If a package p imports package q, the completion of
q's init functions happens before the start of any of p's.

The completion of all init functions is synchronized before
the start of the function main.main.

两条规则:被 import 的包的 init 先完成;所有 init 完成同步先于 main.main 开始。这是唯一不需要你写任何同步代码就成立的边——包级变量的初始化顺序因此是可靠的。

5.2.3 goroutine 创建:go 语句

The go statement that starts a new goroutine
is synchronized before the start of the goroutine's execution.

go f() 这一句同步先于 f 开始执行。这意味着 go 语句之前的所有普通写,对新 goroutine 一定可见。实测:

var a string

func f(wg *sync.WaitGroup) {
	defer wg.Done()
	fmt.Println("in goroutine a =", a)
}

func main() {
	var wg sync.WaitGroup
	a = "hello, world"
	wg.Add(1)
	go f(&wg)      // a 的写在 go 之前,f 里必能看到
	wg.Wait()
}

-race 运行结果:

in goroutine a = hello, world

干净,无竞争报告。注意方向:go 语句建立的是「语句之前 → 新 goroutine 开始」这一条边,它不建立反方向的边——新 goroutine 里的写,对启动方没有可见性保证。这正是下一条的坑。

5.2.4 goroutine 结束:不建立任何边

The exit of a goroutine is not guaranteed to be synchronized before
any event in the program.
...
If the effects of a goroutine must be observed by another goroutine,
use a synchronization mechanism such as a lock or channel
communication to establish a relative ordering.

这是全套规则里最反直觉、也最常被踩的一条:**一个 goroutine 退出了,不等于它的写对别人可见。**官方反例(go func() { a = "hello" }() 之后直接 print(a))在 5.3 节会跑出真实竞争报告。这里先记住结论:想「等一个 goroutine 干完」,必须用 WaitGroup、channel 或锁,不能靠「它应该跑完了」。

5.2.5 channel:四种情形

channel 是文档里篇幅最大的一节,因为它是最主要的同步手段。规则有四种:

(1)发送 → 对应接收完成。

A send on a channel is synchronized before the completion of the
corresponding receive from that channel.

(2)关闭 → 因关闭而返回零值的接收。

The closing of a channel is synchronized before a receive that returns a zero value
because the channel is closed.

实测(c := make(chan int, 10),goroutine 写 s 后 close(c),main <-c 后打印):

var c = make(chan int, 10)
var s string

func f() { s = "hello, world"; close(c) }

func main() { go f(); <-c; fmt.Println(s) }
hello, world

-race 干净。把 close(c) 换成 c <- 0 效果相同——都是「同步写样操作」。

(3)无缓冲 channel:接收 → 对应发送完成。

A receive from an unbuffered channel is synchronized before the completion of
the corresponding send on that channel.

无缓冲 channel 上,接收方与发送方是「握手」,所以这条边的方向与(1)相反:接收先于发送完成。文档特别强调,如果换成带缓冲(make(chan int, 1))这条边就不成立——这正是 5.3 节要跑的反例。

(4)带缓冲的第 k 次接收 → 第 k+C 次发送完成。

The kth receive from a channel with capacity C is synchronized before the completion of the k+Cth send on that channel.

这条把前面的规则推广到缓冲 channel,也是「用缓冲 channel 做计数信号量」的合法性依据:容量 C 对应最多 C 个并发,第 k 次「释放」(接收)与第 k+C 次「获取」(发送)之间被连上了边。

5.2.6 锁:Unlock → Lock

For any sync.Mutex or sync.RWMutex variable l and n < m,
call n of l.Unlock() is synchronized before call m of l.Lock() returns.

注意措辞:第 n 次 Unlock 同步先于第 m 次 Lock 的「返回」(n < m)。是「返回」,不是「调用」——因为 Lock 可能阻塞等待,只有拿到锁返回时才保证看到临界区里的写。实测:

var mu sync.Mutex
var s string

func main() {
	mu.Lock()
	go func() { s = "hello, world"; mu.Unlock() }()
	mu.Lock()
	fmt.Println(s)
}
hello, world

-race 干净。TryLock 也有明确规则:

A successful call to l.TryLock (or l.TryRLock)
is equivalent to a call to l.Lock (or l.RLock).
An unsuccessful call has no synchronizing effect at all.

也就是说,TryLock 失败时一点同步效果都没有——不能因为「我试过锁了」就认为建立了边。

5.2.7 Once

The completion of a single call of f() from once.Do(f)
is synchronized before the return of any call of once.Do(f).

once.Do(f) 里 f 的完成,同步先于任何一次 once.Do(f) 的返回。这把「一次初始化、多处读取」的模型钉死了。实测(4 个 goroutine 各调一次 doprint,每个都 once.Do(setup) 后打印):

hello, world
hello, world
hello, world
hello, world

四行全部非空,-race 干净。对比 5.3 节会用 done bool 手写「双重检查锁」的版本——那个会打出竞争,因为「观察到 done=true」不蕴含「观察到 a 的写」。Once 的价值就在于它显式建立了这条边。

5.2.8 原子操作

The APIs in the sync/atomic
package are collectively “atomic operations”
that can be used to synchronize the execution of different goroutines.
If the effect of an atomic operation A is observed by atomic operation B,
then A is synchronized before B.
All the atomic operations executed in a program behave as though executed
in some sequentially consistent order.

两条:A 的效果被 B 观察到 ⇒ A synchronized before B;所有原子操作构成一个全序(顺序一致)。第二条比 C++ 的 relaxed 语义强得多——Go 的原子操作默认就是 SC 的。实测:

var ready atomic.Bool
var s string

func main() {
	go func() { s = "hello, world"; ready.Store(true) }()
	for !ready.Load() {
	}
	fmt.Println(s)
}
hello, world

干净。这里 ready.Store(true) 被 ready.Load() 观察到,于是建立了 s 的写 → s 的读 的边。这正是普通 bool 做不到的——把 atomic.Bool 换成 var done bool,就变成 5.3 节那个「busy waiting」反例,-race 会报竞争。

5.2.9 终结器

A call to SetFinalizer(x, f) is synchronized before the finalization call f(x).

runtime.SetFinalizer(x, f) 同步先于 f(x) 被调用。这条边保证终结器能看到对象被 SetFinalizer 时的状态。

5.2.10 总表

建立边的操作方向关键约束
包 init 完成→ main.main 开始无需代码,自动成立
go f() 语句→ f 开始执行反向不成立
goroutine 退出不建立任何边必须显式同步
channel 发送→ 对应接收完成—
channel 关闭→ 返回零值的接收—
无缓冲接收→ 对应发送完成仅无缓冲
第 k 次接收(容量 C)→ 第 k+C 次发送完成缓冲 channel
Unlock(第 n 次)→ Lock 返回(第 m 次,n<m)TryLock 失败无效果
once.Do(f) 中 f 完成→ 任意 once.Do(f) 返回—
原子操作 A 被 B 观察到A → B原子操作全局顺序一致
SetFinalizer(x,f)→ f(x)—

一句话收束:**建立 happens-before 是写并发代码的「手动挡」——你要主动选一个同步操作,把写和读接起来。**接得起来的那七类操作就是全部工具箱;接不起来的场景(最常见的是「等 goroutine 结束」)就是 bug 的温床。

阅读导航:上一节:5.1 Go 内存模型正式定义 · 下一节:5.3 可见性陷阱与 -race 实测 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「golang」更多文章

  1. 《Go 语言编程实战》目录
  2. 《Go 语言编程实战》18.3 上线、观测与迭代
  3. 《Go 语言编程实战》18.2 故障演练