• sync.Once机制详细分析及避坑之道



    sync.Once相信很多人都用的较多,越看起来简单迷惑越大,别小看这数十行代码,蕴藏的东西可不少。

    鉴于以往的经验,这里进行分析汇总,致力于一文搞定这里的所有问题并避免误入歧途。是不是干货,你说了算。

    目录

    以源码为鉴

    来抓取一下有效信息

    详细分析一下

    避坑指南

    情形1

    情形2

    问题汇总与分析

    问题1: 多个Do分别做了相同的事情怎么办,谁起效果?

    问题2: 多个Do分别做了不同的事情到底会执行几次?

    问题3: 单个Do做了不同的事情起不起效果?


     

    以源码为鉴

    结构定义

    1. // Once is an object that will perform exactly one action.
    2. //
    3. // A Once must not be copied after first use.
    4. type Once struct {
    5.     // done indicates whether the action has been performed.
    6.     // It is first in the struct because it is used in the hot path.
    7.     // The hot path is inlined at every call site.
    8.     // Placing done first allows more compact instructions on some architectures (amd64/386),
    9.     // and fewer instructions (to calculate offset) on other architectures.
    10.     done uint32
    11.     m    Mutex
    12. }


    核心方法

    1. // Do calls the function f if and only if Do is being called for the
    2. // first time for this instance of Once. In other words, given
    3. //     var once Once
    4. // if once.Do(f) is called multiple times, only the first call will invoke f,
    5. // even if f has a different value in each invocation. A new instance of
    6. // Once is required for each function to execute.
    7. //
    8. // Do is intended for initialization that must be run exactly once. Since f
    9. // is niladic, it may be necessary to use a function literal to capture the
    10. // arguments to a function to be invoked by Do:
    11. //     config.once.Do(func() { config.init(filename) })
    12. //
    13. // Because no call to Do returns until the one call to f returns, if f causes
    14. // Do to be called, it will deadlock.
    15. //
    16. // If f panics, Do considers it to have returned; future calls of Do return
    17. // without calling f.
    18. //
    19. func (o *Once) Do(f func()) {
    20.     // Note: Here is an incorrect implementation of Do:
    21.     //
    22.     //    if atomic.CompareAndSwapUint32(&o.done, 0, 1) {
    23.     //        f()
    24.     //    }
    25.     //
    26.     // Do guarantees that when it returns, f has finished.
    27.     // This implementation would not implement that guarantee:
    28.     // given two simultaneous calls, the winner of the cas would
    29.     // call f, and the second would return immediately, without
    30.     // waiting for the first's call to f to complete.
    31.     // This is why the slow path falls back to a mutex, and why
    32.     // the atomic.StoreUint32 must be delayed until after f returns.
    33.     if atomic.LoadUint32(&o.done) == 0 {
    34.         // Outlined slow-path to allow inlining of the fast-path.
    35.         o.doSlow(f)
    36.     }
    37. }
    38. func (o *Once) doSlow(f func()) {
    39.     o.m.Lock()
    40.     defer o.m.Unlock()
    41.     if o.done == 0 {
    42.         defer atomic.StoreUint32(&o.done, 1)
    43.         f()
    44.     }
    45. }


    来抓取一下有效信息

    1,Once是一个将执行一个动作的对象。

     

    2,如果 once.Do(f) 被多次调用,只有第一次调用会调用 f, 即使 f 在每次调用中具有不同的值,也就是说,先入为主,你其他人全部忽略

     

    3,如果f的执行发生了panic,Do认为它已经返回(已被调用过); 再次执行Do时不会再调用f,也就是说,好话不说2遍


    详细分析一下

     

    用原子操作判断了done属性,如果值为0才会去调用f,执行f后修改为1,否则什么也不做,因此这就决定了,Do中不论你放的是什么,要做几件事情,每件事情是否相同,它对这些都不关心对于单个once对象来说,只要你执行了Do,它就认为已执行过f,此后再次执行Do就不会再执行f了。

     

    注意看这段:

    1.     // Note: Here is an incorrect implementation of Do:
    2.     //
    3.     //    if atomic.CompareAndSwapUint32(&o.done, 0, 1) {
    4.     //        f()
    5.     //    }

    特地标注这种方式,实际就是为了避免错误的实现,为什么会错误?

    来看这样一个场景(如果按先CompareAndSwapUint32的思路,CompareAndSwapUint32确实可以保证只执行一次,但,往下看):

     

    do在执行时的时候要先判断是否需要继续往下走,而不是通过compare来直接比较交换,一开始如果直接CompareAndSwapUint32的话,第一次执行的执行者势必会把done变为1,在修改为1后、在执行f结束之前,如果有其他人继续执行once. do,会发现done值已经是1,因此其他人不会再执行f 就去直接使用了 f产生的实际结果,就会导致一些不堪设想的后果。

    这里其他人指的就是不同的goroutine。

    那如何保证第一个人执行f 期间,也就是执行完毕之前,其他人等第一个人执行结束再判断呢而不是发现是1就返回?

    方式就是用互斥锁控制。

    第二个人进来后发现临界区被锁,会阻塞住,直到锁释放,锁正常释放后(也就是第一个人执行完f后),第二个人经过再次判断发现done已经被改为1了,此时第二个人放弃执行f ,然后使用了已经被执行过得f产生的结果就很正常了,流程顺理成章。

     

    那么第一个人已经进来了,第二个人为什么也能正常进来?(这里进来指的是进入到doSlow中)

    原因就是,在正确实现方式下,会先判断done的值是否为0:

    atomic.LoadUint32(&o.done)

    来确认是否有往下走的必要,并发时两个人完全有可能均走完了这一步判断都为0(因为还没被改掉),然后进行下一步,所以互斥锁就大显身手了。

    综上分析,这是一种思想,不止是这里,相信你在其它地方也会发现此类问题。

    怎么样?短短不到数十行代码蕴藏的可是巨大的能量~

     

    避坑指南

    要注意,看起来好像很简单,但实际坑还是很多的,当然了,很多都是理解/使用不当自行引入的。

    情形1

    如下,

    1. once.Do(func(){
    2.         once.Do(func(){
    3.                 // ...
    4.         })
    5. })

    这种嵌套就相当于连续执行了两遍Lock(),参照源码中的Do实现,这样不死锁才怪呢。

     

    情形2

    once和init作用有交集、Do在执行时,其内部的func执行后创建了一个不被正常使用的对象,init内又初始化了该类型对象的channel,导致真正要被使用的对象没被使用,而使用了一个为空channel的对象,导致流程阻塞,影响极大,可移步:

    sync.Once+对象创建引发的血案(流程阻塞)_ProblemTerminator的博客-CSDN博客sync.Once+对象创建引发的问题排查https://blog.csdn.net/HYZX_9987/article/details/126712689

    可以看到、对象创建、once、channel、并发  千丝万缕,综合起来如不能清晰的明白其中的联系,就会被困扰。

    有了上面的详细分析,下面的疑问基本都不是问题了。

     

    问题汇总与分析


    问题1: 多个Do分别做了相同的事情怎么办,谁起效果?


    来看这一段代码:    

    1. var getOnce sync.Once
    2.     var issucc bool
    3.     go func() {
    4.         fmt.Println("A doing...")
    5.         getOnce.Do(func() {
    6.             issucc = true
    7.             fmt.Println("A get success.")
    8.         })
    9.         fmt.Println("A done")
    10.     }()
    11.     go func() {
    12.         fmt.Println("B doing...")
    13.         getOnce.Do(func() {
    14.             issucc = true
    15.             fmt.Println("B get success.")
    16.         })
    17.         fmt.Println("B done")
    18.     }()
    19.     go func() {
    20.         fmt.Println("C doing...")
    21.         getOnce.Do(func() {
    22.             issucc = true
    23.             fmt.Println("C get success.")
    24.         })
    25.         fmt.Println("C done")
    26.     }()
    27.     time.Sleep(time.Second * 3)

    如上,A、B、C都想去修改issucc,那么结果如何?


    speed running:

    1. C doing...
    2. C get success.
    3. C done
    4. A doing...
    5. A done
    6. B doing...
    7. B done

    如上,C实现了对issucc的修改,而其它两个没有。没错、你没看错,这是正确的,多次尝试,实际上每次都只有一个入口实现对issucc的修改。


    和幂等性的出路特征有点像?

    先别急,看下一个问题。


    问题2: 多个Do分别做了不同的事情到底会执行几次?

    看看示例代码:

    1.     var getOnce sync.Once
    2.     go func() {
    3.         time.Sleep(time.Second * 1)
    4.         fmt.Println("A doing...")
    5.         getOnce.Do(func() {
    6.             fmt.Println("A proc success.")
    7.         })
    8.         fmt.Println("A done")
    9.     }()
    10.     go func() {
    11.         time.Sleep(time.Second * 1)
    12.         fmt.Println("B doing...")
    13.         getOnce.Do(func() {
    14.             fmt.Println("B proc success.")
    15.         })
    16.         fmt.Println("B done")
    17.     }()
    18.     go func() {
    19.         time.Sleep(time.Second * 1)
    20.         fmt.Println("C doing...")
    21.         getOnce.Do(func() {
    22.             fmt.Println("C proc success.")
    23.         })
    24.         fmt.Println("C done")
    25.     }()
    26.     go func() {
    27.         time.Sleep(time.Second * 1)
    28.         fmt.Println("D doing...")
    29.         getOnce.Do(func() {
    30.             fmt.Println("D proc success.")
    31.         })
    32.         fmt.Println("D done")
    33.     }()
    34.     go func() {
    35.         time.Sleep(time.Second * 1)
    36.         fmt.Println("E doing...")
    37.         getOnce.Do(func() {
    38.             fmt.Println("E proc success.")
    39.         })
    40.         fmt.Println("E done")
    41.     }()
    42.     go func() {
    43.         time.Sleep(time.Second * 1)
    44.         fmt.Println("F doing...")
    45.         getOnce.Do(func() {
    46.             fmt.Println("F proc success.")
    47.         })
    48.         fmt.Println("F done")
    49.     }()
    50.     time.Sleep(time.Second * 3)

    上述6个goroutine都用了同一个once对象,各自做各自的事情,结果几何?

    speed running:

    1. F doing...
    2. E doing...
    3. D doing...
    4. B doing...
    5. F proc success.
    6. F done
    7. E done
    8. D done
    9. B done
    10. C doing...
    11. C done
    12. A doing...
    13. A done

    可以看到和问题1类似的结果,once第一此执行后,后面的不论几次Do再来也不会再执行了。

     

    因此:
    对于一个getOnce对象,执行的如果是多个事情,不论是不是相同的事情,只会执行第一个进来的事情。


    举个例子:一个once对象,分别被用在了三件不同事情上,如果once第一次执行用在了第一件事上,那么后续的once.DO将不会执行。


    问题3: 单个Do做了不同的事情起不起效果?

    看看代码:

    1. var (
    2.     testDesc    *TestDesc
    3.     testDesc2   *TestDesc2
    4.     testDescMap map[string]TestDesc
    5.     onceMany    sync.Once
    6. )
    7. type TestDesc struct {
    8.     Name string
    9. }
    10. type TestDesc2 struct {
    11.     Name2 string
    12. }
    13. func newImpl() (map[string]TestDesc, *TestDesc, *TestDesc2) {
    14.     onceMany.Do(func() {
    15.         fmt.Println("once.Do calling...")
    16.         // A
    17.         testDescMap = make(map[string]TestDesc)
    18.         // B
    19.         testDesc = new(TestDesc)
    20.         // C
    21.         testDesc2 = new(TestDesc2)
    22.     })
    23.     fmt.Printf("待返回的testDescMap:%p\n", testDescMap)
    24.     fmt.Printf("待返回的testDesc:%p\n", testDesc)
    25.     fmt.Printf("待返回的testDesc2:%p\n", testDesc2)
    26.     return testDescMap, testDesc, testDesc2
    27. }
    28. func main() {
    29.     newImpl()
    30.     fmt.Println("1 done")
    31.     newImpl()
    32.     fmt.Println("2 done")
    33.     newImpl()
    34.     fmt.Println("3 done")
    35.     newImpl()
    36.     fmt.Println("4 done")
    37.     newImpl()
    38.     fmt.Println("5 done")
    39. }

    speed running:

    1. once.Do calling...
    2. 待返回的testDescMap:0xc000076480
    3. 待返回的testDesc:0xc00004e230
    4. 待返回的testDesc2:0xc00004e240
    5. 1 done
    6. 待返回的testDescMap:0xc000076480
    7. 待返回的testDesc:0xc00004e230
    8. 待返回的testDesc2:0xc00004e240
    9. 2 done
    10. 待返回的testDescMap:0xc000076480
    11. 待返回的testDesc:0xc00004e230
    12. 待返回的testDesc2:0xc00004e240
    13. 3 done
    14. 待返回的testDescMap:0xc000076480
    15. 待返回的testDesc:0xc00004e230
    16. 待返回的testDesc2:0xc00004e240
    17. 4 done
    18. 待返回的testDescMap:0xc000076480
    19. 待返回的testDesc:0xc00004e230
    20. 待返回的testDesc2:0xc00004e240
    21. 5 done

    可以看到Do的f永远只会调用一次,每次使用的对象都是同一个。
     

     

     

  • 相关阅读:
    【C++天梯计划】1.10 二叉树(binary tree)
    tkmybatis通用mapper实现在使用Example进行查询的几种方式
    多线程下的时间处理
    【AGC】如何解决事件分析数据本地和AGC面板中显示不一致的问题?
    JVS多账号统一登录方式介绍(包括低代码与原生应用)
    VSCode中打开md文件的智能提示
    【C++模拟实现】list的模拟实现
    这十一个副业在家就可以完成,疫情在家也有收入,建议收藏
    功率放大器模块工作原理介绍
    Redis缓存面临的缓存穿透问题
  • 原文地址:https://blog.csdn.net/HYZX_9987/article/details/126842479