Once 有且仅有一次执行
1. Once 概述
Once 可以用来执行且仅仅执行一次动作:Once 常常用来初始化单例资源,或者并发访问只需初始化一次的共享资源,或者在测试的时候初始化一次测试资源。
1.1 单例对象初始化
初始化单例资源有很多方法,比如定义 package 级别的变量:
1
2
3
4
5
|
package abc
import time
var startTime = time.Now()
|
或者在 init 函数中
1
2
3
4
5
6
7
8
|
package abc
var startTime time.Time
func init() {
startTime = time.Now()
}
|
又或者在 main 函数开始执行的时候:
1
2
3
4
5
6
7
8
9
10
11
|
package abc
var startTime time.Time
func initApp() {
startTime = time.Now()
}
func main() {
initApp()
}
|
这三种方法都是线程安全的,并且后两种方法还可以根据传入的参数实现定制化的初始化操作。
1.2 延迟初始化
但是很多时候我们是要延迟进行初始化,比如下面初始化网络连接的示例:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
|
package main
import (
"net"
"sync"
"time"
)
var connMu sync.Mutex
var conn net.Conn
func getConn() net.Conn {
connMu.Lock()
defer connMu.Unlock()
if conn != nil {
return conn
}
conn, _ = net.DialTimeout("tcp", "baidu.com:80", 10*time.Second)
return conn
}
func main(){
conn:=getConn()
if conn == nil{
panic("conn is nil")
}
}
|
这种方式虽然实现起来简单,但是有性能问题。一旦连接创建好,每次请求的时候还是得竞争锁才能读取到这个连接。这时候我们就需要 Once 并发原语了。
1.3 Once 的使用
sync.Once 只暴露了一个方法 Do:
- 你可以多次调用 Do 方法,但是只有第一次调用 Do 方法时 f 参数才会执行,这里的 f 是一个无参数无返回值的函数
- 因为当且仅当第一次调用 Do 方法的时候参数 f 才会执行,即使第二次、第三次、第 n 次调用时 f 参数的值不一样,也不会被执行
- 因为这里的 f 参数是一个无参数无返回的函数,所以你可能会通过闭包的方式引用外面的参数
1
|
func (o *Once) Do(f func())
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
|
package main
import (
"net"
"sync"
)
var addr = "baidu.com"
var conn net.Conn
var err error
var once sync.Once
once.Do(func(){
conn, err = net.Dial("tcp", addr)
})
|
有很多标准库中都有 Once 的身影,典型的 math/big/sqrt.go 中实现的一个数据结构,它通过 Once 封装了一个只初始化一次的值:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
// 值是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
}
|
当你使用 Once 的时候,你也可以尝试采用这种结构,将值和 Once 封装成一个新的数据结构,提供只初始化一次的值。
2. Once 实现
很多人觉得 Once 只需要使用一个 flag 标记是否初始化即可,最多使用 atomic 原子操作这个 flag 比如下面这个实现:
1
2
3
4
5
6
7
8
9
10
11
|
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 实现同事需要互斥锁和 flag 的双重检测机制:
- 互斥锁的机制保证只有一个 goroutine 进行初始化,并在 f() 未执行完成时,其他 goroutine 等待
- flag 用于 f() 执行之后快速成功,以及保证只有一次初始化
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
|
type Once struct {
done uint32
m Mutex
}
func (o *Once) Do(f func()) {
// 1. flag 用于快速成功,不用在 f() 完成后,仍去竞争锁
if atomic.LoadUint32(&o.done) == 0 {
o.doSlow(f)
}
}
func (o *Once) doSlow(f func()) {
// 2. f() 未执行完时,多个 goroutine 都会争抢锁,从而等待 f() 执行完成
o.m.Lock()
defer o.m.Unlock()
// 3. 双检查机制保证只有 f() 只执行一次
if o.done == 0 {
defer atomic.StoreUint32(&o.done, 1)
f()
}
}
|
所谓的双检查就是,即便进入 doSlow 后获取到锁,也要判断初始化是否已经完成。
3.Once 采坑点
使用 Once 有两个常见错误:
- 死锁: Do 方法会执行一次 f,但是如果 f 中再次调用这个 Once 的 Do 方法的话,就会导致死锁的情况出现
- 初始化未完成: 如果 f 方法执行的时候 panic,或者 f 执行初始化资源的时候失败了,这个时候,Once 还是会认为初次执行已经成功了,即使再次调用 Do 方法,也不会再次执行 f。
Once 有一个比较典型的采坑案例,场景是这样的: Once Do 方法只能初始化一次,有时候我们需要能够重新初始化,即为 Once 增加一个 Reset 方法,Reset 之后再调用 once.Do 就又可以初始化了。Go 的核心开发者 Ian Lance Taylor 给了一个简单的解决方案,即 Reset 的时候将 原有的 Once 变量(例如变量ponce)赋值一个新的 Once 实例即可 (ponce = new(sync.Once))。这样在新的 ponce 就可以再次执行初始化。但是我们不能像这样: ponce.Do(ponce.Reset())
在 Do 方法中,重新给 ponce 赋值。原因在于
- 在执行 ponce.Reset 的时候 Once 内部的 Mutex 首先会加锁,
- 在 Reset 中更改了 Once 指针的值之后,结果在执行完 Reset 释放锁的时候,释放的是一个刚初始化未加锁的 Mutex,所以就 panic 了
下面的 doSlow 方法就演示了这个错误:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
|
package main
import (
"sync"
)
type Once struct {
m sync.Mutex
}
func (o *Once) doSlow() {
o.m.Lock()
defer o.m.Unlock()
// 这里更新的o指针的值!!!!!!!, 会导致上一行Unlock出错
*o = Once{}
}
func main() {
var once Once
once.doSlow()
}
|
Ian Lance Taylor 介绍的 Reset 方法没有错误,但是你在使用的时候千万别再初始化函数中 Reset 这个 Once,否则势必会导致 Unlock 一个未加锁的 Mutex 的错误。这里再多补充一下这个 panic 的触发逻辑:
- Once doSlow 实现中有
o.m.Lock; defer o.m.Unlock()
- 如果调用 Do(Reset()) 就会导致这样的调用顺序: 初始 o.m.Lock() -> Reset 设置新的 o.m -> 调用新的 o.m.Unlock(),释放未加锁的锁,导致 panic。
使用 Once 真的不容易犯错,想犯错都很困难,因为很少有人会傻傻地在初始化函数 f 中递归调用 f,这种死锁的现象几乎不会发生。另外如果函数初始化不成功,我们一般会 panic,或者在使用的时候做检查,会及早发现这个问题,在初始化函数中加强代码。
4. Once 的扩展
4.1 可多次初始化的 Once
针对初始化未完成的情况,我们可以自己实现一个类似 Once 的并发原语,既可以返回当前调用 Do 方法是否正确完成,还可以在初始化失败后调用 Do 方法再次尝试初始化,直到初始化成功才不再初始化了。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
|
// 一个功能更加强大的Once
type Once struct {
m sync.Mutex
done uint32
}
// 传入的函数f有返回值error,如果初始化失败,需要返回失败的error
// Do方法会把这个error返回给调用者
func (o *Once) Do(f func() error) error {
if atomic.LoadUint32(&o.done) == 1 { //fast path
return nil
}
return o.slowDo(f)
}
// 如果还没有初始化
func (o *Once) slowDo(f func() error) error {
o.m.Lock()
defer o.m.Unlock()
var err error
if o.done == 0 { // 双检查,还没有初始化
err = f()
if err == nil { // 初始化成功才将标记置为已初始化
atomic.StoreUint32(&o.done, 1)
}
}
return err
}
|
4.2 可获取是否初始化的 Once
目前的 Once 实现可以保证你调用任意次数的 once.Do 方法,它只会执行这个方法一次。但是,有时候我们需要一个是否初始化的标记。标准库的 Once 并不会告诉你是否初始化完成了。所以通常我们需要一个辅助变量,自己去检查是否初始化完成:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
type AnimalStore struct {once sync.Once;inited uint32}
func (a *AnimalStore) Init() // 可以被并发调用
a.once.Do(func() {
longOperationSetupDbOpenFilesQueuesEtc()
atomic.StoreUint32(&a.inited, 1)
})
}
func (a *AnimalStore) CountOfCats() (int, error) { // 另外一个goroutine
if atomic.LoadUint32(&a.inited) == 0 { // 初始化后才会执行真正的业务逻辑
return 0, NotYetInitedError
}
//Real operation
}
|
另一个解决方案是,我们可以自己去扩展 Once 的并发原语,为其提供一个返回是否已初始化的 Done 方法:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
|
// Once 是一个扩展的sync.Once类型,提供了一个Done方法
type Once struct {
sync.Once
}
// Done 返回此Once是否执行过
// 如果执行过则返回true
// 如果没有执行过或者正在执行,返回false
func (o *Once) Done() bool {
return atomic.LoadUint32((*uint32)(unsafe.Pointer(&o.Once))) == 1
}
func main() {
var flag Once
fmt.Println(flag.Done()) //false
flag.Do(func() {
time.Sleep(time.Second)
})
fmt.Println(flag.Done()) //true
}
|
注: 相信有人跟我一样看到 atomic.LoadUint32((*uint32)(unsafe.Pointer(&o.Once))) == 1
时怀疑人生路,这个怎么能判断 Once是否执行过了呢。这是因为你看的是鸟哥扩展的 Once 实现,done 字段在后:
1
2
3
4
|
type Once struct {
m sync.Mutex
done uint32
}
|
go Once 源码里面,done 字段是在前的:
1
2
3
4
|
type Once struct {
done uint32
m Mutex
}
|
参考
本文内容摘录自:
- 极客专栏-鸟叔的 Go 并发编程实战