sync.once.do只执行一次,因其通过原子读写done字段和互斥锁实现双重检查机制,确保无论多少goroutine并发调用,函数体仅执行一次;若panic仍标记完成,不重试。

sync.Once.Do 为什么只执行一次
sync.Once 的核心是内部的 done 字段(uint32 类型)和一个互斥锁。每次调用 Do 时,它先原子读取 done:如果已为 1,则直接返回;否则加锁,再次检查(双重检查),未执行则调用传入函数,并在返回前原子写入 1。这意味着哪怕 100 个 goroutine 同时调用 Do,也仅有一个能真正执行函数体,其余全部阻塞等待后直接返回。
常见错误现象:Do 传入的函数若 panic,sync.Once 仍会将 done 置为 1 —— 即“执行完成”状态被标记,后续调用不再尝试,panic 不会重试。这点容易被忽略,导致初始化失败却无感知。
- 务必在传入函数中自行 recover panic,或确保初始化逻辑足够健壮
- 不要依赖
Do的多次重试机制:它不重试,只保证“最多一次” -
Do接受的是func(),无法直接传参;需通过闭包捕获变量,注意变量逃逸和生命周期
初始化全局配置时如何安全使用 sync.Once
典型场景:应用启动时加载配置、连接数据库、初始化日志句柄。这些操作不能重复执行,且可能被多个 goroutine 并发触发(如 HTTP handler、定时任务、后台 worker)。
实操建议:
立即学习“go语言免费学习笔记(深入)”;
- 把
sync.Once声明为包级变量(而非局部或结构体字段),避免因作用域混乱导致多个实例 - 初始化函数应尽量轻量;耗时操作(如网络请求)失败需明确返回错误并记录,但
Do本身不暴露错误,需额外变量承载结果 - 示例中常配合
sync.OnceValue(Go 1.21+)更简洁,但老版本只能靠闭包 + 指针变量模拟
比如加载配置:
var configOnce sync.Once
var globalConfig *Config
func GetConfig() *Config {
configOnce.Do(func() {
c, err := loadConfigFromYAML("config.yaml")
if err != nil {
log.Fatal("failed to load config: ", err)
}
globalConfig = c
})
return globalConfig
}
sync.Once.Do 和 init 函数的区别在哪
init 在包导入时、main 执行前运行,是编译期确定的单次执行;而 sync.Once.Do 是运行时按需触发,可延迟到第一次实际使用时才执行。
关键差异:
-
init无法按条件跳过,也无法捕获错误(panic 会导致整个程序启动失败);Do可放在任意函数内,支持条件判断与错误处理分支 - 多个
init函数按源码顺序执行,有隐式依赖;Do完全由调用方控制时机,解耦更强 - 测试时,
init无法重置或重新运行;sync.Once实例可新建,便于单元测试隔离
所以,需要“懒加载”或“失败可恢复”的一次性操作,别硬塞进 init。
并发调用 Do 时性能开销大吗
在绝大多数场景下,sync.Once.Do 的开销极小。首次执行时需加锁 + 原子操作,后续调用仅一次原子读(atomic.LoadUint32),几乎无成本。
但要注意两个边界情况:
- 高并发争抢首次执行(如上万 goroutine 同时首次调用):锁竞争会短暂升高,但持续时间极短(仅函数执行期间),一般无需优化
- 误将
Do放在热路径里(例如每秒数万次的 HTTP 请求头解析函数中):即使只是原子读,也会带来不必要的指令开销;应确保Do只用于真正的初始化点,而非业务逻辑循环内
真正容易被忽略的是:很多人以为 sync.Once 能替代读写锁保护共享数据 —— 它不能。它只管“执行一次”,不提供对后续数据访问的同步保障。初始化完的变量,若会被并发读写,仍需额外同步机制(如 sync.RWMutex 或原子操作)。










