这篇文章主要介绍了Golang并发利器sync.Once怎么使用的相关知识,内容详细易懂,操作简单快捷,具有一定借鉴价值,相信大家阅读完这篇Golang并发利器sync.Once怎么使用文章都会有所收获,下面我们一起来看看吧。
sync.Once 基本概念
什么是 sync.Once
sync.Once是
Go语言中的一种同步原语,用于确保某个操作或函数在并发环境下只被执行一次。它只有一个导出的方法,即
Do,该方法接收一个函数参数。在
Do方法被调用后,该函数将被执行,而且只会执行一次,即使在多个协程同时调用的情况下也是如此。
sync.Once 的应用场景
sync.Once 主要用于以下场景:
单例模式:确保全局只有一个实例对象,避免重复创建资源。
延迟初始化:在程序运行过程中需要用到某个资源时,通过
sync.Once动态地初始化该资源。
只执行一次的操作:例如只需要执行一次的配置加载、数据清理等操作。
sync.Once 应用实例
单例模式
在单例模式中,我们需要确保一个结构体只被初始化一次。使用
sync.Once可以轻松实现这一目标。
package main import ( "fmt" "sync" ) type Singleton struct{} var ( instance *Singleton once sync.Once ) func GetInstance() *Singleton { once.Do(func() { instance = &Singleton{} }) return instance } func main() { var wg sync.WaitGroup for i := 0; i < 5; i++ { wg.Add(1) go func() { defer wg.Done() s := GetInstance() fmt.Printf("Singleton instance address: %p ", s) }() } wg.Wait() }
上述代码中,
GetInstance函数通过
once.Do()确保
instance只会被初始化一次。在并发环境下,多个协程同时调用
GetInstance时,只有一个协程会执行
instance = &Singleton{},所有协程得到的实例
s都是同一个。
延迟初始化
有时候希望在需要时才初始化某些资源。使用
sync.Once可以实现这一目标。
package main import ( "fmt" "sync" ) type Config struct { config map[string]string } var ( config *Config once sync.Once ) func GetConfig() *Config { once.Do(func() { fmt.Println("init config...") config = &Config{ config: map[string]string{ "c1": "v1", "c2": "v2", }, } }) return config } func main() { // 第一次需要获取配置信息,初始化 config cfg := GetConfig() fmt.Println("c1: ", cfg.config["c1"]) // 第二次需要,此时 config 已经被初始化过,无需再次初始化 cfg2 := GetConfig() fmt.Println("c2: ", cfg2.config["c2"]) }
在这个示例中,定义了一个
Config结构体,它包含一些设置信息。使用
sync.Once来实现
GetConfig函数,该函数在第一次调用时初始化
Config。这样,我们可以在真正需要时才初始化
Config,从而避免不必要的开销。
sync.Once 实现原理
type Once struct { // 表示是否执行了操作 done uint32 // 互斥锁,确保多个协程访问时,只能一个协程执行操作 m Mutex } func (o *Once) Do(f func()) { // 判断 done 的值,如果是 0,说明 f 还没有被执行过 if atomic.LoadUint32(&o.done) == 0 { // 构建慢路径(slow-path),以允许对 Do 方法的快路径(fast-path)进行内联 o.doSlow(f) } } func (o *Once) doSlow(f func()) { // 加锁 o.m.Lock() defer o.m.Unlock() // 双重检查,避免 f 已被执行过 if o.done == 0 { // 修改 done 的值 defer atomic.StoreUint32(&o.done, 1) // 执行函数 f() } }
sync.Once结构体包含两个字段:
done和
mu。
done是一个
uint32类型的变量,用于表示操作是否已经执行过;
m是一个互斥锁,用于确保在多个协程访问时,只有一个协程能执行操作。
sync.Once结构体包含两个方法:
Do和
doSlow。
Do方法是其核心方法,它接收一个函数参数
f。首先它会通过原子操作
atomic.LoadUint32(保证并发安全) 检查
done的值,如果为 0,表示
f函数没有被执行过,然后执行
doSlow方法。
在
doSlow方法里,首先对互斥锁
m进行加锁,确保在多个协程访问时,只有一个协程能执行
f函数。接着再次检查
done变量的值,如果
done的值仍为 0,说明
f函数没有被执行过,此时执行
f函数,最后通过原子操作
atomic.StoreUint32将
done变量的值设置为 1。
为什么会封装一个 doSlow 方法
doSlow方法的存在主要是为了性能优化。将慢路径(
slow-path)代码从
Do方法中分离出来,使得
Do方法的快路径(
fast-path)能够被内联(
inlined),从而提高性能。
为什么会有双重检查(double check)的写法
从源码可知,存在两次对
done的值的判断。
第一次检查:在获取锁之前,先使用原子加载操作
atomic.LoadUint32检查
done变量的值,如果
done的值为 1,表示操作已执行,此时直接返回,不再执行
doSlow方法。这一检查可以避免不必要的锁竞争。
第二次检查:获取锁之后,再次检查
done变量的值,这一检查是为了确保在当前协程获取锁期间,其他协程没有执行过
f函数。如果
done的值仍为 0,表示
f函数没有被执行过。
通过双重检查,可以在大多数情况下避免锁竞争,提高性能。
加强的 sync.Once
sync.Once提供的
Do方法并没有返回值,意味着如果我们传入的函数如果发生
error导致初始化失败,后续调用
Do方法也不会再初始化。为了避免这个问题,我们可以实现一个 类似
sync.Once的并发原语。
package main import ( "sync" "sync/atomic" ) type Once struct { done uint32 m sync.Mutex } func (o *Once) Do(f func() error) error { if atomic.LoadUint32(&o.done) == 0 { return o.doSlow(f) } return nil } func (o *Once) doSlow(f func() error) error { o.m.Lock() defer o.m.Unlock() var err error if o.done == 0 { err = f() // 只有没有 error 的时候,才修改 done 的值 if err == nil { atomic.StoreUint32(&o.done, 1) } } return err }
上述代码实现了一个加强的
Once结构体。与标准的
sync.Once不同,这个实现允许
Do方法的函数参数返回一个
error。如果执行函数没有返回
error,则修改
done的值以表示函数已执行。这样,在后续的调用中,只有在没有发生
error的情况下,才会跳过函数执行,避免初始化失败。
sync.Once 的注意事项
死锁
通过分析
sync.Once的源码,可以看到它包含一个名为
m的互斥锁字段。当我们在
Do方法内部重复调用
Do方法时,将会多次尝试获取相同的锁。但是
mutex互斥锁并不支持可重入操作,因此这将导致死锁现象。
func main() { once := sync.Once{} once.Do(func() { once.Do(func() { fmt.Println("init...") }) }) }
初始化失败
这里的初始化失败指的是在调用
Do方法之后,执行
f函数的过程中发生
error,导致执行失败,现有的
sync.Once设计我们是无法感知到初始化的失败的,为了解决这个问题,我们可以实现一个类似
sync.Once的加强
once,前面的内容已经提供了具体实现。