Golang学习 - sync.Once —— 只执行一次的动作

📅 2026/7/17 4:41:23
Golang学习 - sync.Once —— 只执行一次的动作
sync.Once —— 只执行一次的动作为什么需要 Once在程序中有这样一类需求某个初始化操作成本很高比如加载配置文件、建立数据库连接、初始化缓存我们希望在程序的生命周期内只执行一次并且第一次需要它的时候才执行惰性初始化。最朴素的想法是varconfig*ConfigfuncGetConfig()*Config{ifconfignil{configloadConfig()}returnconfig}这在单 goroutine 下没问题但在并发场景有两大隐患多次初始化多个 goroutine 同时看到config nil都去执行loadConfig()。部分初始化即使把 nil 判断换成别的标记位由于 CPU 缓存和编译器指令重排一个 goroutine 可能看到标记位变了但数据还没写完——读到脏数据。用 Mutex 可以解决第一个问题但每次调用都加锁代价太高。用 RWMutex double-check 可以部分缓解但代码变得又长又容易写错。sync.Once 就是为解决这个问题而生的。Once 的用法varonce sync.Oncevarinstance*SingletonfuncGetInstance()*Singleton{once.Do(func(){instanceSingleton{data:make(map[string]string)}})returninstance}Once 只有一个方法func(o*Once)Do(ffunc())f没有参数也没有返回值。如果初始化需要返回值或错误要通过闭包捕获外部变量。只有第一次调用Do才会真正执行f后续调用直接跳过。无论多少个 goroutine 并发调用Dof只执行一次。所有在Do返回之后进入的 goroutine 都能看到f执行的全部副作用——Once 提供了 happens-before 保证。内部原理Once 的实现非常精简——就是一个 Mutex 加一个原子布尔值Go 1.9 优化为先用 atomic 快路径判断避免大部分场景的锁开销typeOncestruct{doneuint32m Mutex}func(o*Once)Do(ffunc()){ifatomic.LoadUint32(o.done)0{o.doSlow(f)}}快路径绝大多数调用通过原子读直接返回无需锁。只有第一次调用和极少数的竞争情况才走慢路径加锁。典型应用场景1. 单例模式Go 中最干净的单例写法var(instance*DB once sync.Once)funcGetDB()*DB{once.Do(func(){instanceDB{conn:connect()}})returninstance}对比其他语言的 double-check locking 或者静态初始化init()函数Once 的写法没有复杂的 if 嵌套没有显式的锁操作语义清晰直观2. 延迟加载配置程序启动时不加载第一次请求配置时才加载var(config*AppConfig configOnce sync.Once)funcGetAppConfig()*AppConfig{configOnce.Do(func(){configparseConfigFile(/etc/app/config.yaml)})returnconfig}3. 资源关闭确保某个清理动作只执行一次varcloseOnce sync.OncefuncShutdown(){closeOnce.Do(func(){db.Close()cache.Flush()logger.Sync()})}即使多个 goroutine 因为各种原因调用了Shutdown资源也只会被释放一次。4. 初始化注册表比如在包级别注册一些 handler但注册操作依赖运行时信息var(handlersmap[string]Handler initOnce sync.Once)funcRegister(namestring,h Handler){initOnce.Do(func(){handlersmake(map[string]Handler)})handlers[name]h}关键注意事项1. Once 不可复制varo sync.Once o2:o// 危险复制后两个 Once 独立各自能执行一次 DoAftero.Do()executes,o2.Do()will not know it’s done and will execute again — defeating the whole purpose.2. Do 的参数函数不能 panic或者说 panic 后不会复位如果Do(f)中的f发生了 panicOnce 会认为执行已完成。后续的Do不会再重试相当于初始化失败了但被记为完成。解决方案在f内部 recover或确保f不会 panic。once.Do(func(){deferfunc(){ifr:recover();r!nil{// 此时 once 已被标记为 done后续 Do 不会重试// 需要额外处理也许把 once 重新赋值为零值但这不是推荐做法}}()mustInit()})更好的做法是让初始化函数返回 error然后在外层判断var(instance*DB initErrerroronce sync.Once)funcGetDB()(*DB,error){once.Do(func(){instance,initErrconnectDB()})returninstance,initErr}3. Once 不是用来限制函数调用次数的它不是为了最多调用一次而设计的而是为了确保初始化发生一次。如果你需要限制某个操作的总执行次数应该用计数或其他机制。Once 与 init() 的对比特性sync.Onceinit()执行时机第一次调用时惰性包被导入时急切可控性程序主动触发语言自动触发是否可跳过如从不调用则不执行导入即执行无法跳过适用场景延迟加载、按需初始化注册驱动、注入依赖总结优先用init()做一些轻量级的注册工作重量级、不一定需要的初始化用sync.Once。小结sync.Once 解决的是有且仅有一次初始化这个并发问题。它的核心价值在于用 3 行代码替换掉原本可能写成 20 行的 double-check locking并且提供了内存可见性的保证。当你需要一个全局变量只被初始化一次而且希望按需加载时sync.Once 就是正确答案。