Go · sync 包与内存模型

数据竞争是未定义行为不是概率问题 — 原语全家福 · happens-before 规则 · race detector 工作流

sync / atomic — 并发原语全家福 Mutex 互斥锁 正常模式: 自旋+抢 · 饥饿模式(1.9+): 等待>1ms 转 FIFO, 尾延迟有保证 RWMutex 读写锁 读共享写独占; 写等待时新读者阻塞 读多写少才赚, 否则比 Mutex 更慢 WaitGroup 计数信号量 Add(n) / Done() / Wait() 等一组 goroutine 全部完成 Once 恰好一次 单例/懒初始化的标准答案 sync.OnceValue (1.21+) 带返回值 atomic 原子操作 Add / Load / Store / CompareAndSwap 单变量无锁读写 (atomic.Pointer 1.19+) sync.Map 并发安全 map 读多写少/键不相交时优于锁+map 否则: RWMutex + 普通 map 更快 数据竞争 — 未定义行为, 不是概率 两 goroutine 访问同一变量, 至少一个是写, 且无同步 counter++ 实际是: 读 → +1 → 写 (三条指令) 交错执行 → 丢更新; 还可能读到撕裂/过期值 map 并发写: 直接 fatal 不可 recover 检测: go test -race / go run -race (CI 必开) Go 内存模型 — happens-before 边 只有跨过 HB 边, 一侧的写才保证对另一侧可见 ch 发送 → 对应接收 · Unlock → 后续 Lock Once.Do 返回 → 后续 Do · go 启动 → 新 G 内代码 atomic 操作全序 · WaitGroup.Wait 返回 → Done 前的写 没有 HB 边的"看起来对了" = 依赖运气与版本 怎么选? — 从需求出发, 不从信仰出发 单变量计数/开关 → atomic · 复合不变量(转账/缓存结构) → Mutex 读多写少且键基本不相交 → sync.Map · 数据所有权转移/事件通知 → channel 等一组任务完成 → WaitGroup (+ errgroup 管错误) · 懒初始化 → Once 口诀: 传数据用 channel, 保护数据用锁; 一切并发改动都要过 -race Legend sync 原语 危险区 内存模型 决策指引

数据竞争三要素

  • • 同一变量 + 并发访问 + 至少一个写
  • • 无同步就是未定义行为, 不是"小概率错"
  • • -race 能抓的是"发生过", 抓不住"没发生"

HB 边是"可见性"的法律

  • • channel send → recv 是最强同步边之一
  • • Unlock → Lock: 锁内写对下一个持锁者可见
  • • 没有 HB 边, 缓存一致性不给你任何承诺

原语即工具箱

  • • atomic: 单变量, 无锁, 最快
  • • Mutex: 复合不变量的唯一正解
  • • channel: 传递所有权, 不是"更高级的锁"

💡 一句话理解

Go 的并发安全由两层支撑: 工具层(Mutex/atomic/Once/sync.Map…)解决"怎么同步", 法律层(内存模型的 happens-before 规则)定义"什么才算同步成功"。数据竞争不是小概率 bug 而是未定义行为 — 唯一正确姿势是: 写并发代码时明确每条共享数据的 HB 边, 并让 -race 常驻 CI。

🧠 必知必会 必考 & 必会

