go并发编程-sync.Once

go并发编程-sync.Once

sync.Once 是 Go 标准库提供的使函数只执行一次的对象,常应用于单例模式,例如初始化配置、保持数据库连接等。它可以在代码的任意位置初始化和调用,可以延迟到使用时再执行,并且在并发场景下是线程安全的。

sync.Once首次使用后不可复制,否则无法起到限制函数只能执行一次的作用。

使用场景

type Config struct{}

var Instance Config
var once sync.Once

func InitConfig() *Config {
    once.Do(func(){
        Instance = &Config{}
    })
    return Instance
}

多数情况下,sync.Once用来控制变量的初始化:

  1. 当且仅当第一次访问某个变量时,进行初始化(写);
  2. 变量初始化过程中,所有读都被阻塞,直到初始化完成;
  3. 变量仅初始化一次,初始化完成后驻留在内存里。

与init()的区别

通常我们也会使用init()方法进行初始化,与sync.Once不同的是,init()方法是在其所在的package首次加载时执行的,一方面延长了程序加载时间,另一方面,如果初始化的变量一直没有使用,还会造成内存空间的浪费。

源码解析

type Once struct {
    done int32
    m Mutex
}

Once结构体中只有两个字段:

  • done:表示操作是否已完成;
  • m:用来保证线程安全。

关于为什么要把done放在结构体的前面?

// done indicates whether the action has been performed.
// It is first in the struct because it is used in the hot path.
// The hot path is inlined at every call site.
// Placing done first allows more compact instructions on some architectures (amd64/386),
// and fewer instructions (to calculate offset) on other architectures.

什么是hot path?

hot path 代表了一系列执行非常频繁的指令。

当访问结构体的第一个字段时,我们可以直接获取结构的指针以访问第一个字段;

如果要访问其他字段,除了需要拿到结构体指针,我们还需要得到第一个字段的偏移量才能访问到第二个字段的地址。对于机器代码来说,CPU必须对struct指针执行额外偏移量的加法运算才能获得要访问的值的地址。

因此,将done放在结构体的前面,可以使得机器指令更加紧凑且计算量更少。

Once具体实现

func (o *Once) Do(f func()) {
    if atomic.LoadUint32(&o.done == 0) {
        o.doSlow(f)
    }
}
func (o *Once) doSlow(f func()) {
   o.m.Lock()
   defer o.m.Unlock()
   if o.done == 0 {
      defer atomic.StoreUint32(&o.done, 1)
      f()
   }
}

上面的代码是sync.Once的官方实现,比较简单,当o.done==0时,代表f还没有执行,进入到doSlow方法中,首先获取sync.Once的锁,加锁成功则继续执行,否则进入等待,直到mutex锁释放。f执行成功后设置done的值,标志f执行完成。

我们再来看看下面这段代码:

func (o *Once) Do(f func()) {
    if atomic.CompareAndSwapUint32(&o.done, 0, 1){
        f()
    }
}

Do需要保证当它返回时,f已经执行完成。

上面这种实现则无法保证Do返回的时候f已经执行完成。如果同时有两个线程调用Do方法,此时成功拿到CAS锁的线程将继续执行f,而获取失败的线程则直接返回,而此时f可能还未执行完成。

这就是为什么doSlow会使用到mutex,主要为了保证在并发场景下,f只执行一次,且atomic.LoadUint32必须等到f执行完成后才能返回。