前面几篇我们聊了 Mutex、WaitGroup 和 Cond,它们各自解决不同维度的并发问题。这一篇聊一个相对简单但极其实用的原语——sync.Once,它解决的是 “确保某个操作只执行一次” 的问题,最经典的场景就是单例资源的延迟初始化。

一、为什么需要 Once?

单例资源的初始化有好几种方式,按执行时机可以分成两类:

🚀

程序启动时初始化

package 级别变量、init() 函数、main 中调用。天然线程安全(Go 保证 init 串行),但无法延迟——程序启动就初始化,用不用都占资源。

首次使用时初始化

按需初始化,不浪费资源,但并发安全需要自己保证。Once 就是为这个场景而生的。

延迟初始化更灵活,但并发安全怎么办?最直接的想法是用 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 的字段非常简单:

字段类型作用
doneatomic.Uint320 = 未执行,1 = 已执行。fast path 只做一次原子读
msync.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()
    }
}

用流程图表示:

graph TD A[once.Do f] --> B{done == 1?} B -->|是| Z[直接返回
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?

sequenceDiagram participant A as goroutine A participant O as Once participant B as goroutine B A->>O: done==0 ✓ B->>O: done==0 ✓ A->>O: Lock() ✓ B->>O: Lock() 阻塞... A->>O: double-check done==0 A->>O: 执行 f() A->>O: done=1 A->>O: Unlock() B->>O: 获取锁 B->>O: double-check done==1 → 不执行 ✅ B->>O: 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 执行
    }
}

这个实现有一个严重问题——不等待

sequenceDiagram participant A as goroutine A participant O as Once participant B as goroutine B A->>O: CAS(0→1) ✓ B->>O: CAS(0→1) ✗ B-->>B: 直接返回 💀 B-->>B: 使用 conn → nil! Note over A: 还在执行 f()... A-->>A: 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")
        })
    })
}
graph TD A[once.Do outer] --> B[Lock] B --> C[执行 outer] C --> D[once.Do inner] D --> E{done == 0
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.OnceFuncfunc(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 + 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 行。但正因为简单,它的边界也很清晰:只执行一次,不管成功失败,不支持重置。理解这些边界,才能在对的场景用对它。