sync.once.do 必须用指针调用,因其 done 字段需原子修改,值拷贝会导致每次操作独立副本而失效;do 方法接收器为 once,嵌入结构体时若值传递会隐式拷贝,应声明为 sync.once 或确保指针传递。

sync.Once.Do 为什么必须用指针调用
因为 sync.Once 的内部状态(主要是 done 字段)只能通过指针修改。值拷贝会复制整个结构体,导致每次调用 Do 都在操作一个全新的、未标记的副本,done 永远为 0,也就永远不生效。
常见错误现象:Do 被反复执行,日志不停打印,初始化逻辑跑多次;调试时发现 once.done 始终是 0 —— 其实你看到的是拷贝体里的字段,不是原变量。
- Go 的
sync.Once结构体里只有done uint32和一个未导出的m sync.Mutex,没有其他字段 -
Do方法签名是func (o *Once) Do(f func()),接收器明确是*Once - 如果误写成
var once sync.Once; once.Do(...),编译器会自动取地址(因为方法需要指针),但若封装在结构体字段里就容易翻车
嵌入 sync.Once 字段时最容易踩的坑
当把 sync.Once 作为自定义结构体的字段时,直接调用字段方法看似没问题,但若该结构体本身是值类型传递(比如函数参数、map value、切片元素),就会触发隐式拷贝,Do 失效。
使用场景:常见于单例管理器、懒加载配置解析器、带初始化逻辑的连接池包装器。
立即学习“go语言免费学习笔记(深入)”;
- 错误写法:
type ConfigLoader struct { once sync.Once; cfg *Config }然后loader.once.Do(...)在 loader 是参数传入或从 map 取出时失效 - 正确做法:字段声明为
once *sync.Once,初始化时用&sync.Once{};或者确保 loader 始终以指针方式传递和使用 - 更稳妥的方式:把
sync.Once封装进方法里,暴露Load() *Config,内部统一用指针访问,避免使用者掉坑
Do 内部如何靠原子操作保证线程安全
Do 不是靠锁全程保护函数执行,而是用 atomic.LoadUint32 快速判断是否已执行过;仅当未执行时才加锁、再 double-check、再执行、最后用 atomic.StoreUint32 标记完成。这是经典的 “check-lock-check-store” 模式。
性能影响:绝大多数情况下是无锁的,只有首次调用有锁开销;并发读完全无竞争,比纯互斥锁高效得多。
- 关键点:
done字段必须是uint32类型,且对齐,才能被atomic包安全操作 - 不能手动改
once.done = 1—— 这绕过原子写,可能造成内存可见性问题,其他 goroutine 看不到更新 - 传给
Do的函数如果 panic,sync.Once仍会标记为已完成(即不会重试),这点和某些语言的 lazy init 不同
替代方案对比:sync.Once vs. sync.OnceValue(Go 1.21+)
sync.OnceValue 是 Go 1.21 引入的增强版,专为“执行一次并返回值”设计,内部同样依赖指针接收器,但语义更清晰、类型安全更强。
使用场景:当你需要懒加载一个不可变对象(如解析后的 JSON 配置、预编译正则、全局 registry 实例)时,OnceValue 比手写 sync.Once + sync.Once.Do + 闭包捕获变量 更简洁可靠。
-
OnceValue的Do方法返回any,实际应配合类型断言或泛型封装使用 - 它内部也强制要求指针调用,行为一致:值拷贝会导致重复计算
- 如果你还在用 Go sync.Once + 外部变量更稳
最常被忽略的一点:无论 Once 还是 OnceValue,只要变量本身生命周期结束(比如局部变量、函数返回后的栈对象),其内部状态就丢失了 —— 它们不是全局注册表,只是单个实例的状态控制器。










