Skip to content
← 返回Go列表
知识专题

Go 内存模型:happens-before、原子发布与数据竞争 ​

前置:channel 与取消、Mutex 与 atomic、指针和 map。Go 1.27.2;本章的两个完整程序在各自空模块中运行 go run .,均只使用标准库。本轮仅静态分析同步关系与竞态反例,未执行示例或检测器。目标是能为跨 G 的每一次共享读写指出同步依据,不能依赖“机器上一直对”。

happens-before:建立可证明的观察顺序 ​

同一 G 中的先后,加上同步操作建立的跨 G 顺序,再传递连接,形成 happens-before(先行发生)关系。两个共享访问若没有这种顺序、至少一个是写且不是全部原子访问,就是数据竞争。无数据竞争程序享有顺序一致性保证;这不等于多步业务操作自动成为事务。

常用关系:启动 G 前的动作先于该 G 开始;channel 发送先于对应接收完成;关闭 channel 先于因关闭而返回零值的接收;Mutex 的 Unlock 与后续相应 Lock 建立顺序;WaitGroup 的 Done 与它解除的 Wait 返回建立顺序。G 自己退出不会自动让别的 G 观察到它写过的所有数据。缓冲 channel 不是所有方向都等价的屏障,不能把“我发送成功”解释成“对方已经处理完”。

完整 main.go,模块声明可用 go mod init example.com/publication(go 1.27.0):

go
package main

import "fmt"

func main() {
    ready := make(chan struct{})
    var message string
    go func() {
        message = "published"
        close(ready)
    }()
    <-ready
    fmt.Println(message)
}

预期输出:

text
published

证明链:message 写入 → close(ready) → 因关闭完成的接收 → message 读取。发布后没有继续修改 message,所以读取安全。若改成 go func(){ message = ... }() 后 Sleep 再读,缺少同步关系;若 close 后继续写 message,也破坏了读写约束。channel 只负责通知,不替你冻结共享数据。

atomic.Pointer:发布不可变配置快照 ​

读多写少的配置可以先完整构造,再用 atomic.Pointer 发布。Load 得到的对象及它引用的 map/slice 必须在发布后保持不可变;原子指针不保护 map 后续写入。下面是另一独立完整 main.go(go mod init example.com/snapshot,go 1.27.0):

go
package main

import (
    "fmt"
    "sync/atomic"
)

type Config struct { Limits map[string]int }

func main() {
    var current atomic.Pointer[Config]
    current.Store(&Config{Limits: map[string]int{"workers": 2}})
    done := make(chan struct{})
    go func() {
        next := &Config{Limits: map[string]int{"workers": 4}}
        current.Store(next)
        close(done)
    }()
    <-done
    snapshot := current.Load()
    fmt.Println(snapshot.Limits["workers"])
}

预期输出:

text
4

所有更新都构造新 map,没有原地修改旧对象;闭包通知只让本例输出固定,真实读者可在任意时刻读到旧或新的一份完整快照。初始化 Store 在读者启动之前完成;通用封装还需规定 Load 返回 nil 的行为。atomic.Pointer 与 Mutex 都不能在首次使用后复制。若配置内部需要原地更新,改为用同一把锁保护读写和复合不变量更清晰。

“原子计数 + 原子计数”不保证两者相加的业务约束;读者仍可在两次操作之间观察到状态。CAS 循环还要考虑竞争重试、对象生命周期与 ABA 等协议问题;除非已有证据需要,不自行实现无锁容器。

race:检测执行过的竞争,不证明所有执行 ​

完整测试模块见并发实践。进入 examples/go/system-lab 执行 go test -race -count=10 ./...。检测会给程序加读写监控,有明显 CPU/内存开销;支持平台与 C 工具链要求以本机 Go 版本为准。缺少工具链时保留明确失败信息,普通 go test 不算竞态检测通过。

本批提供刻意错误的独立反例 cmd/race/main.go,只能用来学习检测,不能复制到业务代码:

go
// Deliberately racy counterexample. Run only with: go run -race ./cmd/race
package main

import (
	"fmt"
	"sync"
)

func main() {
	var wg sync.WaitGroup
	var count int
	for range 2 {
		wg.Add(1)
		go func() {
			defer wg.Done()
			for range 10000 {
				count++
			}
		}()
	}
	wg.Wait()
	fmt.Println(count)
}

执行 go run -race ./cmd/race,预期非零退出,stderr 包含 WARNING: DATA RACE。具体数字不固定,不能以打印值正确就认定安全。Wait 保证最后读取发生在两次写结束之后,仍无法排序两个 G 内部的 count++;修复需把每次加一放入同一 Mutex 临界区,或使用 atomic.Int64.Add,不能只保护最后的 Println。

竞态检测只覆盖本次实际执行路径;无报告也可能遗漏某分支。死锁、错误的锁顺序、两次原子操作之间的逻辑竞争,即使没有数据竞争也可能错。测试应结合同步证明、负面输入和重复执行;进程级死锁有时报告 all goroutines are asleep - deadlock!,但有后台运行 G 时也可能只表现为请求卡住,不能等这个报错才排查。

练习与自检 ​

解释发布例子若 close 后把 message 再改写为何不安全;尝试用一次 map 原地写替代新快照,说明哪对访问失去保护。为两计数器的“总额不变”设计一个无中间态的接口。

参考解法

close 只排序它之前的写,后面的写与接收后的读没有顺序。原子发布排序的是完整对象的发布/观察,不能覆盖后来的普通 map 写。总额不变可在同一锁内同时更新和读取两个字段,或者整体发布一个不可变结构;分别 Load 两个原子字段不等于一致快照。避免在真实服务中故意触发竞态,用独立反例和检测器观察。

依据:Go 内存模型、sync 同步约定、atomic、race detector。中文阅读对照:Go 101 内存顺序保证;结论以规范及标准库约定为准。核对日期 2026-10-17。

继续阅读