一個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給了咱們一個很好的例子,同時也是一個很好的啓發。

相關文章
相關標籤/搜索