一、使用场景
在实际的项目开发中,我们会遇到需要初始化单例资源的场景,在Go中,初始化单例资源的方法由很多,例如:
定义Package包级别的变量
go
package onceDemo
import "time"
var startTime = time.Now()
通过定义包级别的全局变量,在程序启动时能够进行初始化。
init函数初始化
go
package onceDemo
import "time"
var startTime time.Time
func init() {
startTime = time.Now()
}
main函数执行初始化函数
go
package main
import (
"fmt"
"time"
)
var startTime time.Time
func initConfig() {
startTime = time.Now()
}
func main() {
initConfig()
fmt.Println(startTime)
}
这三种初始化的方法都是线程安全的方法,并且后两种方法可以根据实际传入的参数来定制化初始化操作。
当然我们也会遇到需要进行延迟初始化的场景,在单例资源初始化时,可能会用到如下的方法:
go
package main
import (
"net"
"sync"
"time"
)
// 使用互斥锁保证线程安全
var mu sync.Mutex
var conn net.Conn
func getConn() net.Conn {
mu.Lock()
defer mu.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")
}
}
上述通过互斥锁Mutex
的方式来保证初始化的线程安全,实现方式简单,但存在一定的性能问题。当连接创建好后,每次请求获取连接实例的时候,还需要竞争锁才能够读取到连接,这个过程比较浪费资源,因为连接如果创建好之后,其实就不需要锁的保护了。
在这种场景下,可以使用Go中提供的Once
并发原语来解决。
二、Once介绍
在Go的并发原语中,Once
可以用来执行且仅执行一次的动作,常常用于单例对象的初始化场景。
具体来说,sync.Once
对外只暴露一个Do
方法,Do
方法可以多次调用,但只有第一次调用Do
方法时,f
参数才会执行,后续的Do
方法调用f参数都不会执行,f
是一个无参数无返回值的函数。
go
func (o *Once) Do(f func())
举个例子:
go
package main
import (
"fmt"
"sync"
)
func main() {
var once sync.Once
fun1 := func() {
fmt.Println("fun1")
}
once.Do(fun1) // fun1
fun2 := func() {
fmt.Println("fun2")
}
once.Do(fun2) // 无输出
}
上述代码中,定义了fun1
和fun2
两个不同的函数,但是对于同一个Once
实例来说,第一次调用Do
方法时,传入的func1
函数会执行,后续第二次调用Do
方法传入的fun2
函数却不会执行。
这是因为同一个Once
实例,当且仅当第一次调用Do
方法时,参数f
才会执行,即使后续调用第二次、第三次、第n次Do
方法,甚至后续的每次Do
方法调用时参数f
都不同,参数f
也不会被执行。
Do
方法在使用上,也可以通过传入闭包函数的方式来引用匿名函数外部的参数 来作为Do
方法的参数,因为f
参数是一个无参数无返回的函数:
go
func main() {
var once sync.Once
var addr = "https://golang.google.cn/"
var conn net.Conn
var err error
once.Do(func() {
conn, err = net.Dial("tcp", addr)
})
}
在实际的使用中,绝大多数情况下都会使用闭包的方式去初始化外部的一个资源,例如:
go
func NewStore(db *gorm.DB) *datastore {
// 确保 S 只被初始化一次
once.Do(func() {
S = &datastore{db}
})
return S
}
另外在math/big/sqrt.go
中实现的一个数据结构,它通过Once
封装了一个只初始化一次的值:
go
// 值是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
}
上述代码中,threeOnce
结构体将sync.Once
和*Float
封装成一个对象,并且提供了three
方法只初始化一次的值v,three
方法虽然每次都调用threeOnce.Do
方法,但是参数只会被调用一次。
在实际使用Once
时,可以采用上述将值与Once
封装成一个新的数据结构的方式,并且提供只初始化一次值的方法。
总的来说,Once
并发原语解决的问题和使用场景:Once
常用于初始化单例资源,或者并发访问只需初始化一次的共享资源,或者在测试的时候初始化一次测试资源。
三、实现原理
Once
并发原语实现上,你可能会说很简单,只需要使用一个flag
标记,通过flag
标记来标识是否初始化过即可,通过atomic
原子操作来修改这个flag
,例如:
go
type Once struct {
done uint32
}
func (o *Once) Do(f func()) {
if !atomic.CompareAndSwapUint32(&o.done, 0, 1) {
return
}
f()
}
上述方式虽说是一种实现Once
的方式,但存在一个很大的问题,即如果参数f
执行时间很长 ,则在后续调用Do
方法的goroutine
虽然通过CAS
得到done
已经设置为已执行,但在获取初始化资源时,可能会得到空资源,这是由于f
还未执行完成的原因导致。
一个正确的Once
实现需要使用一个互斥锁,保证在初始化时如果有并发的goroutine
进入Do
方法,则进入doSlow
方法。
互斥锁的作用在于保证只有一个goroutine
来进行初始化操作,同时利用双检查机制,再次判断once.done
是否为0,如果为0,则为第一次执行,执行完毕后将once.done
置为1,然后释放互斥锁。
通过使用互斥锁,即使多个goroutine
同时进入doSlow
方法,也会因为双检查机制而使后续进入的goroutine
检查到once.done
置为了1,不会再执行f
。既保证了并发的goroutine
会等待f
完成,而且还不会多次执行f
。
Once
原语的实现如下:
go
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()
}
}
四、注意事项
在使用Once
时,如果使用不当,也会造成错误,从而发生意想不到的后果。
错误一:死锁
Do
方法在第一次调用时,会执行一次f
,但是,如果在f
中再次调用这个Once
实例的Do
方法,则会导致死锁的情况发生。这是由于Lock
的递归调用导致的死锁。我们都知道互斥锁是不支持重入的,并且Once
在实现上也使用了互斥锁。
go
func main() {
var once sync.Once
once.Do(func() {
once.Do(func() {
fmt.Println("初始化操作")
})
})
}
错误二:未初始化成功
当我们调用Do
方法执行f时,如果f方法执行的时候发生了panic
,或者f
执行初始化资源时失败了,这时Once
还是会认为初次执行已经成功,即使再调用Do
方法也不会再执行f
。
既然执行过 Once.Do
方法也可能因为函数执行失败的原因未初始化资源,并且以后也没机会再次初始化资源,那么这种初始化未完成的问题该怎么解决呢?
我们可以通过自己实现一个类似Once
的并发原语,既可以返回当前调用Do
方法是否正确完成,并且可以在初始化失败的时候,再次调用Do
方法来再次尝试初始化,直到初始化成功才不再执行Do
方法。
go
type Once struct {
m sync.Mutex
done uint32
}
// Do 传入的函数f有返回值error,如果初始化失败,则返回失败的error
// Do 方法将error返回给Do方法的调用者
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 { // 如果f执行成功,则将done置为1,初始化成功才将标记置为已初始化
atomic.StoreUint32(&o.done, 1)
}
}
return err
}
上述代码中,为Do
方法以及参数都增加error
返回参数,如果f
执行失败,则会将这个error
返回给调用者。并且在slowDo
方法中,如果f
调用失败了,并不会将标记为done
进行更改,以便后续能够的goroutine
能够再次执行goroutine
。直到f执行成功后,才会将done
的值修改为1。
五、总结
在Go中,并发原语Once
以用来执行且仅仅执行一次动作,常常用于单例对象的初始化场景。
sync.Once
只暴露了一个方法 Do
,可以多次调用 Do
方法,但是只有第一次调用 Do
方法时 f
参数才会执行。
一旦遇到只需要初始化一次的场景,首先想到的就应该是 Once
并发原语。
参考文章