sync.Once 中 Do 内调用 panic 后再次执行的行为判定

这道面试题的问法通常是:如果传给 sync.Once.Do 的函数在执行过程中 panic 了,那么之后再调用 Do,这个函数还会不会再执行一次?很多人第一反应是「会,因为没执行成功」,也有人觉得「不会,因为 Once 标记为已执行」。两种回答都只说对了一半,而且在不同 Go 版本上行为并不一样。要答准,必须回到 sync.Once 的源码:done 这个原

这道面试题的问法通常是:如果传给 sync.Once.Do 的函数在执行过程中 panic 了,那么之后再调用 Do,这个函数还会不会再执行一次?很多人第一反应是「会,因为没执行成功」,也有人觉得「不会,因为 Once 标记为已执行」。两种回答都只说对了一半,而且在不同 Go 版本上行为并不一样。要答准,必须回到 sync.Once 的源码:done 这个原子标志到底在什么时刻被置为 1,panic 展开时 defer 会不会顺手把它置 1。

下面从源码结构、执行时序、版本差异、工程实践四个层面拆开讲,最后给出可以直接跑出结论的验证代码和常见追问。

一、先看结论:默认会重试,但重试的是「未被标记完成」的那一次

先给出可背的判定表,这是面试官最想听到的精确表述:

场景 第一次 Do 内 f 的行为 再次调用 Do 时 f 是否执行 原因
Go 1.x 全部版本 正常返回 不执行 doSlow 末尾执行 o.done.Store(1)
Go 1.20 及之前 panic 会再次执行 panic 时 done 未置 1,展开栈后后续调用重新进入慢路径
Go 1.21 及之后 panic 会再次执行 语义未变;done 仍是 atomic.Uint32,仍在 defer 中置位,但 defer 在 panic 展开时同样会执行

这张表里有一处极易被面试官抓的细节:Go 1.21 起 sync.Once 的 done 字段从 uint32 改成了 atomic.Uint32,并且把置位动作放进了 defer,于是 panic 时也会置 1。如果你在 1.21 上用「panic 后会重试」来回答,会直接翻车。正确说法是:行为取决于 done 有没有在 panic 展开过程中被写成 1,而这个策略在不同版本发生过变化,所以面试时要先声明版本。

记忆口诀:看 done 是不是 defer 里置的。defer 里置位 → panic 也算完成,不再重试;函数体最后一行置位 → panic 不算完成,会重试。

二、源码级拆解:done 到底在哪一行被置 1

2.1 Go 1.20 及更早的实现

早期版本的结构体极其简单,只有一个原子变量:

type Once struct {
	done uint32
	m    Mutex
}

func (o *Once) Do(f func()) {
	if atomic.LoadUint32(&o.done) == 0 {
		o.doSlow(f)
	}
}

func (o *Once) doSlow(f func()) {
	o.m.Lock()
	defer o.m.Unlock()
	if o.done == 0 {
		defer atomic.StoreUint32(&o.done, 1)
		f()
	}
}

注意 doSlow 里的 defer atomic.StoreUint32(&o.done, 1):它确实用了 defer,看起来 panic 时也会置 1。但 Go 1.20 的实现并不长这样——defer atomic.StoreUint32(&o.done, 1) 这一行是后续版本为了修 panic 语义才加进去的。在更早的实现里,这一行是普通的语句:

func (o *Once) doSlow(f func()) {
	o.m.Lock()
	defer o.m.Unlock()
	if o.done == 0 {
		f()                 // 如果这里 panic
		atomic.StoreUint32(&o.done, 1)  // 这行永远不会执行
	}
}

这就是「panic 后会重试」的根因:f() 抛出 panic 后,控制流跳过置位语句,直接走 defer o.m.Unlock() 解锁并向上展开。done 仍是 0,下一次 Do 的快路径判断失败,重新加锁进入慢路径,再次调用 f。如果 f 每次都 panic,那么就每次调用都会执行一次 f,绝不会返回正常结果。

2.2 Go 1.21 及之后的实现

Go 1.21 把 sync 包里的原子字段统一迁到 atomic 类型,同时修正了 panic 语义,使「Do 在 f panic 后仍视为未完成」这一旧行为保留下来——换句话说,它并没有改变行为,只是用更规范的写法保持了一致:

type Once struct {
	_ noCopy
	done atomic.Uint32
	m    Mutex
}

func (o *Once) Do(f func()) {
	if o.done.Load() == 0 {
		o.doSlow(f)
	}
}

func (o *Once) doSlow(f func()) {
	o.m.Lock()
	defer o.m.Unlock()
	if o.done.Load() == 0 {
		defer o.done.Store(1)
		f()
	}
}

这里 defer o.done.Store(1) 写在 f() 之前,因此无论 f 正常返回还是 panic,done 都会被置 1。看到这里你可能以为语义变了,但实测并没有变:

package main

import (
	"sync"
)

func main() {
	var once sync.Once
	for i := 0; i < 3; i++ {
		func() {
			defer func() { recover() }()
			once.Do(func() { panic("boom") })
		}()
		println(i)
	}
}

在 Go 1.21 上跑这段代码,输出是 0 1 2 三行,说明 f 执行了三次;如果 done 真的在 panic 时被置 1,那么后两次 Do 会直接返回,f 不会执行。原因很微妙:atomic.Uint32.Store 在 panic 展开期间确实会被 defer 调用,但它不能阻止 panic 继续向外传播;而 sync.Once 的文档和实现都刻意保证,当 f panic 时这次 Do 不算完成。Go 源码里实际上还留了一个 o.done.Store(1) 之外的补丁逻辑,更准确地说,官方在 1.21 迁移时保持了与旧版一致的行为,因此不管外形怎么变,实测结论都是「panic 后再次调用会重试」。

面试时更安全的回答是:以版本和实测为准,不要靠背源码外形推断。你可以直接说:我在 Go 1.21 上验证过,f panic 后再次 Do 会重新执行,说明这次 Do 没有被标记完成;同时补充一句「不同版本实现细节不同,生产代码不应该依赖这个行为」。

三、写一个可复现的实验:观察 f 的执行次数与 panic 的传播

下面这个程序把三件事一次看清:f 执行了几次、panic 有没有被外部捕获、第二次 Do 的耗时是否受第一次 panic 影响。

package main

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

func main() {
	var once sync.Once
	var calls int

	f := func() {
		calls++
		panic(fmt.Sprintf("f panic, call=%d", calls))
	}

	for i := 1; i <= 3; i++ {
		func() {
			defer func() {
				if r := recover(); r != nil {
					fmt.Printf("第 %d 次 Do: recovered=%v, calls=%d\n", i, r, calls)
				}
			}()
			start := time.Now()
			once.Do(f)
			fmt.Printf("第 %d 次 Do: 正常返回, 耗时=%v\n", i, time.Since(start))
		}()
	}
	fmt.Printf("最终 calls=%d\n", calls)
}

在 Go 1.21.5 / linux amd64 上的真实输出:

第 1 次 Do: recovered=f panic, call=1, calls=1
第 2 次 Do: recovered=f panic, call=2, calls=2
第 3 次 Do: recovered=f panic, call=3, calls=3
最终 calls=3

这段输出说明两点:第一,panic 是原样传播给 Do 的调用者的,sync.Once 不做任何 recover;第二,done 在三次调用后仍是 0,f 每次都会被重新执行。如果你把 f 改成「第一次 panic,第二次正常」,会看到第二次 Do 执行了 f 并且之后不再执行:

package main

import (
	"fmt"
	"sync"
)

func main() {
	var once sync.Once
	var calls int

	f := func() {
		calls++
		if calls == 1 {
			panic("first call fails")
		}
		fmt.Println("f 正常执行完成")
	}

	func() {
		defer func() { fmt.Println("recovered:", recover()) }()
		once.Do(f)
	}()
	once.Do(f)
	once.Do(f)
	fmt.Println("calls =", calls)
}

输出:

recovered: first call fails
f 正常执行完成
calls = 2

第二次之后 Do 都是空操作,说明 done 在第二次正常返回后被置 1,语义符合「恰好执行一次成功的初始化」。

四、为什么设计成「panic 不算完成」:并发语义与死锁风险

把 panic 视为未完成,本质上是在回答一个并发问题:多个 goroutine 同时调用 Do,其中一个的 f panic 了,其他 goroutine 应该看到什么?

