前言
本篇为笔者翻译 VictoriaMetrics 公司博客 《handling concurrency in Go》系列的第二篇,主要介绍了sync.Once及其底层相关原理。
sync.Once
sync.Once 是 Go 中最简单的同步原语之一,但它的内部实现比你想象的要复杂。了解它的工作原理是一个很好的机会,能够掌握原子操作和互斥锁的使用。
在这篇讨论中,我们将详细拆解 sync.Once,看看它是什么、如何正确使用它,以及最重要的部分——它是如何在底层工作的。我们还将看看它的“亲戚们”:OnceFunc、OnceValue[T] 和 OnceValues[T, K]。
sync.Once 是啥
sync.Once 是当你需要某个函数只执行一次时的理想选择,无论该函数被调用多少次,或者有多少个 goroutine 同时调用它。
它非常适合用来初始化单例资源,这些资源只在应用程序生命周期中初始化一次,比如设置数据库连接池、初始化日志记录器,或许是配置度量系统等。
var once sync.Once
var conf Config
func GetConfig() Config {
once.Do(func() {
conf = fetchConfig()
})
return conf
}
如果 GetConfig() 被多次调用,fetchConfig() 只会执行一次。
sync.Once 的真正好处是,它延迟执行某些操作直到首次需要(懒加载),这可以提高运行时性能并减少初始内存使用。例如,如果一个大查找表只在首次访问时创建,那么在那一刻之前不会占用内存和处理时间。
通常,sync.Once 更适合用来初始化(外部)资源,而不是使用 init()。
现在,有一点非常重要:一旦使用了 sync.Once 来执行一个函数,之后就不能再重用它。一旦执行完毕,它就完成了任务。
var once sync.Once
func main() {
once.Do(func() {
fmt.Println("This will be printed once")
})
once.Do(func() {
fmt.Println("This will not be printed")
})
}
// Output:
// This will be printed once
sync.Once 没有内置的重置方式。一旦完成任务,它就永远无法再被使用。
接下来,有一个有趣的情况。如果你传递给 Once.Do 的函数在执行过程中发生 panic,sync.Once 仍然将其视为“任务完成”。这意味着未来对 Do(f) 的调用将不会再次执行该函数。这可能会比较棘手,特别是当你试图捕获 panic 并在之后处理错误时,它没有重试机制。
另外,如果你需要处理函数 f 可能出现的错误,代码可能会显得有些不方便:
var once sync.Once
var config Config
func GetConfig() (Config, error) {
var err error
once.Do(func() {
config, err = fetchConfig()
})
return config, err
}
这在第一次调用时工作正常。
问题是,如果 fetchConfig() 失败,只有第一次调用会接收到错误。后续的调用(第二次、第三次等)将不会返回该错误。为了让行为在多次调用中保持一致,我们需要将 err 声明为包作用域的变量,如下所示:
var once sync.Once
var config Config
var err error
func GetConfig() (Config, error) {
once.Do(func() {
config, err = fetchConfig()
})
return config, err
}
不过好消息是,从 Go 1.21 开始,我们获得了 OnceFunc、OnceValue 和 OnceValues。
这些基本上是对 sync.Once 的便捷封装,能够使事情变得更顺畅,同时不牺牲任何性能。OnceFunc 是相当直接的,它接受一个函数 f 并将其包装在另一个函数中,这个包装函数可以被调用多次,但 f 本身只会执行一次:
var wrapper = sync.OnceFunc(printOnce)
func printOnce() {
fmt.Println("This will be printed once")
}
func main() {
wrapper()
wrapper()
}
// Output:
// This will be printed once
即使你多次调用 wrapper(),printOnce() 也只会在第一次调用时执行。
如果 f() 在第一次执行时发生 panic,那么每次调用 wrapper() 都会 panic,且会有相同的错误。这就像是它锁定了失败状态,确保你的应用不会在关键初始化失败的情况下继续执行。
但这并没有很好地解决优雅处理错误的问题。
接下来,我们来看更有用的:OnceValue 和 OnceValues。这些非常酷,因为它们会记住 f 的结果,并在未来的调用中直接返回缓存的结果。
var getConfigOnce = sync.OnceValue(func() Config {
fmt.Println("Loading config...")
return fetchConfig() // 假设这是一个开销较大的操作
})
func main() {
config1 := getConfigOnce() // Loading config...
config2 := getConfigOnce() // 不会打印,直接返回缓存的配置
...
}
// Output:
// Loading config...
在第一次调用 getConfigOnce() 后,它就只会返回相同的结果,而不会重新执行 fetchConfig()。除了懒加载,您不再需要处理闭包。
那么,错误呢?获取某些东西通常涉及错误处理。
这时,sync.OnceValues 就派上用场了。它的工作方式与 OnceValue 相似,但允许返回多个值,包括错误。因此,你可以在第一次运行时缓存结果以及可能出现的错误。
var config Config
var getConfigOnce = sync.OnceValues(fetchConfig)
func main() {
var err error
config, err = getConfigOnce()
if err != nil {
log.Fatalf("Failed to fetch config: %v", err)
}
...
}
现在,getConfigOnce() 的行为就像一个普通函数 — 它仍然是并发安全的,缓存了结果,并且只会花费一次执行函数的成本。之后,每次调用都非常廉价。
"那么,如果发生错误,这个错误也会被缓存吗?"
不幸的是,会的。无论是 panic 还是错误,结果和失败状态都会被缓存。所以,调用代码需要注意,它可能会处理缓存的错误或失败状态。如果需要重试,必须创建一个新的 sync.OnceValues 实例来重新运行初始化。
另外,在这个例子中,我们返回错误作为第二个值,以匹配我们习惯的函数签名,但实际上,它可以是任何值,具体取决于你的需求。
内部原理
如果你不熟悉 Go 中的原子操作或同步技术,那么 sync.Once 是一个很好的入门点,因为它是最简单的同步原语之一。
它比像 sync.Mutex 这样的工具要简单得多,而 sync.Mutex 是 Go 中最复杂的同步工具之一。所以,让我们先退一步,思考一下 sync.Once 是如何实现的。
以下是 sync.Once 的基本结构:
type Once struct {
done atomic.Uint32
m Mutex
}
你会注意到,done 字段使用了原子操作。
有一个有趣的细节,done 被放在结构体的最上面,这是有原因的。在许多 CPU 架构(如 x86-64)中,访问结构体中的第一个字段更快,因为它位于内存块的基地址。这个小小的优化使得 CPU 可以更直接地加载第一个字段,而不需要计算内存偏移。
另外,将其放在顶部有助于内联优化,稍后我们会讨论这一点。
那么,当我们实现 sync.Once 来确保一个函数只执行一次时,第一件事想到的是什么?使用互斥锁,对吧?
func (o *Once) Do(f func()) {
o.m.Lock()
defer o.m.Unlock()
if o.done.Load() == 0 {
o.done.Store(1)
f()
}
}
这个实现简单有效。思路是,互斥锁(o.m.Lock())只允许一个 goroutine 进入临界区。然后,如果 done 仍然为 0(表示函数尚未执行),它会将 done 设置为 1 并执行函数 f()。
这是 Rob Pike 在 2010 年编写的原始版本的 sync.Once。
现在,虽然这个版本有效,但它并不是最优的。即便是第一次调用之后,每次调用 Do(f) 时,它仍然会获取锁,这意味着 goroutines 需要相互等待。我们可以通过在任务已经完成时快速退出来提升性能。
func (o *Once) Do(f func()) {
if atomic.LoadUint32(&o.done) == 1 {
return
}
// slow path
o.m.Lock()
defer o.m.Unlock()
if o.done.Load() == 0 {
o.done.Store(1)
f()
}
}
这样我们就有了一个快速路径,当 done 标志被设置时,我们直接跳过锁并立即返回。如果标志没有设置,我们就进入慢路径,锁住互斥锁,重新检查 done,然后执行函数。
在获取锁之后,我们需要重新检查 done,因为在检查标志和真正锁定互斥锁之间,可能有其他 goroutine 已经执行了 f() 并设置了标志。我们还在调用 f() 之前设置了 done 标志。这样做的目的是,即使 f() 发生 panic,我们也能将其标记为“成功”,防止它再次执行。
但这个做法也有问题。
想象一下这种情况,我们将 done 设置为 1,但 f() 还没有完成,可能它卡在了一个很长的网络调用上。
(上图: sync.Once 发生竞态)
现在,另一个 goroutine 来了,它检查标志,看到已经设置了,错误地认为“太好了,资源已经准备好了!”但实际上,资源仍在获取中。那么会发生什么呢?空指针解引用并 panic!资源还没准备好,系统尝试在它准备好之前就使用它。
我们可以通过像这样使用 defer 来解决问题:
func (o *Once) Do(f func()) {
if o.done.Load() == 1 {
return
}
// slow path
o.m.Lock()
defer o.m.Unlock()
if o.done.Load() == 0 {
defer o.done.Store(1)
f()
}
}
你可能会想,“好吧,这看起来很稳固。”但它仍然不是完美的。
关键点是 Go 支持所谓的内联优化。
如果一个函数足够简单,Go 编译器会将其“内联”,即将函数的代码直接粘贴到函数调用的位置,从而使得它更高效。然而,我们的 Do() 函数仍然太复杂,无法内联,因为它有多个分支、defer 和函数调用。
为了帮助编译器做出更好的内联决策,我们可以将慢路径的逻辑移动到另一个函数中:
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()
}
}
这使得 once.Do() 函数变得更加简单,可以被编译器内联。
尽管从我们的角度来看,现在有两个函数调用,但实际上并非如此。o.done.Load() 是一个原子操作,Go 编译器以特殊的方式处理它(编译器本地实现),所以它不会增加函数调用的复杂度。
"“为什么不直接内联
doSlow()?”"
原因是,在第一次调用 Do(f) 后,常见的场景是快速路径——只是检查函数是否已经运行。
在实际应用中,在 f() 运行一次(即慢路径)后,通常会有许多后续调用 once.Do(f),它们只需要快速检查 done 标志,而不需要加锁或重新运行函数。
这就是为什么我们优化了快速路径,在快速路径中,我们只检查是否已经完成并立即返回。而且还记得我们之前提到的 done 字段为何放在 Once 结构体的最前面吗?这是因为它通过更容易访问来加快快速路径的执行速度。
现在,我们有了完美的 sync.Once 版本,但最后一个难题来了。Go 团队还提到了使用比较并交换(CAS)操作的实现版本,这使得 Do() 函数变得更简单:
func (o *Once) Do(f func()) {
if o.done.CompareAndSwap(0, 1) {
f()
}
}
这个想法是,能够成功将 done 从 0 替换为 1 的 goroutine 就“赢了”竞赛并执行 f(),而其他 goroutines 则会直接返回。
但是,Go 团队为什么不使用这个版本呢?在读下一节之前,你能猜出原因吗?因为我们之前讨论过这个错误。
是的,这把我们带回了之前讨论的错误:
当“获胜”的 goroutine 仍在执行 f() 时,其他 goroutine 可能已经检查了 done 标志,错误地认为 f() 已经完成,并继续使用尚未完全准备好的资源。
而这就是问题所在!sync.Once 在实现和使用上都很简单,但要完全正确地实现它却相当棘手。
思考
内联是个比较复杂的话题,如果读者使用过ebpf监听userspace,可能不太希望编译器的内联行为🤔。