sync.once.do 能保证只执行一次是因为其用原子整数 done 标记状态,通过 atomic.compareandswapuint32 实现“抢赢才执行”,其余 goroutine 直接跳过;内部结构含 uint32 done 和 mutex m,done 用于无锁快速判断,m 仅在真正执行时加锁;do 方法分三步:原子读 done、cas 抢占、抢赢者加锁执行并释放;禁止复制 once,否则 done 重置致重复执行;嵌套调用可能引发死锁。

sync.Once.Do 为什么能保证只执行一次
因为 sync.Once 内部用一个原子整数 done 标记是否已执行,且所有逻辑都基于 atomic.CompareAndSwapUint32 实现——它不是靠锁排队,而是靠“抢赢了才干,没抢赢就跳过”。只要有一个 goroutine 成功把 done 从 0 改成 1,其余所有调用都会直接返回,不进临界区。
底层结构体里藏着两个关键字段
源码中 sync.Once 实际定义为:
type Once struct {
done uint32
m Mutex
}
其中:
-
done是第一个字段,且是uint32类型,这是为了在热路径(hot path)上被编译器内联并做原子读 ——atomic.LoadUint32(&o.done)可以无锁快速判断是否已完成 -
m是备用的互斥锁,仅当需要真正执行函数时才加锁,避免每次调用都争抢锁 - 注意:
Once不能被复制,一旦复制,done字段会重置为 0,导致重复执行 —— 这是常见误用点
Do 方法的三步原子协作流程
每次调用 o.Do(f) 实际走的是“先查后锁再确认”三阶段:
- 第一步:快速原子读
atomic.LoadUint32(&o.done),若为 1,直接返回 - 第二步:若为 0,尝试用
atomic.CompareAndSwapUint32(&o.done, 0, 1)抢占标记位;仅抢赢者进入下一步 - 第三步:抢赢者持
o.m.Lock()执行f(),完成后释放锁;其余 goroutine 在锁外自旋等待,等锁释放后再次检查done,发现已是 1 就彻底退出
这个设计让 99% 的后续调用完全绕过锁,性能接近无同步开销。
容易被忽略的死锁和循环依赖风险
看似安全的 Do 在嵌套调用时可能瞬间死锁,例如:
var onceA, onceB sync.Once
initA := func() { onceB.Do(initB) }
initB := func() { onceA.Do(initA) }
onceA.Do(initA) // 死锁
原因在于:onceA.Do 持锁期间调用了 onceB.Do,而后者又反过来要等 onceA 解锁 —— 但 onceA 正卡在等 onceB 先完成。这类问题不会报错,程序直接 hang 住,调试时需特别留意初始化函数内部是否间接触发了其他 Once.Do 调用。











