Go 高并发实战(5):Mutex、Atomic、内存模型与并发正确性
并发安全不是“没有 concurrent map panic”,而是所有共享状态的不变量在任意合法交错下都成立。
本期建立同步原语的选择方法,并用 race detector、压力测试和业务不变量验证正确性。
原理图:同步操作建立可见性顺序
锁不只是阻止两个 goroutine 同时进入,还建立写入对后续读取的可见性。atomic、channel 和 Once 也提供各自的同步关系。普通 bool 自旋没有这种契约,即使测试中恰好读到新值也不合法。
一、先找共享可变状态
数据在 goroutine 间有三种处理方式:
- 不共享:复制或保持请求局部;
- 转移所有权:通过 channel 交给唯一 owner;
- 受控共享:Mutex、RWMutex 或 atomic 建立同步。
优先减少共享,再优化锁。一个不存在的共享 map 比“高性能无锁 map”更容易证明。
二、happens-before 是可见性契约
Go 内存模型规定了 channel send/receive、close、Mutex unlock/lock、Once 和 atomic 等同步关系。没有同步时,一个 goroutine 写入的值不保证被另一个正确观察:
var ready bool
var value int
go func() {
value = 42
ready = true
}()
for !ready {}
println(value) // data race,不是合法的发布协议
用 channel close、Mutex 或 atomic 发布。不要依赖“在我的机器上一直输出 42”。
三、Mutex 的正确粒度
type Cache struct {
mu sync.Mutex
data map[string]Value
}
func (c *Cache) GetOrLoad(ctx context.Context, key string) (Value, error) {
c.mu.Lock()
value, ok := c.data[key]
c.mu.Unlock()
if ok {
return value, nil
}
value, err := loadRemote(ctx, key) // 锁外 I/O
if err != nil {
return Value{}, err
}
c.mu.Lock()
existing, loaded := c.data[key]
if !loaded {
c.data[key] = value
}
c.mu.Unlock()
if loaded {
return existing, nil
}
return value, nil
}
拆锁避免 I/O 长尾放大锁等待,但引入重复加载窗口。按业务选择容忍重复、singleflight、per-key 锁或 owner goroutine。
3.1 RWMutex 不一定更快
读锁仍有原子和缓存一致性成本;写者到来会改变后续读者行为。临界区很短、核数不高或写比例不低时,普通 Mutex 可能更快。必须用真实读写比例 benchmark,并同时看吞吐和 P99。
四、Atomic 适合单值状态
type Gate struct {
open atomic.Bool
hits atomic.Uint64
}
func (g *Gate) Allow() bool {
if !g.open.Load() {
return false
}
g.hits.Add(1)
return true
}
两个 atomic 操作不会自动组成事务:open 可能在 Load 后改变。若业务要求“允许判断与计数必须属于同一版本”,应使用锁、CAS 状态机或单 owner。
atomic.Value 适合发布不可变配置快照:写方构造完整对象后 Store,读方 Load 后不再修改。不要 Store 后继续改变 map/slice 内部。
五、分片减少热点与伪共享
type shard struct {
mu sync.Mutex
data map[string]Value
}
type ShardedCache struct {
shards [64]shard
}
func (c *ShardedCache) shard(key string) *shard {
return &c.shards[fnv32(key)%uint32(len(c.shards))]
}
分片数不是越多越好:会增加内存、哈希成本和跨分片操作复杂度。热点 key 仍会集中在单片,需要 per-key 合并、热点复制或业务拆分。
多个高频 atomic 若恰好位于同一 cache line,会产生 false sharing。只有 profile/benchmark 证明它是热点后再考虑 padding,避免把底层布局假设扩散到业务代码。
六、Data race 与逻辑竞态
go test -race ./...
go test -race -count=50 ./internal/cache
race detector 只发现实际执行到的未同步冲突访问。让并发集成测试覆盖真实路径;它有显著 CPU/内存开销,不应无评估长期跑全部生产流量。
以下余额扣减即使每次读写都加锁,也可能违反业务不变量:
请求 A 读余额 100 ─┐
请求 B 读余额 100 ─┼─ 两者都判断足够,再分别扣 80
这要靠数据库事务、条件更新、版本号、幂等键或串行 owner 解决,而不是 race detector。
七、锁顺序与死锁
- 为多把锁定义全局顺序;
- 不在回调、日志 formatter 或未知函数中持锁;
- 尽量不同时持有多把锁;
- 锁内不做 channel send、网络、磁盘和 sleep;
- 故障时采 goroutine dump,寻找互相等待的栈。
八、sync.Map、普通 Map 与 copy-on-write
sync.Map 适合键集合稳定且多读少写,或不同 goroutine 操作不同键的特定模式;它不是普通 map 的无脑高性能替代。需要跨键不变量、类型安全或复合更新时,普通 map + Mutex 更清晰。
配置/路由表可用 copy-on-write:
type Snapshot struct { Routes map[string]Route }
var current atomic.Pointer[Snapshot]
func Reload(next *Snapshot) {
// next 及其内部 map 在发布后只读。
current.Store(next)
}
func Lookup(key string) (Route, bool) {
snapshot := current.Load()
route, ok := snapshot.Routes[key]
return route, ok
}
写成本高但读路径无锁。必须保证深层对象也不可变,不能只替换外层指针后继续修改内部 slice/map。
九、锁竞争的量化方法
开启采样后,mutex profile 的累计 delay 代表等待影响而不仅是持锁时间;block profile 还覆盖 channel、Cond 等同步等待。分析时区分:
- contention 次数高但单次很短;
- 次数低但某次持锁极长;
- 热点在业务锁还是 runtime 内部锁;
- 优化后是否把争用转移到了 allocator、分片锁或下游。
go test -run='^$' -bench=BenchmarkCache -benchmem -mutexprofile=mutex.out ./internal/cache
go tool pprof -http=:0 mutex.out
benchmark 必须避免编译器消除结果,固定数据分布,并包含热点 key 而非全随机 key。
十、不可复制的同步类型
包含 Mutex、RWMutex、Once、atomic 的结构在首次使用后不应复制。方法通常使用指针 receiver,构造后通过指针传递。复制锁会让调用方以为共享同一保护,实际锁住不同副本。
运行 go vet ./... 可发现部分 copylocks 问题,但 API 设计仍应避免返回包含锁的大结构值。
十一、并发测试的三层防线
- 不变量测试:总额、唯一性、状态转换始终正确;
- race 测试:覆盖真实并行读写路径;
- 压力/模糊测试:随机调度、取消、错误、重复请求和超时。
测试不能依赖 time.Sleep 猜调度顺序。用 barrier/channel 控制关键交错,令逻辑竞态可重复出现。
11.1 用受控交错复现库存超卖
func TestBrokenReserve(t *testing.T) {
inventory := NewBrokenInventory(1)
bothRead := make(chan struct{})
proceed := make(chan struct{})
inventory.AfterRead = func() {
bothRead <- struct{}{}
<-proceed
}
results := make(chan bool, 2)
go func() { results <- inventory.Reserve(1) }()
go func() { results <- inventory.Reserve(1) }()
<-bothRead
<-bothRead
close(proceed)
successes := 0
for range 2 {
if <-results { successes++ }
}
if successes != 1 {
t.Fatalf("inventory invariant broken: successes=%d", successes)
}
}
测试强制两个请求都在更新前读到旧库存,因此不依赖调度运气。修复应把“检查 + 扣减”放入同一锁、事务或 CAS 状态转换,并保留该测试防回归。
11.2 锁分片的数据流与边界
分片只降低独立 key 的锁争用;跨 shard 事务仍需固定锁顺序,热点 key 仍集中在一片。必须使用真实 key 分布压测,不能只用均匀随机 benchmark。
十二、本期实战验收
- 给共享 cache 同时运行 race、benchmark 和锁 profile;
- 比较 Mutex、RWMutex、64 分片在真实读写比下的 P99;
- 为一个逻辑竞态写确定性复现,并使用版本条件更新修复;
- 文档化所有多锁路径的顺序和共享状态不变量。
上一篇:Go-4:背压与系统韧性 · 下一篇:Go-6:压测、pprof 与线上事故排查。