为什么会"丢更新"
counter++ 是读-改-写三条指令, 两个 goroutine 交错后各写回自己的 +1, 总增量丢失。atomic.AddInt64 把三步合成一条 CPU 指令, 从根上消除交错。
go func() { counter++ }()      // 读→+1→写 三条指令
go func() { counter++ }()      // → 结果可能 = 1, 丢了 1 次
atomic.AddInt64(&counter, 1)   // 关键: 三步合成一条原子指令
Mutex 两种模式
正常模式: 解锁时新到者与唤醒者抢锁(吞吐好, 可能饿); 饥饿模式(1.9+): 某 G 等待超 1ms 切换为 FIFO 交接(公平, 尾延迟稳)。生产上的意义: 有严格 P99 要求时不用担心 Mutex 抽奖。
mu.Lock(); critical(); mu.Unlock()
// 正常模式: 新到者与唤醒者抢, 吞吐好
// 某 G 等待 > 1ms → 饥饿模式: FIFO 交接 (1.9+)
// 关键: 严格 P99 场景尾延迟有保证, 不怕抽奖
RWMutex 适用面
读锁开销本身不小, 临界区极短或写频繁时比 Mutex 更慢; 只有"读多写少 + 读临界区较长(如遍历大结构)"才明显收益。先测再换。
var mu sync.RWMutex
mu.RLock(); defer mu.RUnlock()
traverse(bigTree)              // 读临界区长(遍历)才划算
// 关键: 临界区极短或写频繁时, RWMutex 反而更慢
sync.Map 的甜区
官方文档写明两类场景: 写少读多且键集稳定; 或多个 goroutine 读写不相交的键。其余场景 RWMutex+map 通常更快且类型安全(泛型前)。
var m sync.Map
m.Store(k, v); v, ok := m.Load(k)
// 甜区1: 写少读多且键集稳定 (如配置表)
// 甜区2: 多 G 读写不相交键 (如每连接缓存)
// 关键: 其余场景 RWMutex+map 通常更快且类型安全
零值可用
sync.Mutex/Once/WaitGroup 都是零值可用 — 声明即可用, 无须构造函数; 也因此拷贝即事故(锁被复制成两把)。
var mu sync.Mutex            // 声明即用, 无须构造
mu.Lock(); x++; mu.Unlock()
type S struct{ mu *sync.Mutex }  // 关键: 传指针不传值
// 零值可用的代价: 拷贝即事故 (go vet: copies lock)
-race 的原理与边界
编译时插桩, 记录每次访存的 happens-before 向量时钟, 运行时发现无同步的并发读写即报告。代价: 内存 5-10x、CPU 2-20x 慢 — 只用于测试, 不上生产; 且只能报"发生了的", 覆盖率不足照样漏。
# go test -race ./...   ← CI 必开
# 原理: 编译插桩 + 向量时钟, 无同步的并发读写即报
# 边界: 内存 5-10x / CPU 2-20x; 只报"发生了的"
// 关键: 只用于测试环境, 不上生产
errgroup
golang.org/x/sync/errgroup: WaitGroup + 错误收集 + ctx 联动取消, 并行子任务的现代默认选择。
g, ctx := errgroup.WithContext(ctx)
g.Go(func() error { return fetchA(ctx) })
g.Go(func() error { return fetchB(ctx) })
err := g.Wait()   // → 任一 error: ctx 取消, 返回首个错误
// 关键: WaitGroup + 错误收集 + 联动取消 一件套

🏭 生产实战 real world

场景 1 · 高频计数器: atomic 而非 Mutex

var qps atomic.Int64                // 1.19+ 泛型原子类型, 零值可用
qps.Add(1)                          // 中间件里每个请求 +1
_ = qps.Load()                       // 监控协程每秒读一次
// Mutex 版本在同一热点上慢 3-5 倍 (锁竞争 + 缓存行弹跳)

场景 2 · 配置热更新: atomic.Pointer

网关路由表每分钟全量重建, 读多写极少 — 读侧无锁, 写侧整体替换:

var routes atomic.Pointer[map[string]Route]

func reload(newRoutes *map[string]Route) {
    routes.Store(newRoutes)          // 一次原子替换, 读侧立刻可见
}
func match(path string) Route {
    r := routes.Load()               // 无锁读; 拿到的是不可变快照
    return (*r)[path]               // 快照永不修改 → 天然并发安全
}

套路核心: 写时整体替换不可变快照 — 读侧永远拿到完整一致的一代数据, 比"锁住 map 逐项改"又快又安全。

场景 3 · 单例连接池: Once / OnceValue

var initDB = sync.OnceValue(func() *sql.DB {   // 1.21+
    db, err := sql.Open("postgres", dsn)
    if err != nil { log.Fatal(err) }
    return db
})
// 任意并发首次调用只有一个真正初始化, 其余阻塞等待结果
func Query(q string) { initDB().Query(q) }

场景 4 · singleflight 合并并发回源

缓存击穿的 Go 标准解法 — 同 key 并发只放一个穿透:

import "golang.org/x/sync/singleflight"

var sf singleflight.Group
func GetUser(ctx context.Context, id string) (*User, error) {
    if u := cache.Get(id); u != nil { return u, nil }
    v, err, _ := sf.Do(id, func() (any, error) {    // 同 key 并发在此合并
        u, err := db.GetUser(ctx, id)
        if err == nil { cache.Setex(id, u, 60) }
        return u, err
    })
    return v.(*User), err                // 所有等待者拿到同一结果
}

