一個commit引發的思考

apocelipes發表於2019-07-06

這幾天我翻了翻golang的提交記錄,發現了一條很有意思的提交:bc593ea,這個提交看似簡單,但是引人深思。

commit講了什麼

commit的標題是“sync: document implementation of Once.Do”,顯然是對文件做些補充,然而奇怪的是為什麼要對某個功能的實現做文件說明呢,難道不是配合程式碼+註釋就能理解的嗎?

根據commit的描述我們得知,Once.Do的實現問題在過去幾個月內被問了至少兩次,所以官方決定澄清:

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新增的內容如下:

一個commit引發的思考

乍一看可能平平無奇,然而仔細思考過後,我們就會發現問題了。

眾所周知,sync.Once用於保證某個操作只會執行一次,因此我們首先考慮到的就是為了併發安全加mutex,但是once對效能有一定要求,所以我們選用原子操作。

這時候atomic.CompareAndSwapUint32很自然的就會浮現在腦海裡,而下面的結構也很自然的就給出了:

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

然而正是這種自然聯想的方案卻是官方否定的,為什麼?

原因很簡單,舉個例子,我們有一個模組,使用模組裡的方法前需要初始化,否則會報錯:

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

相關文章