Go 鼓励用 channel 传递数据,但 channel 不会替代锁。缓存、连接表、统计值和懒加载状态都可能更适合用 sync 包。真正需要避免的是没有明确所有权,同时又没有同步的共享状态。
本文以 Go 1.26.1 源码为准。sync.Map 和 WaitGroup 在近几个 Go 版本中都有明显变化,看旧文章时要先看版本。
先记住“synchronizes before”
锁的价值不只是“同一时刻只有一个 goroutine 进来”。它还建立内存顺序:一次 Unlock 在后续成功的 Lock 之前发生,后者能看到临界区中已经完成的写入。
这是为什么不能用一个普通 bool 自己模拟锁,也是为什么“我看这行读写是原子的”不足以证明并发代码正确。
Mutex:优先把临界区写对
Go 1.26.1 的公开 sync.Mutex 内部包装了 internal/sync.Mutex。底层仍然是一个 state int32 加一个 sema uint32。
state 同时记录锁定、已唤醒、饥饿模式和等待者数量。无竞争时,Lock 用一次 CAS 完成快路径。竞争时则可能经历短暂自旋,然后在 runtime 信号量上等待。
Mutex 有正常和饥饿两种模式。正常模式下,新到的 goroutine 可以和被唤醒的等待者竞争,吞吐更好。等待超过 1 ms 的 goroutine 可能使锁进入饥饿模式,解锁时直接将所有权交给队首等待者,以控制极端尾延迟。
这些内部优化不会救一个过大的临界区。锁内做网络 I/O、日志格式化或无界计算,通常比选 Mutex 还是 RWMutex 影响更大。
type Counter struct {
mu sync.Mutex
n int64
}
func (c *Counter) Add(delta int64) {
c.mu.Lock()
c.n += delta
c.mu.Unlock()
}
Mutex 第一次使用后不能复制。带锁的结构体方法通常使用指针 receiver,传参也不要按值复制。go vet -copylocks 可以抓到很多这类问题。
TryLock 不是常规控制流
TryLock 在成功时和 Lock 有相同的同步语义,失败时不建立任何同步关系。标准库文档也提醒,正确的 TryLock 用法存在,但很少。如果代码开始轮询 TryLock 或失败就 sleep,通常应该先重新设计工作流。
RWMutex:要有读多写少的证据
RWMutex 允许多个读者或一个写者进入临界区。有写者开始等待后,新的 RLock 也会阻塞,否则持续进入的读者可能让写者永远拿不到锁。
这也带来三个边界:
- 不支持递归读锁。等待中的写者会阻止新的
RLock。 - 不能从
RLock直接升级到Lock。 - 不能把
Lock直接降级成RLock。
RWMutex 有更多原子操作和计数状态。读操作很短、竞争很少时,普通 Mutex 可能更快。不要用“90% 读就必须 RWMutex”这类固定阈值,直接对实际临界区做 benchmark。
WaitGroup:Go 1.26 优先用 Go 方法
Go 1.26.1 的 WaitGroup 将任务计数和等待者数量编码在一个 atomic.Uint64 中,另有一个 runtime 信号量。旧文章里的手动内存对齐结构已不适用。
Go 1.25 开始提供 WaitGroup.Go,Go 1.26 中可以直接写:
var wg sync.WaitGroup
for _, job := range jobs {
job := job
wg.Go(func() {
process(job)
})
}
wg.Wait()
Go 内部完成 Add(1)、启动 goroutine 和正常返回时的 Done,避免把 Add 错写到 goroutine 内部。传入的函数不应 panic。Go 1.26 的实现会重新抛出 panic,不会先 Done 让 Wait 与致命 panic 竞赛。
仍然可以手动 Add/Done,但要遵守:
wg.Add(1) // 先 Add
go func() {
defer wg.Done()
work()
}()
当计数为 0 时,正数 Add 必须发生在 Wait 之前。复用 WaitGroup 时,新一轮 Add/Go 必须等上一轮所有 Wait 返回后再开始。
Once:等初始化真正完成
Once 不能只用一次 CAS 实现。如果第一个 goroutine 尚未执行完 f,其他 goroutine 就看到 done 并返回,它们可能读到半初始化状态。
Go 1.26.1 的 Once 使用 atomic.Bool 做快路径,慢路径使用 Mutex。只有 f 返回后才设置 done,等待者会在 Mutex 上等到初始化完成。
var once sync.Once
var cfg *Config
func ConfigValue() *Config {
once.Do(func() {
cfg = loadConfig()
})
return cfg
}
如果 f panic,Once 仍然认为它已执行,以后的 Do 不会重试。需要返回值或缓存 panic 时,可以使用 sync.OnceValue、OnceValues 和 OnceFunc。
sync.Map:现在是 HashTrieMap
Go 1.26.1 的 sync.Map 不再是常见旧文章中的 readOnly + dirty + misses。它包装了 internal/sync.HashTrieMap[any, any]:查找按哈希值的分段沿 trie 下行,节点通过原子指针发布,更新在局部间接节点上加锁。
它仍然是特化数据结构。官方文档给出两类主要场景:
- 一个 key 只写一次,后续大量读取,例如只增长的缓存。
- 多个 goroutine 读写互不重叠的 key 集合。
大多数业务状态仍然更适合带类型的 map[K]V + Mutex/RWMutex。这样可以在一次加锁中维护“索引与计数同步更新”之类的复合不变式。
Range 不是一致性快照。并发修改同一个 key 时,它可能看到遍历期间任一时刻的值。
Pool:只放可丢弃的临时对象
sync.Pool 是并发安全的临时对象缓存。它为每个 P 维护本地区域,本地没有对象时可以从其他 P 偷取,GC 还会让本地对象进入 victim cache 并逐渐淘汰。
调用者必须假设 Get 随时可能返回新对象,不能用 Pool 保存连接、会话、限额或其他不能丢的状态。
var buffers = sync.Pool{
New: func() any { return new(bytes.Buffer) },
}
func encode(v any) ([]byte, error) {
b := buffers.Get().(*bytes.Buffer)
b.Reset()
defer func() {
if b.Cap() <= 64<<10 { // 避免长期留住超大 buffer
buffers.Put(b)
}
}()
if err := json.NewEncoder(b).Encode(v); err != nil {
return nil, err
}
return bytes.Clone(b.Bytes()), nil
}
返回 bytes.Clone 很重要,否则调用者拿到的 slice 仍然指向即将放回 Pool 的 buffer。
Cond:等待“条件成立”
sync.Cond 将一个条件变量与 Locker 绑定。Wait 会原子地解锁并挂起 goroutine,被 Signal 或 Broadcast 唤醒后重新加锁再返回。
条件必须放在循环中检查。唤醒只表示“现在值得再看一次”,不代表轮到当前 goroutine 重新拿锁时条件仍然成立。
cond.L.Lock()
for len(queue) == 0 {
cond.Wait()
}
item := queue[0]
queue = queue[1:]
cond.L.Unlock()
一次性通知更适合关闭 channel,传递任务更适合 channel。Cond 主要用在多个 goroutine 围绕同一个可反复变化的条件等待,例如有界队列的“非空”和“未满”。
怎么选
| 需求 | 默认选择 | 需要注意的边界 |
|---|---|---|
| 保护普通共享状态 | Mutex | 缩小临界区,不要复制锁 |
| 读多写少且竞争明显 | RWMutex | 用 benchmark 证明,不可升级或降级 |
| 等待一组任务 | WaitGroup.Go | 任务不应 panic,复用要等上一轮 Wait 结束 |
| 只执行一次的初始化 | Once / OnceValue | panic 后不重试 |
| 可丢弃的高频临时对象 | Pool | 清理旧状态,限制超大对象 |
| 特定并发 key/value 访问模式 | sync.Map | 先考虑类型安全的 map + lock |
| 反复等待共享状态达到某个条件 | Cond | Wait 放在循环中 |
| 传递数据或所有权 | channel | 设计取消、反压和关闭责任 |