Go sync 包
sync 提供 Mutex、RWMutex、WaitGroup、Once、Map、Cond 等共享内存同步原语;何时用锁、何时用 channel,以及复制与解锁纪律。
[!info] 关联笔记
- 所属 MOC:并发 MOC · 学习路线
- 前置概念:goroutine、channel
- 后续概念:happens-before、race detector、内存模型
- 容易混淆:channel 万能论、把锁当业务架构
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
结合场景再看三个关注点
-
Add在go之前
否则Wait可能误以为“没有任务”而直接返回,销量统计偏小。 -
同一不变量同一把锁
sold++不是原子的“一个操作”,无锁会丢更新;-race能抓到。 -
锁保护的是共享状态,不是业务架构本身
复杂流水线仍可用 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()
规则:
- 锁保护的是不变量,通常跨多个字段。
- 所有读写路径必须约定同一把锁。
- 优先
defer mu.Unlock()防遗漏;但持锁时间长或需要精细分段解锁时要手写 Unlock。 - 不可复制已使用的 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()
要点:
Add的正增量必须在Wait可能返回之前发生,且通常在go之前。Done等价于Add(-1);计数为负会 panic。- WaitGroup 只表达完成,不传播 error / cancel。
- 不要把 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) | 已有清晰不变量,只需互斥 |
| 数据流即架构 | 数据结构即架构 |
经验法则:
- 数据有明确主人 → channel 或单 goroutine 拥有。
- 多方就地改同一块状态 → Mutex(或 atomic)。
- 既要共享又要取消 → 锁保护状态 + context 管生命周期,不要混成一团。
内存模型关系
解锁与加锁、WaitGroup 的 Done/Wait、Once 的 Do 完成等,在 Go Memory Model 下建立 happens-before。详见 go-happens-before-and-synchronization。
设计动机
- 给共享内存一条标准、可组合的底层路径
- 保持原语正交:锁不管任务图,WaitGroup 不管错误
- 强迫不变量局部化:锁的粒度即设计边界
边界情况与反直觉行为
- 复制锁
mu2 := mu或func f(m sync.Mutex)按值拷会破坏互斥。应传指针或嵌入后只用指针接收者操作。 - 锁顺序
多锁时固定全局顺序,否则 AB-BA 死锁。 - 持锁调外部
持锁调用用户回调/IO/再获取另一把锁,是死锁与延迟放大器。 - WaitGroup 复用
计数归零后可以再用于新一轮,但不要在 Wait 尚未看到归零时并发混乱重用。 - RWMutex 升级
持有 RLock 时再 Lock 同一把会自死锁。 - 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。
工程实践
- 临界区尽量小,但不要拆到破坏不变量。
- 锁与数据放一起:
type Store struct { mu sync.Mutex; m map[K]V }。 - 对外 API 不把锁交给调用方乱 Lock。
- 测试加
-race(go-race-detector)。 - 取消用 context,完成用 WaitGroup,互斥用 Mutex——三种职责分开。
- 文档化不变量:“mu 保护 m 与 closed 字段”。
可验证实验
- 无锁并发
counter++,go run -race报警。 - 按值传递
sync.Mutex的 struct,观察失效/race。 WaitGroup.Add放在子 goroutine 开头且主线程立刻Wait,构造漏等。Once.Do两次,确认函数体只跑一次。- 读多写少场景对比
MutexvsRWMutex(基准见 go-benchmarking,勿先验迷信)。
本节总结
- 本质:共享内存下的标准同步工具箱。
- 关键纪律:同一锁、同一不变量、不复制、Unlock 成对。
- 与 channel:表达数据流 vs 保护就地状态。
- 最易错:WaitGroup 时序、锁拷贝、用锁代替架构。
- 下一步:context 生命周期;同步与可见性。
自测题
概念题
- 为什么“锁保护的是不变量”而不只是“保护某个字段”?
WaitGroup.Add为何通常要在go之前?- 什么情况下你选 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 职责?
参考答案
展开
- 不变量常跨多字段(如 map 与 len、或 linked 结构);只锁一个字段仍可能读到半更新。
- 否则 Wait 可能在 Add 之前看到 0 而提前返回。
- 多方就地更新共享状态、数据流模型不自然时。
代码:值接收者复制了含 Mutex 的 struct,锁与 n 都是副本,原 Counter 未正确同步/更新;应使用指针接收者。
工程:状态用 RWMutex(或 Mutex)保护;停机与请求生命周期用 context;不要用 channel 替代每一次 map 读。Clear 与写路径拿写锁。
延伸阅读与资料来源
| 资料 | 类型 | 支撑 |
|---|---|---|
| Package sync | 标准库 | API 与语义 |
| Go Memory Model | 规范 | 同步与可见性 |
| Effective Go — Concurrency | 文档 | 并发风格 |
| Code Review Comments | Wiki | 实践约束 |
| Share Memory By Communicating | 博客 | channel 与共享对照 |
笔记元信息
- 建议文件名:
go-sync-package.md - 所属阶段:阶段四
- 本篇状态:已深化
- 建议下一篇:Go context