sync.once.do确保同一实例下函数仅执行一次,依赖uint32原子变量标记状态(0未执行/1已执行),panic后仍置为1且不重试,所有goroutine同步等待首次调用完成。

sync.Once.Do 只执行一次,前提是用对了实例、没 panic、且函数体本身不带副作用误判。 它不是魔法,而是一个靠原子操作标记状态的同步原语——执行完就“钉死”,失败不重试,也不暴露成功与否。
sync.Once.Do 为什么看起来“只执行一次”
底层用一个 uint32 原子变量记录状态:0 表示未执行,1 表示已执行。每次调用 Do 都先 atomic.LoadUint32 检查,只有读到 0 才尝试 atomic.CompareAndSwapUint32 把它设为 1 并真正执行传入函数;一旦设成 1,后续所有调用直接返回,不进函数体。
关键点:
- 这个“一次”是针对同一个
sync.Once实例而言的——包级变量、结构体字段都行;局部声明(比如在函数里var once sync.Once)就完全失效 - 它不捕获 panic:
Do里函数 panic 后,状态仍被设为1,后续调用直接返回,不会重试也不会恢复 panic - 所有并发调用
Do的 goroutine 都会阻塞等待那个“中签”的 goroutine 执行完(无论成功或 panic),再一起继续
Go 单例初始化必须搭配包级变量和错误处理
只写 once.Do(func() { instance = &Service{} }) 是危险的:如果 &Service{} 初始化失败(比如 DB 连接超时),instance 就是 nil,后续调用直接 panic,且无从感知原因。
正确姿势是把实例和错误统一管理:
- 声明三个同级包变量:
serviceOnce sync.Once、service *Service、serviceErr error -
Do闭包内完成全部初始化逻辑,并一次性赋值这两个变量 - 对外函数返回
(*Service, error),强制调用方检查err != nil - 不要在
Do里做长耗时阻塞操作(如无超时的 HTTP 请求),否则所有 goroutine 会卡住等它结束
闭包传参和变量捕获的常见陷阱
Do 参数必须是 func() 类型,不能直接传带参函数(比如 once.Do(loadConfig("prod")) 会立刻执行并忽略返回值)。
正确写法是用闭包包裹,但要注意变量捕获问题:
- 错误示例:
for _, env := range []string{"dev", "prod"} { once.Do(func() { loadConfig(env) }) }→ 所有闭包共享同一个env地址,最终都传入最后一个值 - 正确做法一:在循环内显式复制变量:
env := env; once.Do(func() { loadConfig(env) }) - 正确做法二:用参数方式传入:
once.Do(func(v string) { loadConfig(v) }("prod")) - 更推荐:把初始化逻辑抽成独立函数,避免闭包复杂度
什么时候不该用 sync.Once
它只解决“某段逻辑最多执行一次”,不是万能初始化控制器。
- 需要根据参数做不同初始化?→ 改用
sync.Map或map[Key]*sync.Once,注意 key 必须可比较 - 初始化可能被取消或需超时控制?→
sync.Once不支持context.Context,得换errgroup.Group或手动加超时逻辑 - 初始化失败后还想重试?→ 它没有重置接口,得自己封装
atomic.Bool+ 循环判断,或交由上层控制生命周期 - 副作用不可逆(比如发了一次请求、写了一个临时文件)?→ 一旦失败,
sync.Once就把它“固化”了,这种场景单例本身可能就不合适
最常被忽略的一点:sync.Once 的零值是有效的,不需要 new 或显式初始化;但如果你把它嵌进 struct 并用指针方法调用 Do,就得确保那个 struct 实例不是每次新建的——否则又回到“每次都是新 Once”的坑里。
golang免费学习笔记(深入):立即使用
在学习笔记中,你将探索golang的核心概念和高级技巧!











