基本用法
sync.Once
官方描述 Once is an object that will perform exactly one action, 即 sync.Once
是一个对象,它提供了保证某个动作只被执行一次功能。何处需要保障某个动作只执行一次呢?这让我们想起了资源的初始化,这些资源的初始化往往使用单例模式。
单例模式是一种设计模式,它确保一个类仅有一个实例,并提供一个全局访问点来获取该实例。这样可以确保在整个程序中只有一个共享的实例,以避免不必要的对象创建和节省资源。
单例模式通常用于管理全局状态、数据库连接池、线程池等需要全局唯一性的场景。
在 Go
语言中,全局变量会在程序启动时自动初始化。因此,如果在定义全局变量时给它赋值,则对象的创建也会在程序启动时完成,可以通过此来实现单例模式,以下是一个示例代码:
type MySingleton struct {
// 字段定义
}
var mySingletonInstance = &MySingleton{
// 初始化字段
}
func GetMySingletonInstance() *MySingleton {
return mySingletonInstance
}
在上面的代码中,我们定义了一个全局变量 mySingletonInstance
并在定义时进行了赋值,从而在程序启动时完成了对象的创建和初始化。在 GetMySingletonInstance
函数中,我们可以直接返回全局变量 mySingletonInstance
,从而实现单例模式。
我们可以使用 init
函数来实现单例模式。init
函数是在包被加载时自动执行的函数,因此我们可以在其中创建并初始化单例对象,从而保证在程序启动时就完成对象的创建。以下是一个示例代码:
package main
type MySingleton struct {
// 字段定义
}
var mySingletonInstance *MySingleton
func init() {
mySingletonInstance = &MySingleton{
// 初始化字段
}
}
func GetMySingletonInstance() *MySingleton {
return mySingletonInstance
}
在上面的代码中,我们定义了一个包级别的全局变量 mySingletonInstance
,并在 init
函数中创建并初始化了该对象。在 GetMySingletonInstance
函数中,我们直接返回该全局变量,从而实现单例模式。
当然我们也可以使用使用了Go
语言的sync.Mutex
来实现单例模式,以下是一个示例代码:
package main
import (
"fmt"
"sync"
)
type Singleton struct {
data int
}
var (
once sync.Mutex
instance *Singleton
)
func GetInstance() *Singleton {
if instance == nil {
once.Lock()
defer once.Unlock()
if instance == nil {
instance = &Singleton{data: 42}
}
}
return instance
}
func main() {
// 获取单例实例
singleton1 := GetInstance()
fmt.Println("Singleton 1 data:", singleton1.data)
// 再次获取单例实例,应该是同一个实例
singleton2 := GetInstance()
fmt.Println("Singleton 2 data:", singleton2.data)
// 验证两个实例是否相同
if singleton1 == singleton2 {
fmt.Println("Singleton instances are the same.")
} else {
fmt.Println("Singleton instances are different.")
}
}
在上面的示例中,我们仍然使用了包级别的变量 instance
来保存单例实例,并使用 sync.Mutex
来保证在并发情况下只有一个 goroutine
能够创建实例。尽管这种方法可以工作,但它引入了额外的互斥锁,可能会对性能产生一些影响。
在实际应用中,使用 sync.Once
仍然是更常见和更优雅的选择。
sync.Once
对外只提供了一个函数:
func (o *Once) Do(f func()) {...}
我们只需要调用它即可使用,先来看一个例子,来说明 sync.Once
的特性,即保证某个动作只被执行一次功能:
import (
"fmt"
"sync"
)
func f1() {
fmt.Println("This is f1 fun\r\n")
}
func f2() {
fmt.Println("This is f2 fun\r\n")
}
func f3() {
fmt.Println("This is f3 fun\r\n")
}
func main() {
var once sync.Once
once.Do(f1)
once.Do(f2)
once.Do(f3)
}
#执行结果
This is f1 fun
上面代码使用 once.Do
调用了 函数 f1
、f2
、f3
但结果只有 f1
函数被执行了,f2
、 f3
函数都被忽略了。
你看,sync.Once
的使用场景很明确。常常用来初始化单例资源,或者并发访问只需初始化一次的共享资源,或者在测试的时候初始化一次测试资源。
下面代码是经过 sync.Once
改写过的单例模式:
package main
import (
"fmt"
"sync"
)
// Singleton 是一个单例结构
type Singleton struct {
data int
}
var instance *Singleton
var once sync.Once
// GetInstance 用于获取单例实例
func GetInstance() *Singleton {
once.Do(func() {
instance = &Singleton{data: 42}
})
return instance
}
func main() {
// 获取单例实例
singleton1 := GetInstance()
fmt.Println("Singleton 1 data:", singleton1.data)
// 再次获取单例实例,应该是同一个实例
singleton2 := GetInstance()
fmt.Println("Singleton 2 data:", singleton2.data)
// 验证两个实例是否相同
if singleton1 == singleton2 {
fmt.Println("Singleton instances are the same.")
} else {
fmt.Println("Singleton instances are different.")
}
}
在上面的示例中,我们定义了一个名为 Singleton
的结构,表示单例对象。通过 GetInstance
函数来获取单例实例。在 GetInstance
函数中,我们使用 sync.Once
来确保 instance
只会被创建一次。这样,在多次调用 GetInstance
时,都会返回相同的实例。
结构体
sync.Once
结构体很简单,如下:
type Once struct {
done uint32
m Mutex
}
主要字段解释如下:
-
done
用于判定函数是否执行。值为1
,则表示函数已经执行过;如果为0
,则表示未执行过; -
m
是一个互斥锁, 说明sync.Once
采取了锁的方式保证了同步性。
源码分析
sync.Once
对外提供的一个方法:
func (o *Once) Do(f func()) {
//加载done值,如果值为1,则直接结束,如果值为0,则进入 doSlow函数
if atomic.LoadUint32(&o.done) == 0 {
o.doSlow(f)
}
}
其实在 Go
源码注释中,还提供了一种未使用锁得错误使用方法:
if atomic.CompareAndSwapUint32(&o.done, 0, 1) {
f()
}
直接通过 atomic
包的CompareAndSwapUint32
方法更改 done
值,虽然这个能确保操作是原子操作,但是最大的问题是如果并发调用,一个 goroutine
执行,另外一个不会等正在执行的这个成功之后返回,而是直接就返回了,这就不能保证传入的方法一定会先执行一次了。
为了优化性能,所以引进了atomic.LoadUint32
+ doSlow
方法,将慢路径(slow-path
)代码从 Do
方法中分离出来,使得 Do
方法的快路径(fast-path
)能够被内联(inlined
),从而提高性能。
来看 doSlow
函数:
func (o *Once) doSlow(f func()) {
o.m.Lock() //加锁
defer o.m.Unlock() //函数执行完解锁
//如果 o.done值为0,则调用执行传入的f函数,然后更改o.done为1
if o.done == 0 {
defer atomic.StoreUint32(&o.done, 1)
f()
}
}
一个正确的 sync.Once
实现要使用一个互斥锁,这样初始化的时候如果有并发的 goroutine
,就会进入doSlow
方法。互斥锁的机制保证只有一个 goroutine
进行初始化,同时利用双检查的机制(double-checking
),再次判断 o.done
是否为 0
,如果为 0
,则是第一次执行,执行完毕后,就将 o.done
设置为 1
,然后释放锁。
为什么要存在两次对 done
的值的判断?
- 第一次检查:在获取锁之前,先使用原子加载操作
atomic.LoadUint32
检查done
变量的值,如果done
的值为 1,表示操作已执行,此时直接返回,不再执行doSlow
方法。这一检查可以避免不必要的锁竞争。 - 第二次检查:获取锁之后,再次检查
done
变量的值,这一检查是为了确保在当前协程获取锁期间,其他协程没有执行过f
函数。如果done
的值仍为 0,表示f
函数没有被执行过。
通过双重检查,可以在大多数情况下避免锁竞争,提高性能。
总结
来通过几个问题,来引入一些总结性的注意点:
sync.Once()
方法传入的函数中再次调用sync.Once()
方法会有什么问题吗?
如下代码:
func main() {
once := sync.Once{}
once.Do(func() {
once.Do(func() {
fmt.Println("init...")
})
})
}
通过分析 sync.Once
的源码,可以看到它包含一个名为 m
的互斥锁字段。当我们在 Do
方法内部重复调用 Do
方法时,将会多次尝试获取相同的锁。但是 mutex
互斥锁并不支持可重入操作,因此这将导致死锁现象。
-
sync.Once()
方法中传入的函数发生了panic
,重复传入还会执行吗?如下代码:
func panicDo() { once := &sync.Once{} defer func() { if err := recover();err != nil{ once.Do(func() { fmt.Println("run in recover") }) } }() once.Do(func() { panic("panic i=0") }) }
sync.Once.Do
在执行的过程中如果f
出现panic
,后面也不会再执行了;所以不会打印任何东西,sync.Once.Do
方法中传入的函数只会被执行一次,哪怕函数中发生了panic
; -
下面函数会打印出什么呢?
如下代码:
func nestedDo() { once1 := &sync.Once{} once2 := &sync.Once{} once1.Do(func() { once2.Do(func() { fmt.Println("test") }) }) }
sync.Once
保证了传入的函数只会执行一次,所以 打印test
。once1
,once2
是两个对象,互不影响。所以sync.Once
是使方法只执行一次对象的实现。
参考资源:
晁岳攀(鸟窝) https://time.geekbang.org/column/intro/100061801
mohuishou https://lailin.xyz/post/go-training-week3-once.html