Theory · Golang · Runtime

sync 并发原语底层

Mutex 的 32 位 state 位图 → 饥饿模式;sync.Map 的 read/dirty 双表 —— 每个 API 背后都是一个精巧的位运算状态机

锁家族

Mutex 位布局与自旋、RWMutex 的负数 readerCount、1ms 饥饿切换(Go 1.9+)

协调家族

WaitGroup 计数信号量、Once 双检查、atomic CAS 指令与泛型化(1.19+)

专用容器

sync.Map 双表提升、Pool 两级 victim cache;15 组 QA + 死锁清单

sync 包是并发必考区,从"怎么用"一路问到"为什么这么设计"。这份 deck 按三条线组织:锁家族把 Mutex 的 32 位 state 位图拆开讲,饥饿模式是核心考点;协调家族讲 WaitGroup、Once、atomic 的实现;专用容器讲 sync.Map 和 Pool 的结构。所有版本敏感点都查过 release notes:饥饿模式 1.9 引入、OnceFunc 1.21、WaitGroup.Go 1.25。

开场 · Why Sync Primitives

两个 goroutine 各加一百万次,结果不是两百万

先看一段"看起来绝对没错"的代码,再谈工具。

var n int

for i := 0; i < 2; i++ {
    go func() {
        for j := 0; j < 1_000_000; j++ {
            n++              // ← 不是一步!
        }
    }()
}
// 期望 2_000_000
// 实测:每次都不一样,常见 1_0xx_xxx ~ 1_9xx_xxx
为什么错:n++ 其实是三步

① 从内存出 n;② 在寄存器里加 1;③ 写回内存。两个 goroutine 可能同时读到 100、各自算出 101、再各自写回——两次加法,只增加了 1。

比"算错"更糟的三件事

问题后果
结果依赖调度时序同一份代码每次跑出的数都不一样,本地复现不了线上的偶发故障
锁太粗 / 太细太粗 → 并行退化成串行;太细 → 漏锁或死锁
抢不到锁要睡挂起 + 唤醒要经调度,微秒级——比无竞争时贵两个数量级
Go 的回答:按"要保护的边界"分三类给工具

单个变量 → atomic(一条 CPU 指令,不睡眠,个位数纳秒);多个字段一起改 → Mutex(无竞争时一次 CAS,十几纳秒);等一批人干完 / 只初始化一次 → WaitGroup / Once选工具 = 选边界的粒度。(以上为量级,绝对值随硬件与版本变化)

开场用一个能跑出来的反例立住问题:两个 goroutine 各加一百万次,结果不是两百万,因为 n++ 是读、加、写三步,两个 goroutine 可能同时读到同一个旧值再各自写回。比算错更糟的是结果依赖调度时序、锁粒度两难、以及抢不到锁要睡的微秒级代价。Go 的回答是按要保护的边界分三类给工具:单变量用 atomic 一条指令,多字段一起改用 Mutex,等一批人或只初始化一次用 WaitGroup 和 Once。记住选工具就是选边界的粒度。

读之前 · Before You Read

先花一分钟对齐九个词,后面每一页都会用到

术语全部是本 deck 真正会用到的,没有凑数;前置知识不在本 deck 内的,用链接指过去。

术语(中英对照)一句话白话定义
数据竞争 data race两个 goroutine 同时碰同一块内存、至少有一个在写,且没有约定顺序——结果取决于谁先跑到
临界区 critical section被锁保护的那段代码。同一时刻只允许一个 goroutine 在里面执行
不变量 invariant需要被保护的"正确性约束",通常是多个字段之间必须同时成立的关系(如余额与流水对得上)
原子操作 atomic一条 CPU 指令完成的读改写。中间不可能被别的核插进来,要么全做要么全不做
CAS 比较并交换"如果现在还是旧值 A,就改成新值 B";发现不是 A,说明被别人抢先了,本次失败
术语(中英对照)一句话白话定义
互斥锁 Mutex同一时刻只允许一个持有者;其余人要么抢、要么睡
自旋 spin不睡觉,反复尝试抢锁,赌持有者马上就放。烧 CPU 换延迟,赌错了就是白烧
信号量 semaphoreruntime 提供的"挂起 / 唤醒"机制。睡下去不占 CPU,被唤醒才继续——代价是要走一次调度
饥饿 starvation某个等待者长期抢不到锁(总被新来者插队)。Go 用 1ms 阈值切饥饿模式兜底
顺序一致性 seq-cstGo atomic 采用的内存序:所有 goroutine 看到的操作顺序一致。简单但略保守,Go 不开放更细的 acquire/release
最小心智模型(后面所有机制都是这句话的实现细节):所有并发原语只做一件事——在正确的位置插入一道"同一时刻只能一个(或一个也不许)"的边界atomic单个变量的边界,Mutex/RWMutex一段代码的边界,WaitGroup/Once/Cond时序的边界。选工具 = 选边界的粒度。
三篇前置(本 deck 不展开):gmp-scheduler.html —— G / P / M 与调度,解释"挂起唤醒为什么贵";channel-internals.html —— 另一种并发风格:用通信传递所有权而非共享内存;../os/thread-sync.html —— 操作系统侧的自旋 / 睡眠权衡与 futex 快慢两阶段。
术语页给九个词:数据竞争、临界区、不变量、原子操作、CAS、互斥锁、自旋、信号量、饥饿、顺序一致性。最小心智模型一句话:所有并发原语都只是在正确的位置插入一道边界,atomic 管单个变量、Mutex 管一段代码、WaitGroup 和 Once 管时序,选工具就是选边界的粒度。前置指三个 deck:GMP 讲调度与挂起唤醒的代价,channel 讲传递所有权的另一种风格,OS 同步讲内核侧的自旋睡眠权衡。

