sync.once.do 能保证只执行一次是因为内部用 uint32 原子变量配合 atomic.compareandswapuint32 实现无锁判断,函数返回(含 panic)后状态永久置为“已完成”,后续调用直接跳过。

sync.Once.Do 为什么能保证只执行一次
因为 sync.Once 内部用一个 uint32 原子变量标记是否已执行,配合 atomic.CompareAndSwapUint32 实现无锁判断。只要 Do 的函数返回,状态就永久置为“已完成”,后续所有调用直接跳过函数体——哪怕那个函数 panic 了,once 也认为“已经试过了”,不会再重试。
常见错误现象:Do 传入的函数里有 panic,结果单例没初始化成功,但后续调用全静默失败;或者误以为 once 能重试,手动包了一层 recover 却忘了同步返回值,导致变量仍是零值。
- 必须把初始化逻辑(包括赋值、连接、加载配置等)全部写在
Do的函数里,不能拆到外面 - 不要在
Do函数里做耗时阻塞操作(比如等网络超时),否则所有并发 goroutine 都会卡住等它结束 - 如果初始化可能失败,应在函数内处理错误并设默认值或 panic,
sync.Once不负责错误传播
单例变量该声明成指针还是值类型
取决于初始化成本和是否需要方法集继承。绝大多数情况应该声明为 *T 指针类型,原因很实际:避免每次调用都拷贝结构体;方便后续给类型加 receiver 方法;且 sync.Once 初始化后通常要长期持有并复用。
使用场景:数据库连接池、配置管理器、日志实例、全局缓存——这些对象本身体积大、带锁、有状态,天然适合指针。
立即学习“go语言免费学习笔记(深入)”;
- 如果 T 是小结构体(比如只有几个 int 字段),且所有方法都是值接收者,用值类型也行,但可读性和扩展性差
- 千万别声明成
T值类型再在Do里取地址赋给全局变量,这会造成逃逸和额外分配 - 初始化函数返回值类型必须和变量类型严格一致,
var ins *Config就得让Do里的函数赋值给ins,不能返回Config再取地址
多个单例之间有依赖时怎么安全初始化
sync.Once 本身不支持依赖声明,靠调用顺序和嵌套 Do 实现。核心原则是:谁被依赖,谁先定义 once 和变量;依赖方在自己的 Do 函数里主动触发被依赖方的 Do。
错误示例:A 依赖 B,但 A 的初始化函数里没调用 BOnce.Do(...),而是直接访问 BInstance —— 此时 B 可能还没初始化,得到零值。
- 被依赖的单例(如 B)仍需独立的
sync.Once和变量,不能省略 - 依赖方(如 A)的
Do函数里第一件事就是确保 B 已就绪:BOnce.Do(initB) - 避免循环依赖,否则会死锁;Go 编译期不检查,只能靠设计约束
- 如果依赖链深(A→B→C),每个环节都要显式触发前序
Do,别指望“全局 init 顺序”
为什么不用 init 函数替代 sync.Once
init 函数在包加载时执行,无法按需延迟初始化,也无法响应运行时参数(比如从 flag 或环境变量读配置)。更重要的是,init 是包级单次,而 sync.Once 是变量级单次——你可以有多个 sync.Once 实例控制不同对象,但整个包只有一个 init。
性能影响:init 在程序启动时集中执行,可能拖慢冷启动;sync.Once 把开销摊到第一次使用时,更适合微服务或 CLI 工具这类按需加载场景。
- 如果单例初始化完全不依赖运行时输入(比如硬编码的常量映射表),
init更简单,但灵活性归零 -
sync.Once有轻微原子操作开销,但实测在大多数业务场景下可忽略(纳秒级) - 测试时,
sync.Once支持重置(通过反射或重新声明变量),init完全不可测
真正容易被忽略的是:一旦用 sync.Once,就必须接受“初始化失败即永久失败”这个事实。没有 retry、没有 fallback、不抛异常给调用方——你得在 Do 函数里自己兜底,比如连不上 Redis 就用内存 map 顶着,而不是指望下次调用能重来。










