sync.Once 的使用场景

sync.Once 是 Golang package 中使方法只执行一次的对象实现,作用与 init 函数类似。但也有所不同。

  • init 函数是当所在的 package 首次被加载时执行,若迟迟未被使用,则既浪费了内存,又延长了程序加载时间。
  • sync.Once 可以在代码的任意位置初始化和调用,因此可以延迟到使用时再执行,并发场景下是线程安全的。

使用方法

在多数情况下,sync.Once 被用于控制变量的初始化,这个变量的读写满足如下三个条件:

  • 当且仅当第一次访问某个变量时,进行初始化(写);
  • 变量初始化过程中,所有读都被阻塞,直到初始化完成;
  • 变量仅初始化一次,初始化完成后驻留在内存里。

sync.Once 仅提供了一个方法 Do,参数 f 是对象初始化函数。

  1. func (o *Once) Do(f func())
  1. 只有在当前的 Once 实例第一次调用 Do 方法时,才会真正执行 f。哪怕在多次调用 Do 中间 f 的值有所变化,也只会被实际调用一次;
  2. Do 针对的是只希望执行一次的初始化操作,由于f 是没有参数的,如果需要传参,可以采用包装一层 func 的形式来实现:config.once.Do(func() { config.init(filename) })
  3. 在对f 的调用返回之前,不会返回对Do的调用,所以如果f方法中又调用来Do方法,将会死锁。所以不要做这样的操作:

    1. func main() {
    2. var once sync.Once
    3. once.Do(func() {
    4. once.Do(func() {
    5. fmt.Println("hello kenmawr.")
    6. })
    7. })
    8. }
  4. 如果 f 抛出了 panic,此时Do会认为f已经返回,后续多次调用Do也不会再触发对 f 的调用。

作者:ag9920
链接:https://juejin.cn/post/7088305487753510925
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

使用示例

在实际的工作中,你可能会有这样的需求:让代码只执行一次,哪怕是在高并发的情况下,比如创建一个单例。
针对这种情形,Go 语言为我们提供了 sync.Once 来保证代码只执行一次,如下所示:

  1. package main
  2. import (
  3. "fmt"
  4. "sync"
  5. )
  6. func main() {
  7. var once sync.Once
  8. done := make(chan bool)
  9. for i := 0; i < 10; i++ {
  10. go func() {
  11. //把要执行的函数(方法)作为参数传给once.Do方法即可
  12. once.Do(greet)
  13. done <- true
  14. }()
  15. }
  16. for i := 0; i < 10; i++ {
  17. <-done
  18. }
  19. }
  20. func greet() {
  21. fmt.Println("hello world")
  22. }

输出结果:

  1. hello world

虽然启动了 10 个协程来执行 greet 函数,但是因为用了 once.Do 方法,所以函数 greet 只会被执行一次。也就是说在高并发的情况下,sync.Once 也会保证 greet 函数只执行一次。

问题

sync.Once类型值的Do方法是怎么保证只执行参数函数一次的?
与 sync.WaitGroup 类型一样,sync.Once 类型(以下简称 Once 类型)也属于结构体类型,同样也是开箱即用和并发安全的

由于这个类型中包含了一个 sync.Mutex 类型的字段,所以,复制该类型的值也会导致功能的失效。

Once 类型的 Do 方法只接受一个参数,这个参数的类型必须是 func() ,即:无参数声明和结果声明的函数。
该方法的功能并不是对每一种参数函数都只执行一次,而是只执行“首次被调用时传入的”那个函数,并且之后不会再执行任何参数函数。

  1. func main() {
  2. var count int
  3. increment := func() { count++ }
  4. decrement := func() {
  5. fmt.Println("enter decrement")
  6. count--
  7. }
  8. var once sync.Once
  9. once.Do(increment)
  10. once.Do(decrement)
  11. fmt.Printf("count is %v", count) // count is 1
  12. }

可以看到 count 并不是 0,同时呢我们发现 decrement 函数中并没有进去,所以就验证了上面的说法。once 只执行一次 Do 方法。

如果你有多个只需要执行一次的函数,那么就应该为它们中的每一个都分配一个 sync.Once 类型的值(以下简称 Once 值)

  1. func main() {
  2. var count int
  3. increment := func() { count++ }
  4. decrement := func() {
  5. fmt.Println("enter decrement")
  6. count--
  7. }
  8. var onceIn sync.Once // 给 increment 函数声明一个 Once 类型值
  9. var onceDe sync.Once // 给 decrement 函数声明一个 Once 类型值
  10. onceIn.Do(increment)
  11. onceDe.Do(decrement)
  12. fmt.Printf("count is %v", count)
  13. // enter decrement
  14. // count is 0
  15. }