Panorama

先选对工具:七类原语的适用面

原语适用不适用 / 陷阱底层机制
Mutex保护复合不变量(多个字段一致)不可重入;defer 太粗放大临界区state 位图 + 信号量 sema
RWMutex读极多写极少;临界区较长不可递归;写多时反而更慢w Mutex + 读者计数 + 双信号量
WaitGroup等一批 goroutine 收口Add/Wait 时机错配;重用需等 Wait 返回64 位计数 + 信号量
Once初始化恰好一次f panic 也算"完成"atomic load 快路径 + mutex
atomic单个标量的原子读写改无法保护多字段一致性CPU 原子指令(LOCK CMPXCHG 等)
sync.Map读多写少 / key 集合不相交写多场景慢于 mutex+mapread/dirty 双表 + miss 提升
sync.Pool复用无状态临时对象减 GC连接等有状态资源;可能被 GC 静默收走per-P 缓存 + 两级 victim
选型口径:"默认 mutex+map;单标量计数/标志用 atomic;读多写少且 key 稳定上 sync.Map——先测量再优化,sync.Map 不是万能加速器。"channel 传递所有权 vs 共享内存的取舍见 channel deck。
开篇先立目录:七类原语各自的适用面和陷阱,一表总览。选型口径要背:默认 mutex 加 map,单标量用 atomic,sync.Map 只在读写不均衡且 key 集合稳定时用。后面每页展开一个家族,注意所有底层机制都围绕两个积木——位运算状态机和 runtime 信号量,这是 Go 并发原语的设计主轴。

Mutex · state Bits

Mutex:一个 int32 装下全部状态

Mutex.state 的位布局 32 位 state 从低位到高位依次为:bit0 locked 已持有、bit1 woken 有唤醒中的 goroutine、bit2 starving 饥饿模式、bit3 起为等待者计数;sema 信号量配合挂起与唤醒。下方给出源码常量定义。 Mutex.state · int32(位布局,从 bit0 起) waiter 计数bit3…bit31 starvingbit2 = 4 wokenbit1 = 2 lockedbit0 = 1 semauint32 唤醒队列 锁被持有? 有唤醒中? 饥饿模式? 等待 goroutine 数 // src/sync/mutex.go(对照 Go 1.27) type Mutex struct { state int32; sema uint32 } mutexLocked = 1<<iota // 1 mutexWoken = 2 · mutexStarving = 4 mutexWaiterShift = 3 · starvationThresholdNs = 1e6 三个标志的分工 · locked:CAS 置位成功即拿锁(快路径) · woken:有线程正被唤醒要抢锁 → 新来的不再 置位 woken,减少无谓唤醒(防惊群浪费) · starving:进入饥饿模式 → Unlock 走直接 交接(handoff),不再给新来者插队机会 waiter 计数与标志位共用一个 int32: 一次原子读写覆盖全部状态,无锁拆分
Mutex 的全部状态装在一个 int32 里:低三位是 locked、woken、starving 三个标志,高位是等待者计数,旁边一个 uint32 信号量管挂起唤醒。位图设计的收益是快路径只需要一条 CAS:state 从零变成 locked 即拿锁。woken 位的作用是防止惊群式的无谓唤醒;starving 位切换饥饿模式,让 Unlock 走直接交接。这三个位的运转就是后两页的主角。

Lock Path

加锁全路径:CAS → 自旋 → 排队

