Sync.Once.Do只执行一次,因其用atomic操作控制uint32类型的done字段,函数返回(含panic)后done置1,后续调用立即返回;panic不重试,需新建Once实例。

Sync.Once.Do 为什么只执行一次
Sync.Once 的核心是内部一个 uint32 类型的 done 字段,用原子操作 atomic.LoadUint32 和 atomic.CompareAndSwapUint32 控制状态。它不依赖锁阻塞 goroutine,而是让后续调用者「自旋等待」直到函数返回——但注意:这个等待不是忙等,Once 内部用了 runtime_Semacquire 等待信号量,实际是挂起 goroutine。
- 只要
Do传入的函数返回(无论 panic 还是正常结束),done就被置为 1,后续所有调用立刻返回,不再执行函数体 - 如果函数 panic,
Once仍认为“已执行”,不会重试——这点常被忽略,错误处理必须在函数内部做 - 不能靠多次调用
Do触发重试,要重试得新建一个sync.Once实例
单例初始化时怎么安全返回值
sync.Once 本身不返回值,所以常见写法是配合包级变量 + 惰性初始化函数。关键在于:初始化逻辑和返回值获取必须在同一作用域内完成,避免竞态。
- 不要这样写:
var instance *MyType; func GetInstance() *MyType { once.Do(func(){ instance = new(MyType) }); return instance }——instance赋值和读取之间没有 happens-before 保证(虽然实际中因once的内存屏障通常安全,但语义不明确) - 推荐写法:把实例声明、初始化、返回全包进
Do的闭包里,用闭包变量承接结果 - 示例:
var ( instance *DB once sync.Once ) func GetDB() *DB { once.Do(func() { instance = &DB{conn: connectToDB()} }) return instance }
Once.Do 传函数时容易踩的坑
最典型问题是「提前求值」:把带参数的函数直接调用后传进去,导致每次 Do 都执行一次,失去单次语义。
- 错误:
once.Do(connectDB())——connectDB()立即执行,返回值(可能是nil或函数)传给Do,根本没用 - 错误:
once.Do(func() { initConfig(cfgPath) }),但cfgPath是外部变量且可能被修改 —— 闭包捕获的是变量引用,不是快照 - 正确:确保传入的是函数字面量,且所有外部依赖在闭包创建时已确定;必要时显式拷贝值:
path := cfgPath; once.Do(func() { initConfig(path) }) - 另一个坑:
Do接收func(),不能传带返回值的函数,也不能传方法值(除非接收者是地址且类型匹配)
和 double-checked locking 对比有啥实际区别
Go 里有人手写双重检查(先读 volatile 变量,再加锁再判),但没必要。Go 的 sync.Once 在底层做了更精细的优化:它用 unsafe.Pointer + 原子操作 + 协程调度协作,避免了锁竞争开销,也规避了 C/C++ 里因内存模型宽松导致的重排序问题。
立即学习“go语言免费学习笔记(深入)”;
- 性能上:首次调用略慢(需初始化信号量),后续调用几乎零开销(一次原子读);而手写双重检查每次都要至少一次原子读 + 条件判断
- 可读性和维护性差太多:手写容易漏掉
runtime.Gosched或内存屏障,Once是标准库验证过的模式 - 兼容性无差别:所有 Go 版本都支持,无需考虑
go version < 1.9的atomic.Value替代方案
真正复杂的地方在于:一旦初始化失败(比如配置加载出错、网络不可达),你没法通过 Once 机制自动恢复——它就卡死了。这种场景得自己加兜底逻辑,比如 fallback 实例、重试计数器,或者换用更灵活的 lazy-init 包。









