前面几篇我们聊了 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 + DoOnceFunc/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 方法         │
└──────────────────────────────────────────────────────────┘

七、实战建议

  1. 延迟初始化首选 Once——相比 Mutex,首次之后零开销;相比 init(),按需加载不浪费
  2. Go 1.21+ 优先用 OnceValue/OnceValues——更简洁、类型安全、panic 处理更合理
  3. f 中不要嵌套 Do——会死锁,把初始化逻辑拍平
  4. f 可能失败时要特殊处理——Once 不区分成功和失败,失败了也不会重试
  5. 不要复制 Once——和所有同步原语一样,传指针
# 两个应该加入 CI 的命令(和前面的文章一样)
go vet ./...
go test -race ./...

Once 是 Go 并发原语中最简单的一个——API 只有一个方法,源码不到 30 行。但正因为简单,它的边界也很清晰:只执行一次,不管成功失败,不支持重置。理解这些边界,才能在对的场景用对它。