Mutex 加锁决策流程 Lock 先尝试一次 CAS 快路径;失败进入 lockSlow:满足多核、本地队列空闲等条件时最多自旋四次;仍失败则等待者计数加一并挂到信号量;被唤醒后按正常或饥饿模式决定是竞争拿锁还是直接交接;正常模式下等够一毫秒切饥饿。 CAS 成功 失败 自旋失败 Lock() → CAS state 0 → locked 无竞争快路径:一条原子指令,无函数调用进入慢路径 lockSlow:能否自旋? 多核 · GOMAXPROCS>1 · 本 P runq 空 · 非饥饿 · <4 次 自旋(active_spin ~30 cycle ×4) 乐观赌"临界区马上结束":期间反复尝试 CAS waiter++ · 信号量排队挂起 semacquire(sema):G 摘出运行队列,交给 runtime 唤醒 被唤醒:上次等待 > 1ms? 是 → 置 starving 进入饥饿模式(下一页) 拿锁:饥饿直取 / 正常再竞争 CAS 正常模式唤醒者与新来者赛跑——插队(barging)可能发生 四个自旋条件的"为什么" · 多核:单核自旋只会白烧时间片(持有者无法 与你并行推进) · 本 P runq 空:自旋不吃别的 G 的份额, 且持有者大概率也在本 P 附近运行 · 非饥饿:饥饿模式下自旋是对公平性的破坏 · 次数上限:自旋是烧 CPU 换延迟,期望收益 为负就该睡 Unlock 侧:清 locked 位 → 有 waiter 且非饥饿 → semrelease 唤醒一个;饥饿则 handoff=true 源码:sync/mutex.go Lock/lockSlow/Unlock
加锁路径四段式:先一条 CAS 快路径,无竞争时一次原子指令搞定。失败进 lockSlow,满足四个条件才自旋:多核、本 P 运行队列空、非饥饿、次数不超四次——每个条件都有明确的为什么,右边逐条列了。自旋还拿不到就 waiter 计数加一、挂到信号量上睡。被唤醒时看上次等了多久:超过一毫秒切饥饿模式,否则在正常模式下和新来者赛跑,可能被插队。

Starvation · Go 1.9+

正常 vs 饥饿:吞吐与公平的切换(1ms)

正常模式:吞吐优先

Unlock 唤醒队头 waiter,但锁不直接给它——唤醒者要与新到的 goroutine 竞争 CAS。新来者正在 CPU 上跑、无需调度开销,赢面大(插队 barging)。临界区短时吞吐高;副作用:队头可能一直抢不过 → 尾延迟雪崩。

饥饿模式:公平优先

waiter 等待超过 1ms(starvationThresholdNs = 1e6)→ 置 starving 位。此后 Unlock 直接把锁交给队头(handoff),新来者不再尝试 CAS,乖乖去队尾排队。队头拿到即可能让本 waiter 退出饥饿(等待时长重新评估)。

维度正常模式饥饿模式
锁的移交唤醒后竞争(新来者可插队)FIFO 直接交接给队头 waiter
自旋允许(满足四条件)禁止(自旋破坏公平)
切换入某 waiter 等待 > 1ms
切换出队尾 waiter(自己是最后一个)或本次等待 < 1ms
版本与出处:饥饿模式 Go 1.9 引入(fix:重负载下尾延迟长尾,见 mutex.go 文档注释 "Mutex fairness")。答题口径:"1ms 是实测调出的平衡点:比临界区典型耗时长,短临界区几乎不触发;触发后 FIFO 保证有界等待。"
饥饿模式是 Mutex 的核心考点。正常模式吞吐优先:唤醒的 waiter 和新来者赛跑,正在跑的新来者赢面大,短临界区吞吐高,但队头可能永远抢不到。等待超一毫秒就切饥饿:FIFO 直接交接,新来者只能排队,等待时间有界。切换出的两个条件要记全:只剩最后一个 waiter,或者本次等待没超一毫秒。版本点是 Go 1.9 引入,动机是消除重负载下的尾延迟。

RWMutex · Write Preference

RWMutex:负数 readerCount 实现写优先

// src/sync/rwmutex.go(对照 Go 1.27)
type RWMutex struct {
    w           Mutex        // 写者互斥
    writerSem   uint32       // 写者等读者清场
    readerSem   uint32       // 读者等写者离开
    readerCount atomic.Int32 // 读者计数(可为负)
    readerWait  atomic.Int32 // 写者前需等走的读者数
}
const rwmutexMaxReaders = 1 << 30

