天天看點

一個commit引發的思考

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