Skip to content
Go
sync — 同步原语

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 语义保证与实现

  1. f 恰好执行一次;
  2. 执行期间所有其他 Do 调用阻塞,直到 f 返回;
  3. f panic 也算"执行过了”,不会重试——后续调用直接返回。若初始化可能 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
}

三个关键点:

  1. Wait 必须在持有锁时调用——条件是受锁保护的状态。
  2. 用 for 不用 if——被唤醒不代表条件成立(可能有别的等待者抢先消费,或虚假唤醒)。
  3. 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 它优化的两种场景(官方定义)

  1. 写一次、读多次:只增长的缓存、配置表。
  2. 多个 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 必须理解的语义

  1. 池里的对象随时可能被 GC 回收——Put 进去不保证下次 Get 还拿得到。设计上是"缓存"不是"所有权”。
  2. Get 返回的对象状态未知(可能是刚 New 的,也可能是别人 Put 的脏对象),永远先 Reset 再用。
  3. GC 时的行为:每次 GC 会清空主要池,旧对象进入"备用队列"最多再活一轮 GC——即对象平均只能存活 1~2 个 GC 周期。依赖"池里放 100 个预热连接"注定失败。
  4. 池是并发安全的、按 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)能定位锁竞争是否值得优化。