// Lock(): 先抢 w.Mutex,再
//   readerCount -= 1<<30  → 变负 = "有写者在等"
//   readerWait = 现存读者数,等它们 RUnlock 清场
负数哨兵:readerCount 减去 1<<30 后:<0 即"写者在等/持有"——此后新读者 RLock 发现负数 → 挂 readerSem 等写者完成。一个减法同时表达"写者优先"与"读者计数"。
规则内容
写优先(防写饿死)文档:Lock 被调用后,"concurrent calls to RLock will block until the writer has acquired (and released)"——已持读锁者不受影响,新读者全部让路
不可递归持 RLock 再 RLock:若此刻有写者排队 → 第二次 RLock 睡在 readerSem,而写者在等你放锁 → 死锁(文档明确警告)
readerWait 的作用写者记录"还要等几个读者退出",最后一个 RUnlock 发现 --readerWait==0 → 唤醒 writerSem
成本提醒RLock/RUnlock 各一次原子加减;写多场景读者原子操作互相"打架"(cache line 弹跳)——写多反而比 Mutex 慢
RWMutex 的巧思在负数 readerCount:写者 Lock 时把计数减去二的三十次方,从此负数就是"有写者在等"的哨兵,新读者自动让路——写优先防写饿死,一个减法同时实现计数和优先级。两条硬规则:写者要等 readerWait 清零才能上场;读锁不可递归,写者排队时嵌套 RLock 直接死锁,这是文档原文警告。工程提醒:写多场景 RWMutex 反而更慢,读者原子操作互相打 cache line。

WaitGroup · Go 1.25 Go()

WaitGroup:计数 + 信号量,配对规则是全部考点

// src/sync/waitgroup.go(对照 Go 1.27)
type WaitGroup struct {
    noCopy noCopy
    state  atomic.Uint64 // 高32位:计数 低32位:waiters
    sema   uint32
}

wg.Add(n)  // counter += n;归零且 waiters>0
           // → semrelease 唤醒全部
wg.Wait()  // counter==0 → 快路径返回;
           // 否则 waiters++ 挂 sema
wg.Done()  // 就是 Add(-1)

// Go 1.25 新增:
wg.Go(func() { … }) // ≈ Add(1)+go+defer Done()
noCopy:WaitGroup 禁止拷贝——state/sema 语义绑定在"这一个"对象上。go vet copylocks 检查所有含 noCopy 的类型传值行为。
规则(文档原文级)说明
正数 Add 必须先于 Wait(或等价的并发前置)counter 为零时 Wait 立即返回——晚 Add = 丢等。Go 1.25 新增 go vet waitgroup 分析器静态抓这个错
counter 归零后再 Add 的时机"new Add calls must happen after all previous Wait calls have returned"——重用必须等 Wait 全部返回;违者 panic("WaitGroup is reused before previous Wait has returned")
counter 不能为负Add 负数越过零 → panic "negative WaitGroup counter"

为什么"goroutine 里 Add"是经典事故

go func(){ wg.Add(1) … }:Wait 可能在 Add 执行前就跑完。正确姿势:go 之前 Add,或直接用 1.25 的 wg.Go(f)——从 API 层消灭这个错位。

WaitGroup 的结构很朴素:一个 64 位原子整数,高 32 位是计数低 32 位是等待者数,加一个信号量。考点全在配对规则:正数 Add 必须先于 Wait,否则 Wait 直接返回;重用必须等上一轮 Wait 全部返回;计数不能减成负数。goroutine 里 Add 是经典事故,Go 1.25 直接给了 wg.Go 方法从 API 层消灭这个错位,同时 go vet 新增 waitgroup 分析器做静态检查。注意 noCopy 防拷贝设计。

Once · OnceFunc (1.21)

Once:双检查;f panic 也算"完成"

// src/sync/once.go(对照 Go 1.27)
type Once struct {
    done atomic.Uint32
    m    Mutex
}
func (o *Once) Do(f func()) {
    if o.done.Load() == 0 {   // 快路径:原子读
        o.doSlow(f)
    }
}
func (o *Once) doSlow(f func()) {
    o.m.Lock()
    defer o.m.Unlock()
    if o.done.Load() == 0 {   // 双检查
        defer o.done.Store(1) // ⚠ 先于 f 的 defer
        f()                   //   → f panic 也置 done
    }
}

done.Store 挂在 f 之前的 defer 上:panic 展开时先执行它——这就是"f panic 也算完成"的实现机制

行为定义(官方文档)
并发 Do恰好一个 goroutine 执行 f,其余阻塞等待其完成——不是跳过
f panic(Once.Do)"Do considers it to have returned; future calls of Do return without calling f"——done 已置位,后续调用静默返回(实测确认)
OnceFunc/OnceValue(s)(Go 1.21+)"the returned function will panic with the same value on every call"——每次调用重放同一个 panic,失败不被吞
设计取舍题:Do 把 panic 吞成"初始化成功"(适合 best-effort 初始化);OnceFunc 把失败传染给所有调用者(适合"初始化失败 = 系统坏"的场景)。1.21 加 OnceFunc 正是补这个语义空洞。
Once 的实现要点两个:快路径是一次原子读,慢路径 mutex 加双检查。最值钱的是 panic 行为:done 的置位挂在 f 之前的 defer 上,f panic 展开时先置位,所以官方文档说 Do 认为它已返回,后续调用静默跳过——这个行为我实测过。对比 Go 1.21 的 OnceFunc:f panic 后返回的函数每次调用重放同一个 panic,失败不被吞。两者的取舍是标准的设计问答:一个是尽力而为初始化,一个是失败必须暴露。

