登录
推荐 文章 Go 技术 课程 下载 专题 AI
首页 >  Golang >  Go问答

sync.Once 初始化函数再次调用自己为何会死锁

来源:17golang原创

时间:2026-11-13 02:05:59 282浏览 收藏

在 Go 里,sync.Once 的初始化函数如果再次调用同一个 Once,程序不会“识别到已经在执行就跳过”,而是会一直等待,最终表现为死锁。原因很具体:外层 Do 必须等初始化函数返回,内层同一个 Do 又必须等外层初始化完成,两条等待形成了闭环。

官方地址:https://pkg.go.dev/sync#Once.Do

结论:sync.Once 适合保护一次性的、不会再次进入同一初始化入口的动作。初始化函数里不要直接或间接调用同一个 once.Do;把“构造依赖”和“读取已完成结果”拆成两个阶段即可。

最小案例:初始化函数又进入同一个 Do

下面的代码只有一个 sync.Once,但初始化函数 initConfig 又调用了 loadConfig。而 loadConfig 内部仍然使用这个 once,所以它实际上重入了同一个初始化门。

package main

import (
    "fmt"
    "sync"
)

var (
    once sync.Once
    cfg  string
)

func loadConfig() string {
    once.Do(initConfig)
    return cfg
}

func initConfig() {
    // 这里再次进入同一个 once.Do,形成初始化重入。
    cfg = loadConfig()
}

func main() {
    // 外层 Do 等待 initConfig 返回,但 initConfig 永远等不到内层 Do 返回。
    fmt.Println(loadConfig())
}

这段代码的关键不是全局变量,也不是 fmt.Println。只要出现“同一个 Once 的 Do(f) 尚未返回,f 又触发同一个 Do”这条关系,就具备死锁条件。

外层 sync.Once 初始化函数重入同一个 Once 后形成等待闭环的结构说明图
图1:重入结构说明图,外层初始化等待函数返回,函数又等待同一个 Once 完成;这是静态说明图,不是运行截图。

为什么第二次调用不会直接跳过

Do 的语义是:第一次调用负责执行 f,其他调用要等这次执行结束后才能返回。它要保证初始化函数返回之后,后续调用才能看到已经完成的结果;因此“正在执行”和“已经完成”不是同一个状态。

按调用顺序展开,等待关系如下:

  1. 外层 loadConfig 进入 once.Do(initConfig)。
  2. 这个调用成为当前 Once 的初始化执行者,并进入 initConfig。
  3. initConfig 调用 loadConfig,再次执行同一个 once.Do(initConfig)。
  4. 内层调用必须等待前一次初始化完成;但前一次初始化正卡在内层调用之前,无法返回。

所以它不是“重复执行初始化”导致的循环,而是“第二次调用等待第一次完成,第一次又等第二次返回”的同步闭环。即使只有一个 goroutine,也能发生这种死锁;并发数量不是必要条件。

从调用栈怎么判断是同一个 Once 重入

看到程序卡住时,可以先看调用链而不是猜锁竞争。重点寻找下面的重复路径:

loadConfig
  -> once.Do(initConfig)
     -> initConfig
        -> loadConfig
           -> once.Do(initConfig)  // 第二次进入同一 Once
              -> 等待第一次初始化返回

如果多个 goroutine 同时首次调用,其他 goroutine 也可能停在同一个 Do 上,但它们只是被首个初始化调用阻塞。真正需要优先检查的是“首个初始化函数是否调用了自己的入口”,包括通过辅助函数、回调或依赖对象间接调用。

可以用超时把演示代码变成一个不会永久占住主流程的诊断样例。这里的超时只负责暴露“初始化没有完成”,不能把死锁修好。

package main

import (
    "fmt"
    "sync"
    "time"
)

