Go 併發編程 — sync-Once 單實例模式的思考

併發經典場景

Go 併發編程的場景中,有一個特別經典的場景,就是併發創建對象的時候。一般僞代碼如下:

1if ( /* 如果對象不存在 */) {
2    // 那麼就創建對象 
3}
4
5

因爲是併發的環境,所以多個 goroutine 短時間內得出的判斷都是一樣的:都判斷得到對象是不存在的,這時候大家的的行爲也特別一致,每個 goroutine 磨刀霍霍就是創建。這時候如果不加以控制,那麼會導致程序邏輯出問題。

會導致對象重複創建多次,並且可能不斷的被替換和丟棄。

怎麼解決?


加鎖互斥

最簡單的方法:加鎖互斥。保證判斷和創建這兩個動作是原子操作,這樣就不存在併發誤判的時間窗,那麼就不會存在以上問題了。

1lock ...
2{
3    if ( /* 如果對象不存在 */) {
4        // 那麼就創建對象 
5    }
6}
7unlock ...
8
9

加鎖不可怕,鎖衝突纔可怕,如果每次都要搶一把鎖,那性能就划不來了。

Once

在 Go 的併發庫對此有另外一個實現:sync.Once 對象。這是一個非常小巧的實現,對象實現代碼極其精簡。這個庫非常方便的實現 Go 的單實例設計模式。

我們換一個思路,我們不做判斷,需要一個庫能夠提供只執行一次的語義,那麼也能實現我們的目的。

沒錯,就是直接調用 Once.Do 執行創建對象,業務方甚至都不需要再做多餘的判斷的動作,如下:

1once.Do(/* 創建對象 */)
2
3

對,就是這麼簡單,上面的調用就能保證在併發的上下文中,保持正確性。那麼 sync.Once 對外保證了什麼語義呢?

劃重點:確保傳入函數只被執行一次。

這裏注意思考一個點:只執行一次是針對庫的還是針對實例的?

劃重點:只執行一次的語義是和具體的 once 變量綁定的。

怎麼理解?舉個例子:

1var once1 sync.Once
2var once2 sync.Once
3
4once1.Do( f1 )
5once2.Do( f2 )
6
7

f1f2 各保證執行一次。

單例模式

單例模式模式可以說是設計模式裏最簡單的了。Go 怎麼實現只創建一個對象呢?

非常簡單,就是藉助 sync.Once 結構。舉個完整的例子:

 1// 全局變量(我們只希望創建一個)
 2var s *SomeObject
 3// 定義一個 once 變量
 4var once sync.Once
 5// 只希望創建一個,單例模式
 6func GetInstance() *SomeObject {
 7    once.Do(func(){
 8        // 創建一個對象,賦值指針給全局變量
 9        s = &SomeObject{}
10    })
11    return s
12}
13
14

這樣,我們就實現了單例模式,每次調用 GetInstance 函數返回的對象都只有一個。那麼 sync.Once 是怎麼做到的呢?

Once 的實現

以下就是 Once 實例的全部源碼實現,非常的簡短,也非常的有趣。

 1package sync
 2import (
 3    "sync/atomic"
 4)
 5type Once struct {
 6    done uint32
 7    m    Mutex
 8}
 9
10func (o *Once) Do(f func()) {
11    // 思考題:爲什麼這裏不用 cas 來判斷?
12    if atomic.LoadUint32(&o.done) == 0 {
13        o.doSlow(f)
14    }
15}
16
17func (o *Once) doSlow(f func()) {
18    o.m.Lock()
19    defer o.m.Unlock()
20    if o.done == 0 {
21        // 思考題:爲什麼這裏用 defer 來加計數?
22        defer atomic.StoreUint32(&o.done, 1)
23        f()
24    }
25}
26
27

以上的 sync.Once 實現非常簡短,但是也有兩個值得思考的地方。

  1. 爲什麼 Once.Do 裏面沒有用 cas 判斷?原子操作豈不是更快?

  2. 爲什麼 Once.doSlow 裏面用 defer 來加計數,而不是直接操作?

思考:爲什麼沒有用 cas 原子判斷?


什麼是 cas

Go 裏面有是 atomic.CompareAndSwapUint32 實現這 cas 的功能。cas 就是 Compare And Swap 的縮寫,把判斷和賦值包裝成一個原子操作。我們看一下 cas 的僞代碼實現:

 1func cas(p : pointer to int, old : int, new : int) bool {
 2    // *p 不等於 old 的時候,返回 false
 3    if *p != old {
 4        return false
 5    }
 6    // *p 等於 old 的時候,賦值新值,並返回 true
 7    *p = new
 8    return true
 9}
10
11

上面的就是 cas 的僞代碼實現,cas 保證上面的邏輯是原子操作。思考下,爲什麼 Once 不能用如下的實現:

1if atomic.CompareAndSwapUint32(&o.done, 0, 1) {
2    f()
3}
4
5

第一眼看過去,好像也能實現 Once 只執行一次語義?

這個代碼看起來 o.done == 0 的時候,會賦值 o.done==1,然後執行 f()。其他併發請求的時候, o.done == 1,就不會再進到這個分支裏,貌似也可以?