sync/atomic · CAS

atomic:CPU 原子指令 + 泛型化(1.19+)

指令级根基

CAS → x86 LOCK CMPXCHG / arm64 LDAXR+STXR;Add → LOCK XADD。全部顺序一致性(seq-cst),不开放 acquire/release——比 C++ 内存序简单,略保守。

与 Mutex 的取舍

atomic 适合单标量:计数器、标志位、指针替换;复合不变量(多字段一起改)必须 Mutex。atomic 不睡眠不调度,Mutex 慢路径要走调度——开销量级差一个数量级。

// Go 1.19+ 泛型类型(自动处理对齐)
var n atomic.Int64        // 内嵌 align64
n.Add(1) · n.Load() · n.CompareAndSwap(o, v)
var p atomic.Pointer[Config]
p.Store(cfg) // 指针替换:无锁配置热更新
// atomic.Value:any 版(1.4+)
var v atomic.Value
v.Store(Config{…})
v.Store("str")  // panic: inconsistently typed
// Go 1.23 新增:返回旧值
atomic.And(&x, mask) · atomic.Or(&x, bits)
API要点
atomic.ValueStore 后类型固定:再存其他类型 panic(inconsistently typed);Load 返回 any 需断言
atomic.Int64/Pointer[T] 等(1.19+)泛型封装:类型安全 + 32 位平台 8 字节对齐由内嵌 align64 保证——手写字段对齐曾是经典坑
atomic.And/Or(1.23+)原子位运算,返回旧值(release notes 原文)

指针替换模式

atomic.Pointer 存不可变快照:写方整份替换、读方 Load 用本地副本——生态最常用的"无锁读、低频写"结构(sync.Map 同款)。

atomic 的根基是 CPU 指令:CAS 对应 LOCK CMPXCHG,加法对应 LOCK XADD,全部顺序一致性语义,Go 不开放更细的内存序。与 mutex 的取舍一句话:单标量用 atomic,多字段复合不变量必须 mutex。三个版本点:1.19 的泛型类型顺带解决了 32 位平台 int64 对齐的经典坑;atomic.Value 存过一种类型再存别的会 panic;1.23 新增 And 和 Or 位运算返回旧值。指针替换模式是生态里最常用的无锁读结构。

sync.Map · read/dirty

sync.Map:read 无锁快路径 + dirty 慢路径

sync.Map 的 read/dirty 双表结构 read 是 atomic 指针指向的只读 map,Load 走它无需加锁;dirty 是可写全量表,新 key 先进 dirty;misses 计数达到 dirty 长度时把 dirty 整表提升为 read。entry 用原子指针持有值,删除软置 nil,expunged 哨兵标记未进 dirty 的删除项。 miss 提升 entry.p 指向 Map 结构体(rwmutex.go 同目录 map.go) mu Mutex // 只护 dirty read atomic.Pointer[readOnly] dirty map[any]*entry misses atomic.Int // readOnly{ m map[any]*entry; amended bool } // amended=true 表示"有新 key 在 dirty" Load 路径 1. read.m[key] 命中 → 返回 (无锁!一次 atomic load) 2. miss → misses++ 3. misses ≥ len(dirty) → dirty 整表提升为 read (dirty 置 nil,重建成本后置) 双表分工 · read:只读快照,Load/Range/ 已有 key 的 Store 都走它,零锁 · dirty:包含全量 key(read 的 + 新增的),写新 key 才碰它 · 新 key 写入 → amended=true, 此后 read 的 Load miss 累积 · 提升后 miss 清零、dirty=nil, 下次写新 key 再全量重建 entry.p 的三种状态 p → 真值 正常存在 p → nil 已删除(软删) p → expunged 已删且未随提升 复制进 dirty 的哨兵 · 删除优先 p=nil(CAS 改指针,不碰锁); 若该 entry 不在 dirty,才 mu.Lock 改 expunged · value 用指针而非 map value 直存: 更新值 = 改一个原子指针,read 无需失效 // Go 1.20 起 read/dirty 内部改用 atomic.Pointer
sync.Map 双表结构:read 是原子指针指着的只读快照,Load 命中它就返回,全程无锁;dirty 是全量可写表,新 key 先进这里。miss 计数达到 dirty 长度就把 dirty 整表提升为 read。entry 用原子指针持值,软删置 nil,expunged 哨兵标记没跟上提升的删除项。结构决定的性能特征:读多写少快,写新 key 多就反复触发全量重建,反而慢。

When to Use · Pool Victim

sync.Map 的适用边界;sync.Pool 的两级 victim

sync.Map:官方给的两个场景

