Sync Once:不是吧,不到 20 行源碼居然來回改了這麼多次

大家好,我是好久不見的薯條,上篇文章 編寫一個配置化的 Kafka Proxy,讓你分鐘級別接入 Kafka 的閱讀量很慘淡,搞得我那段時間有點喪,可能大家還是更喜歡 Golang 方面的文章,也可能是那篇寫的有點搓... 這幾天北京降溫又下雨,我久違的感冒了,秋高氣爽,讀者朋友們要注意多加衣服啊,感冒還是很難受的。

這篇 once 的文章前前後後看了好多參考,改了好幾遍,最終出來這麼個鳥樣子,個人感覺併發編程這塊水很深,因爲這塊不僅涉及 Golang 源碼,還涉及到彙編、操作系統、甚至是硬件的知識,真是學無止境,有興趣的朋友可以查一下Read AcquireWrite Release和 Golang 官方的 Memory Model 一文。

以下是正文:


type Resource struct {
 addr string
}

var Res *Resource
var once sync.Once

func GetResourceOnce(add string) *Resource {

 once.Do(func() {
  Res = &Resource{addr: add}
 })

 return Res
}

func main() {
 fmt.Println(GetResource("beijing"))
}
// output:{beijing}

例子:

var Resp *Resource
var mut sync.Mutex

func GetResourceMutex(add string) *Resource {
 mut.Lock()
 defer mut.Unlock()

 if Resp != nil {
  return Resp
 }

 Resp = &Resource{addr: add}

 return Resp
}

1. 爲啥源碼引入Mutex而不是CAS操作
3. 爲啥要有fast path, slow path
4. 加鎖之後爲啥要有done==0,爲啥有double check,爲啥這裏不是原子讀
4.store爲啥要加defer
5.爲啥是atomic.store,不是直接賦值1

Once 開始的地方

type Once struct {
 m    Mutex
 done bool
}

func (o *Once) Do(f func()) {
 o.m.Lock()
 defer o.m.Unlock()
 if !o.done {
  o.done = true
  f()
 }
}

在這段 2010 年 8 月 15 日提交的代碼中,作者藉助Mutex實現 Once 語義,執行的時候先加一把互斥鎖,保證只有一個協程可以操作done變量,等f函數執行完解鎖。

這樣的代碼相當於 mvp 版本,管用,但是略顯粗糙,一個最顯而易見的缺點:每次都要執行 Mutex 加鎖操作,對於 Once 這種語義有必要嗎,是否可以先判斷一下 done 的 value 是否爲 true,然後再進行加鎖操作呢?

第一次進化

於是 Once 開始了第一次進化,這次優化改進了上面提到的問題:若 Once 已經初始化,那麼 Do 內部將不會執行搶鎖操作。做這份代碼改動的哥們經過測試發現這樣改在不同核的 benchmark 中有 92%-99% 的耗時提升。

type Once struct {
 m    Mutex
 done int32
}

func (o *Once) Do(f func()) {
 if atomic.AddInt32(&o.done, 0) == 1 {
  return
 }
 // Slow-path.
 o.m.Lock()
 defer o.m.Unlock()
 if o.done == 0 {
  f()
  atomic.CompareAndSwapInt32(&o.done, 0, 1)
 }
}

在這段代碼中,在 slow-path 加鎖後,要繼續判斷 done 值是否爲 0,確認 done 爲 0 後纔要執行f()函數,這是因爲在多協程環境下僅僅通過一次atomic.AddInt32判斷並不能保證原子性,比如倆協程 g1、g2,g2 在 g1 剛剛執行完atomic.CompareAndSwapInt32(&o.done, 0, 1)進入了 slow path,如果不進行 double check,那 g2 又會執行一次f()

在這次改動中,作者用一個 int32 變量done表示 once 的對象是否已執行完,有兩個地方使用到了atomic包裏的方法對o.done進行判斷,分別是,用AddInt32函數根據o.done的值是否爲 1 判斷 once 是否已執行過,若執行過直接返回;f()函數執行完後,對o.done通過 cas 操作進行賦值 1。

這兩處地方的存在有一定的爭議性,在源碼 cr 的過程中就被問到atomic.CompareAndSwapInt32(&o.done, 0, 1)可否被o.done == 1替換, 答案是不可以。