实战用法

任何只希望执行一次的操作

初始化

很多同学可能会有疑问,我直接在 init() 函数里面做初始化不就可以了吗?效果上是一样的,为什么还要用 sync.Once,这样还需要多声明一个 once 对象。

原因在于:

  • init() 函数是在所在包首次被加载时执行,若未实际使用,既浪费了内存,又延缓了程序启动时间。
  • sync.Once 可以在任何位置调用,而且是并发安全的,我们可以在实际依赖某个变量时才去初始化,这样「延迟初始化」从功能上讲并无差异,但可以有效地减少不必要的性能浪费。

我们来看 Golang 官方的 html 库中的一个例子,我们经常使用的转义字符串函数

  1. func UnescapeString(s string) string

在进入函数的时候,首先就会依赖包里内置的 populateMapsOnce 实例(本质是一个 sync.Once) 来执行初始化 entity 的操作。这里的entity是一个包含上千键值对的 map,如果init()时就去初始化,会浪费内存。

  1. var populateMapsOnce sync.Once
  2. var entity map[string]rune
  3. func populateMaps() {
  4. entity = map[string]rune{
  5. "AElig;": '\U000000C6',
  6. "AMP;": '\U00000026',
  7. "Aacute;": '\U000000C1',
  8. "Abreve;": '\U00000102',
  9. "Acirc;": '\U000000C2',
  10. // 省略后续键值对
  11. }
  12. }
  13. func UnescapeString(s string) string {
  14. populateMapsOnce.Do(populateMaps)
  15. i := strings.IndexByte(s, '&')
  16. if i < 0 {
  17. return s
  18. }
  19. // 省略后续的实现
  20. ...
  21. }

单例模式

开发中我们经常会实现 Getter 来暴露某个非导出的变量,这个时候就可以把 once.Do 放到 Getter 里面,完成单例的创建。

  1. package main
  2. import (
  3. "fmt"
  4. "sync"
  5. )
  6. type Singleton struct{}
  7. var singleton *Singleton
  8. var once sync.Once
  9. func GetSingletonObj() *Singleton {
  10. once.Do(func() {
  11. fmt.Println("Create Obj")
  12. singleton = new(Singleton)
  13. })
  14. return singleton
  15. }
  16. func main() {
  17. var wg sync.WaitGroup
  18. for i := 0; i < 5; i++ {
  19. wg.Add(1)
  20. go func() {
  21. defer wg.Done()
  22. obj := GetSingletonObj()
  23. fmt.Printf("%p\n", obj)
  24. }()
  25. }
  26. wg.Wait()
  27. }
  28. /*--------- 输出 -----------
  29. Create Obj
  30. 0x119f428
  31. 0x119f428
  32. 0x119f428
  33. 0x119f428
  34. 0x119f428
  35. **/

关闭channel

一个channel如果已经被关闭,再去关闭的话会 panic,此时就可以应用 sync.Once 来帮忙。

  1. type T int
  2. type MyChannel struct {
  3. c chan T
  4. once sync.Once
  5. }
  6. func (m *MyChannel) SafeClose() {
  7. // 保证只关闭一次channel
  8. m.once.Do(func() {
  9. close(m.c)
  10. })
  11. }

实现原理

sync.Once 源码:

  1. type Once struct {
  2. done uint32
  3. m Mutex
  4. }
  5. // 大写 只对外暴露
  6. func (o *Once) Do(f func()) {
  7. if atomic.LoadUint32(&o.done) == 0 {
  8. // 原子获取 done 的值,判断 done 的值是否为 0,如果为 0 就调用 doSlow 方法,进行二次检查。
  9. o.doSlow(f)
  10. }
  11. }
  12. func (o *Once) doSlow(f func()) {
  13. // 二次检查时,持有互斥锁,保证只有一个 goroutine 执行。
  14. o.m.Lock()
  15. defer o.m.Unlock()
  16. if o.done == 0 {
  17. // 二次检查,如果 done 的值仍为 0,则认为是第一次执行,执行参数 f,并将 done 的值设置为 1。
  18. defer atomic.StoreUint32(&o.done, 1)
  19. f()
  20. }
  21. }

sync.once 仅提供了一个Do()方法,其参数为待执行的函数,该函数的代码块期望仅被执行一次。下面看代码实现过程:

首先,atomic读取done字段值是否被改变,然后当如果没有改变时执行doSlow方法。当进入doSlow方法,开始执行锁操作,在并发环境下仅有一个线程被执行,然后基于done字段是否被改变执行待执行函数,如果没有改变则执行f函数。当代码块执行后,done字段被激活。

参考