Go sync.Once:简约而不简单的并发利器

本文正在参加「金石方案」

本文首发于稀土,转载请联络授权

作者:陈明勇

大众号:Go技术干货

简介

在某些场景下,咱们需求初始化一些资源,例如单例对象、装备等。完成资源的初始化有多种办法,如界说 package 级别的变量、在 init 函数中进行初始化,或许在 main 函数中进行初始化。这三种办法都能保证并发安全,并在程序启动时完结资源的初始化。

然而,有时咱们期望采用推迟初始化的办法,在咱们真实需求资源的时候才进行初始化,这种需求保证并发安全,在这种情况下,Go 语言中的 sync.Once 供给一个优雅且并发安全的解决方案,本文将对其进行介绍。

sync.Once 根本概念

什么是 sync.Once

sync.OnceGo 语言中的一种同步原语,用于保证某个操作或函数在并发环境下只被履行一次。它只要一个导出的办法,即 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\n", 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"])
   // 第2次需求,此刻 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 结构体包括两个字段:donemudone 是一个 uint32 类型的变量,用于表明操作是否已经履行过;m 是一个互斥锁,用于保证在多个协程拜访时,只要一个协程能履行操作。

sync.Once 结构体包括两个办法:DodoSlowDo 办法是其核心办法,它接纳一个函数参数 f。首要它会经过原子操作atomic.LoadUint32(保证并发安全) 查看 done 的值,假如为 0,表明 f 函数没有被履行过,然后履行 doSlow 办法。

doSlow 办法里,首要对互斥锁 m 进行加锁,保证在多个协程拜访时,只要一个协程能履行 f 函数。接着再次查看 done 变量的值,假如 done 的值仍为 0,说明 f 函数没有被履行过,此刻履行 f 函数,最后经过原子操作 atomic.StoreUint32done 变量的值设置为 1。

为什么会封装一个 doSlow 办法

doSlow 办法的存在首要是为了功能优化。将慢途径(slow-path)代码从 Do 办法中分离出来,使得 Do 办法的快途径(fast-path)能够被内联(inlined),从而提高功能。

为什么会有两层查看(double check)的写法

从源码可知,存在两次对 done 的值的判断。

  • 第一次查看:在获取锁之前,先运用原子加载操作 atomic.LoadUint32 查看 done 变量的值,假如 done 的值为 1,表明操作已履行,此刻直接回来,不再履行 doSlow 办法。这一查看能够避免不必要的锁竞赛。
  • 第2次查看:获取锁之后,再次查看 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,前面的内容已经供给了具体完成。

小结

本文具体介绍了 Go 语言中的 sync.Once,包括它的根本界说、运用场景和运用实例以及源码剖析等。在实践开发中,sync.Once 经常被用于完成单例形式和推迟初始化操作。

尽管 sync.Once 简略而又高效,可是错误的运用可能会造成一些意外情况,需求分外小心。

总之,sync.OnceGo 中十分实用的一个并发原语,能够帮助开发者完成各种并发场景下的安全操作。假如遇到只需求初始化一次的场景,sync.Once 是一个十分好的选择。