一個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添加的內容如下:

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

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


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM