sync.Once 在初始化失败需要重试时,如何改造才能线程安全

解读

国内一线/二线厂的高并发面试里,这道题出现的频率极高。
面试官真正想考察的是:

  1. 你是否理解 sync.Once 的“单次”语义是不可逆的——只要 Do 返回,无论 f 是否 panic/error,done 都会被置 1,后续调用直接空跑。
  2. 你是否能在 不破坏并发安全 的前提下,把“失败重试”需求落地,而不是简单回答“不用 sync.Once”。
  3. 你对 内存可见性、锁粒度、性能退化 是否有工程级权衡能力。

一句话:把“只执行一次”改造成“只执行成功一次”,且全程无锁竞争放大。

知识点

  1. sync.Once 源码:done 字段+互斥锁,done 仅当 f 无 panic 才置 1,一旦置 1 永不回退。
  2. happens-before 规则:Once 返回后,done=1 对所有 goroutine 可见;若 f 失败,done 仍=1,但结果未初始化,后续调用直接跳过,导致“静默不可用”。
  3. 双重检测(DCL) 在 Go 中不可照搬:需配合 atomicmutex 保证可见性,否则编译器+CPU 重排会击穿。
  4. 错误传播:初始化失败需把 error 暴露给所有等待者,避免“第一个 goroutine 失败,其余 goroutine 以为成功”。
  5. 性能底线:高并发场景下,失败重试路径必须 无锁快速失败,成功路径只牺牲一次锁。

答案

给出国内面试官最认可的两种写法,按场景选择即可。

方案一:atomic+mutex 轻量级重试(推荐,无锁快速失败)

type OnceSuccess struct {
    done uint32          // 0:未成功  1:已成功
    mu   sync.Mutex
    err  error           // 最近一次初始化错误,对所有等待者可见
}

func (o *OnceSuccess) Do(f func() error) error {
    // 快速路径:已成功直接返回
    if atomic.LoadUint32(&o.done) == 1 {
        return nil
    }
    // 慢路径:抢锁重试
    o.mu.Lock()
    defer o.mu.Unlock()
    if o.done == 1 {          // 再检查一次,防止重复执行
        return nil
    }
    o.err = f()               // 执行真正的初始化
    if o.err == nil {
        atomic.StoreUint32(&o.done, 1)
    }
    return o.err
}

使用示例:

var initOnce OnceSuccess
func InitDB() error {
    return initOnce.Do(func() error {
        // 真正的连接逻辑
        return connect()
    })
}

特点:

  • 成功后无锁:atomic 读 done,性能与原生 Once 几乎一致。
  • 失败可重试:err 被记录,但 done 仍 0,下次调用继续抢锁重试。
  • 线程安全:err 写操作在锁内,读操作也只在锁内,无 data race。

方案二:sync.Cond 广播通知(适合大量 goroutine 同时等待,失败抖动场景)

type OnceCond struct {
    mu    sync.Mutex
    done  bool
    err   error
    ready sync.Cond
}

func NewOnceCond() *OnceCond {
    o := &OnceCond{}
    o.ready.L = &o.mu
    return o
}

func (o *OnceCond) Do(f func() error) error {
    o.mu.Lock()
    defer o.mu.Unlock()
    if o.done {
        return o.err
    }
    o.err = f()
    o.done = true
    o.ready.Broadcast()   // 唤醒所有阻塞的 goroutine
    return o.err
}

特点:

  • 所有 goroutine 阻塞在 Wait(),成功/失败统一返回,避免惊群+重复执行。
  • 失败也可重试:上层可封装循环调用 Do,直到 err==nil。

拓展思考

  1. 指数退避:失败重试时,可在 Do 外层加 time.Backoff,防止瞬间把下游打挂;注意退避逻辑不要放在锁内,否则放大临界区。
  2. 上下文取消:初始化可能依赖网络/磁盘,建议把 context.Context 传进 f,超时后快速失败,避免 goroutine 堆积。
  3. metrics 埋点:国内生产环境要求可观测,失败次数、成功耗时、重试次数务必打到 Prometheus,方便告警。
  4. 与单例模式结合:不要把 OnceSuccess 暴露为全局变量,用 包级私有变量+导出函数 封装,防止业务方绕过统一入口。
  5. 泛型封装(Go1.18+):可抽象成 OnceSuccess[T],把初始化结果直接返回,避免二次类型断言,提升框架级复用度。