func main() {
    var once sync.Once
    done := make(chan struct{})

    go func() {
        once.Do(func() {
            // 初始化函数再次调用同一个 Do,故意制造重入等待。
            once.Do(func() {})
        })
        close(done)
    }()

    select {
    case 

正确拆法:Do 里只构造,外层负责使用

修复的核心是让初始化函数拥有清晰的终点。它可以调用一个不再触碰同一个 Once 的普通构造函数,把结果写入变量;初始化完成后,其他代码只读取结果,不重新通过初始化入口获取结果。

package main

import (
    "fmt"
    "sync"
)

var (
    once sync.Once
    cfg  string
)

func buildConfig() string {
    // 这个函数只负责构造结果,不调用 loadConfig,也不触碰 once。
    return "production"
}

func loadConfig() string {
    once.Do(func() {
        // 初始化闭包有明确终点,返回后 Once 才会对其他调用放行。
        cfg = buildConfig()
    })
    return cfg
}

func main() {
    fmt.Println(loadConfig())
}

如果构造过程还需要多个依赖,也应把依赖作为参数传进 buildConfig,不要让它回头调用 loadConfig。这样调用图是单向的:Do → 构造 → 写入结果 → 返回;读取阶段只消费结果。

sync.Once 一次初始化与初始化完成后读取结果分离的结构说明图
图2:安全拆分结构图,把一次性构造与后续读取分开,多个调用者不再回到初始化入口;这是静态说明图,不是运行截图。

间接重入也要沿依赖链排查

直接写出 once.Do 很容易发现,间接调用则常藏在初始化回调里。例如 initConfig 调用 newClient,newClient 为了读取默认配置又调用 loadConfig。虽然函数名不同,依然是同一个 Once 重入。

可以用三个问题快速检查:

  • 初始化闭包调用的每个函数,是否最终会回到读取入口?
  • 传给初始化函数的回调,是否会在构造期间被同步执行?
  • 构造对象的依赖,是否为了取配置、日志或默认值再次触发初始化?

如果答案不明确,先画出从 once.Do 出发的同步调用链。只要链路回到同一个 Once,就应该把读取动作移到 Do 返回之后,或者把依赖改成显式参数。

和 panic、并发等待的边界

死锁和 panic 后的行为不要混为一谈。官方文档说明:如果初始化函数 panic,Once 会把这次调用视为已经返回,后续调用不会再次执行该函数;这和“初始化函数一直没有返回”的重入死锁是两种不同边界。

同样,多个 goroutine 同时调用同一个 Do 并不天然有问题。正常情况下,一个 goroutine 执行初始化,其他 goroutine 等待;初始化返回后,它们继续执行。只有初始化函数本身同步回到同一个 Do,等待链才会闭合。

排查结论速查

现象应先看什么处理方向
单 goroutine 也卡住初始化函数是否重入同一 Once拆出不触碰 Once 的构造函数
多个 goroutine 都卡在 Do首个初始化调用的调用栈先定位首个调用者,不要只增加 goroutine
初始化回调层级很深是否存在间接回到读取入口的路径把依赖改成显式参数或单向调用
初始化曾经 panic是否误以为 Once 会自动重试单独设计错误结果与重试策略,不靠重入

常见追问

换成另一个 sync.Once 能解决吗?

只能改变状态对象,不能自动修复依赖环。如果新的初始化仍同步调用回原入口,或者两个 Once 互相调用,仍然可能形成等待闭环。

把内层调用放进 goroutine 可以吗?

这通常只是把直接死锁变成等待、泄漏或竞态,不能让初始化逻辑自然完成。除非你明确设计异步生命周期、结果通道和退出条件,否则应保持初始化链同步且单向。

需要手动把 sync.Once 重置吗?

sync.Once 没有面向业务的安全重置流程。需要重试时,通常应把“可重试加载”设计成带状态和错误返回的组件,而不是在初始化函数里再次调用同一个 Do。

记住一个判断就够了:sync.Once 保证的是一次动作完成后的可见性,不是递归初始化的容错器。让 Do 内部只做一次性构造,让构造函数不回到同一个入口,死锁就能在设计阶段被切断。

声明:本文转载于:17golang原创 如有侵犯,请联系study_golang@163.com删除
相关阅读
更多>
最新阅读
更多>
课程推荐
更多>