① key 集合写入后基本不变(append-only cache);② 多 goroutine 读写不相交的 key 集合。命中任一即显著快于 Mutex+map;写新 key 多则反复 dirty 重建,反而慢——先 benchmark。

sync.Pool 结构

per-P 的 poolLocal:private 槽(无锁)+ shared 链(可被偷);外加victim 两级:GC 时 local 降级为 victim、旧 victim 丢弃(1.13+)——对象最多活两个 GC 周期,是"软缓存"不是持久存储。

Pool 的 Get 优先级

Get():
  1. 本 P private        // 无锁
  2. 本 P shared 头部     // 加锁
  3. 偷其他 P shared      // 负载均衡
  4. victim / victimShared // 上一代
  5. New()(若设置)

poolCleanup 挂在 GC STW 阶段:local→victim、victim 清空(src/sync/pool.go)

Pool 误用后果 / 修法
存连接、文件等有状态资源GC 静默收走对象,连接裸奔——有状态资源要显式池化(自带生命周期管理)
Put 回带残留状态的对象复用后读到上次的脏字段——Put 前 Reset,或只放"纯缓冲"([]byte 等)
Put 大对象当免费缓存两个 GC 周期内被清——当"长期缓存"用会反复 miss + New,白忙
期望 Get 出来的对象一直可用Get 后对象所有权归你、用完 Put;跨 GC 的假设不成立
sync.Map 的适用边界官方写得很清楚:key 写后不变或读写集合不相交。Pool 的重点是两级 victim:GC 时本地缓存降级成 victim,更老的一代直接丢,所以对象最多活两个 GC 周期,它是减 GC 压力的软缓存。误用清单按真实事故排:连接这类有状态资源不能用 Pool,Put 回脏对象是隐蔽 bug,把 Pool 当长期缓存是方向性错误。标准库自己的用法是最可靠的风向标。

Cond · errgroup

补充两件:sync.Cond 与 x/sync/errgroup

sync.Cond:条件变量

c := sync.NewCond(&mu)

func consumer() {
    mu.Lock()
    for !ready {        // ⚠ 必须 for 循环
        c.Wait()        // Unlock→挂起→醒来再 Lock
    }
    mu.Unlock()
}
func producer() {
    mu.Lock(); ready = true; mu.Unlock()
    c.Broadcast()
}
两条铁律:① Wait 必须在持锁状态调用(内部先 Unlock 睡、醒后自动重 Lock);② 判断条件必须用 for 不能 if——Broadcast 唤醒后被别人抢走条件,醒来要重查。生产多用 channel 替代,Cond 适合"广播多消费者"的窄场景。

errgroup(golang.org/x/sync)

g, ctx := errgroup.WithContext(ctx)
g.SetLimit(8)          // 并发上限(信号量)
for, url := range urls {
    url := url
    g.Go(func() error {
        return fetch(ctx, url)
    })
}
err := g.Wait() // 第一个非nil error
                // + ctx 自动 cancel
行为说明
Wait 返回值第一个非 nil error(其余仍会等完)
WithContext首个 error 触发 ctx cancel——扇出取消的标准姿势
panic 传递Go() 内部 recover,Wait 时 re-panic——补上跨 goroutine panic 缺口(呼应 defer deck)
SetLimit内置信号量限流,替代手写 worker pool
两个补充件。Cond 的两条铁律:Wait 必须持锁调用,内部先解锁睡醒再重锁;条件判断必须 for 循环,防唤醒后条件被抢。生产代码 channel 能覆盖大部分场景,Cond 留给广播型。errgroup 是工程最常用的并发收口:Wait 返回第一个错误、WithContext 自动级联取消、Go 内部 recover 后在 Wait 重放 panic,正好补上跨 goroutine panic 的缺口,SetLimit 提供限流。

Deadlock Checklist

九宗罪:死锁与误用清单

