前面几篇我们聊了 Mutex、WaitGroup 和 Cond,它们各自解决不同维度的并发问题。这一篇聊一个相对简单但极其实用的原语——sync.Once,它解决的是 “确保某个操作只执行一次” 的问题,最经典的场景就是单例资源的延迟初始化。
一、为什么需要 Once?
单例资源的初始化有好几种方式,按执行时机可以分成两类:
┌─────────────────────────────────────────────────────────────┐
│ 程序启动时初始化 │
├─────────────────────────────────────────────────────────────┤
│ 1. package 级别变量 var startTime = time.Now() │
│ 2. init 函数 func init() { startTime = ... } │
│ 3. main 函数中调用 func main() { initApp() } │
│ │
│ ✅ 天然线程安全(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 次调用: 第 N 次调用(N > 1):
Lock() → 创建连接 → Unlock() Lock() → 读到 conn 不为 nil → Unlock()
✅ 正确 ✅ 正确,但 ❌ 每次都要竞争锁!连接只需要创建一次,但之后每次读取都要竞争锁。在高并发场景下,这个锁会成为瓶颈。
Once 的方案:只在第一次调用时执行初始化,后续调用零开销。
二、Once 的基本用法
Once 的 API 极简,核心就一个方法:
┌────────────────────────────────────────────────────────────┐
│ sync.Once │
├────────────────────────────────────────────────────────────┤
│ 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 次调用: 第 N 次调用(N > 1):
执行 f(),创建连接 读取 done 标记(原子操作)→ 直接返回
✅ 正确 ✅ 正确,且 ✅ 零锁开销!多个 goroutine 同时调用 getConn(),只有一个会执行初始化,其余要么等待、要么直接返回已初始化的结果。
三、Once 的实现原理
Once 的实现短到可以完整展示,但里面的设计很精妙。
内部结构
┌──────────────────────────────────────────────────────┐
│ sync.Once 内部结构 │
├──────────────────────────────────────────────────────┤
│ │
│ done atomic.Uint32 │
│ ┌────────────┐ │
│ │ 0 = 未执行 │ ← 原子读,fast path 零开销 │
│ │ 1 = 已执行 │ │
│ └────────────┘ │
│ │
│ 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()
}
}用流程图表示:
once.Do(f) 调用
│
├── done == 1? ── 是 ──→ 直接返回(fast path,原子读)
│
└── done == 0
│
│── Lock()
│
├── done == 1? ── 是 ──→ Unlock() → 返回(其他人已执行完)
│
└── done == 0
│
│── 执行 f()
│── done = 1
│── Unlock()
│── 返回为什么要 double-check?
goroutine A goroutine B
│ │
│── done==0 ✓ │── done==0 ✓
│── Lock() ✓ │── Lock() (阻塞...)
│── done==0 → 执行 f() │
│── done=1 │
│── Unlock() │── (获取锁)
│ │── done==1 → 不执行 ✅
│ │── Unlock()如果没有 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 A goroutine B
│ │
│── CAS(0→1) ✓ │── CAS(0→1) ✗
│── 执行 f()... │── 直接返回 💀
│ (还在初始化中) │── 使用 conn → nil!
│── f() 完成 │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")
})
})
}once.Do(outer)
│── Lock()
│── 执行 outer
│ │── once.Do(inner)
│ │ │── done==0(outer 还没设 done=1)
│ │ │── 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 连接:
第 1 次:f() 执行 → 连接失败 → done=1
第 2 次:done==1 → 直接返回 → conn 仍然是 nil 💀
第 3 次:done==1 → 直接返回 → conn 仍然是 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(f func()) func() │
│ 返回一个函数,无论调用多少次,f 只执行一次 │
├─────────────────────────────────────────────────────────────────┤
│ sync.OnceValue[T](f func() T) func() T │
│ 同上,但 f 有返回值,后续调用返回缓存值 │
├─────────────────────────────────────────────────────────────────┤
│ sync.OnceValues[T1, T2](f func() (T1, T2)) func() (T1, T2) │
│ 同上,f 有两个返回值(常见于 (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 行。但正因为简单,它的边界也很清晰:只执行一次,不管成功失败,不支持重置。理解这些边界,才能在对的场景用对它。