Golang编程之Once

357 阅读4分钟

Once可以用来执行且仅仅执行一次动作,常常用于单例对象的初始化场景。

Once的使用场景

sync.Once 只暴露了一个方法 Do,你可以多次调用 Do 方法,但是只有第一次调用 Do方法时f参数才会执行,这里的f是一个无参数无返回值的函数

func (o *Once) Do(f func())

因为当且仅当第一次调用 Do方法的时候参数f才会执行,即使第二次、第三次、第n 次调用时f参数的值不一样,也不会被执行,比如下面的例子,虽然f1 和f2 是不同的函数,但是第个函数 f2 就不会执行。

package main
import (
"fmt"
"sync"
)

func main() {

	var once sync.Once
	
	// 第一个初始化函数
	f1 := func() {
		fmt.Println("in f1")
	}
	once.Do(f1) // 打印出 in f1
	
	// 第二个初始化函数
	f2 := func() {
		fmt.Println("in f2")
	}
	once.Do(f2) // 无输出
}

因为这里的f 参数是一个无参数无返回的函数,所以你可能会通过闭包的方式引用外面的参数,比如:

var addr = "baidu.com"
var conn net.Conn
var err error

once.Do(func() {
	conn, err = net.Dial("tcp", addr)
})

而且在实际的使用中,绝大多数情况下,你会使用闭包的方式去初始化外部的一个资源。 你看,Once 的使用场景很明确,所以,在标准库内部实现中也常常能看到 Once 的身影。

比如标准库内部@cache的实现上,就使用了 Once 初始化 Cache 资源,包括 defaultDir 值的获取:

func Default() *Cache { // 获取默认的Cache
	defaultOnce.Do(initDefaultCache) // 初始化cache
	return defaultCache
}

// 定义一个全局的cache变量,使用Once初始化,所以也定义了一个Once变量
var (
defaultOnce sync.Once
defaultCache *Cache
)

func initDefaultCache() { //初始化cache,也就是Once.Do使用的f函数
	......
	defaultCache = c
}

// 其它一些Once初始化的变量,比如defaultDir
var (
	defaultDirOnce sync.Once
	defaultDir string
	defaultDirErr error
)

除此之外,还有保证只调用一次 copyenv 的 envOnce,strings 包下的 Replacer,time 包中的@测试,Go 拉取库时的@proxy,net.pipe,crc64,Regexp,...,数不胜数。我给你重点介绍一下很值得我们学习的 math/big/sgrt.go 中实现的一个数据结构,它通过 Once封装了一个只初始化一次的值:

// 值是3.0或者0.0的一个数据结构
var threeOnce struct {
	sync.Once
	v *Float
}

// 返回此数据结构的值,如果还没有初始化为3.0,则初始化
func three() *Float {
	threeOnce.Do(func() { // 使用Once初始化
		threeOnce.v = NewFloat(3.0)
	})
	return threeOnce.v
}

它将sync.Once 和*Float 封装成一个对象,提供了只初始化一次的值v。你看它的 three 方法的实现,虽然每次都调用 threeOnce.Do 方法,但是参数只会被调用一次。 当你使用 Once 的时候,你也可以尝试采用这种结构,将值和 Once 封装成一个新的数据结构,提供只初始化一次的值

总结一下 Once 并发原语解决的问题和使用场景: Once 常常用来初始化单例资源,或者并发访问只需初始化一次的共享资源,或者在测试的时候初始化一次测试资源

Once原理

很多人认为实现一个 Once 一样的并发原语很简单,只需使用一个 flag 标记是否初始化过即可,最多是用atomic 原子操作这个 flag,比如下面的实现:

type Once struct {
	done uint32
}

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

这确实是一种实现方式,但是,这个实现有一个很大的问题,就是如果参数 f 执行很慢的 话,后续调用 Do 方法的 goroutine 虽然看到 done 已经设置为执行过了,但是获取某些 初始化资源的时候可能会得到空的资源,因为 f 还没有执行完。

所以,一个正确的 Once 实现要使用一个互斥锁,这样初始化的时候如果有并发的 goroutine,就会进入doSlow 方法。互斥锁的机制保证只有一个 goroutine 进行初始 化,同时利用双检查的机制(double-checking),再次判断 o.done 是否为 0,如果为 0,则是第一次执行,执行完毕后,就将 o.done 设置为 1,然后释放锁。

即使此时有多个 goroutine 同时进入了 doSlow 方法,因为双检查的机制,后续的 goroutine 会看到 o.done 的值为 1,也不会再次执行 f。

这样既保证了并发的 goroutine 会等待 f 完成,而且还不会多次执行 f。

type Once struct {
done uint32
M Mutex
}

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()
	}
}