现象 / 根源修法 / 检测
① Mutex 重入同一 goroutine 二次 Lock:等自己放锁 → 永久阻塞拆临界区 / 递归数据重构;Go 锁故意不支持重入(保持简单)
② 双锁交叉G1 持 A 等 B,G2 持 B 等 A全局固定加锁顺序;检测:go run -race + 压测
③ RWMutex 递归读锁读锁内再 RLock,写者插入即死锁(第 6 页)重入逻辑上提 / 改 RWMutex→Mutex
④ mutex 拷贝struct 按值传递/赋值后锁失效,两份 state 各自为政一律 *Mutex;go vet copylocks 静态检查(noCopy)
⑤ WaitGroup.Add 错位goroutine 里 Add → Wait 提前返回go 前 Add 或 wg.Go(1.25);vet waitgroup
⑥ WaitGroup 重用竞态counter 归零后立刻复用,旧 Wait 尚未返回等 Wait 返回再重用;或每轮新 WaitGroup
⑦ Cond 用 if 判条件被唤醒但条件已被抢走 → 脏数据for + 条件检查(第 12 页)
⑧ atomic.Value 换类型Store 第二种具体类型 → panic类型恒定;多类型用 Pointer[T] 分变量
⑨ sync.Pool 存有状态资源GC 静默回收 → 连接消失(第 11 页)有状态资源显式池化
全 deadlock 的兜底:所有 goroutine 都睡着时 runtime 打 "fatal error: all goroutines are asleep - deadlock!" 直接崩(不可 recover);只死一部分时无提示,靠 go test -race + 超时 + pprof goroutine profile 排查。
九宗罪按出现频率排。前三个是死锁类:重入、双锁交叉、RWMutex 递归读锁——Go 的锁故意不支持重入,把复杂度留给静态结构。中间三个是误用类:mutex 拷贝、WaitGroup 两种错位。最后三个是语义类。兜底知识要记:全体 goroutine 都睡着 runtime 会报 all goroutines are asleep 的 fatal 直接崩,不可 recover;部分死锁没有提示,只能靠 race detector、超时和 goroutine profile。

Interview QA · 1/2

锁家族 8 连问

1 · Mutex 底层结构?快路径为什么快?

state 位图sema一条 CAS

state int32(低三位 locked/woken/starving + 高位 waiter 计数)+ sema uint32。无竞争时 Lock 就是一次 CAS(0→locked),用户态一条原子指令;有竞争才进 lockSlow 的自旋/排队。

2 · 正常模式和饥饿模式怎么切换?

1ms 阈值FIFO handoffGo 1.9

waiter 等待超过 1ms(starvationThresholdNs=1e6)切饥饿:Unlock 直接把锁交队头,新来者只排队不自旋不插队。退出:队尾 waiter 或本次等待 <1ms。1.9 引入,治重负载尾延迟。

3 · 自旋的条件是什么?为什么?

多核runq 空非饥饿<4 次

多核且 GOMAXPROCS>1、本 P 本地 runq 空、非饥饿模式、自旋次数上限 4。逻辑:单核自旋白烧时间片;runq 有活别占 CPU;饥饿下自旋破坏 FIFO 公平;次数上限防期望收益为负。

4 · RWMutex 怎么实现写优先?

readerCount 负数rwmutexMaxReaders

写者 Lock 时先拿 w.Mutex,再把 readerCount 减 1<<30 使其变负——负数即"写者在等",新读者 RLock 发现负数就挂 readerSem 让路;readerWait 记录还需等几个读者退出,最后一个 RUnlock 唤醒 writerSem。

5 · RWMutex 为什么不可递归?

嵌套读锁死锁

持读锁再 RLock:若此刻有写者在排队,第二次 RLock 发现 readerCount 为负 → 挂起等写者;写者又在等第一次读锁释放 → 循环等待。文档原文明确警告不支持递归读锁。

6 · WaitGroup 内部结构?重用规则?

64 位原子计数先 Wait 返回再重用

noCopy + 一个 64 位原子值(高 32 位计数、低 32 位 waiters)+ sema。Add 归零且有人等 → semrelease 全唤醒。重用规则:新 Add 必须发生在上次 Wait 全部返回之后,否则 panic;counter 减过零 panic "negative WaitGroup counter"。

7 · Once 里 f panic 了,后续 Do 会怎样?

done 已置位静默返回OnceFunc 重放

Do:done 的置位挂在 f 之前的 defer 上,panic 展开先置位 → 后续 Do 静默返回不重试(文档原文 considers it to have returned)。OnceFunc(1.21)相反:返回的函数每次调用重放同一 panic——失败不被吞。

8 · atomic.Value 换类型会怎样?怎么绕?

panic inconsistently typed

首次 Store 锁定具体类型,再 Store 别的类型 panic。绕法:类型恒定设计,或多类型用 atomic.Pointer[T] 各存各的;1.19 后泛型类型(Int64/Pointer[T])取代手写对齐的 int64 字段。

QA 第一组覆盖锁家族。第二题报出 1ms 阈值和 Go 1.9 的版本点。第四题的负数 readerCount 是精髓,能讲出"一个减法同时表达计数和优先级"就赢了。第七题 panic 行为要区分 Once.Do 静默返回和 OnceFunc 重放 panic 两套语义。第八题顺带提 1.19 泛型类型解决了 32 位对齐问题。

Interview QA · 2/2

容器与工程 7 连问

9 · sync.Map 为什么读快?内部结构?

read 只读快照dirtymisses 提升

read 是 atomic 指针指的只读 map,Load 命中零锁;dirty 是含新 key 的全量表,由 mu 保护。Load miss 计数达 len(dirty) 时整表提升为 read。entry 用原子指针持值——改值只动指针,read 不失效。

10 · sync.Map 什么时候用?什么时候别用?

