sync.Once.Do 通过 uint32 原子变量配合 atomic.CompareAndSwapUint32 和互斥锁双重保障,确保初始化函数仅执行一次;一旦开始执行(含 panic),后续调用直接返回,不重试。
sync.Once.Do 为什么能保证只执行一次
sync.once.do 内部用一个 uint32 原子变量标记是否已执行,配合 atomic.compareandswapuint32 和互斥锁双重保障。它不关心传入函数是否 panic —— 一旦执行开始(哪怕中途 panic),后续调用都会直接返回,不再重试。
这意味着:如果初始化函数里有未处理的 panic,单例对象可能处于半初始化状态,且无法再次触发初始化。这是最容易被忽略的风险点。
- 初始化函数必须自行处理所有错误,不能依赖外层重试
- 不要在
Do中调用可能阻塞过久的操作(如网络请求未设超时),否则会卡住所有后续调用 -
sync.Once不提供获取执行结果的机制,需额外用变量承载初始化产出
典型单例结构:指针 + once.Do + init 函数
Go 没有类,单例本质是包级变量 + 初始化控制。常见写法是定义一个导出的指针变量和一个非导出的 initFunc,再用 sync.Once.Do 包裹。
var (
instance *Config
once sync.Once
)
func GetConfig() *Config {
once.Do(func() {
instance = &Config{
Timeout: 30,
Host: os.Getenv("API_HOST"),
}
// 这里可以加载文件、连接 DB 等
if err := instance.loadFromYAML("/etc/app/config.yaml"); err != nil {
// 必须处理错误,比如 log.Fatal 或设置默认值
log.Printf("failed to load config: %v", err)
instance.Timeout = 10 // fallback
}
})
return instance
}
注意:instance 是包级变量,GetConfig 是唯一出口。别把 once 放进 struct 里 —— 那就不是全局单例了。
并发安全但不解决对象内部线程安全
sync.Once.Do 只保证初始化过程串行,不给单例对象本身加锁。如果 *Config 的字段会被多 goroutine 读写(比如动态更新配置),你得自己加锁或改用原子操作。
- 纯读场景(如配置只在启动时加载):无需额外同步
- 读多写少场景:考虑用
sync.RWMutex保护写操作 - 字段本身是原子类型(如
int64):可用atomic.StoreInt64替代锁
别误以为用了 sync.Once 就万事大吉 —— 它只管“出生”,不管“长大后怎么活”。
测试时如何重置 sync.Once 状态
sync.Once 没有公开的重置方法,单元测试中无法“重新初始化”。常见应对方式:
- 把
sync.Once和单例变量一起封装进可替换的接口或 struct 字段,测试时注入 mock - 使用函数变量替代硬编码的
once.Do调用,测试时替换为自定义逻辑 - 接受“初始化只测一次”,在测试前用
init()或提前调用确保状态干净(适合简单场景)
硬编码的 sync.Once + 全局变量组合,在测试隔离性上天然受限,设计初期就得想好可测性。


















