sync 工具箱:Mutex、RWMutex、Once、Cond、Pool 怎么选
选择同步原语时,最重要的问题不是“哪个性能高”,而是“我要保护的不变量是什么”。如果两个字段必须一起变化,一个再快的原子计数器也保护不了它们之间的关系;如果只是发布一份只读配置,一把全局互斥锁又可能让读路径承担不必要的竞争。
可以先用这张表做初筛:
| 需求 | 优先考虑 |
|---|---|
| 多个字段组成一个临界区 | sync.Mutex |
| 读明显多于写,且临界区不太短 | sync.RWMutex,先测量 |
| 初始化只执行一次 | sync.Once / OnceValue |
| 等待某个状态条件变化 | sync.Cond,或 channel |
| 复用可丢弃的临时对象 | sync.Pool |
| 等待一组任务结束 | sync.WaitGroup |
| 特定缓存模式或 key 相互独立 | sync.Map |
Mutex 保护的是不变量,不是变量
type Account struct {
mu sync.Mutex
balance int64
frozen int64
}
func (a *Account) Available() int64 {
a.mu.Lock()
defer a.mu.Unlock()
return a.balance - a.frozen
}锁与被保护状态应放在同一个类型中,并让字段保持未导出。否则调用方可以绕过方法直接访问字段,锁只剩下装饰作用。
不要按“每个字段一把锁”机械拆分。balance >= frozen 是跨字段不变量,必须在同一临界区里检查和更新:
func (a *Account) Freeze(amount int64) error {
a.mu.Lock()
defer a.mu.Unlock()
if amount < 0 || a.balance-a.frozen < amount {
return ErrInsufficientBalance
}
a.frozen += amount
return nil
}Mutex 没有 goroutine 所有权:可以在一个 goroutine 锁定、另一个解锁,但这种设计通常很难推理。优先让锁的获取与释放出现在同一词法范围。
Mutex、RWMutex、Once、Pool、WaitGroup 等类型首次使用后都不应复制。包含它们的 struct 通常使用指针接收者,也不要按值放进 map 或 channel。
RWMutex 不一定比 Mutex 快
RWMutex 允许多个读者并发,写者独占。它有额外状态和调度成本;如果临界区很短、写入并不少,普通 Mutex 可能更快、更公平,也更容易理解。
使用 RWMutex 前至少确认:
- 读操作占绝大多数;
- 读临界区足够长,读并发确实能带来收益;
- 没有在读锁下调用不受控的慢函数;
- benchmark 或 profile 能看到锁竞争。
RWMutex 不支持升级和降级:持有 RLock 后再 Lock 会死锁,持有写锁也不能原子降级成读锁。需要“读取后条件更新”时,释放读锁、获取写锁后必须重新检查条件:
cache.mu.RLock()
value, ok := cache.data[key]
cache.mu.RUnlock()
if ok {
return value
}
cache.mu.Lock()
defer cache.mu.Unlock()
if value, ok := cache.data[key]; ok { // 重新检查
return value
}
value = load(key)
cache.data[key] = value
return value如果 load 很慢,是否应在锁外执行又涉及重复加载和 singleflight,这需要根据业务语义设计,不能只靠换一把锁解决。
TryLock 很少是正常控制流
Mutex.TryLock 和 RWMutex.TryLock 适合少量特殊场景,例如诊断或允许跳过的后台维护任务。业务代码如果写成“抢不到锁就 sleep 再试”,通常是在手写低效自旋或掩盖锁粒度问题。
锁竞争应通过缩短临界区、分片、不可变快照或重构所有权处理,不是不断 TryLock。
Once:只运行一次,也只记住第一次
var loadConfig = sync.OnceValue(func() *Config {
return mustReadConfig()
})
func ConfigValue() *Config {
return loadConfig()
}Go 1.21 起,OnceFunc、OnceValue 和 OnceValues 减少了手写结果字段的样板代码,并正确处理并发调用。
传统 Once.Do 有两个容易忽略的行为:
- 如果函数 panic,这次调用仍被视为“已经执行”,后续不会重试;
- 如果函数内部再次调用同一个 Once,会死锁,因为第一次调用尚未返回。
需要“失败后可以重试”的初始化不应使用 Once。它本质上是一次性状态机,不是重试器。
Cond:等待条件,而不是等待通知
Cond 适合多个 goroutine 等待共享状态变化。Wait 必须放在循环里:
type Queue struct {
mu sync.Mutex
ready *sync.Cond
values []Job
closed bool
}
func NewQueue() *Queue {
q := &Queue{}
q.ready = sync.NewCond(&q.mu)
return q
}
func (q *Queue) Pop() (Job, bool) {
q.mu.Lock()
defer q.mu.Unlock()
for len(q.values) == 0 && !q.closed {
q.ready.Wait()
}
if len(q.values) == 0 {
return Job{}, false
}
job := q.values[0]
q.values = q.values[1:]
return job, true
}Wait 会原子地解锁并挂起,醒来后重新加锁。醒来只表示“状态可能变了”,不保证你的条件一定成立:其他 goroutine 可能先一步消费,Broadcast 也会唤醒所有等待者。
简单的一次性通知、所有权转移或流水线用 channel 往往更清楚;Cond 更适合围绕复杂共享状态的等待。
Pool 不是对象仓库
sync.Pool 保存的是随时可以被丢弃的临时对象。GC 可以在任何时候清空池,因此不能用它存连接、限量令牌、必须归还的对象或业务缓存。
适合的例子是高频临时 buffer:
var buffers = sync.Pool{
New: func() any { return new(bytes.Buffer) },
}
func encode(value any) ([]byte, error) {
buffer := buffers.Get().(*bytes.Buffer)
buffer.Reset()
defer buffers.Put(buffer)
if err := json.NewEncoder(buffer).Encode(value); err != nil {
return nil, err
}
return bytes.Clone(buffer.Bytes()), nil
}返回前必须复制 buffer.Bytes(),否则 buffer 放回池后,调用方持有的切片可能被下一次使用覆盖。Pool 降低分配的同时扩大了对象复用范围,所有权要求反而更严格。
是否使用 Pool 应由 allocation profile 和 benchmark 驱动。小对象的手工池化可能增加逃逸、同步和清理成本。
选原语的顺序
- 先写出需要保持的状态不变量;
- 优先选择最直接表达它的原语,通常从 Mutex 开始;
- 让同步原语和状态封装在一起;
- 用 race detector 验证正确性;
- 只有 profile 证明存在竞争或分配瓶颈时,再换更复杂的结构。
同步代码首先是正确性代码,其次才是性能代码。