append-onlykey 不相交写多反而慢

官方两个场景:key 写后基本不变;多 goroutine 操作不相交的 key 集合。写新 key 频繁 → 反复 miss + dirty 全量重建,比 Mutex+map 慢。默认 mutex+map,benchmark 证明读多写少再换。

11 · sync.Pool 的 GC 行为?能存连接吗?

victim 两级两个 GC 周期不能

GC 时 local 降级为 victim、旧 victim 丢弃——对象最多活两个 GC 周期。所以是减 GC 压力的软缓存:连接等有状态资源不能用(被静默收走),只放无状态纯缓冲;Get 顺序 private→shared→偷→victim→New。

12 · atomic 和 mutex 怎么选?atomic 能替代锁吗?

单标量 vs 复合不变量seq-cst

单变量的计数/标志/指针替换用 atomic(用户态指令、不调度);需要"多字段一起变"的复合不变量必须 mutex。atomic 全部顺序一致性,Go 不开 acquire/release;CAS 循环自己写容易出 ABA 与忙等——能用 mutex 就别炫技。

13 · errgroup 的 Wait 和 panic 行为?

首个 errorctx 级联取消Wait 时 re-panic

Wait 返回第一个非 nil error(其余 goroutine 仍等完);WithContext 后首个 error cancel ctx——扇出取消标准姿势。Go() 内部 recover、Wait 时 re-panic:跨 goroutine panic 有了官方通道。SetLimit 信号量限流。

14 · go vet 能静态查出哪些并发误用?

copylockswaitgroup(1.25)lostcancel

copylocks:锁按值拷贝(noCopy);waitgroup:Add 位置错(1.25 新分析器);lostcancel:context cancel 被丢。死锁类(重入、双锁)vet 查不了——靠 -race、压测与 review。CI 里 vet + -race 是并发代码最低配置。

15 · mutex 保护的是代码还是数据?defer Unlock 有什么讲究?

保护不变量临界区最小化

保护的是数据/不变量,不是那几行代码——心智模型错了就会漏锁(不同路径访问同字段却走不同锁)。defer Unlock 保证路径完备但把临界区拉到函数尾:长尾操作(IO/RPC)放锁外,先拷出数据再 defer。

QA 第二组覆盖容器与工程。第十题强调默认 mutex 加 map,benchmark 说了算。第十二题的选型边界要能举例:计数器 atomic,账户余额扣减这种复合操作 mutex。第十四题 vet 三件套加 race detector 是 CI 最低配置。第十五题是心智模型题:锁保护的是不变量不是代码,defer 会拉长临界区,IO 要放锁外。

Related & References

相关知识点与参考

同领域 deck

channel 底层实现 →(hchan 的锁与 sudog 队列,同一信号量机制)
GMP 调度模型 →(sema 挂起/唤醒与 G 的调度交互)
内存分配与逃逸分析 →(per-P 缓存思想同源)
defer/panic/recover →(errgroup 的 panic 传递、OnceFunc 与 panic)
OS · 同步与互斥 →(锁的内核侧:自旋/睡眠权衡与 futex 快慢两阶段)

答题串联 · 一图流

Mutex → state 位图 → CAS 快路径 → 自旋 → 1ms 饥饿
RWMutex → 负数 readerCount → 写优先 → 不可递归
WaitGroup/Once → 计数+信号量 → 配对规则 → panic 语义
sync.Map/Pool → 结构决定场景 → 误用清单 + vet 四件套

参考来源(全部结论可溯源)

src/sync/mutex.go(对照 Go 1.27)state 位布局、lockSlow 自旋条件、正常/饥饿模式切换(含 "Mutex fairness" 注释)
src/sync/rwmutex.goreaderCount 负数哨兵、rwmutexMaxReaders、readerWait 清场
src/sync/{waitgroup,once,pool,map}.go64 位计数、done 先置位、victim 两级清理、read/dirty 与 expunged
pkg.go.dev/syncRWMutex 写优先与递归警告、Once.Do panic 语义、Pool 的 GC 行为、WaitGroup 重用规则原文
go.dev/doc/go1.19 · go1.21 · go1.23 · go1.25泛型 atomic 类型 / OnceFunc 系 / atomic.And、Or / WaitGroup.Go 与 vet waitgroup 分析器
pkg.go.dev/golang.org/x/sync/errgroupWait 首个 error、WithContext 级联取消、SetLimit、panic 传递
收尾页给同领域链接和参考来源。版本敏感结论都标注了出处:饥饿模式在 mutex.go 注释和 CL 历史里,OnceFunc 是 1.21、atomic 泛型是 1.19、And Or 是 1.23、WaitGroup.Go 和 vet 分析器是 1.25。sync 包的文档原文本身就是最好的复习材料——写优先警告、Once panic 语义都是官方定义而非博客转述。