
sync.Once 与 sync.Cond 源码与并发控制陷阱一、核心概念与架构设计这两个原语解决的是并发控制里方向相反的两个问题sync.Once要保证一件事在并发下只发生一次。它有性能上限要求Do的首次调用之后的每一次调用都要近乎免费否则单例初始化的 fast-path 会成为热点路径的开销。sync.Cond要保证条件不满足时等待者精确挂起、条件变化时精确唤醒。它补的是 Mutex 的盲区锁只提供互斥不提供等到某个条件成立再继续的能力。两者的坑都很集中。Once 的坑在 panic 语义f 恐慌之后后续调用到底会不会重试Cond 的坑在信号时效Signal 发出时如果没人 Wait通知就永远消失。两个问题的答案都写在源码里。二、深度原理与底层剖析2.1 sync.Oncefast-path 一次原子读// 位于 sync/once.gotypeOncestruct{done atomic.Uint32// 0 未执行1 已执行m Mutex}func(o*Once)Do(ffunc()){// fast-pathdone 已经是 1直接返回成本一次原子读x86 上普通 LOADifo.done.Load()0{o.doSlow(f)}}func(o*Once)doSlow(ffunc()){o.m.Lock()defero.m.Unlock()ifo.done.Load()0{// 双重检查抢到锁时可能别人已完成defero.done.Store(1)// 注意f panic 时这行 defer 依然执行f()}}逐层拆解fast-path 的成本构成。atomic.Uint32.Load在 x86/ARM64 无竞争下编译为一条普通加载指令Go 的原子 Load 默认 seq-cst 语义但在 x86 上 LOAD 本身就满足。也就是说一千万个 goroutine 反复过Do总成本就是一千万次 L1 缓存命中级别的读几纳秒一次。这是把done单独拆成原子变量而不是塞进 Mutex 状态位的原因。双重检查锁定Double-Check Locking。慢路径持锁后必须再读一次done两个并发DoA 先抢到锁执行 fB 在锁上排队A 完成释放B 拿锁、复查发现 done 已是 1直接返回。没有第二次检查就会执行两次 f。panic 语义不重试。defer o.done.Store(1)在函数返回时执行而 panic 的堆栈展开unwinding过程中 defer 照常运行。所以 f 恐慌后done 依然被置为 1后续所有Do调用直接返回f 不会重试。这与文档一致“if f panics, Do considers it to have returned”。第三节的示例会实测验证这一点。这个设计是合理的f 恐慌通常意味着初始化逻辑有 bug重试只会放大问题需要重试语义的场景应该自己包装 recover 与状态复位。Go 1.21 新增的 OnceFunc / OnceValue / OnceValues把f 的返回值缓存下来这个高频模式标准化了同时修掉了用户自己实现时常见的两类错误忘记在 f 之前先 Do 完成初始化就并发读取缓存变量以及 f panic 后缓存变量处于半初始化状态。Go 1.23 又有一个实现级修复OnceFunc 系列把 f 放进独立 goroutine 执行再传播 panic保证恐慌堆栈完整此前 panic 会被 recover 再重抛丢失原始栈帧。业务代码里手写 double-checked 单例的场景建议直接换成 OnceFunc。2.2 sync.CondnotifyList 与 runtime 的挂起机制typeCondstruct{noCopy noCopy// 编译期禁止拷贝L Locker// 关联的锁通常是 *sync.Mutex / *sync.RWMutexnotify notifyList// runtime 侧的等待队列checker copyChecker// 检测运行期 Cond 被拷贝}func(c*Cond)Wait(){t:runtime_notifyListAdd(c.notify)// 1. 先登记 ticket原子自增c.L.Unlock()// 2. 释放锁避免死锁runtime_notifyListWait(c.notify,t)// 3. 挂起等 ticket 被通知c.L.Lock()// 4. 醒来后重新持锁}Wait的四步顺序是 Cond 正确性的全部秘密先拿 ticket 再解锁。ticket 是全局递增的序号notifyListWait按序号挂起。这个顺序保证了从 Wait 挂起到被唤醒之间任何Signal/Broadcast都能覆盖到本等待者。解锁必须发生在登记之后。反过来就死锁持锁挂起通知者拿不到锁永远无法改条件。醒来后重新持锁。这让检查条件 - Wait - 处理数据整体处于锁的保护下。notifyList的实现位于runtime/sema.go与信号量的 sudog 队列同源内部维护一对 ticket 计数器wait与notifySignal把notify1并唤醒一个对应 ticket 的等待者Broadcast把notify直接跳到wait当前值并唤醒区间内全部等待者。它比信号量队列轻因为不需要处理锁交接语义只需要精确的序号配对。2.3 丢信号Cond 没有记忆Cond 的通知是一次性的、无记忆的。Signal在没有等待者时调用什么都不会发生这个通知不会存起来等下一个 Wait。由此推出 Cond 的两条铁律铁律一必须用 for 循环重检条件。for!condition(){// 用 for不能用 ifc.Wait()}原因有三重Broadcast 唤醒的多个等待者里只有第一个能消费到资源其余醒来发现条件已不成立OS 层面存在虚假唤醒spurious wakeup的可能条件可能在 Wait 返回前又被第三方改掉。写成 if 的代码在低并发测试下可能全部通过上线后在高争抢下随机出错这类 bug 的复现成本极高。铁律二先改条件再 Signal且条件修改必须在持锁状态下。Signal/Broadcast允许不持锁调用语法上合法但会引入检查条件与 Wait 之间的窗口竞态正确姿势是持锁改条件、持锁或改完立即发通知。三、完整可运行示例packagemainimport(fmtsynctime)// 演示一Once.Do 中的函数 panic 后后续调用不会再执行 f。// 源码实现里 f() 外包裹了 defer o.done.Store(1)// 即使 panic 在展开堆栈时也会先把 done 置为 1。funconcePanic(){varonce sync.Once calls:0f:func(){callspanic(boom)}func(){deferfunc(){recover()}()// 吞掉第一次 paniconce.Do(f)}()once.Do(f)// 第二次调用done1直接返回f 不再执行fmt.Printf( f 实际执行次数: %dpanic 后 done 仍被置位后续调用不再重试\n,calls)}// 演示二sync.Cond 正确用法。消费者先持锁检查条件并 Wait// 生产者修改条件后 Broadcast 唤醒全部等待者。funccondCorrect(){var(mu sync.Mutex condsync.NewCond(mu)queue[]intdoneboolwg sync.WaitGroup)fori:0;i3;i{wg.Add(1)gofunc(idint){deferwg.Done()for{mu.Lock()// 必须用 for 循环重新检查条件// Wait 被唤醒后条件可能已被其他消费者消费掉。forlen(queue)0!done{cond.Wait()// 原子地释放锁并挂起}iflen(queue)0done{mu.Unlock()return}v:queue[0]queuequeue[1:]mu.Unlock()_v}}(i)}mu.Lock()fori:0;i6;i{queueappend(queue,i)}donetruecond.Broadcast()// 唤醒所有等待者让其重新检查 donemu.Unlock()wg.Wait()fmt.Println( 3 个消费者全部退出Broadcast for 循环重检)}// 演示三经典的丢信号错误先 Signal 后 Wait。// Cond 本身不记忆历史唤醒若等待者尚未进入 Wait// Signal 发出的通知会直接消失。funccondMissedSignal(){var(mu sync.Mutex condsync.NewCond(mu)wg sync.WaitGroup)wg.Add(1)gofunc(){deferwg.Done()time.Sleep(50*time.Millisecond)// 模拟等待者晚到了一步mu.Lock()fmt.Println( 消费者开始 Wait生产者的 Signal 早已丢失)cond.Wait()// 将永远等不到通知只能靠超时兜底mu.Unlock()fmt.Println( 消费者被唤醒)}()mu.Lock()cond.Signal()// 此时消费者还没 Wait通知凭空消失mu.Unlock()time.Sleep(200*time.Millisecond)// 生产补救再广播一次模拟超时兜底mu.Lock()cond.Broadcast()mu.Unlock()wg.Wait()fmt.Println( Signal 先于 Wait 发生 通知丢失需 for 循环 超时兜底)}funcmain(){fmt.Println( 演示一sync.Once 的 panic 语义 )oncePanic()fmt.Println( 演示二sync.Cond 正确的生产者-消费者 )condCorrect()fmt.Println( 演示三sync.Cond 丢信号陷阱 )condMissedSignal()}输出与解读 演示一sync.Once 的 panic 语义 f 实际执行次数: 1panic 后 done 仍被置位后续调用不再重试 演示二sync.Cond 正确的生产者-消费者 3 个消费者全部退出Broadcast for 循环重检 演示三sync.Cond 丢信号陷阱 消费者开始 Wait生产者的 Signal 早已丢失 消费者被唤醒 Signal 先于 Wait 发生 通知丢失需 for 循环 超时兜底演示三值得动手改一改把消费者的time.Sleep(50ms)去掉让它先于生产者进入 WaitSignal 就能立即唤醒它程序瞬间跑完。同一个程序几十毫秒的时序差把正确变成了挂死这就是 Cond 类 bug 的本质正确性依赖时序而时序在测试环境不可控。四、生产踩坑与调优建议1. 单例初始化优先用包级 sync.Once 或 OnceFunc而不是 init()。init 在进程启动时串行执行初始化慢会拖长启动时间且无法失败恢复Once 把初始化推迟到首次使用配合懒加载可以跳过用不到的重资源模块如某些存储 driver。注意 Once 包住的内容里不要调用同一个 Once自死锁。2. 初始化失败的语义要自己设计。Once 的 panic 不重试语义意味着连接数据库失败就 panic 的话这个服务永远不会有第二次初始化机会。正确的分层是 Once 里只做决定成败的策略把可重试的失败用返回值暴露出去由调用方决定重试或降级。3. Cond 适合资源池与优雅关停消息投递不要用它。Cond 的通知不排队、无容量拿它做事件投递必然丢事件带缓冲的 channel 或 ring buffer 才是事件队列的正解。Cond 的舒适区是多等待者共享一个互斥保护的资源池需要池空挂起、池非空唤醒语义。另外 Go 1.24 的testing/synctest包可以在时间气泡里精确测试这类时序敏感代码值得在并发测试中引入。4. Cond.Wait 持有的锁必须是同一个。sync.NewCond(mu)之后所有 Wait/Signal 的参与方必须用同一把 mu且 Wait 期间不得提前 Unlock。混用两把锁会出现通知者与等待者在不同临界区操作同一条件的窗口症状是偶发的重复消费或条件覆盖。5. 广播风暴的代价评估。Broadcast 一次性唤醒全部等待者100 个等待者醒来后抢同一把锁会出现惊群式的锁排队。高并发下如果等待者很多而资源每次只满足一个把 Broadcast 换成循环 Signal每次资源就绪 Signal 一个或者在条件检查前加一层资源配额预判能显著降低无效唤醒。五、总结与下篇预告Once 用一个原子done加双重检查锁定把并发下只执行一次压缩到 fast-path 一次原子读的成本panic 后 done 依然置位语义是视为已完成而非重试。Cond 基于 runtime 的 notifyList 序号配对机制实现精确挂起与唤醒正确性依赖两条铁律for 循环重检条件、先改条件后通知它没有信号记忆时序错了通知就会凭空消失。