Go 基础体系 · 第 23/113 篇。示例统一基于 Go 1.26.4;核心片段可能省略 package 与 import,完整程序可直接按文中结构运行。
Go 内存模型与数据竞争:happens-before 才是并发正确性
本文所有代码与工具行为均以 Go 1.26.4 为基准。Go 内存模型回答的不是“goroutine 大概按什么顺序运行”,而是:一个 goroutine 的内存操作在什么条件下保证能被另一个 goroutine 观察到。工程结论很直接:并发修改共享数据时,必须用 channel、锁或 atomic 建立文档规定的同步关系;靠时间、CPU 原子写入或测试偶然通过,都不构成证明。
内存模型刻意只提供写正确程序所需的最低承诺。编译器会内联、消除或重排代码,CPU 与缓存也不需要按源码行逐条向其他核暴露效果。同步原语把局部执行连接成可推理的全局偏序,这个偏序就是 happens-before。
1. 什么是数据竞争
若两个 goroutine 并发访问同一内存位置,至少一个是写,并且这些访问不是由同步关系排序,就存在数据竞争。这里的“内存位置”可能是变量、字段、数组元素或实现上共享的对象内容;仅仅访问同一个 map 对象并不要求键相同才危险。
var total int
var wg sync.WaitGroup
for i := 0; i < 100; i++ {
wg.Add(1)
go func() {
defer wg.Done()
total++ // 读、加一、写回,不是一个同步操作
}()
}
wg.Wait()
数据竞争是程序缺陷,不只是“结果偶尔少一”。Go 对无竞争程序提供可顺序一致地解释的强基础;对有竞争程序只保留有限实现约束,不能借此构造可靠协议。尤其不能把“机器字读写通常不可撕裂”误解为可见性和顺序保证。
2. sequenced-before、synchronized-before 与 happens-before
单个 goroutine 内,语言求值规则和控制流给出 sequenced-before:例如先计算赋值右侧,再执行后续语句。跨 goroutine 的某些同步操作之间形成 synchronized-before,如解锁与后续加锁、channel 发送与对应接收。
happens-before 是这两类关系并在一起后的传递闭包。若写 W happens-before 读 R,而且在它们之间没有覆盖该位置的另一次写,R 才有可靠可见性依据。
G1: 初始化 data --程序顺序--> close(ready)
│ 同步
G2: <-ready --程序顺序--> 读取 data
墙上时钟的先后不是 happens-before。time.Sleep、日志先打印、断点观察到某顺序,都不建立内存模型关系。调度器抢占、GOMAXPROCS=1 或 goroutine 恰好没重叠,也不能修复竞态。
3. goroutine 创建与退出分别保证什么
go f() 的启动发生在 f 的执行之前,因此启动前准备好且之后不再并发修改的数据可由新 goroutine 读取。反方向不成立:goroutine 执行完不会自动与外部读取同步。
var value string
done := make(chan struct{})
go func() {
value = "ready"
close(done)
}()
<-done
fmt.Println(value) // close 与观察关闭建立顺序
若删掉 done,只用 Sleep 等“应该执行完”,读取 value 仍是竞态。应通过接收结果、等待 WaitGroup 或其他同步完成信号建立边。程序退出也不会等待后台 goroutine,因此它既不是同步手段,也不是资源收尾协议。
4. Channel 建立的同步关系
无缓冲 channel 的发送与对应接收会合;发送完成 happens-before 接收完成。有缓冲 channel 中,第 k 次接收 happens-before 第 k+C 次发送完成,其中 C 是容量,这条规则解释了用容量为 N 的 channel 作为计数信号量为何能限制并发。
关闭 channel happens-before 接收方观察到 channel 已关闭。注意“把值放入有缓冲 channel 后发送立即返回”不代表接收方已处理该值;若需要处理完成确认,必须再有 acknowledgement。
type snapshot struct{ Name string }
ch := make(chan snapshot, 1)
item := snapshot{Name: "v1"}
ch <- item
received := <-ch
fmt.Println(received.Name)
传值后双方若仍共享值内部引用,竞态仍可能出现。例如 slice 头被复制,但底层数组共享;发送方在接收方读取时继续修改数组并不安全。可转移所有权、深拷贝,或继续用同步保护。
5. Mutex、RWMutex、Once 与 Cond 的保证
对同一把 sync.Mutex,第 n 次 Unlock happens-before 第 m 次成功 Lock,其中 n < m。锁保护的是跨字段不变量,读写双方必须遵守同一协议。只给写方加锁而让读方裸读,仍是竞态。
RWMutex 的读锁和写锁有文档规定的排序,但不能升级:持有 RLock 时再 Lock 会死锁风险。它也不保证业务公平性,是否优于普通 Mutex 应由竞争 profile 和基准判断。
Once.Do(f) 中 f 的返回 happens-before 任意 Do 返回;f 若 panic,Once 仍视为已执行。Cond.Wait 会原子解锁并等待,醒来后重新加锁;Signal 只是可能唤醒等待者,条件必须在持锁循环中检查,因为状态可能又被其他 goroutine 改变。
c.L.Lock()
for !ready() {
c.Wait()
}
consume()
c.L.Unlock()
这些原语的完整使用约束属于 sync 主题;内存模型关心的是它们建立了哪些边。复制已经使用的锁、Once 或 Cond 会把协议拆开,通常是严重错误。
6. Atomic 是同步,不是普通变量的加速开关
sync/atomic 的操作是顺序一致的原子操作。若原子操作 A 的效果被原子操作 B 观察到,则 A synchronized-before B。应优先使用 atomic.Int64、atomic.Bool、atomic.Pointer[T] 等类型化 API,避免对齐和类型错误。
独立计数或发布不可变快照适合 atomic:
type Config struct{ Endpoint string }
var current atomic.Pointer[Config]
current.Store(&Config{Endpoint: "v1"})
cfg := current.Load()
fmt.Println(cfg.Endpoint)
发布后不得再原地修改 Config,否则读者仍会与修改竞争。更新时构造新对象再 Store。多个 atomic 字段无法自动组成事务:读者可能看到版本号已更新而配置仍旧,或两个余额来自不同状态。组合不变量优先用 Mutex,或用单个 atomic 指针发布完整不可变快照。
7. 典型竞态:map、slice、接口与闭包
普通 map 不支持并发读写。运行时有时会报 concurrent map read and map write,但这只是尽力诊断,不是可捕获、可依赖的安全机制。对简单共享 map 使用 Mutex;读多写少且键稳定的特定场景可评估 sync.Map。
Slice 的长度、容量和底层数组是不同层面。两个 goroutine 向同一 slice append 会竞争 slice 头和数组;即使各写不同索引,也必须确保数组已固定大小、索引确实不重叠,且结果发布有同步关系。
接口值包含动态类型和数据两部分,并发赋值与读取也不是业务上的原子快照。闭包捕获的外部变量、循环复用的缓冲区、错误变量和测试中的共享 mock 都是常见来源。Go 1.22 后 range 迭代变量语义减少了一类闭包错误,但捕获循环外的可变变量仍然会竞争。
buffer := make([]byte, 4096)
for _, conn := range connections {
n, _ := conn.Read(buffer)
go process(buffer[:n]) // 下一轮 Read 会覆盖同一底层数组
}
修复应为每项任务拥有独立数据、在移交前复制,或用受控缓冲池且明确归还时点,而不是给 process 前加 Sleep。
8. “看似无害”的发布和双重检查
下面的标志发布是竞态,读者看到 ready == true 时并不因此获得普通字段的可靠发布保证:
var cfg Config
var ready bool
// writer
cfg = loadConfig()
ready = true
// reader
if ready {
use(cfg)
}
用 channel close、Mutex、Once 或 atomic 指针发布完整值。经典双重检查若外层裸读共享指针,同样有竞态;用 sync.Once 最清楚。不要手写自旋等待普通 bool,编译器无需让循环每次都按你的想象重新加载它。
Mutex 保护的指针被取出后也要问:锁外使用时,指向对象会不会被锁内代码原地修改?锁只对临界区内的访问建立协议,不会给对象永久冻结。常用方案是锁内复制值、转移所有权,或将发布对象设计为不可变。
9. Race Detector 怎样工作、能发现什么
-race 编译会插桩内存访问,并在运行时记录同步事件,用动态 happens-before 算法报告实际执行路径上的冲突访问。报告通常包含当前访问栈、先前冲突访问栈,以及相关 goroutine 的创建栈。
go test -race ./...
go test -race -count=20 ./internal/cache
go test -race -run TestRefresh -cpu=1,4,16 ./...
go run -race ./cmd/service
看到报告应先定位两个访问是否指向同一对象,再沿创建栈查所有权和缺失同步。不要只在报告那一行塞锁:完整不变量可能跨多行、多字段或回调边界。一个进程发现一次竞态后可能产生级联报告,先修最早、最根本的共享状态。
Race Detector 只覆盖执行过的路径;没有报告不是证明。它会增加 CPU 和内存开销,不宜直接作为常态生产构建。cgo/汇编和未插桩组件存在覆盖边界。应在 CI、压力测试和代表性集成场景中运行,并让并发测试真正交错。
10. GORACE、退出码与报告治理
GORACE 可配置报告路径、历史大小、退出码等。路径可用 stdout、stderr 或文件前缀。增大 history 能减少极复杂并发下的 “failed to restore the stack”,代价是更多内存。
GORACE='halt_on_error=1 strip_path_prefix=/workspace/ exitcode=66' go test -race ./...
GORACE='log_path=/tmp/race history_size=2' go test -race -count=50 ./...
不要用 //go:build !race 长期排除真正失败的业务代码。只有测试在插桩开销下必然超时、依赖不支持 race 的外部组件等少数场景才考虑隔离,并记录替代覆盖。报告可能包含路径和业务数据地址,CI 产物也要按内部诊断信息管理。
11. 竞态、逻辑竞态与死锁不是一回事
数据竞争的定义是未同步的冲突内存访问。程序可以没有数据竞争但仍有逻辑竞态:两个请求都在锁内读取库存 1,分别解锁做外部调用,再各自锁内扣减,单次访问全受保护却违反整体事务。修复要扩大状态机或使用数据库条件更新,不是让 Race Detector 报警。
反过来,过度加锁可能消除数据竞争却引入死锁、锁顺序反转或尾延迟。Channel 协议也可能无竞态地永久阻塞。竞态检测、goroutine dump、mutex/block profile 与业务不变量测试解决的是不同问题,应组合使用。
还要区分值是否新鲜与是否安全。受锁保护的缓存快照可能有意允许旧数据,但它是定义好的陈旧;裸读碰巧得到新值仍是不安全。正确性先明确一致性契约,再选择同步实现。
12. 设计无竞争代码的工程方法
优先让所有权减少共享:每个任务使用自己的输入,channel 传递不可变值或所有权,聚合 goroutine 独占状态。确需共享时,先写出不变量和锁范围,再选择 Mutex。只有独立标志、计数和不可变快照发布才优先 atomic。
代码评审逐个检查 goroutine 启动点:捕获了哪些变量,输入由谁继续修改,结果通过何种同步发布,退出后谁等待。公共类型应在注释写清并发安全性;默认不要假设类型并发安全。测试使用 barrier channel 同时放行任务,比任意 Sleep 更容易制造目标交错。
诊断顺序通常是:保留完整 race 报告,复现实例对象,识别缺失的 happens-before,按业务不变量选择所有权或同步方案,再以 -race 和确定性断言验证。不要用降低并发度、延长时间或复制报错变量来掩盖协议缺口。
13. 可运行综合示例:不可变快照与并发更新
下面程序用 atomic.Pointer 发布完整不可变快照,用原子计数记录读取次数,用 WaitGroup 建立任务完成关系。更新者复制 map 后发布,因此读者永远不会和写者共同修改同一张 map。若改为原地写 old.Limits,go test -race 会揭示竞态,且普通 map 还可能崩溃。
package main
import (
"fmt"
"sync"
"sync/atomic"
)
type Config struct {
Version int
Limits map[string]int
}
type Store struct {
current atomic.Pointer[Config]
reads atomic.Int64
}
func NewStore(initial Config) *Store {
store := &Store{}
store.current.Store(&initial)
return store
}
func (s *Store) Load() *Config {
s.reads.Add(1)
return s.current.Load()
}
func (s *Store) Replace(key string, value int) {
for {
old := s.current.Load()
limits := make(map[string]int, len(old.Limits)+1)
for k, v := range old.Limits {
limits[k] = v
}
limits[key] = value
next := &Config{Version: old.Version + 1, Limits: limits}
if s.current.CompareAndSwap(old, next) {
return
}
}
}
func main() {
store := NewStore(Config{Version: 1, Limits: map[string]int{"api": 10}})
var wg sync.WaitGroup
for i := 0; i < 4; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for j := 0; j < 1000; j++ {
cfg := store.Load()
_ = cfg.Limits["api"]
}
}()
}
for value := 11; value <= 20; value++ {
store.Replace("api", value)
}
wg.Wait()
final := store.Load()
fmt.Printf("version=%d api=%d reads=%d\n", final.Version, final.Limits["api"], store.reads.Load())
}
并发正确性的核心不是“用了某个并发包”,而是能画出完整的 happens-before 图,并证明所有冲突访问都被它排序。图画不出来时,代码速度、测试次数和运行时表现都不能替代证明。
系列导航与关联阅读
- 系列入口:Go 完整技术体系学习路线:从语法、并发到框架、中间件与 AI
- 上一篇:Go context 完整指南:取消、超时、Deadline 与 Value
- 下一篇:Go goroutine 生命周期:泄漏、打断、错误传播与优雅关闭
- 延伸:Go sync 与 atomic:Mutex、RWMutex、WaitGroup、Once 和 Cond
- 延伸:Go channel 完整基础:发送、接收、缓冲、关闭与所有权
- 进阶:Go 数据竞争排查:读懂内存模型与 Race Detector
官方资料
本文依据 Go 官方规范、标准库文档和 Go 官方博客重新梳理;正文与示例由 WR BLOG 编写。

评论
0 条讨论