一个commit引起的思考

这几天我翻了翻golang的提交记录,发现了一条颇有意思的提交:bc593ea,这个提交看似简单,可是引人深思。git

commit讲了什么

commit的标题是“sync: document implementation of Once.Do”,显然是对文档作些补充,然而奇怪的是为何要对某个功能的实现作文档说明呢,难道不是配合代码+注释就能理解的吗?github

根据commit的描述咱们得知,Once.Do的实现问题在过去几个月内被问了至少两次,因此官方决定澄清:golang

It's not correct to use atomic.CompareAndSwap to implement Once.Do,
and we don't, but why we don't is a question that has come up
twice on golang-dev in the past few months.
Add a comment to help others with the same question.编程

不过这不是这个commit的精髓,真正有趣的部分是添加的那几行注释。安全

有趣的疑问

commit添加的内容以下:并发

乍一看可能平平无奇,然而仔细思考事后,咱们就会发现问题了。函数

众所周知,sync.Once用于保证某个操做只会执行一次,所以咱们首先考虑到的就是为了并发安全加mutex,可是once对性能有必定要求,因此咱们选用原子操做。性能

这时候atomic.CompareAndSwapUint32很天然的就会浮如今脑海里,而下面的结构也很天然的就给出了:atom

func (o *Once) Do(f func()) {
    if atomic.CompareAndSwapUint32(&o.done, 0, 1) {
        f()
    }
}

然而正是这种天然联想的方案倒是官方否认的,为何?3d

缘由很简单,举个例子,咱们有一个模块,使用模块里的方法前须要初始化,不然会报错:

module.go:

package module

var flag = true

func InitModule() {
    // 这个初始化模块的方法不能够调用两次以上,以便于结合sync.Once使用
    if !flag {
        panic("call InitModule twice")
    }

    flag = false
}

func F() {
    if flag {
        panic("call F without InitModule")
    }
}

main.go:

package main

import (
    "module"
    "sync"
    "time"
)

var o = &sync.Once{}

func DoSomeWork() {
    o.Do(module.InitModule()) // 不能屡次初始化,因此要用once
    module.F()
}

func main() {
    go DoSomeWork() // goroutine1
    go DoSomeWork() // goroutine2
    time.Sleep(time.Second * 10)
}

如今无论goroutine1仍是goroutine2后运行,module都能被正确初始化,对于F的调用也不会panic,但咱们不能忽略一种更常见的状况:两个goroutine同时运行会发生什么?

咱们列举其中一种状况:

  1. goroutine1先运行,这时若是按咱们所想的once实现,CAS操做成功,InitModule开始执行
  2. 这时goroutine2也在运行,但CAS由于别的routine操做成功,这里返回失败,InitModule执行被跳过
  3. Once.Do返回就意味着咱们须要的操做已经被执行,这时goroutine2开始执行F()
  4. 可是咱们的InitModule在goroutine1中由于某些缘由没执行完,因此咱们不能调用F
  5. 因而问题发生了

你可能已经看出问题了,咱们没有等到被调用函数执行完就返回了,致使了其余goroutine得到了一个不完整的初始化状态。

解决起来也很简单:

  1. 咱们先判断执行标志,若是已经执行过就直接返回
  2. 由于是判断执行标志而不修改,就会有多个routine同时判断位true的状况,咱们用mutex原子化对被调用函数f的操做
  3. 得到mutex以后先检查执行标志,以避免重复执行
  4. 接着调用f
  5. 而后咱们把执行标志设置为1
  6. 最后解除mutex,当其余进入判断的routine重复上述过程时就能保证f只会被调用一次了

这是代码:

func (o *Once) Do(f func()) {
    if atomic.LoadUint32(&o.done) == 0 {
        // Outlined slow-path to allow inlining of the fast-path.
        o.doSlow(f)
    }
}

func (o *Once) doSlow(f func()) {
    o.m.Lock()
    defer o.m.Unlock()
    if o.done == 0 {
        defer atomic.StoreUint32(&o.done, 1)
        f()
    }
}

结束语

从这个问题咱们能够看到,并发编程其实并不难,咱们给出的解决方案是至关简单的,然而难的在于如何全面的思考并发中会遇到的问题从而编写并发安全的代码。

golang的这个commit给了咱们一个很好的例子,同时也是一个很好的启发。

相关文章
相关标签/搜索