前面几篇我们聊了 Mutex、WaitGroup 和 Cond,它们各自解决不同维度的并发问题。这一篇聊一个相对简单但极其实用的原语——sync.Once,它解决的是 “确保某个操作只执行一次” 的问题,最经典的场景就是单例资源的延迟初始化。
一、为什么需要 Once?
单例资源的初始化有好几种方式,按执行时机可以分成两类:
程序启动时初始化
init() 函数、main 中调用。天然线程安全(Go 保证 init 串行),但无法延迟——程序启动就初始化,用不用都占资源。首次使用时初始化
延迟初始化更灵活,但并发安全怎么办?最直接的想法是用 Mutex:
var connMu sync.Mutex
var conn net.Conn
func getConn() net.Conn {
connMu.Lock()
defer connMu.Unlock()
if conn != nil {
return conn // 已创建,直接返回
}
conn, _ = net.DialTimeout("tcp", "baidu.com:80", 10*time.Second)
return conn
}这样做是正确的,但有性能问题——第 1 次创建之后,后续每次读取仍要竞争 connMu。在高并发场景下,这个锁会成为瓶颈。
Once 的方案:只在第一次调用时执行初始化,后续调用零开销。
二、Once 的基本用法
Once 的 API 极简,核心就一个方法:Do(f func()) —— 保证 f 只执行一次,无论多少 goroutine 调用。
用 Once 改写上面的连接初始化:
var conn net.Conn
var once sync.Once
func getConn() net.Conn {
once.Do(func() {
conn, _ = net.DialTimeout("tcp", "baidu.com:80", 10*time.Second)
})
return conn
}第 1 次调用会执行 f() 创建连接;后续调用只做一次原子读 done 就直接返回——零锁开销。多个 goroutine 同时调用 getConn(),只有一个会执行初始化,其余要么等待、要么直接返回已初始化的结果。
三、Once 的实现原理
Once 的实现短到可以完整展示,但里面的设计很精妙。
内部结构
Once 的字段非常简单:
| 字段 | 类型 | 作用 |
|---|---|---|
done | atomic.Uint32 | 0 = 未执行,1 = 已执行。fast path 只做一次原子读 |
m | sync.Mutex | 保护首次执行,只有 slow path 才用到 |
Do 的执行流程
// 源码简化版,展示核心思路
func (o *Once) Do(f func()) {
if o.done.Load() != 0 { // fast path: 原子读
return // 已执行过,直接返回
}
o.doSlow(f) // slow path: 首次执行
}
func (o *Once) doSlow(f func()) {
o.m.Lock()
defer o.m.Unlock()
if o.done.Load() == 0 { // double-check
defer o.done.Store(1)
f()
}
}用流程图表示:
fast path 原子读] B -->|否| C[Lock] C --> D{done == 1?
double-check} D -->|是| E[Unlock] --> Z2[返回
其他人已执行完] D -->|否| F[执行 f] F --> G[done = 1] G --> H[Unlock] H --> I[返回]
为什么要 double-check?
如果没有 double-check,goroutine B 在获取到锁后也会执行 f(),就不是"只执行一次"了。
为什么不用 CAS 代替 Mutex?
你可能想到一个更"聪明"的实现——用 CAS(Compare-And-Swap)代替 Mutex:
// ❌ 错误的 CAS 实现
func (o *Once) Do(f func()) {
if o.done.CompareAndSwap(0, 1) {
f() // 只有 CAS 成功的 goroutine 执行
}
}这个实现有一个严重问题——不等待:
goroutine B 发现 CAS 失败后直接返回,但此时 A 还没执行完 f(),B 拿到的资源还没初始化好。
Mutex 的做法是:B 阻塞在 Lock() 上,等 A 执行完 f() 并 Unlock() 后,B 才继续执行。 这保证了 Do 返回时,f 一定已经执行完毕。
四、使用 Once 最常踩的 3 个坑
坑 1:死锁——在 f 中再次调用 Do
var once sync.Once
func setup() {
once.Do(func() {
once.Do(func() { // 💀 死锁!
fmt.Println("inner")
})
})
}outer 还没设 done=1} E --> F[Lock ← 💀 同一个 Mutex 死锁!]
Once 内部的 Mutex 不支持重入。Do 在 f() 执行完之后才设置 done=1,所以嵌套调用时 done 仍为 0,会走到 slow path 再次 Lock,形成死锁。
解决方案:避免嵌套调用,把初始化逻辑拍平:
// ✅ 拍平初始化逻辑
var once sync.Once
func setup() {
once.Do(func() {
initA()
initB() // 直接调用,不要嵌套 Do
})
}坑 2:f 执行出错,Once 不会重试
var once sync.Once
var conn net.Conn
func getConn() (net.Conn, error) {
var err error
once.Do(func() {
conn, err = net.DialTimeout("tcp", "baidu.com:80", time.Second)
})
return conn, err
}如果第一次 DialTimeout 失败了(比如网络抖动),Once 不会给你第二次机会——done 已经被设为 1,后续调用永远返回 nil 连接。Once 保证的是"f 只执行一次",不是"f 成功执行一次"。
解决方案 1:在 f 中处理错误,确保 f 不会失败(比如加重试):
once.Do(func() {
for i := 0; i < 3; i++ {
conn, err = net.DialTimeout("tcp", "baidu.com:80", time.Second)
if err == nil {
return
}
time.Sleep(time.Second)
}
})解决方案 2:不用 Once,自己实现一个"成功才算完成"的版本:
type OnceSuccess struct {
done atomic.Bool
mu sync.Mutex
}
func (o *OnceSuccess) Do(f func() error) error {
if o.done.Load() {
return nil // fast path
}
o.mu.Lock()
defer o.mu.Unlock()
if o.done.Load() {
return nil
}
if err := f(); err != nil {
return err // ✅ 失败不设 done,下次还会重试
}
o.done.Store(true)
return nil
}坑 3:复制 Once
和其他同步原语一样,Once 不能复制:
// ❌ 复制 Once
func init(o sync.Once) { // 值传递,复制了 Once
o.Do(func() { /* ... */ })
}
// ✅ 传指针
func init(o *sync.Once) {
o.Do(func() { /* ... */ })
}复制后的 Once 和原来的 Once 是两个独立的实例,各自独立计数。如果原来的 done 已经为 1,复制出来的也是 1,Do 永远不会执行。
五、Go 1.21 新增:OnceFunc / OnceValue / OnceValues
Go 1.21 在 sync 包新增了三个便捷函数,让 Once 的常见用法更简洁:
| 函数 | 签名 | 说明 |
|---|---|---|
sync.OnceFunc | func(f func()) func() | 返回一个函数,无论调用多少次,f 只执行一次 |
sync.OnceValue[T] | func(f func() T) func() T | 同上,但 f 有返回值,后续调用返回缓存值 |
sync.OnceValues[T1, T2] | func(f func() (T1, T2)) func() (T1, T2) | 两个返回值(常见于 (value, error) 模式) |
对比传统用法和新 API:
// 传统写法:需要手动管理 Once 和外部变量
var (
once sync.Once
config *Config
)
func GetConfig() *Config {
once.Do(func() {
config = loadConfig()
})
return config
}// ✅ Go 1.21+ 新写法:更简洁
var GetConfig = sync.OnceValue(func() *Config {
return loadConfig()
})
// 直接调用
cfg := GetConfig()带错误处理的版本:
// ✅ OnceValues:处理 (value, error) 模式
var GetDB = sync.OnceValues(func() (*sql.DB, error) {
return sql.Open("mysql", "dsn...")
})
// 使用
db, err := GetDB()新 API 的优势:
| 特性 | sync.Once + Do | OnceFunc/Value/Values |
|---|---|---|
| 变量管理 | 手动声明外部变量 | 无需额外变量 |
| 类型安全 | 需要自己保证 | ✅ 泛型自动推导 |
| 返回值 | 需要闭包写到外部变量 | ✅ 直接返回 |
| panic 处理 | 设置 done=1 后 panic | ✅ 每次调用都会 re-panic |
| 代码量 | 较多 | 极简 |
注意:OnceFunc/OnceValue/OnceValues 有一个和
Once.Do不同的行为——如果 f 发生了 panic,后续调用会 重新 panic 相同的值,而不是静默跳过。这是更安全的设计。
六、Once 的适用场景
适合用 Once
- 延迟初始化单例资源:数据库连接、配置文件、日志器
- 只需关闭一次的资源:close channel、关闭文件
- 只需注册一次的回调:
http.HandleFunc、插件注册 - 只需计算一次的值:正则编译、模板解析
不适合用 Once
- 初始化可能失败且需要重试 → 用自定义
OnceSuccess - 初始化依赖运行时参数且参数可能变化 → 用 Mutex / atomic
- 需要重置状态重新初始化 → Once 没有
Reset方法
七、实战建议
- 延迟初始化首选 Once——相比 Mutex,首次之后零开销;相比 init(),按需加载不浪费
- Go 1.21+ 优先用 OnceValue/OnceValues——更简洁、类型安全、panic 处理更合理
- f 中不要嵌套 Do——会死锁,把初始化逻辑拍平
- f 可能失败时要特殊处理——Once 不区分成功和失败,失败了也不会重试
- 不要复制 Once——和所有同步原语一样,传指针
# 两个应该加入 CI 的命令(和前面的文章一样)
go vet ./...
go test -race ./...Once 是 Go 并发原语中最简单的一个——API 只有一个方法,源码不到 30 行。但正因为简单,它的边界也很清晰:只执行一次,不管成功失败,不支持重置。理解这些边界,才能在对的场景用对它。