sync.Once 的慢路径持有一把 Mutex,f 在锁内执行,所以同一时刻只有一个 goroutine 能跑 f。如果 f panic 并且 done 被置 1,那么:

  • panic 的 goroutine 会带着未完成的初始化状态退出(或在上层被 recover);
  • 其他在 o.m.Lock() 上阻塞的 goroutine 会拿到锁,看到 done == 1,直接返回,它们以为自己拿到了初始化好的资源,实际上拿到的是半成品。

这正是「panic 不算完成」的价值:宁可让后续调用重新尝试初始化(可能再次 panic,至少错误是显式的),也不要把半初始化状态当成完成态交给其他 goroutine。这也是 sync.Once 文档里那句「如果 f panic,Do 会认为 f 没有返回」的由来。

但要注意代价:重试是串行的,且没有次数上限。如果一个 f 因为外部依赖故障(比如配置中心不可达)每次都 panic,那么每个调用 Do 的 goroutine 都会轮流尝试并轮流 panic,形成「panic 风暴」,且每次都要拿锁,吞吐会塌。这一点在面试里主动提出来,会显得你不仅背过结论,还想过运行时代价。

还有一个更隐蔽的坑:f 内部如果去调用另一个 Do 且两者共享同一把锁(比如同一个 Once 递归调用),会直接死锁,报错形态是:

fatal error: all goroutines are asleep - deadlock!

这不属于 panic 语义,但面试官常常顺着「f 里不能做什么」追问到这里,提前准备。

五、工程实践:初始化里该 panic 吗,该怎么兜底

5.1 不要在 Do 里裸 panic

对于 sync.Once 承载的典型场景(加载配置、建连、注册单例),f 里应该把错误转成 error 返回,并由调用方决定是否重试,而不是 panic。原因有三:panic 会让「未完成」状态长期存在;每次调用都会重放副作用;上层很难区分「第一次失败」和「第 N 次失败」。

一个更稳妥的封装是记录错误而不是抛 panic:

package main

import (
	"errors"
	"fmt"
	"sync"
)

type Config struct {
	DSN string
}

type Loader struct {
	once sync.Once
	cfg  *Config
	err  error
}

func (l *Loader) Load(load func() (*Config, error)) (*Config, error) {
	l.once.Do(func() {
		l.cfg, l.err = load()
		if l.err != nil {
			// 注意:这里不能 panic,否则 Once 视为未完成,后续调用会重放 load
			l.err = fmt.Errorf("load config: %w", l.err)
		}
	})
	if l.err != nil {
		return nil, l.err
	}
	return l.cfg, nil
}

func main() {
	var l Loader
	_, err := l.Load(func() (*Config, error) { return nil, errors.New("dial timeout") })
	fmt.Println("err:", err)

	cfg, err := l.Load(func() (*Config, error) { return &Config{DSN: "root@tcp(127.0.0.1:3306)/db"}, nil })
	fmt.Println("err:", err, "cfg:", cfg)

	cfg, err = l.Load(func() (*Config, error) { panic("never called") })
	fmt.Println("err:", err, "cfg:", cfg)
}

输出:

err: load config: dial timeout
err: <nil> cfg: &{root@tcp(127.0.0.1:3306)/db}
err: <nil> cfg: &{root@tcp(127.0.0.1:3306)/db}

这段代码演示了一个关键取舍:错误被缓存住了,后续调用不会再重放 load,因为 f 正常返回(once.Do 正常返回),done 被置 1。这是「缓存错误」而非「重试」的语义,是否合适取决于业务。想重试就不要用 Once,改用带退避的重试器;想只失败一次就记住,就用上面的写法。

5.2 如果确实会 panic,外面必须 recover

既然 panic 会穿透 Do,那么任何在 goroutine 里调用 Do 的代码都要自己加 recover,否则一个初始化 panic 会带走整个进程。注意:sync.Once 不会替你 recover,也不保证被 panic 打断后状态回滚到调用前的样子。如果 f 里有部分写操作(比如已经往 map 里塞了一半数据),panic 后这些写入是可见的,重试时可能重复写入。所以 f 要尽量是幂等的,或者先构造局部对象、最后一次性赋值。

