AIGC标识 sync.Once —— 只执行一次的动作

sync.Once —— 只执行一次的动作

为什么需要 Once

在程序中有这样一类需求:某个初始化操作成本很高(比如加载配置文件、建立数据库连接、初始化缓存),我们希望在程序的生命周期内只执行一次,并且第一次需要它的时候才执行(惰性初始化)。

最朴素的想法是:

var config *Config

func GetConfig() *Config {
    if config == nil {
        config = loadConfig()
    }
    return config
}

这在单 goroutine 下没问题,但在并发场景有两大隐患:

  1. 多次初始化:多个 goroutine 同时看到 config == nil,都去执行 loadConfig()
  2. 部分初始化:即使把 nil 判断换成别的标记位,由于 CPU 缓存和编译器指令重排,一个 goroutine 可能看到标记位变了但数据还没写完——读到脏数据。

用 Mutex 可以解决第一个问题,但每次调用都加锁代价太高。用 RWMutex + double-check 可以部分缓解,但代码变得又长又容易写错。

sync.Once 就是为解决这个问题而生的。

Once 的用法

var once sync.Once
var instance *Singleton

func GetInstance() *Singleton {
    once.Do(func() {
        instance = &Singleton{data: make(map[string]string)}
    })
    return instance
}

Once 只有一个方法:

func (o *Once) Do(f func())
  • f 没有参数也没有返回值。如果初始化需要返回值或错误,要通过闭包捕获外部变量。
  • 只有第一次调用 Do 才会真正执行 f,后续调用直接跳过。
  • 无论多少个 goroutine 并发调用 Dof 只执行一次。
  • 所有在 Do 返回之后进入的 goroutine 都能看到 f 执行的全部副作用——Once 提供了 happens-before 保证。

内部原理

Once 的实现非常精简——就是一个 Mutex 加一个原子布尔值(Go 1.9+ 优化为先用 atomic 快路径判断,避免大部分场景的锁开销):

type Once struct {
    done uint32
    m    Mutex
}

func (o *Once) Do(f func()) {
    if atomic.LoadUint32(&o.done) == 0 {
        o.doSlow(f)
    }
}

快路径:绝大多数调用通过原子读直接返回,无需锁。只有第一次调用(和极少数的竞争情况)才走慢路径加锁。

典型应用场景

1. 单例模式

Go 中最干净的单例写法:

var (
    instance *DB
    once     sync.Once
)

func GetDB() *DB {
    once.Do(func() {
        instance = &DB{conn: connect()}
    })
    return instance
}

对比其他语言的 double-check locking 或者静态初始化 init() 函数,Once 的写法:

  • 没有复杂的 if 嵌套
  • 没有显式的锁操作
  • 语义清晰直观

2. 延迟加载配置

程序启动时不加载,第一次请求配置时才加载:

var (
    config     *AppConfig
    configOnce sync.Once
)

func GetAppConfig() *AppConfig {
    configOnce.Do(func() {
        config = parseConfigFile("/etc/app/config.yaml")
    })
    return config
}

3. 资源关闭

确保某个清理动作只执行一次:

var closeOnce sync.Once

func Shutdown() {
    closeOnce.Do(func() {
        db.Close()
        cache.Flush()
        logger.Sync()
    })
}

即使多个 goroutine 因为各种原因调用了 Shutdown,资源也只会被释放一次。

4. 初始化注册表

比如在包级别注册一些 handler,但注册操作依赖运行时信息:

var (
    handlers   map[string]Handler
    initOnce   sync.Once
)

func Register(name string, h Handler) {
    initOnce.Do(func() {
        handlers = make(map[string]Handler)
    })
    handlers[name] = h
}

关键注意事项

1. Once 不可复制

var o sync.Once
o2 := o // 危险!复制后两个 Once 独立,各自能执行一次 Do

After o.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 发生了 panic,Once 会认为执行已完成。后续的 Do 不会再重试,相当于初始化"失败了但被记为完成"。

解决方案:在 f 内部 recover,或确保 f 不会 panic。

once.Do(func() {
    defer func() {
        if r := recover(); r != nil {
            // 此时 once 已被标记为 done,后续 Do 不会重试
            // 需要额外处理:也许把 once 重新赋值为零值,但这不是推荐做法
        }
    }()
    mustInit()
})

更好的做法是让初始化函数返回 error,然后在外层判断:

var (
    instance *DB
    initErr  error
    once     sync.Once
)

func GetDB() (*DB, error) {
    once.Do(func() {
        instance, initErr = connectDB()
    })
    return instance, initErr
}

3. Once 不是用来限制函数调用次数的

它不是为了"最多调用一次"而设计的,而是为了"确保初始化发生一次"。如果你需要限制某个操作的总执行次数,应该用计数或其他机制。

Once 与 init() 的对比

特性 sync.Once init()
执行时机 第一次调用时(惰性) 包被导入时(急切)
可控性 程序主动触发 语言自动触发
是否可跳过 如从不调用则不执行 导入即执行,无法跳过
适用场景 延迟加载、按需初始化 注册驱动、注入依赖

总结:优先用 init() 做一些轻量级的注册工作;重量级、不一定需要的初始化用 sync.Once

小结

sync.Once 解决的是"有且仅有一次初始化"这个并发问题。它的核心价值在于用 3 行代码替换掉原本可能写成 20 行的 double-check locking,并且提供了内存可见性的保证。当你需要一个全局变量只被初始化一次,而且希望按需加载时,sync.Once 就是正确答案。

posted @ 2026-07-24 09:31  FfHUCisI  阅读(2)  评论(0)    收藏  举报