Go sync.Once 很简单...真的是这样吗?
sync.Once
可能是最容易使用的同步原语,但其内部可能比你想象的更复杂。
通过同时使用原子操作和互斥锁,我们有了一个很好的机会来理解它的工作原理。
在这次讨论中,我们将分解 sync.Once
是什么,如何正确使用它,以及最重要的是,它在底层是如何工作的。我们还将看看它的"亲戚":OnceFunc
、OnceValue[T]
和 OnceValues[T, K]
。
什么是 sync.Once?
当你需要一个函数只运行一次,无论它被调用多少次或有多少个 goroutine 同时访问它时,sync.Once
就是你要找的工具。
它非常适合初始化单例资源,这些资源在应用程序生命周期中只应该发生一次,比如设置数据库连接池、初始化日志记录器或配置指标系统等。
var once sync.Once var conf Config func GetConfig() Config { once.Do(func() { conf = fetchConfig() }) return conf }
如果多次调用 GetConfig()
,fetchConfig()
只会执行一次。
sync.Once
的真正好处是延迟某些操作直到首次需要(懒加载),这可以提高运行时性能并减少初始内存使用。例如,如果只有在首次访问时才创建大型查找表,那么创建表的内存和处理时间将被节省。
大多数情况下,它比使用 init()
更适合初始化(外部)资源。
现在,需要记住一件重要的事:一旦你使用 sync.Once
对象运行了一个函数,那就是全部了——你不能重复使用它。一旦完成,就完成了。
在 sync.Once
成功使用 Do(f)
运行函数后,它会在内部用一个标志(done
)将自己标记为"完成",从那时起,任何进一步调用 Do(f)
都不会再次运行该函数,即使是不同的函数。
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()
失败,只有第一次调用会收到错误。任何后续调用——第二次、第三次等——都不会返回该错误(正如 venicedreamway[7] 指出的)。要使行为在所有调用中保持一致,我们需要将 err
设为包级变量,像这样:
var once sync.Once var config Config var err error func GetConfig() (Config, error) { var err 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() // Pretend this is expensive }) func main() { config1 := getConfigOnce() // Loading config... config2 := getConfigOnce() // No print, just returns the cached config ... } // 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
相比,它真的很简单,后者是 Go 中 最棘手的同步[0] 工具之一。所以让我们退一步,思考 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()
)只允许一个协程同时进入临界区。然后,如果 done
仍为 0(表示函数尚未运行),它将 done
设置为 1 并运行函数 f()
。
这实际上是 Rob Pike 在 2010 年编写的 sync.Once
的原始版本。
现在,我们刚刚看到的版本可以正常工作,但性能不是最佳的。即使在第一次调用后,每次调用 Do(f)
时仍然需要获取锁,这意味着协程彼此等待。通过在任务已完成时添加快速退出,我们肯定可以做得更好。
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()
函数变得更加简单,可以被编译器内联。
尽管从我们的角度看,现在有 2 个函数调用,但实际上并非如此。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() } }
其思想是哪个 goroutine 能成功将 done 的值从 0 交换到 1,就会"赢得"竞争并运行 f()
,而所有其他 goroutine 只会返回。
但为什么 Go 团队不使用这个版本?在阅读下一节之前,你能猜出原因吗?正如我们已经讨论过的这个错误。
是的,这让我们回到了我们之前讨论过的同一个错误:
使用比较并交换(CAS)操作的 sync.Once
当"获胜"的 goroutine 仍在运行 f()
时,其他 goroutine 可能会来检查 done
标志,认为 f()
已经完成,并继续使用尚未完全准备好的资源。
就是这样!sync.Once
在实现和使用上都很简单,但事实证明要正确实现它是相当棘手的。
参考链接
- Go sync.Mutex:正常模式和饥饿模式: https://victoriametrics.com/blog/go-sync-mutex
- Go sync.WaitGroup 和对齐问题: https://victoriametrics.com/blog/go-sync-waitgroup
- Go sync.Pool 及其机制: https://victoriametrics.com/blog/go-sync-pool
- Go sync.Cond,最被忽视的同步机制: https://victoriametrics.com/blog/go-sync-cond
- Go sync.Map:为正确的工作选择正确的工具: https://victoriametrics.com/blog/go-sync-map
- Go Singleflight 在代码中融化,而不是在数据库中: https://victoriametrics.com/blog/go-singleflight
- venicedreamway: https://www.reddit.com/user/venicedreamway/
- X(@func25): https://twitter.com/func25
- Go 数组的工作原理及 For-Range 的巧妙之处: https://victoriametrics.com/blog/go-array
- Go 切片:要么大,要么回家: https://victoriametrics.com/blog/go-slice
- Go Maps 详解:键值对实际是如何存储的: https://victoriametrics.com/blog/go-map
- Golang Defer:从基础到陷阱: https://victoriametrics.com/blog/defer-in-go
- 深入 Go 的 Unique 包:字符串内部化简化: https://victoriametrics.com/blog/go-unique-package-intern-string
- 依赖管理,或 go mod vendor:这是什么?: https://victoriametrics.com/blog/vendoring-go-mod-vendor
- VictoriaMetrics: https://docs.victoriametrics.com/