场景 5 · 分片锁 map: 高并发读写的标配

一把锁保一个大 map 撑不住时, 按 hash 分 N 片各锁各的:

type ShardedMap[T any] struct {
    shards [ 64 ]struct {
        mu sync.RWMutex
        m  map[string]T
    }
}
func (s *ShardedMap[T]) Set(k string, v T) {
    i := fnv32(k) % 64                    // 同 key 恒定落同一片
    s.shards[i].mu.Lock(); defer s.shards[i].mu.Unlock()
    s.shards[i].m[k] = v                   // 64 片 = 并发度 ×64
}

场景 6 · 有界队列 + 满载降载

背压三件套: 有界缓冲 + 非阻塞尝试 + 满载策略:

queue := make(chan Event, 10000)
func submit(e Event) error {
    select {
    case queue <- e:
        return nil
    default:                              // 满了: 立刻降载, 不阻塞业务
        dropped.Inc()
        return ErrBusy                     // 上层转同步写/丢弃/告警
    }
}

场景 7 · errgroup.SetLimit 并行限流

现代并行子任务的完整形态: 并发上限 + 错误联动一行搞定:

g, ctx := errgroup.WithContext(ctx)
g.SetLimit(20)                               // 最多 20 个并发子任务
for _, id := range ids {
    g.Go(func() error {                    // 超 20 个会在 Go 内部排队
        return syncOne(ctx, id)             // 失败 → ctx 取消 → 全体快速退出
    })
}
err := g.Wait()

场景 8 · 读写锁的实测选型

读多写少别拍脑袋 — 三行代码压测出真相:

// go test -bench Map -benchmem -cpu 4,16
func BenchmarkRWMutex(b *testing.B) { ... }   // RWMutex + map
func BenchmarkSyncMap(b *testing.B) { ... }   // sync.Map
func BenchmarkShard(b *testing.B) { ... }     // 分片锁
// 常见结论: 读极多写极少 → sync.Map; 读写均衡 → 分片锁; 小数据 → 单 Mutex 反而最快

场景 9 · 优雅停机: once + WaitGroup

多次触发的关闭信号只生效一次, worker 全部退出后才返回:

type Pool struct {
    wg   sync.WaitGroup
    stop chan struct{}
    once sync.Once                      // 保证 close 只发生一次
}
func (p *Pool) Shutdown() {
    p.once.Do(func() { close(p.stop) })    // 广播退出, 重复调用安全
    p.wg.Wait()                         // 等所有 worker 收尾
}

场景 10 · CAS 状态机: 无锁任务领取

多 worker 抢任务, 用 atomic CAS 避免"检查+设置"的竞态窗口:

var state atomic.Int32                 // 0=idle 1=running 2=done
func claim() bool {                     // 恰好一个 worker 成功
    for {
        s := state.Load()
        if s != 0 { return false }         // 已被领走
        if state.CompareAndSwap(s, 1) {      // 原子"检查+占用"
            return true
        }                                // CAS 失败: 有人抢先, 重试看最新值
    }
}

⚠️ 编码注意与常见坑 pitfalls

