在 Go 语言的并发编程中,常常会遇到需要确保某个操作仅执行一次的场景。sync.Once
是 Go 标准库中的一个简单而强大的工具,专门用于解决这种需求。本文将深入解析 sync.Once
的使用方法和原理,帮助你更好地理解 sync.Once
在并发控制中的用法。
sync.Once
sync.Once
是 Go 语言 sync
包中的一种同步原语。它可以确保一个操作(通常是一个函数)在程序的生命周期中只被执行一次,不论有多少 goroutine 同时调用该操作,这就保证了并发安全。
根据 sync.Once
的特点,很容易想到它的几种常见使用场景:
- 单例模式:确保某个对象或配置仅初始化一次,例如使用单例模式初始化数据库连接池、配置文件加载等。
- 懒加载:在需要时才加载某些资源,且保证它们只会加载一次。
- 并发安全的初始化:当初始化过程涉及多个 goroutine 时,使用
sync.Once
保证初始化函数不会被重复调用。
快速上手
sync.Once
用法非常简单,示例如下:
1 | package main |
首先使用 var once sync.Once
声明了一个 sync.Once
类型的变量 once
,但不必显式初始化,这也是 sync
下很多包的惯用法。
然后定义了一个函数 onceBody
,接着启动 10 个 goroutine 并发调用 once.Do(onceBody)
,最终等待所有 goroutine 执行结束并退出。
执行示例代码,得到如下输出:
1 | $ go run once/main.go |
和预期一样,once.Do
能够保证传递给它的函数 onceBody
只被执行一次。
其实就算不启用多个 goroutine,直接在主 goroutine 中调用多次 once.Do(onceBody)
,也能保证只执行一次:
1 | package main |
执行示例代码,得到如下输出:
1 | $ go run once/main.go |
执行结果也是一样的,仅会执行一次。
至此,我们就学会了如何使用 sync.Onec
。以上就是 sync.Onec
提供的全部 API 了,没错它仅对外暴露了一个 Do
方法。
现在,我想你应该知道如何使用 sync.Onec
来实现单例模式了,我在另一篇文章《Go 常见设计模式之单例模式》中也有讲解。
详细介绍
我们快速上手了 sync.Onec
的使用方法,下面我们来看下 Go 官方是如何介绍 sync.Onec
的。
Go 官方文档 对 sync.Once
的介绍只有简单三句话:
Once is an object that will perform exactly one action.
A Once must not be copied after first use.
In the terminology of the Go memory model, the return from f “synchronizes before” the return from any call of once.Do(f).
意思是说:
Once 是一个对象,它会确保某个操作只执行一次。
在首次使用后,Once 对象不能被复制。
根据 Go 内存模型的术语,f 函数的返回 “synchronizes before“ 于 once.Do(f) 的任何调用返回。
首先第一句话中所说的只执行一次的特性我们已经见识过了。
对于第二句话中的 Once 对象不能被复制,其实 sync
中很多对象都有这个特性,在我们稍后阅读源码时会有体现。
而第三句话不太好理解,实际上它想表达的是,在使用 sync.Once
的 Do
方法执行 f
函数后,f
的结果会对所有调用 once.Do(f)
的其他 goroutine 可见。这种“先行发生”(synchronizes before)的保证意味着,f
的执行结果会在所有调用 once.Do(f)
的 goroutine 中同步,因此所有 goroutine 都能获得一致的结果。
具体来说:
- 当
f
函数在一个 goroutine 中被once.Do(f)
首次调用时,f
会执行,并保证它的效果在内存中对其他 goroutine 可见。 - 之后的所有
once.Do(f)
调用都不会重新执行f
,但它们会“同步”f
的结果,确保f
的结果已经生效,并对调用它们的 goroutine 可见。
这样,sync.Once
可以在多 goroutine 场景中安全地执行初始化等需要确保一次性操作的函数,而无需担心数据不一致的问题。
此外我们还需要注意一点,once.Do(f)
接收的函数 f
是没有返回值,所以所说 f
函数的执行的效果是指它执行的副作用。
如下示例就是利用 f
函数执行的副作用来修改变量 i
的值:
1 | package main |
执行示例代码,得到如下输出:
1 | $ go run once/main.go |
f
函数对变量 i
的值影响仅有一次。
源码解读
接下来我们再来看下 sync.Onec
源码,学习下它是如何实现的:
1 | // Copyright 2009 The Go Authors. All rights reserved. |
嗯,你没看错 sync.Onec
的源码竟然如此简单,算上全部的注释和空行,也才仅有 78 行代码,而注释占了一大半行数。
首先来看 Once
结构体的定义:
1 | type Once struct { |
Once
结构体有两个属性,done
属性上的注释告诉我们它是有意被放在结构体第一个字段的,在某些架构能够减少 CPU 执行的指令数,以优化性能,作为 hot path
。
关于为什么放在结构体第一个字段就能优化性能,简单一句话来解释就是,第一个字段与结构体本身的指针地址是相同的,访问 Once
结构体无需指针偏移操作,就可以直接操作 done
属性。hot path
更多解释的细节,我在另一篇文章《Go 语言中的结构体内存对齐你了解吗?》中有所讲解,你可以跳转过去查看。
另外 done
属性是 atomic.Uint32
类型,我们顺便来看下 atomic.Uint32
是如何定义的:
1 | // A Uint32 is an atomic uint32. The zero value is zero. |
这里有一个特殊字段 _ noCopy
,标识这个结构体不可复制,所以这也就是为什么前文中提到 Once 对象不能被复制的原因了。
使用 noCopy
字段来标识结构体不可复制,是 Go 语言中的惯用法,我在另一篇文章《Go 中空结构体惯用法,我帮你总结全了!》中有讲解。
Once
结构体的 n
属性没什么好说的,就是一个互斥锁 Mutex
。
接下来看Do
方法的实现:
1 | func (o *Once) Do(f func()) { |
可以看到,Do
方法内部还通过注释贴心的解释了为什么不使用 o.done.CompareAndSwap(0, 1)
的实现,而是使用 o.done.Load()
+ Mutex
的实现。
Do
方法处理两种 case
,先是 if o.done.Load() == 0
的判断,这是一个 fast-path
,如果成立,则调用 o.doSlow(f)
进入 slow-path
,否则 fast-path
执行结束直接返回了。
这里简单解释下 fast-path
和 slow-path
:
fast-path
:一段针对常见操作或最佳情况进行优化的代码路径。在这条路径上,通常执行步骤最少、效率最高。所以fast path
通常在设计上避免了昂贵的操作(如加锁、IO 操作等)以提高性能。slow-path
:用于处理较为罕见或复杂的情况,通常执行步骤较多、性能较低。这类路径通常在少数情况下才会被执行,比如当代码需要处理边缘情况或复杂的操作时。
所以说,Do
方法绝大多数情况下都会通过 fast-path
直接返回,只有第一次调用才会进入 o.doSlow(f)
逻辑。
在 doSlow
方法内部,先加锁,然后再一次检查了 if o.done.Load() == 0
。很明显这是一个 Double-Check Locking
,保证极端情况下的并发安全。我在《Go 常见设计模式之单例模式》中有讲解如何使用 Double-Check Locking
来实现单例模式,感兴趣的读者可以跳转过去查看。
现在,sync.Once
的源码就都解读完成了。
当然,细心的读者可能注意到,注释中其实写了为什么要将 slow-path
分离出来,单独定义一个函数,目的是为了对 fast-path
进行内联优化。
将 slow-path
逻辑放在单独的 doSlow
函数中可以使 Do
方法的快路径更简洁,这样还有助于 Go 编译器对 fast-path
进行内联优化(即直接嵌入到调用处),从而减少函数调用的开销,提高性能。
我们可以来验证一下内联是否生效,示例代码如下:
1 | package once |
执行 go build
时传入 -gcflags='-m'
构建参数可以查看内联情况:
1 | $ go build -gcflags='-m' inlining/once/main.go |
打印日志中出现 inlining
关键字表示 main
中调用 sync.Once.Do
方法时确实存在内联优化。
作为对比,我们再来实现一个没有将 slow-path
分离出来的 Once
版本:
1 | package sync |
使用示例如下:
1 | package once |
执行 go build
查看内联情况:
1 | $ go build -gcflags='-m' inlining/myonce/main.go |
这一次编译器确实没有进行内联优化,可见 doSlow
函数的封装还是起了作用的。
sync.Once
就这么多功能,不过在 Go 1.21 中 Go 官方又增加了三个 sync.Once
相关函数:OnceFunc
、OnceValue
和 OnceValues
,来增强 sync.Once
功能,接下来我们就依次介绍下。
sync.OnceFunc
源码解读
首先我们来看一下 sync.OnceFunc
源码实现:
https://github.com/golang/go/blob/go1.23.0/src/sync/oncefunc.go#L11
1 | // OnceFunc returns a function that invokes f only once. The returned function |
根据 OnceFunc
上方的代码注释可知:
OnceFunc
返回一个仅调用f
一次的函数。这个返回的函数可以并发调用。- 如果函数
f
执行时出现panic
,则返回的函数将在每次调用时会产生同样的panic
值。
可以发现,其实 OnceFunc
函数就是对 once.Do
的封装,不过显然它考虑了更多情况,使用 defer
+ recover
对 panic
进行捕获。用变量 p
暂存了 panic
信息,并且当多次调用 OnceFunc
返回函数时,都会重新 panic
。
NOTE:
如果你不熟悉
defer
、panic
和recover
,我在《Go 错误处理指北:Defer、Panic、Recover 三剑客》一文中对这三者进行了详细讲解,供你参考。
使用示例
sync.OnceFunc
使用示例如下:
1 | package main |
执行示例代码,得到如下输出:
1 | $ go run oncefunc/main.go |
如果发生 panic
会怎样呢,我们可以尝试一下:
1 | package main |
执行示例代码,得到如下输出:
1 | $ go run oncefunc/panic/main.go |
作为对比,如果 sync.Once.Do
遇到 panic
又会怎样呢?示例如下:
1 | package main |
执行示例代码,得到如下输出:
1 | $ go run once/panic/main.go |
由此可见,可以认为 sync.OnceFunc
是比 sync.Once.Do
更好用的接口,它帮我们考虑了函数 f
发生 panic
情况,所以可以考虑优先使用这个实现。
sync.OnceValue
源码解读
我们再来看下 sync.OnceValue
源码实现:
https://github.com/golang/go/blob/go1.23.0/src/sync/oncefunc.go#L43
1 | // OnceValue returns a function that invokes f only once and returns the value |
可以看到,与 OnceFunc
不同的是 OnceValue
使用了泛型,OnceValue
接收的函数 f
是带有返回值的,并且它返回的函数也带有返回值。
也就是说,相较于 OnceFunc
,OnceValue
相当于是进化版,它接收的 f
函数签名不同,可以支持返回一个值,而其他的地方与 OnceFunc
实现并无区别,内部也只是多了一个使用 result T
记录返回值的逻辑。
使用示例
sync.OnceValue
使用示例如下:
1 | package main |
执行示例代码,得到如下输出:
1 | $ go run oncevalue/main.go |
sync.OnceValues
源码解读
最后,我们再来看下 sync.OnceValues
源码实现:
https://github.com/golang/go/blob/go1.23.0/src/sync/oncefunc.go#L74
1 | // OnceValues returns a function that invokes f only once and returns the values |
OnceValues
与 OnceValue
的唯一区别就是它支持返回两个值,f
函数签名也变了 f func() (T1, T2)
,所以我们可以想到最常见的使用方式,函数 f
返回一个 value
和一个 error
,这也是 Go 函数惯用法。
使用示例
sync.OnceValues
使用示例如下:
1 | package main |
执行示例代码,得到如下输出:
1 | $ go run oncevalues/main.go |
现在,sync.Once
以及它的三个相关函数我们就都讲解完成了。
总结
sync.Once
是一个非常实用的同步工具,它以简洁高效的方式,确保操作只执行一次,避免了重复初始化的开销。在多 goroutine 调用场景下,它能提供可靠的并发控制,是 Go 并发编程中不可或缺的工具。常用于单例模式、懒加载、并发安全的初始化等场景。
Go 1.21 还发布了几个 sync.Once
相关的函数 sync.OnceFunc
、sync.OnceValue
和 sync.OnceValues
,来增强 sync.Once
功能。
我们可以发现一些规律:
sync.Once.Do
是sync.Once
暴露的唯一接口,对于参数f
函数确保仅执行一次。- 而
sync.OnceFunc
、sync.OnceValue
和sync.OnceValues
,三者是对sync.Once
的封装,都能实现once
的功能,并且这三者对f
函数产生panic
的情况进行了处理,保证多次调用它们都能产生同样的panic
。 sync.Once.Do
和sync.OnceFunc
接收的参数f
函数,无参数无返回值。sync.OnceValue
接收的参数f
函数有 1 个返回值。sync.OnceValues
接收的参数f
函数有 2 个返回值。
本文示例源码我都放在了 GitHub 中,欢迎点击查看。
希望此文能对你有所启发。
延伸阅读
- sync Documentation:https://pkg.go.dev/sync@go1.23.0
- Go 1.21 Release Notes:https://go.dev/doc/go1.21#syncpkgsync
- What does “hot path” mean in the context of sync.Once?:https://stackoverflow.com/questions/59174176/what-does-hot-path-mean-in-the-context-of-sync-once
- Go 语言中的结构体内存对齐你了解吗?:https://jianghushinian.cn/2024/07/07/do-you-understand-the-memory-alignment-of-structs-in-the-go/#hot-path
- Go 中空结构体惯用法,我帮你总结全了!:https://jianghushinian.cn/2024/06/02/i-have-summarized-all-the-usages-of-empty-struct-in-go-for-you/
- Go 错误处理指北:Defer、Panic、Recover 三剑客:https://jianghushinian.cn/2024/10/13/go-error-guidelines-defer-panic-recover/
- Go 常见设计模式之单例模式:https://jianghushinian.cn/2022/03/04/Golang-常见设计模式之单例模式/
- 本文 GitHub 示例代码:https://github.com/jianghushinian/blog-go-example/tree/main/sync/once
联系我
- 公众号:Go编程世界
- 微信:jianghushinian
- 邮箱:jianghushinian007@outlook.com
- 博客:https://jianghushinian.cn