深入理解 sync.Once:单例模式的绝佳选择
sync.Once是让函数方法只被调用执行一次的实现,其最常应用于单例模式之下,例如初始化系统配置、保持数据库唯一连接等。
sync.Once的单例模式示例
1package main
2
3import (
4 "fmt"
5 "sync"
6)
7
8type Instance struct{}
9
10var (
11 once sync.Once
12 instance *Instance
13)
14
15func NewInstance() *Instance {
16 once.Do(func() {
17 instance = &Instance{}
18 fmt.Println("Inside")
19 })
20 fmt.Println("Outside")
21 return instance
22}
23
24func main() {
25 for i := 0; i < 3; i++ {
26 _ = NewInstance()
27 }
28}
输出
1$ go run main.go
2Inside
3Outside
4Outside
5Outside
从上述例子可以看到,虽然多次调用NewInstance()函数,但是Once.Do()中的方法有且仅被执行了一次。那么sync.Once是如何做到这一点的呢?
sync.Once的源码解析
1type Once struct {
2 // done indicates whether the action has been performed.
3 // It is first in the struct because it is used in the hot path.
4 // The hot path is inlined at every call site.
5 // Placing done first allows more compact instructions on some architectures (amd64/x86),
6 // and fewer instructions (to calculate offset) on other architectures.
7 done uint32
8 m Mutex
9}
Once结构体非常简单,其中done是调用标识符,Once对象初始化时,其done值默认为0,Once仅有一个Do()方法,当Once首次调用Do()方法后,done值变为1。m作用于初始化竞态控制,在第一次调用Once.Do()方法时,会通过m加锁,以保证在第一个Do()方法中的参数f()函数还未执行完毕时,其他此时调用Do()方法会被阻塞(不返回也不执行)。
Once.Do()方法的实现细节如下
1func (o *Once) Do(f func()) {
2 // Note: Here is an incorrect implementation of Do:
3 //
4 // if atomic.CompareAndSwapUint32(&o.done, 0, 1) {
5 // f()
6 // }
7 //
8 // Do guarantees that when it returns, f has finished.
9 // This implementation would not implement that guarantee:
10 // given two simultaneous calls, the winner of the cas would
11 // call f, and the second would return immediately, without
12 // waiting for the first's call to f to complete.
13 // This is why the slow path falls back to a mutex, and why
14 // the atomic.StoreUint32 must be delayed until after f returns.
15
16 if atomic.LoadUint32(&o.done) == 0 {
17 // Outlined slow-path to allow inlining of the fast-path.
18 o.doSlow(f)
19 }
20}
21
22func (o *Once) doSlow(f func()) {
23 o.m.Lock()
24 defer o.m.Unlock()
25 if o.done == 0 {
26 defer atomic.StoreUint32(&o.done, 1)
27 f()
28 }
29}
Do()方法的入参是一个无参数输入与返回的函数,当o.done值为0时,执行doSlow()方法,为1则退出Do()方法。doSlow()方法很简单:加锁,再次检查o.done值,执行f(),原子操作将o.done值置为1,最后释放锁。
注意事项
1. 在官方示例代码中,提到了一种错误实现Do()方法的方式。
1func (o *Once) Do(f func()) {
2 if atomic.CompareAndSwapUint32(&o.done, 0, 1) {
3 f()
4 }
5}
当并发多次调用Do()方法时,第一个被执行的Do()方法会将o.done值从0置为1,并执行f(),其他的调用Do()方法会立即被返回。这种处理方式和加锁的方式会有所不同:它不能保证在第一个调用执行Do()方法中的f()函数被执行完毕之前,其他的f()函数会阻塞等待。
1package main
2
3import (
4 "fmt"
5 "sync"
6 "time"
7)
8
9type Config struct {}
10
11func (c *Config) init(filename string) {
12 fmt.Printf("mock [%s] config initial done!\n", filename)
13}
14
15var (
16 once sync.Once
17 cfg *Config
18)
19
20func main() {
21 cfg = &Config{}
22
23 go once.Do(func() {
24 time.Sleep(3 * time.Second)
25 cfg.init("first file path")
26 })
27
28 time.Sleep(time.Second)
29 once.Do(func() {
30 time.Sleep(time.Second)
31 cfg.init("second file path")
32 })
33 fmt.Println("运行到这里!")
34 time.Sleep(5 * time.Second)
35}
输出
1$ go run main.go
2mock [first file path] config initial done!
3运行到这里!
可以看到第二次调用once.Do()时候,其输入参数f()函数虽然没有被执行,但是整个Do()是被阻塞的(被阻塞于o.m.Lock()处),它需要等待首次调用once.Do()执行完毕,才会退出阻塞状态。而错误实现Do()方法的方式,就无法保证此规则的实现。
2. 避免死锁
1package main
2
3import (
4 "fmt"
5 "sync"
6)
7
8func main() {
9 once := sync.Once{}
10 once.Do(func() {
11 fmt.Println("outside call")
12 once.Do(func() {
13 fmt.Println("inside call")
14 })
15 })
16}
输出
1$ go run main.go
2outside call
3fatal error: all goroutines are asleep - deadlock!
注意,同样由于o.m.Lock()处的代码限定,once.Do()内部调用Do()方法时,会造成死锁的发生。
推荐阅读
站长 polarisxu
自己的原创文章
不限于 Go 技术
职场和创业经验
Go语言中文网
每天为你
分享 Go 知识
Go爱好者值得关注