sync.Once 与 sync.Cond 源码与并发控制陷阱
一、核心概念与架构设计
这两个原语解决的是并发控制里方向相反的两个问题:
sync.Once要保证一件事在并发下只发生一次 。它有性能上限要求:Do的首次调用之后的每一次调用都要近乎免费,否则单例初始化的 fast-path 会成为热点路径的开销。sync.Cond要保证条件不满足时等待者精确挂起、条件变化时精确唤醒。它补的是 Mutex 的盲区:锁只提供互斥,不提供"等到某个条件成立再继续"的能力。
两者的坑都很集中。Once 的坑在 panic 语义:f 恐慌之后,后续调用到底会不会重试?Cond 的坑在信号时效:Signal 发出时如果没人 Wait,通知就永远消失。两个问题的答案都写在源码里。
二、深度原理与底层剖析
2.1 sync.Once:fast-path 一次原子读
go
// 位于 sync/once.go
type Once struct {
done atomic.Uint32 // 0 = 未执行,1 = 已执行
m Mutex
}
func (o *Once) Do(f func()) {
// fast-path:done 已经是 1,直接返回,成本一次原子读(x86 上普通 LOAD)
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 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:两个并发 Do,A 先抢到锁执行 f,B 在锁上排队;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.Cond:notifyList 与 runtime 的挂起机制
go
type Cond struct {
noCopy noCopy // 编译期禁止拷贝
L Locker // 关联的锁(通常是 *sync.Mutex / *sync.RWMutex)
notify 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 与 notify),Signal 把 notify+1 并唤醒一个对应 ticket 的等待者,Broadcast 把 notify 直接跳到 wait 当前值并唤醒区间内全部等待者。它比信号量队列轻,因为不需要处理"锁交接"语义,只需要精确的序号配对。
2.3 丢信号:Cond 没有"记忆"
Cond 的通知是一次性的、无记忆的 。Signal 在没有等待者时调用,什么都不会发生,这个通知不会存起来等下一个 Wait。由此推出 Cond 的两条铁律:
铁律一:必须用 for 循环重检条件。
go
for !condition() { // 用 for,不能用 if
c.Wait()
}
原因有三重:Broadcast 唤醒的多个等待者里,只有第一个能消费到资源,其余醒来发现条件已不成立;OS 层面存在虚假唤醒(spurious wakeup)的可能;条件可能在 Wait 返回前又被第三方改掉。写成 if 的代码在低并发测试下可能全部通过,上线后在高争抢下随机出错,这类 bug 的复现成本极高。
铁律二:先改条件再 Signal,且条件修改必须在持锁状态下。 Signal/Broadcast 允许不持锁调用(语法上合法),但会引入"检查条件与 Wait 之间"的窗口竞态,正确姿势是持锁改条件、持锁(或改完立即)发通知。
三、完整可运行示例
go
package main
import (
"fmt"
"sync"
"time"
)
// 演示一:Once.Do 中的函数 panic 后,后续调用不会再执行 f。
// 源码实现里 f() 外包裹了 defer o.done.Store(1),
// 即使 panic 在展开堆栈时也会先把 done 置为 1。
func oncePanic() {
var once sync.Once
calls := 0
f := func() {
calls++
panic("boom")
}
func() {
defer func() { recover() }() // 吞掉第一次 panic
once.Do(f)
}()
once.Do(f) // 第二次调用:done==1,直接返回,f 不再执行
fmt.Printf(" f 实际执行次数: %d(panic 后 done 仍被置位,后续调用不再重试)\n", calls)
}
// 演示二:sync.Cond 正确用法。消费者先持锁检查条件并 Wait,
// 生产者修改条件后 Broadcast 唤醒全部等待者。
func condCorrect() {
var (
mu sync.Mutex
cond = sync.NewCond(&mu)
queue []int
done bool
wg sync.WaitGroup
)
for i := 0; i < 3; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
for {
mu.Lock()
// 必须用 for 循环重新检查条件:
// Wait 被唤醒后条件可能已被其他消费者消费掉。
for len(queue) == 0 && !done {
cond.Wait() // 原子地释放锁并挂起
}
if len(queue) == 0 && done {
mu.Unlock()
return
}
v := queue[0]
queue = queue[1:]
mu.Unlock()
_ = v
}
}(i)
}
mu.Lock()
for i := 0; i < 6; i++ {
queue = append(queue, i)
}
done = true
cond.Broadcast() // 唤醒所有等待者,让其重新检查 done
mu.Unlock()
wg.Wait()
fmt.Println(" 3 个消费者全部退出(Broadcast + for 循环重检)")
}
// 演示三:经典的"丢信号"错误:先 Signal 后 Wait。
// Cond 本身不记忆历史唤醒,若等待者尚未进入 Wait,
// Signal 发出的通知会直接消失。
func condMissedSignal() {
var (
mu sync.Mutex
cond = sync.NewCond(&mu)
wg sync.WaitGroup
)
wg.Add(1)
go func() {
defer wg.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 循环 + 超时兜底)")
}
func main() {
fmt.Println("== 演示一:sync.Once 的 panic 语义 ==")
oncePanic()
fmt.Println("== 演示二:sync.Cond 正确的生产者-消费者 ==")
condCorrect()
fmt.Println("== 演示三:sync.Cond 丢信号陷阱 ==")
condMissedSignal()
}
输出与解读:
== 演示一:sync.Once 的 panic 语义 ==
f 实际执行次数: 1(panic 后 done 仍被置位,后续调用不再重试)
== 演示二:sync.Cond 正确的生产者-消费者 ==
3 个消费者全部退出(Broadcast + for 循环重检)
== 演示三:sync.Cond 丢信号陷阱 ==
消费者开始 Wait(生产者的 Signal 早已丢失)
消费者被唤醒
(Signal 先于 Wait 发生 => 通知丢失,需 for 循环 + 超时兜底)
演示三值得动手改一改:把消费者的 time.Sleep(50ms) 去掉,让它先于生产者进入 Wait,Signal 就能立即唤醒它,程序瞬间跑完。同一个程序,几十毫秒的时序差把"正确"变成了"挂死",这就是 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 循环重检条件、先改条件后通知;它没有信号记忆,时序错了通知就会凭空消失。