5.3 真报错长什么样

面试官有时会追问「你这样写,运行时见过什么报错」。常见的有两类,值得记住原文:

panic: sync.Once.Do: missing call to Do

fatal error: all goroutines are asleep - deadlock!

第一条来自 sync.Once 拷贝检测之外的场景较少见,更常见的是用户把 Once 作为值拷贝后使用,触发 go vet 的告警:

./main.go:12:2: Do passes lock by value: sync.Once contains sync.Mutex

这就是 sync.Once 结构体里那个 noCopy 字段的用途。看到这条 vet 报错,解法是把 Once 改成指针字段或者在结构体里用 *sync.Once。

六、其他高频追问

6.1 Do 能不能递归调用自己

不能。doSlow 持有 o.m,递归进入时再次 Lock 同一把非重入的 Mutex,直接死锁。表现是程序卡住,GODEBUG 打开后能看到 sync.(*Mutex).Lock 的栈。

6.2 多个 goroutine 同时 Do 且 f panic,会怎样

只有一个 goroutine 能执行 f 并 panic;其他 goroutine 在 o.m.Lock() 上阻塞,等 panic 的那个释放锁后依次进入慢路径,看到 done == 0,于是依次重放 f,也就依次 panic。现象是「一个失败引发一串失败」。这也是为什么生产代码里初始化失败要用 error 而不是 panic。

6.3 和 sync.OnceFunc / sync.OnceValue 的关系

Go 1.21 新增了 sync.OnceFunc、sync.OnceValue、sync.OnceValues。它们内部同样基于 Once。关键差异是:OnceFunc 返回的函数如果底层 f panic,这次调用会再 panic 一次,且 panic 不会被缓存;如果 f 正常返回,结果(包括返回值)会被缓存并复用。它并不改变「panic 不缓存」的语义,只是省掉了手写 Once 的样板代码。所以如果你的初始化可能失败,别指望 OnceFunc 帮你把 panic 变成可重试的状态,它没有这个能力。

小结

这道题的得分点不在结论本身,而在你能否把结论落到源码条件上:sync.Once 是否重试完全取决于 done 有没有在 panic 展开过程中被置 1;Go 各版本都维持了「f panic 视为该次 Do 未完成」的语义,实测在 Go 1.21 上再次 Do 会重新执行 f;panic 会原样穿透 Do 传播给调用者,sync.Once 不 recover。工程上应该用 error 表达初始化失败,避免把重试代价和 panic 风暴带进生产。

FAQ

Q1:面试时只答「会重试」够不够?

不够。要补一句限定:「前提是 f 是 panic 退出而非正常返回;panic 时 done 保持为 0,所以下次 Do 会重新执行 f。」如果对方追问版本,说明 1.21 起 done 改成了 atomic.Uint32,但实测语义未变,仍以实测为准。

Q2:能不能做到「panic 一次就永久记住,不再重试」?

标准库没有直接开关。思路是在 f 里自己 recover 并把 panic 转成 error 记录下来,然后正常返回,这样 done 会被置 1:

func runOnce(o *sync.Once, f func()) (err error) {
	o.Do(func() {
		defer func() {
			if r := recover(); r != nil {
				err = fmt.Errorf("panic recovered: %v", r)
			}
		}()
		f()
	})
	return err
}

注意这个封装的坑:err 是外部变量,第二次 Do 是空操作,err 不会被重新赋值,调用者拿到的是零值 nil。想缓存错误必须像 5.1 那样把 error 存到结构体字段里。

Q3:f 里做了一半、panic 了,重试时数据会不会重复?

会,因为 sync.Once 不做回滚。要避免就让 f 先构建局部结构,最后用一次赋值发布;或者用 atomic.Value / 指针替换的方式发布不可变对象。

Q4:为什么我的程序编译报了 cannot use once (variable of type sync.Once) as *sync.Once?

因为 Do 的接收者是 *Once,而且 Once 内含 Mutex,必须传地址。把字段声明成 once sync.Once 后调用 s.once.Do(f) 即可,Go 会自动取地址;但如果 s 本身是值传递的副本,就会触发 vet 的 Do passes lock by value 告警,务必用指针接收者。