現在的 CPU 一般擁有多個核心,而 CPU 的處理速度快於從內存讀取變量的速度,爲了彌補這倆速度的差異,現在 CPU 每個核心都有自己的 L1、L2、L3 級高速緩存,CPU 可以直接從高速緩存中讀取數據,但是這樣一來內存中的一份數據就在緩存中有多份副本,在同一時間下這些副本中的可能會不一樣,爲了保持緩存一致性,Intel CPU 使用了 MESI 協議。

AddInt32方法和CompareAndSwapInt32方法 (均爲 amd64 平臺 runtime/internal/atomic/atomic_amd64.s) 底層都是在彙編層面調用了LOCK指令,LOCK指令通過總線鎖或 MESI 協議保證原子性(具體措施與 CPU 的版本有關),提供了強一致性的緩存讀寫保證,保證LOCK之後的指令在帶LOCK前綴的指令執行之後才執行,從而保證讀到最新的o.done值。

第二次進化

至此 Once 的代碼已經成型了,後面來列舉一些小優化的集合:

小優化一

這個小優化把 done 的類型由int32替換爲uint32, 用CompareAndSwapUint32替換了CompareAndSwapInt32, 用LoadUint32替換了AddInt32方法,LoadUint32底層並沒有LOCK指令用於加鎖,我覺得能這麼寫的主要原因是進入 slow path 之後會繼續用 Mutex 加鎖並判斷o.done的值,且後面的CAS操作是加鎖的,所以可以這麼改。這次優化經過 benchmark 測試性能在不同核心上有 45%-94% 的提升。

小優化二

這次小優化用StoreUint32替換了CompareAndSwapUint32操作,CAS 操作在這裏確實有點多餘,因爲這行代碼最主要的功能是原子性的done = 1

Store 命令的底層是,其中關鍵的指令是XCHG,有的同學可能要問了,這源碼裏沒有LOCK指令啊,怎麼保證 happen before 呢,Intel 手冊有這樣的描述: The LOCK prefix is automatically assumed for XCHG instruction.,這個指令默認帶LOCK前綴,能保證 Happen Before 語義。

TEXT runtime∕internal∕atomic·Store(SB), NOSPLIT, $0-12
 MOVQ ptr+0(FP), BX
 MOVL val+8(FP), AX
 XCHGL AX, 0(BX)
 RET

小優化三

這次的優化在StoreUint32前增加 defer 前綴,增加 defer 是保證 即使f()在執行過程中出現 panic,Once 仍然保證f()只執行一次,這樣符合嚴格的 Once 語義。

除了預防 panic,defer 還能解決指令重排的問題:現在 CPU 爲了執行效率,源碼在真正執行時的順序和代碼的順序可能並不一樣,比如這段代碼中 a 不一定打印 "hello, world",也可能打印空字符串。

var a string
var done bool

func setup() {
 a = "hello, world"
 done = true
}

func main() {
 go setup()
 for !done {
 }
 print(a)
}

而增加了 defer 前綴,能保證,即使出現指令重排,done變量也能在f()函數執行完後才進行 store 操作。

小優化四

這次優化主要是用函數區分開了 fast path 和 slow path,對 fast path 做了內聯優化。這樣進一步降低了使用 Once 的開銷,因爲 fast path 會被內聯到使用 once 的函數調用中,每次調用的時候如果只走到 fast path 那麼連函數調用的開銷都省去了,這次優化在不同核的環境下又有54%-67%的提升。

type St struct {
 ponce *sync.Once
}

func (st *St) Reset() {
 st.ponce = new(sync.Once)
}

func main() {
 s := &St{}

 f1 := func() {
  fmt.Println("hello, world")
 }
 s.Reset()
 s.ponce.Do(f1)
 s.Reset()
 s.ponce.Do(f1)
}


最後,給自己打個廣告

歡迎加入 隨波逐流的薯條 微信羣。

薯條目前有草帽羣、木葉羣、琦玉羣,羣交流內容不限於技術、投資、趣聞分享等話題。歡迎感興趣的同學入羣交流。

入羣請加薯條的個人微信:709834997。並備註:加入薯條微信羣。

歡迎關注我的公衆號~

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