vlambda博客
学习文章列表

深入理解 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}


输出


1go run main.go 
2Inside
3Outside
4Outside
5Outside


从上述例子可以看到,虽然多次调用NewInstance()函数,但是Once.Do()中的方法有且仅被执行了一次。那么sync.Once是如何做到这一点的呢?

深入理解 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, 01) {
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}


输出


1go 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}


输出


1go run main.go 
2outside call
3fatal error: all goroutines are asleep - deadlock!


注意,同样由于o.m.Lock()处的代码限定,once.Do()内部调用Do()方法时,会造成死锁的发生。




推荐阅读




学习交流 Go 语言,扫码回复「进群」即可


站长 polarisxu

自己的原创文章

不限于 Go 技术

职场和创业经验


Go语言中文网

每天为你

分享 Go 知识

Go爱好者值得关注