坑 1 · 拷贝了锁 — 值传递含 Mutex 的 struct / map 里存值类型, 锁被复制, 各锁各的形同虚设, 且 go vet 会报 copies lock。正解: 用指针或嵌入 *sync.Mutex; struct 按 pointer 传递。
func (s S) Inc() { s.mu.Lock(); s.n++; s.mu.Unlock() }   // 错: 值接收者=每次拷贝新锁
// go vet: "Inc passes lock by value: sync.Mutex"
func (s *S) Inc() { s.mu.Lock(); s.n++; s.mu.Unlock() }  // 对: 指针接收者
坑 2 · WaitGroup.Add 写在 goroutine 里 — 调度慢时 Wait 先到, 计数为 0 直接返回, 活儿没人等。正解: Add 必须在 go 语句之前执行。
go func() { wg.Add(1); defer wg.Done(); t() }()  // 错: Wait 可能抢先
wg.Add(1)                          // 对: Add 在 go 语句之前
go func(t Task) { defer wg.Done(); t() }(t)
坑 3 · RWMutex 里重入 — Go 的锁不可重入, 读锁内再取读锁(或同 goroutine Lock 两次) = 死锁。正解: 拆小临界区; 真需要重入 = 设计问题。
func read() { mu.RLock(); defer mu.RUnlock(); helper() }
func helper() { mu.RLock(); defer mu.RUnlock() }    // 错: 重入=死锁
// 对: 拆小临界区, helper 不拿锁 (提供内部版/外部版两个入口)
坑 4 · sync.Map 当万能并发 map — 高频写/键大量重叠时反而比 Mutex+map 慢, 且历史上无泛型易类型混乱。正解: 按官方甜区选用, 别的地方老实用锁。
var m sync.Map
for i := 0; i < 1e6; i++ { m.Store(key(i), i) }  // 错: 高频写反而慢
var mu sync.RWMutex; m2 := map[K]V{}          // 对: 甜区外老实上锁
mu.Lock(); m2[k] = v; mu.Unlock()
坑 5 · "加 volatile 式"的 atomic.Bool 治百病 — atomic 只保单变量; if flag { ... } 的检查与动作之间仍是竞态窗口(TOCTOU)。正解: CAS 状态机 (CompareAndSwap) 或锁住整个决策过程。
if !started.Load() {          // 错: 检查与动作之间仍是窗口
    start()                   //   两个 G 都可能通过检查
    started.Store(true)
}
if started.CompareAndSwap(false, true) {  // 对: 原子决策
    start()                   // → 恰好一个 G 执行
}
坑 6 · -race 通过 = 并发正确 — race detector 只报发生过的竞争, 没跑到路径照样埋雷。正解: -race 是必要条件不是充分条件; 并发设计 review + 压测覆盖仍不可少。
# 错: -race 全绿就宣布并发安全
# 对: 必要不充分 — 补并发 review + 压测覆盖
go test -race -count=100 ./...   # 多轮放大触发概率
# 预发环境混流再挂 -race (非生产层)
坑 7 · for 循环变量捕获(1.22 前) — 闭包捕获复用变量导致并发读到错值, 本质也是内存可见性问题。正解: 升 1.22+ 或显式 v := v。
for _, v := range items {
    go func() { use(v) }()    // 错(1.21-): 并发读到错值
    v := v                      // 对: 显式遮蔽 (或升 1.22+)
    go func() { use(v) }()
}
坑 8 · 锁内调用外部回调 — 持锁期间调用别人注入的函数, 回调再拿别的锁 → 死锁温床。正解: 锁内只做内存操作; 回调/IO 移出临界区(先拷贝数据再放锁)。
mu.Lock(); callback(event); mu.Unlock()   // 错: 持锁调外部函数
mu.Lock(); ev := *event; mu.Unlock()      // 对: 先拷贝数据
callback(ev)                             //    回调移出临界区
坑 9 · defer unlock 粒度过大 — 函数开头 Lock + defer Unlock, 中间一大段 IO 全程持锁。正解: 把需要保护的一小段抽成函数或显式提前 Unlock。
mu.Lock(); defer mu.Unlock()   // 错: 中间 IO 全程持锁
resp, err := callRemote()
mu.Lock(); v := cache[k]; mu.Unlock()   // 对: 只锁取数据一小段
resp, err = callRemote(v)
坑 10 · atomic 保不住多字段一致 — min/max 各自 atomic, 读者仍可能看到新 min 旧 max。正解: 复合不变量用锁; 或整体打包成不可变结构 atomic.Pointer 替换。
type M struct{ min, max atomic.Int64 }   // 错: 各自原子≠整体一致
m.min.Store(2); m.max.Store(9)           //   读者见新 min 旧 max
type M struct{ mu sync.Mutex; min, max int64 }  // 对: 锁保复合不变量
坑 11 · WaitGroup 跨阶段重用 — 上一轮 Wait 与下一轮 Add 交错, 计数混乱。正解: 每轮新建 WaitGroup, 或保证 Add/Wait/Done 的严格顺序(Add 先于一切)。
var wg sync.WaitGroup              // 错: 跨轮共用, Wait/Add 交错
for round := 0; round < n; round++ {
    wg := sync.WaitGroup{}       // 对: 每轮新建
    for _, t := range tasks { wg.Add(1); go work(&wg, t) }
    wg.Wait()
}
坑 12 · Once 里的函数 panic — Do 中的 f panic 会向上传播且 once 标记已完成(f 视同"执行过"), 之后 Do 不再重试。正解: 初始化逻辑自带 recover + 重试入口; 非致命初始化放普通懒加载。
once.Do(func() { mustInit() })  // 错: panic 后视同"已执行"
// 之后 Do 直接返回 → 永久坏状态, 不再重试
// 对: f 内 recover 并暴露重试入口
once.Do(func() { if r := recover(); r != nil { allowRetry() } })
坑 13 · map 迭代中写 — for range 中直接 m[k]=v 是 fatal error(不可 recover), 整个进程崩。正解: 迭代中收集变更, 循环外统一应用; 或每 key 一次 LoadOrStore。
go func() { for k := range m { _ = k } }()
m[k] = v                          // 错: → fatal error:
                                  //   concurrent map iteration and map write
