Go 併發編程 — sync-Once 單實例模式的思考
-
併發經典場景
-
怎麼解決?
-
單例模式
-
Once 的實現
-
思考:爲什麼沒有用 cas 原子判斷?
-
思考:爲什麼
doSlow
用defer
來加計數,而不是f()
之後直接操作? -
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
f1
和 f2
各保證執行一次。
單例模式
單例模式模式可以說是設計模式裏最簡單的了。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
實現非常簡短,但是也有兩個值得思考的地方。
-
爲什麼
Once.Do
裏面沒有用cas
判斷?原子操作豈不是更快? -
爲什麼
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()
還在執行過程,那麼就會出現中間態,全局變量還沒有創建出來,行爲是無法定義的。
那麼怎麼解決?解決非常簡單,兩個思路:
-
熱路徑:用原子讀
o.done
的值,保證競態條件正確; -
既然不能用
cas
原子操作,那就用鎖機制來保證原子性。如果o.done == 0
,那麼就走慢路徑,注意:以下所有邏輯在一把大鎖內 -
先執行
f()
函數; -
然後纔去設置
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 提供的語義:
-
Once.Do
保證只調用一次的語義,無論f()
內部有沒有執行完( panic ); -
只有
f()
執行完成,Once.Do
纔會返回,否則阻塞等待f()
的第一次執行完成;
搶鎖簡要演示:
最開始一輪併發的時候,需要搶鎖,但是隻有這一會兒,不會太久。
之後的常規操作,全都走原子讀即可,非常快速:
總結
-
Once
對外提供f()
只調用一次的語義; -
Once.Do
返回之後,按照約定,f()
一定被執行過一次,並且只執行過一次。如果沒有執行完,會阻塞等待f()
的第一次執行完成; -
Once
只執行一次的語義是跟實例綁定的關係,多個Once
實例的話,每個實例都有一次的機會; -
內部用鎖機制來保證邏輯的原子性,先執行
f()
,然後設置o.done
標識位; -
Once
用defer
機制保證panic
的場景,也能夠保證o.done
標識位被設置; -
Once
實例千萬注意,不要嵌套,內部有鎖,亂用的話容易死鎖;
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/37gV23UVHRA5SYeMEA5Q9w