Go 鼓励用 channel 传递数据,但 channel 不会替代锁。缓存、连接表、统计值和懒加载状态都可能更适合用 sync 包。真正需要避免的是没有明确所有权,同时又没有同步的共享状态。

本文以 Go 1.26.1 源码为准。sync.MapWaitGroup 在近几个 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,不会先 DoneWait 与致命 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.OnceValueOnceValuesOnceFunc

sync.Map:现在是 HashTrieMap

Go 1.26.1 的 sync.Map 不再是常见旧文章中的 readOnly + dirty + misses。它包装了 internal/sync.HashTrieMap[any, any]:查找按哈希值的分段沿 trie 下行,节点通过原子指针发布,更新在局部间接节点上加锁。

它仍然是特化数据结构。官方文档给出两类主要场景:

  1. 一个 key 只写一次,后续大量读取,例如只增长的缓存。
  2. 多个 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,被 SignalBroadcast 唤醒后重新加锁再返回。

条件必须放在循环中检查。唤醒只表示“现在值得再看一次”,不代表轮到当前 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 / OnceValuepanic 后不重试
可丢弃的高频临时对象Pool清理旧状态,限制超大对象
特定并发 key/value 访问模式sync.Map先考虑类型安全的 map + lock
反复等待共享状态达到某个条件CondWait 放在循环中
传递数据或所有权channel设计取消、反压和关闭责任

参考源码