在Go语言并发编程中,某些初始化逻辑只想被执行一次,例如全局配置加载、数据库连接池创建或者单例对象构建。标准库中的sync.Once正是为解决这类需求而设计,它能在多个goroutine同时调用时,确保传入的函数只运行一次,且其他调用会阻塞至该次执行完成。

sync.Once的基本用法
sync.Once是一个结构体,位于sync包中,本身零值即可用。其核心方法是Do(f func()),参数是一个无返回值的函数。无论多少个goroutine调用同一个Once实例的Do,函数f都只会被执行一次。这种机制非常适合延迟初始化,也就是平时说的懒加载。
下面是一段最基础的示例,模拟并发场景下只初始化一次计数器:
package main
import (
"fmt"
"sync"
"time"
)
func main() {
var once sync.Once
var count int
initFunc := func() {
count++
fmt.Println("初始化执行了")
}
for i := 0; i < 5; i++ {
go func(idx int) {
once.Do(initFunc)
fmt.Printf("goroutine %d 完毕,count=%dn", idx, count)
}(i)
}
time.Sleep(time.Second)
}
运行以上代码,控制台只会打印一次“初始化执行了”,而五个goroutine都读到了count等于1的结果。这说明once.Do内部已经帮我们处理好竞态,不需要自己写锁。
如果错误地使用多个Once实例,则无法保证单次执行。每个Once对象独立记录自己的执行状态,因此单例通常定义为包级变量,而不是在函数内局部声明。
底层实现原理
很多开发者好奇,sync.Once为什么这么可靠。其实它的源码非常精简,主要依赖一个done的uint32字段和一个互斥锁。首次进入Do时,会用atomic.LoadUint32读取done,如果已是1就直接返回;如果是0,则进入慢路径加锁,再次确认done为0后才执行函数,并将done置为1。
这种“双重检查”设计减少了锁开销:绝大多数后续调用只需一次原子读就能退出,不必抢锁。源码中slow-path的写法保证了即使多个goroutine同时穿过第一次检查,也只有一个能拿到锁并执行,其余在锁释放后看到done为1随即返回。
// 简化版逻辑,非标准库原文
type Once struct {
done uint32
m sync.Mutex
}
func (o *Once) Do(f func()) {
if atomic.LoadUint32(&o.done) == 0 {
o.doSlow(f)
}
}
func (o *Once) doSlow(f func()) {
o.m.Lock()
defer o.m.Unlock()
if o.done == 0 {
defer atomic.StoreUint32(&o.done, 1)
f()
}
}
需要注意,如果f函数在执行中发生panic,done依然会被置为1,Once不会再尝试执行第二次。这在一些必须成功初始化的场景里是隐患,调用方应在f内部自行recover或保证不panic。
另外,Once不支持重置。一旦执行过,该实例永久生效。若业务需要可重复触发初始化,应自己结合锁与版本号实现,而非复用Once。
常见使用场景与错误写法
最典型的应用是单例模式。下面展示一个线程安全的单例获取函数,利用包级Once避免重复构建:
package singleton
import "sync"
type Config struct {
Name string
}
var (
instance *Config
once sync.Once
)
func GetConfig() *Config {
once.Do(func() {
instance = &Config{Name: "default"}
})
return instance
}
这种方式比用map加锁缓存更直观,也不会出现判断nil后未加锁导致重复赋值的漏洞。不过要小心,Do的参数函数不应该调用同一个Once的Do,否则会死锁,因为slow-path持锁时又去获取同一把锁。
还有一种错误是以为Once能保证函数内所有副作用对外部可见的顺序。实际上,Once只保证函数执行完才让其他调用返回,函数内的变量发布仍应遵循Go内存模型,例如通过通道或原子变量同步。
与自行实现方案的对比
如果不使用sync.Once,开发者可能会写出如下代码:
var initialized bool
var mu sync.Mutex
func initConfig() {
mu.Lock()
defer mu.Unlock()
if !initialized {
// 做初始化
initialized = true
}
}
这种写法虽然也能工作,但每次调用都要加锁,性能较差;若改成先判断再加锁,又容易在极端并发下漏掉检查。sync.Once把这些细节封装好,既安全又高效。
从架构角度看,把“只做一次”的语义交给标准库,能让业务代码更聚焦于逻辑本身。在微服务启动、插件加载、全局监控注册等场合,合理使用sync.Once可以显著降低并发bug出现的概率。