那爲什麼沒用原子操作呢?cas 原子操作不是性能最好的嗎?

細品下,雖然能保證只執行一次,卻有個致命的缺陷:無法在 o.done==1 的時候保證 f() 函數有執行完成。Golang 的標準庫也針對這些提到了這點。

// Do guarantees that when it returns, f has finished. // This implementation would not implement that guarantee: // given two simultaneous calls, the winner of the cas would // call f, and the second would return immediately, without // waiting for the first's call to f to complete.

o.done 判斷爲 0 的時候,立即就設置成了 1 ,這個時候才走到 f() 函數里執行,這裏的語義不再正確。

Once 不僅要保證只執行一次,還要保證當其他用戶看到 o.done==1 導致 Once.Do 返回的時候,確保執行完成。

這個語義很重要嗎?

非常重要,這裏涉及到邏輯的正確性。舉個栗子,我們用 Once.Do 來創建一個唯一的全局變量對象,如果是你回覆了用戶已經 Once.Do 成功,但是卻 f() 還在執行過程,那麼就會出現中間態,全局變量還沒有創建出來,行爲是無法定義的。

那麼怎麼解決?解決非常簡單,兩個思路:

  1. 熱路徑:用原子讀 o.done 的值,保證競態條件正確;

  2. 既然不能用 cas 原子操作,那就用鎖機制來保證原子性。如果 o.done == 0 ,那麼就走慢路徑,注意:以下所有邏輯在一把大鎖內

  3. 先執行 f() 函數;

  4. 然後纔去設置 o.done 爲 1;

第一次可能在鎖互斥的時候,可能會比較慢。因爲要搶鎖,但是隻要執行過一次,就不會在走到鎖內的邏輯了。都是走原子讀的路徑,也是非常快的。

既然提到鎖,我們再來看一個死鎖的例子。Once 內部用鎖來保證代碼的臨界區,那麼就千萬不要嵌套使用,不然會死鎖。如下:

1once1.Do( func(){
2    once1.Do( func(){
3        /* something */
4    } )
5} )
6
7

上面的代碼會死鎖在 once1.m.Lock() 的調用上。

劃重點:千萬不要把 sync.Once 用的複雜,要保持簡潔,嵌套很容易死鎖。

思考:爲什麼 doSlow 用 defer 來加計數,而不是 f() 之後直接操作?


Once.doSlow 整個是在鎖內操作的,所以這段代碼的操作是串行化的。如果 o.done 爲 0,標識沒有執行過 f,整個時候註冊一個 defer 函數 defer atomic.StoreUint32(&o.done, 1) ,然後運行 f() 。

 1func (o *Once) doSlow(f func()) {
 2    o.m.Lock()
 3    defer o.m.Unlock()
 4    if o.done == 0 {
 5        // 思考題:爲什麼這裏用 defer 來加計數?
 6        defer atomic.StoreUint32(&o.done, 1)
 7        f()
 8    }
 9}
10
11

這裏爲什麼要用 defer 來確保執行 o.done 賦值爲 1 的操作呢?踏實把 atomic.StoreUint32(&o.done, 1) 放到 f() 之後不好嗎?

不好!因爲處理不了 panic 的異常。舉個例子:

如果不用 defer ,當 f() 執行的時候出現 panic 的時候(被外層 recover,進程沒掛),會導致沒有 o.done 加計數,但其實 f() 已經執行過了,這就違反語義了。

之前我們說過,defer 註冊的函數,就算 f() 內部執行出現 panic ,也會被執行,所以這裏就保證了 Once 對外的語義:執行過一次,o.done 一定是非 0。

不過,我們繼續思考 panic 場景,如果說 f() 因爲某些原因,導致了 panic,可能並沒有執行完,這種時候,也再不會執行 Once.Do 了,因爲已經執行過一次了。業務自己承擔這個責任,框架已經盡力了。

Once 的語義

這裏歸納出 Once 提供的語義:

  1. Once.Do 保證只調用一次的語義,無論 f() 內部有沒有執行完( panic );

  2. 只有 f() 執行完成,Once.Do 纔會返回,否則阻塞等待 f() 的第一次執行完成;

搶鎖簡要演示:

最開始一輪併發的時候,需要搶鎖,但是隻有這一會兒,不會太久。

之後的常規操作,全都走原子讀即可,非常快速:

總結

  1. Once 對外提供 f() 只調用一次的語義;

  2. Once.Do 返回之後,按照約定,f() 一定被執行過一次,並且只執行過一次。如果沒有執行完,會阻塞等待 f() 的第一次執行完成;

  3. Once 只執行一次的語義是跟實例綁定的關係,多個 Once 實例的話,每個實例都有一次的機會;

  4. 內部用鎖機制來保證邏輯的原子性,先執行 f() ,然後設置 o.done 標識位;

  5. Oncedefer 機制保證 panic 的場景,也能夠保證 o.done 標識位被設置;

  6. Once 實例千萬注意,不要嵌套,內部有鎖,亂用的話容易死鎖;

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/37gV23UVHRA5SYeMEA5Q9w