Go sync 包

sync 提供 Mutex、RWMutex、WaitGroup、Once、Map、Cond 等共享内存同步原语;何时用锁、何时用 channel,以及复制与解锁纪律。

#type / concept #status / growing #tech / dev #resource / go

[!info] 关联笔记

Go sync 包

这个概念为什么会出现

Go 鼓励 share memory by communicating,但真实系统里总有一类状态更适合共享 + 互斥

  • 内存里的缓存 map
  • 连接池计数
  • 结构体上的多字段不变量
  • “进程内只初始化一次”的单次逻辑

若强行把所有共享状态改成 channel 流水线,有时会更绕、更慢、更难推理。sync 包给出小而硬的同步原语:在共享内存模型下建立临界区与等待关系。

它不是并发的“默认入口”,而是 channel 表达不清时的工具箱。

[!abstract] 一句话理解 sync 用锁、等待组、一次性执行等原语协调共享状态;正确性依赖“同一把锁保护同一个不变量”,且 Mutex/WaitGroup/Once 等使用后不能再按值复制。

最小可运行示例

先把示例放进业务场景,再看代码:

场景:秒杀活动里的“已售计数”

活动页同时涌入很多请求,每个成功下单都要把内存里的 sold +1,
最后在活动结束时打印总销量。

sold多 goroutine 共享的可变状态

  • 没有锁 → data race,总数可能丢更新
  • 用 channel 把 +1 丢给单一 owner 也可以,但“保护一个 int 不变量”时,Mutex 更直接

本例用 WaitGroup 等所有下单任务结束,用 Mutex 保护 sold++

package main

import (
	"fmt"
	"sync"
)

func main() {
	var (
		mu   sync.Mutex // 保护 sold 的同一把锁
		wg   sync.WaitGroup
		sold int // 已售件数:多 goroutine 共享
	)

	// 模拟 1000 个并发成功单同时回写销量
	for i := 0; i < 1000; i++ {
		// Add 必须在 go 之前:否则 Wait 可能在 Add 前看到 0 而提前返回
		wg.Add(1)
		go func() {
			// 任务怎么退出都要 Done,避免 Wait 永久阻塞
			defer wg.Done()

			// 临界区:读改写 sold 必须在同一把锁内完成
			mu.Lock()
			sold++
			mu.Unlock()
		}()
	}

	// 活动收尾:等所有回写完成再读总数
	wg.Wait()
	fmt.Println("sold:", sold) // 1000
}

建议运行:

go run .
# 再跑一遍竞态检测(无锁改法会报 race):
go run -race .

期望输出:

sold: 1000

结合场景再看三个关注点

  1. Addgo 之前
    否则 Wait 可能误以为“没有任务”而直接返回,销量统计偏小。

  2. 同一不变量同一把锁
    sold++ 不是原子的“一个操作”,无锁会丢更新;-race 能抓到。

  3. 锁保护的是共享状态,不是业务架构本身
    复杂流水线仍可用 channel;这里是典型的“共享计数 + 临界区”。

核心概念与准确模型

包定位

文档入口:Package sync

常见类型:

类型作用典型场景
Mutex互斥锁保护临界区
RWMutex读写锁读多写少的共享结构
WaitGroup等待一组任务结束fan-out 后 join
Once某逻辑只执行一次惰性初始化
Map并发安全的特殊 map键稳定、写少/场景符合文档时
Cond条件变量等某个条件成立(较少作为首选)
Pool临时对象池降低分配(性能工具,非正确性工具)

另有 sync/atomic(原子操作)常与之配合,但不在 sync 本包内。

Mutex

var mu sync.Mutex
mu.Lock()
// 临界区:不变量在此被破坏与恢复
mu.Unlock()

规则:

  1. 锁保护的是不变量,通常跨多个字段。
  2. 所有读写路径必须约定同一把锁。
  3. 优先 defer mu.Unlock() 防遗漏;但持锁时间长或需要精细分段解锁时要手写 Unlock。
  4. 不可复制已使用的 Mutex(含把含 Mutex 的 struct 按值传递)。

TryLock(较新版本)尝试加锁,失败立即返回,不替代正常 Lock 纪律。

RWMutex

var rw sync.RWMutex
rw.RLock()
_ = data
rw.RUnlock()

rw.Lock()
data = newData
rw.Unlock()
  • 多个读可并发
  • 写独占
  • 写饥饿、锁粒度仍是设计问题;读多写少且临界区值得时再考虑,不要默认到处 RWMutex

WaitGroup

var wg sync.WaitGroup
wg.Add(n)
// 每个任务 defer wg.Done()
wg.Wait()

要点:

  1. Add 的正增量必须在 Wait 可能返回之前发生,且通常在 go 之前。
  2. Done 等价于 Add(-1);计数为负会 panic。
  3. WaitGroup 只表达完成,不传播 error / cancel。
  4. 不要把 WaitGroup 当任务队列。

Once

var once sync.Once
once.Do(func() {
    // 只执行一次;若 panic,后续 Do 视为已调用且不再执行(见当时版本文档)
})

适合惰性初始化。需要可重置的“有时再来一次”不是 Once 的模型。

Map

sync.Map 是特定场景优化的并发 map,不是 map 的通用替换:

  • 键只写入一次多次读,或
  • 多 goroutine 读写不相交键集

否则通常 Mutex + map 更清晰。API:Store / Load / LoadOrStore / Delete / Range 等。

Cond(简要)

sync.Cond 基于 Locker,提供 Wait / Signal / Broadcast

  • 等待某个条件变为真
  • Wait 会原子地解锁并挂起,唤醒后重新加锁
  • 必须在循环里检查条件(避免虚假唤醒与逻辑竞态)

多数业务用 channel 更直观;Cond 出现在更底层或已有 Mutex 状态机中。

与 channel 如何选

更偏向 channel更偏向 sync
所有权转移、流水线、扇入扇出多字段共享结构原地更新
自然背压与结束信号(close)高频短临界区计数/缓存
取消与超时易组合(select)已有清晰不变量,只需互斥
数据流即架构数据结构即架构

经验法则:

  1. 数据有明确主人 → channel 或单 goroutine 拥有。
  2. 多方就地改同一块状态 → Mutex(或 atomic)。
  3. 既要共享又要取消 → 锁保护状态 + context 管生命周期,不要混成一团。

内存模型关系

解锁与加锁、WaitGroup 的 Done/Wait、Once 的 Do 完成等,在 Go Memory Model 下建立 happens-before。详见 go-happens-before-and-synchronization

设计动机

  1. 给共享内存一条标准、可组合的底层路径
  2. 保持原语正交:锁不管任务图,WaitGroup 不管错误
  3. 强迫不变量局部化:锁的粒度即设计边界

边界情况与反直觉行为

  1. 复制锁
    mu2 := mufunc f(m sync.Mutex) 按值拷会破坏互斥。应传指针或嵌入后只用指针接收者操作。
  2. 锁顺序
    多锁时固定全局顺序,否则 AB-BA 死锁。
  3. 持锁调外部
    持锁调用用户回调/IO/再获取另一把锁,是死锁与延迟放大器。
  4. WaitGroup 复用
    计数归零后可以再用于新一轮,但不要在 Wait 尚未看到归零时并发混乱重用。
  5. RWMutex 升级
    持有 RLock 时再 Lock 同一把会自死锁。
  6. race 与逻辑错误
    无 race 不等于业务正确(例如丢更新用错原子范围)。

常见误区

[!warning] 常见误区:channel 取代一切锁 错误:共享 graph/cache 硬拆成 channel 更乱。
正确:所有权清晰用通信;共享不变量用锁。

[!warning] 常见误区:WaitGroup 兼做错误通道 错误:只 Wait,错误丢在 goroutine 里。
正确:errgroup、结果 channel、或受锁保护的 error 收集协议。

[!warning] 常见误区:结构体含 Mutex 却按值返回/传递 错误:return svc 拷走 Mutex。
正确:返回 *Service,文档化不可复制。

[!warning] 常见误区:默认 sync.Map 错误:凡并发 map 就用 sync.Map。
正确:先 Mutex+map;只有文档场景匹配再考虑 sync.Map。

工程实践

  1. 临界区尽量小,但不要拆到破坏不变量。
  2. 锁与数据放一起type Store struct { mu sync.Mutex; m map[K]V }
  3. 对外 API 不把锁交给调用方乱 Lock
  4. 测试加 -racego-race-detector)。
  5. 取消用 context,完成用 WaitGroup,互斥用 Mutex——三种职责分开。
  6. 文档化不变量:“mu 保护 m 与 closed 字段”。

可验证实验

  1. 无锁并发 counter++go run -race 报警。
  2. 按值传递 sync.Mutex 的 struct,观察失效/race。
  3. WaitGroup.Add 放在子 goroutine 开头且主线程立刻 Wait,构造漏等。
  4. Once.Do 两次,确认函数体只跑一次。
  5. 读多写少场景对比 Mutex vs RWMutex(基准见 go-benchmarking,勿先验迷信)。

本节总结

  • 本质:共享内存下的标准同步工具箱。
  • 关键纪律:同一锁、同一不变量、不复制、Unlock 成对。
  • 与 channel:表达数据流 vs 保护就地状态。
  • 最易错:WaitGroup 时序、锁拷贝、用锁代替架构。
  • 下一步context 生命周期;同步与可见性

自测题

概念题

  1. 为什么“锁保护的是不变量”而不只是“保护某个字段”?
  2. WaitGroup.Add 为何通常要在 go 之前?
  3. 什么情况下你选 Mutex 而不是 channel?

代码推理题

type Counter struct {
    mu sync.Mutex
    n  int
}

func (c Counter) Inc() { // 注意接收者
    c.mu.Lock()
    c.n++
    c.mu.Unlock()
}

值接收者有什么问题?

工程思考题

缓存结构要并发读、偶尔写,并支持 Clear 与优雅停机。如何划分 Mutex/RWMutex/channel/context 职责?

参考答案

展开
  1. 不变量常跨多字段(如 map 与 len、或 linked 结构);只锁一个字段仍可能读到半更新。
  2. 否则 Wait 可能在 Add 之前看到 0 而提前返回。
  3. 多方就地更新共享状态、数据流模型不自然时。
    代码:值接收者复制了含 Mutex 的 struct,锁与 n 都是副本,原 Counter 未正确同步/更新;应使用指针接收者。
    工程:状态用 RWMutex(或 Mutex)保护;停机与请求生命周期用 context;不要用 channel 替代每一次 map 读。Clear 与写路径拿写锁。

延伸阅读与资料来源

资料类型支撑
Package sync标准库API 与语义
Go Memory Model规范同步与可见性
Effective Go — Concurrency文档并发风格
Code Review CommentsWiki实践约束
Share Memory By Communicating博客channel 与共享对照

笔记元信息

  • 建议文件名:go-sync-package.md
  • 所属阶段:阶段四
  • 本篇状态:已深化
  • 建议下一篇:Go context
创建于 2026/6/20 更新于 2026/7/15