sync — 同步原语
sync 提供了 Go 并发编程的基础构件。理解它们的底层语义和边界条件是写出正确并发代码的前提。
1. Mutex — 互斥锁
1.1 基础用法
type Counter struct {
mu sync.Mutex
value int
}
func (c *Counter) Inc() {
c.mu.Lock()
defer c.mu.Unlock() // defer 保证即使 panic 也解锁
c.value++
}1.2 底层原理:正常模式与饥饿模式
Go 的 Mutex 不是简单的"抢不到就睡":
- 正常模式:等待者排 FIFO 队列,但刚释放锁时,正在 CPU 上自旋/新到达的 goroutine 有优势——它可能插队直接拿到锁。这样吞吐高(唤醒等待者要付出上下文切换成本)。
- 饥饿模式:当一个等待者超过 1ms 还没拿到锁,锁切换为饥饿模式,释放时直接交接给队首等待者(handoff),插队不再发生。代价是吞吐略降,换来的是公平——不会出现某个 goroutine 永远抢不到锁。
工程含义:Lock 是可阻塞但不保序的(正常模式下)。如果你的业务假设"先到先得",那是错的。
注意:Go 的 Mutex 不可重入。同一 goroutine 对已持有的锁再
Lock()会自己等自己 → 死锁。需要"同一逻辑路径多次进入"时,把加锁段拆成"公开方法加锁 + 内部无锁私有方法"。
1.3 不可复制
type SafeStruct struct {
mu sync.Mutex
data map[string]string
}
// 错误:值接收者 → 每次调用复制一份锁,锁的是副本
func (s SafeStruct) BadMethod() {
s.mu.Lock()
defer s.mu.Unlock()
}
// 正确:指针接收者
func (s *SafeStruct) GoodMethod() {
s.mu.Lock()
defer s.mu.Unlock()
}Mutex 被复制后,副本和原件各自独立,保护作用完全失效(两个 goroutine 各锁各的)。go vet 的 copylocks 检查会拦住这类代码——所有含 sync.Mutex/WaitGroup/RWMutex/Once 的结构体都应通过指针传递。
1.4 锁的粒度
// 差:不相关操作也进锁
func (c *Counter) BadUpdate() {
c.mu.Lock()
defer c.mu.Unlock()
c.value++ // 需要保护
log.Printf(...) // 不需要保护
metrics.Report() // 若内部又拿同一把锁 → 死锁
}
// 好:临界区只包住共享状态
func (c *Counter) GoodUpdate() {
c.mu.Lock()
c.value++
c.mu.Unlock()
log.Printf(...)
metrics.Report()
}原则:锁内不做 IO、不调第三方回调、不递归获取别的锁。需要"读状态→在锁外做昂贵动作"时,先把需要的值拷出来再解锁。
2. RWMutex — 读写锁
核心原则:读共享,写独占。多读不互斥,有写时禁止读。
2.1 基础用法
type SafeMap struct {
mu sync.RWMutex
data map[string]string
}
func (m *SafeMap) Get(key string) (string, bool) {
m.mu.RLock() // 多个读并发
defer m.mu.RUnlock()
val, ok := m.data[key]
return val, ok
}
func (m *SafeMap) Set(key, value string) {
m.mu.Lock() // 写独占
defer m.mu.Unlock()
m.data[key] = value
}2.2 读锁不能升级为写锁(死锁)
// 死锁:持有读锁时申请写锁
func (m *SafeMap) DeadlockedUpdate(key string, fn func(string) string) {
m.mu.RLock()
old := m.data[key]
m.mu.Lock() // ← 死锁:写锁要等所有读锁释放,而当前 goroutine 正持有读锁
m.data[key] = fn(old)
m.mu.Unlock()
m.mu.RUnlock()
}
// 正确:先释放读锁,再拿写锁
func (m *SafeMap) CorrectUpdate(key string, fn func(string) string) {
m.mu.RLock()
old, exists := m.data[key]
m.mu.RUnlock()
if !exists {
return
}
m.mu.Lock()
defer m.mu.Unlock()
m.data[key] = fn(old)
}注意 CorrectUpdate 释放读锁到拿写锁之间有时间窗,别的 goroutine 可能改了值——这叫"检查与行动不原子"。要真原子得用 Mutex 全程保护,或 Map.CompareAndSwap 思路。
2.3 递归 RLock 也会死锁
RWMutex 的读锁不可重入:已持有读锁的 goroutine 再次 RLock() 时,如果中间有写者等待(Go 的实现里写者优先),会自我阻塞。“公开方法 RLock → 内部方法又 RLock"是隐蔽的死锁源,内部一律拆成不加锁的私有函数。
2.4 何时用 RWMutex 而不是 Mutex
- 读远多于写(>10:1)且读临界区不短 → RWMutex 有意义。
- 读写差不多,或临界区极短 → 普通 Mutex 更快(RWMutex 内部计数器更多、状态机更复杂)。
- 拿不准 → 先 Mutex,压测证明读锁竞争是瓶颈再换。
3. WaitGroup — 等待组
var wg sync.WaitGroup
for i := 0; i < 5; i++ {
wg.Add(1) // 必须在启动 goroutine 之前
go func(id int) {
defer wg.Done()
doWork(id)
}(i)
}
wg.Wait() // 计数器归零才返回陷阱:
wg.Add(1)放进 goroutine 内部是竞态:Wait可能在Add之前就返回了。批量启动时,在循环外一次性wg.Add(len(items))更稳。- 计数归零后再
Add(+1)再Wait是合法的复用;但Add(-n)使计数为负会直接 panic。WaitGroup复制即失效(同 Mutex,go vet检查)。
Go 1.25+ 更省心的写法:wg.Go(f)——把"Add(1) + go func + defer Done"三步合一:
for _, item := range items {
wg.Go(func() { process(item) }) // 内部:Add(1) → 启动 goroutine → 结束 Done
}
wg.Wait()注意它的 Add 发生在调用处(同步),所以天然规避了"Add 放进 goroutine 内"的竞态;f 在新 goroutine 里 panic 是 fatal(无法在外层 recover),这点和手写版本行为一致。配套的 vet 分析器 waitgroup(1.25 加入)会在 Go 1.27 起改名为 waitgroupgo,看到 lint 输出换名不必意外。
生产建议:需要"带错误收集的一组任务"时,golang.org/x/sync/errgroup(非标准库但事实标准)比手搓 WaitGroup + channel 收错误省事得多。
4. Once — 一次性执行
var (
instance *Singleton
once sync.Once
)
func GetInstance() *Singleton {
once.Do(func() {
instance = &Singleton{data: "init"}
})
return instance
}4.1 语义保证与实现
f恰好执行一次;- 执行期间所有其他
Do调用阻塞,直到f返回; fpanic 也算"执行过了”,不会重试——后续调用直接返回。若初始化可能 panic 且需要重试,别用 Once。
实现骨架(简化)就是"原子标志快路径 + 双检锁慢路径":
func (o *Once) Do(f func()) {
if atomic.LoadUint32(&o.done) == 0 {
o.doSlow(f) // 锁内再查一次 done
}
}done 用 atomic.Store 在 f 返回时设置,配合 happens-before,Do 返回后所有 goroutine 都能看到 f 里写入的变量,不需要额外加锁。
陷阱:
once.Do内部再调用同一个once.Do→ 不可重入锁,死锁。
4.2 更省心的写法:OnceFunc / OnceValue / OnceValues(Go 1.21+)
var getConfig = sync.OnceValue(func() *Config {
return loadConfig() // 只加载一次,并发安全,返回值带内存序保证
})
var fetchData = sync.OnceValues(func() ([]Item, error) {
return fetchItems()
})
// 失败重试模式:OnceValues 缓存 (T, error),想"失败就不缓存"需要自己判断
conf := getConfig()OnceValue/OnceValues 把"懒加载 + 只成功一次"表达成一行,是单例/配置加载的首选写法。注意 OnceValues 会缓存 error(第一次失败,后面拿到的都是同一个 error),想要"失败重试"得自己写逻辑。
5. Cond — 条件变量
Cond 实现"等待某个条件成立",适合多等待者 + 广播唤醒的场景(channel 表达"通知所有订阅者"比较别扭,Cond 是正解)。
type Queue struct {
items []int
mu sync.Mutex
cond *sync.Cond
}
func NewQueue() *Queue {
q := &Queue{}
q.cond = sync.NewCond(&q.mu) // Cond 绑定一个 Locker(Mutex 或 RWMutex)
return q
}
func (q *Queue) Put(item int) {
q.mu.Lock()
q.items = append(q.items, item)
q.mu.Unlock()
q.cond.Signal() // 唤醒一个等待者(可以在锁外)
}
func (q *Queue) Get() int {
q.mu.Lock()
defer q.mu.Unlock()
for len(q.items) == 0 { // 必须 for,不能 if
q.cond.Wait() // 原子地:解锁→阻塞→被唤醒后重新拿锁
}
item := q.items[0]
q.items = q.items[1:]
return item
}三个关键点:
Wait必须在持有锁时调用——条件是受锁保护的状态。- 用
for不用if——被唤醒不代表条件成立(可能有别的等待者抢先消费,或虚假唤醒)。 Signal/Broadcast可以在锁外——锁内调用会延迟唤醒者拿锁。
5.1 Signal vs Broadcast
- 状态变化恰好满足一个等待者的条件 →
Signal。 - 状态变化让所有等待者都可能可行动(关闭、配置切换)→
Broadcast。 - 拿不准用
Broadcast:Signal漏唤醒 = 永久阻塞,Broadcast多唤醒 = 浪费一次循环判断。
注意:Cond 没有超时等待,也不与 context 联动。Go 1.26+ 依旧如此。要做"等条件 + 可取消",用 channel 或在等待循环里配合外部状态。另外 Cond 不会在 goroutine 退出时自动唤醒自己——等待者被外部取消(如 ctx 关闭)时,需要 Broadcast 把所有人叫醒再让它们检查退出标志。
6. sync.Map — 特殊场景的并发 map
sync.Map 是 sync 里最常被误用的类型:大家以为"并发 map 就用它",实际官方文档明说它是 specialized(特化工具),大多数场景应该用普通 map + 锁。
6.1 它优化的两种场景(官方定义)
- 写一次、读多次:只增长的缓存、配置表。
- 多个 goroutine 读写互不重叠的 key 集合:分片计数器、按 session 隔离的表。
这两种情况下它能显著降低锁竞争。反过来,高频写同一批 key 时它比普通 map+Mutex 更慢——内部要做原子写、装箱(key/value 都是 any)。
6.2 设计演化:从 read/dirty 到哈希三叉树(Go 1.24)
历史设计(Go 1.9 ~ 1.23):内部两张 map——无锁的 read(原子可见)+ 加锁的 dirty,read 未命中次数积累到阈值才把 dirty 整体提升为 read。所以"读多写少才划算",且冷启动有个"晋升"过程。
Go 1.24 起实现整体重做(notes 原文:“The implementation of sync.Map has been changed, improving performance, particularly for map modifications… no longer any ramp-up time”):换成基于哈希前缀树的并发结构(源码里是 syncHashTrieMap),每个节点细粒度加锁——不同 key 集合的修改彼此几乎不争锁,也没有旧版的"晋升预热"问题。回退开关 GOEXPERIMENT=nosynchashtriemap(该实现后来又被移除,仅作历史了解)。
读旧资料时注意区分:“read/dirty 双 map + misses 晋升"已经不再是现行实现,但"sync.Map 适合读多写少/不相交 key"的使用建议依然成立。
6.3 API 与用法
var m sync.Map // 零值即可用;首次使用后不可复制
m.Store("k", 1)
v, ok := m.Load("k")
v, loaded := m.LoadOrStore("k", 2) // loaded=true 说明已有值,本次 Store 没生效
m.Delete("k")
v, loaded := m.LoadAndDelete("k")
// Go 1.23
m.Clear()
// Go 1.24 新增的 CAS 家族
m.Swap(key, new) (previous any, loaded bool)
m.CompareAndSwap(key, old, new) bool
m.CompareAndDelete(key, old) bool
// 遍历:f 返回 false 提前结束
m.Range(func(k, v any) bool {
process(k, v)
return true
})// 典型场景:只读缓存(写一次读多次)
type Cache struct{ m sync.Map }
func (c *Cache) Get(key string) (string, bool) {
v, ok := c.m.Load(key)
if !ok {
return "", false
}
return v.(string), true
}
func (c *Cache) Once(key string, fn func() string) string {
// LoadOrStore 的惯用法:先放占位,保证只计算一次
v, _ := c.m.LoadOrStore(key, fn())
return v.(string)
}注意:
Range提供的是快照式弱一致视图——遍历期间并发写入,“能看到还是看不到"都有序合法(文档措辞:may or may not see entries added later)。它不保证遍历完的瞬间状态与开始一致,也不提供"先收集 keys 再处理"这种假原子。
6.4 什么时候别用 sync.Map
| 场景 | 更优选择 |
|---|---|
| 读写混合、类型要安全 | 普通 map + sync.RWMutex |
| 需要维护 map 之外的不变量(计数、LRU 顺序) | 普通 map + 锁(不变量必须跨操作成立) |
| key 基数极小的计数器 | atomic 或分片 map + 锁 |
| 写远多于读 | 普通 map + Mutex(sync.Map 的装箱/原子开销白付) |
装箱成 any 的另一个代价:编译期类型检查没了,Load 回来的 any 断言写错直接 panic。这也是官方建议"能用普通 map 就用普通 map"的原因之一。
7. Pool — 对象池
var bufPool = sync.Pool{
New: func() any { return new(bytes.Buffer) },
}
buf := bufPool.Get().(*bytes.Buffer)
buf.WriteString("hello")
result := buf.String()
buf.Reset() // 归还前必须重置
bufPool.Put(buf)7.1 必须理解的语义
- 池里的对象随时可能被 GC 回收——
Put进去不保证下次Get还拿得到。设计上是"缓存"不是"所有权”。 Get返回的对象状态未知(可能是刚 New 的,也可能是别人 Put 的脏对象),永远先 Reset 再用。- GC 时的行为:每次 GC 会清空主要池,旧对象进入"备用队列"最多再活一轮 GC——即对象平均只能存活 1~2 个 GC 周期。依赖"池里放 100 个预热连接"注定失败。
- 池是并发安全的、按 P 本地分片的,开销极低。
经典误用:把需要 Close 的资源(DB 连接、文件句柄、TCP conn)放进 sync.Pool。早期 Go 曾支持对象实现
Cleanup() any让池在 GC 时回调清理,该机制早已废弃;现行实现里的poolCleanup只做 victim 缓存轮换,不会通知你的对象任何东西——对象被回收时句柄直接泄漏到进程退出。连接池请用channel自建,或runtime.AddCleanup(1.24)做兜底,别指望 Pool。
适用:高频分配/丢弃且无状态的小对象(bytes.Buffer、json 编码器中间结构)——减少 GC 压力。 不适用:长连接池、需要固定数量、需要初始化即生效的东西。
8. TryLock(Go 1.18+)
if mu.TryLock() {
defer mu.Unlock()
// 拿到锁,正常干活
} else {
// 没拿到:降级/跳过/报错
}定位是死锁规避与非阻塞探测(比如"能拿到锁就顺带做清理,拿不到就算了”),不是常规同步手段。注意只有 TryLock,没有带超时的 TryLock;“等锁最多 100ms” 需要自己用 channel 包装。RWMutex 同样有 TryLock/TryRLock。
9. sync 包常见陷阱总汇
| 陷阱 | 后果 | 检测/规避 |
|---|---|---|
| 值传递含锁的结构体(值接收者) | 锁失效/计数错乱 | go vet copylocks |
| 读锁内申请写锁 | 死锁 | 先 RUnlock 再 Lock |
| 递归 RLock 且中间有写者 | 死锁 | 拆内部无锁私有方法 |
wg.Add 放在 goroutine 内 |
Wait 提前返回/panic | Add 在启动前;或提前 Add(n) |
| Once.Do 内 panic | 不会重试,且可能带出半成品状态 | 初始化失败走显式 error |
| 依赖 sync.Map 做"原子读改写" | 检查与写不原子 | LoadOrStore / CompareAndSwap(1.24+) |
| Pool 里放需要 Close 的资源 | 句柄泄漏 | 用 channel 池 / AddCleanup |
| 误以为 Put 的对象一定还在池里 | 逻辑错误(GC 后出现) | New 兜底,不预填充 |
TryLock 失败分支里仍执行 Unlock |
panic: unlock of unlocked mutex | 失败分支绝不 Unlock |
| 假设 Mutex 先到先得 | 饥饿模式外本就不保证 | 需要强公平时自己设计队列 |
10. 实战建议
- 默认选型顺序:能用 channel 传递所有权就不用锁;要共享状态就 Mutex;读多写少且临界区明显不短时再考虑 RWMutex;sync.Map 只在"只增缓存/不相交 key"两个官方场景用。
- 锁保护的不变量要写注释(“此锁保护 fields: a, b”),临界区里别碰注释外的东西。
- 单例/懒加载一律
sync.OnceValue;不要手写if instance == nil { ... }的无锁单例。 - 上线前跑
-race;压测时runtime/metrics里看锁等待(/sync/mutex/wait/total:seconds)能定位锁竞争是否值得优化。