// 对: 迭代中收集变更, 循环外统一应用 (或 sync.Map)
坑 14 · slice 并发 append — append 的"检查容量+扩容+写"不是原子的, 并发下互相覆盖丢数据(还可能内存损坏)。正解: 加锁; 或预分配 + 各写各下标(goroutine i 写 s[i])。
var s []int
go func() { s = append(s, 1) }()   // 错: 互相覆盖丢数据
go func() { s = append(s, 2) }()
s := make([]int, n)                  // 对: 预分配各写各下标
for i := 0; i < n; i++ { go func(i int) { s[i] = work(i) }(i) }
坑 15 · 给 goroutine 私有数据也上锁 — 每个协程独享的对象加锁纯属开销与噪音。正解: 画清"谁拥有什么" — 所有权明确的对象无需同步, 交接点(参数/chan)才需要。
buf := make([]byte, 0, 4096)          // G 私有
mu.Lock(); buf = append(buf, b...); mu.Unlock()  // 错: 纯开销
buf = append(buf, b...)                    // 对: 所有权明确无需锁
out <- buf                                 //    交接点才需要(chan 自带)
坑 16 · 依赖 sync.Pool 的保留保证 — GC 一到池可能清空, 把它当缓存期待命中是误用。正解: Pool 只为降分配率; 数据缓存用带 TTL 的结构。
pool.Put(userSession)               // 错: 当缓存期待命中
v, _ := pool.Get().(*Session)      // GC 一到池可能清空
// 对: Pool 只降分配率 (Get/Hit 率进指标)
//     数据缓存用带 TTL 结构 (ristretto/bigcache)
坑 17 · atomic 值的"读-改-写"拆开写 — x.Load() 后 x.Store(x.Load()+1) 两步之间已被改。正解: Add / Swap / CompareAndSwap 三件套, 永不手写两步式更新。
x.Store(x.Load() + 1)           // 错: 两步之间已被别的 G 改
x.Add(1)                          // 对: 原子加
for {                             //    复杂逻辑用 CAS 循环
    o := x.Load()
    if x.CompareAndSwap(o, o*2) { break }
}
坑 18 · context.Value 当参数大巴 — 什么值都往 ctx 塞, 隐式依赖 + 并发读还要确认不可变。正解: ctx 只带请求范围的元数据(trace/用户标识); 业务参数显式传。
ctx = context.WithValue(ctx, "order", order)  // 错: 业务参数塞 ctx
process(ctx, order)                             // 对: 显式传参
ctx = context.WithValue(ctx, traceIDKey, tid)   // ctx 只带元数据
坑 19 · 以为 runtime 会报所有死锁 — "all goroutines are asleep" 只在全部 goroutine 卡死时触发; 部分死锁(还有 worker 在跑)永远不报。正解: 超时兜底 + pprof goroutine 常态巡检。
// 错: 以为死锁必然触发 "all goroutines are asleep"
//     部分死锁(还有 worker 在跑)永远不报
select {                          // 对: 超时兜底
case ch <- v:
case <-time.After(3 * time.Second):
    return ErrStuck              // + pprof goroutine 巡检
}
坑 20 · -race 只在本地跑 — 竞态靠触发路径, 本地全绿不代表 CI/预发全绿。正解: -race 进 CI 必跑 + 预发环境长时间挂 -race 混流(接受性能损耗, 只在非生产层)。
# 错: 本地 -race 全绿就不管了
# 对: -race 进 CI 必跑 + 预发长时间混流
go test -race ./...
GOFLAGS=-race ./app --canary    # 预发金丝雀(非生产层, 接受慢)