Go 语言标准库中 atomic.Value
在 Go 語言標準庫中,sync/atomic包將底層硬件提供的原子操作封裝成了 Go 的函數。但這些操作只支持幾種基本數據類型,因此為了擴大原子操作的適用范圍,Go 語言在 1.4 版本的時候向sync/atomic包中添加了一個新的類型Value。此類型的值相當于一個容器,可以被用來“原子地"存儲(Store)和加載(Load)任意類型的值。
歷史起源
我在golang-dev郵件列表中翻到了14年的這段討論[1],有用戶報告了encoding/gob包在多核機器上(80-core)上的性能問題,認為encoding/gob之所以不能完全利用到多核的特性是因為它里面使用了大量的互斥鎖(mutex),如果把這些互斥鎖換成用atomic.LoadPointer/StorePointer來做并發控制,那性能將能提升20倍。
針對這個問題,有人提議在已有的atomic包的基礎上封裝出一個atomic.Value類型,這樣用戶就可以在不依賴 Go 內部類型unsafe.Pointer的情況下使用到atomic提供的原子操作。所以我們現在看到的atomic包中除了atomic.Value外,其余都是早期由匯編寫成的,并且atomic.Value類型的底層實現也是建立在已有的atomic包的基礎上。
那為什么在上面的場景中,atomic會比mutex性能好很多呢?作者?Dmitry Vyukov[2]?總結了這兩者的一個區別:
Mutexes do no scale. Atomic loads do.
Mutex由操作系統實現,而atomic包中的原子操作則由底層硬件直接提供支持。在 CPU 實現的指令集里,有一些指令被封裝進了atomic包,這些指令在執行的過程中是不允許中斷(interrupt)的,因此原子操作可以在lock-free的情況下保證并發安全,并且它的性能也能做到隨 CPU 個數的增多而線性擴展。
好了,說了這么多的原子操作,我們先來看看什么樣的操作能被叫做原子操作?。
原子性
一個或者多個操作在 CPU 執行的過程中不被中斷的特性,稱為原子性(atomicity)?。這些操作對外表現成一個不可分割的整體,他們要么都執行,要么都不執行,外界不會看到他們只執行到一半的狀態。而在現實世界中,CPU 不可能不中斷的執行一系列操作,但如果我們在執行多個操作時,能讓他們的中間狀態對外不可見,那我們就可以宣稱他們擁有了"不可分割”的原子性。
有些朋友可能不知道,在 Go(甚至是大部分語言)中,一條普通的賦值語句其實不是一個原子操作。例如,在32位機器上寫int64類型的變量就會有中間狀態,因為它會被拆成兩次寫操作(MOV)——寫低 32 位和寫高 32 位,如下圖所示:
64位變量的賦值操作
如果一個線程剛寫完低32位,還沒來得及寫高32位時,另一個線程讀取了這個變量,那它得到的就是一個毫無邏輯的中間變量,這很有可能使我們的程序出現詭異的 Bug。
這還只是一個基礎類型,如果我們對一個結構體進行賦值,那它出現并發問題的概率就更高了。很可能寫線程剛寫完一小半的字段,讀線程就來讀取這個變量,那么就只能讀到僅修改了一部分的值。這顯然破壞了變量的完整性,讀出來的值也是完全錯誤的。
面對這種多線程下變量的讀寫問題,我們的主角——atomic.Value登場了,它使得我們可以不依賴于不保證兼容性的unsafe.Pointer類型,同時又能將任意數據類型的讀寫操作封裝成原子性操作(讓中間狀態對外不可見)。
使用姿勢
atomic.Value類型對外暴露的方法就兩個:
-
v.Store(c)?- 寫操作,將原始的變量c存放到一個atomic.Value類型的v里。
-
c = v.Load()?- 讀操作,從線程安全的v中讀取上一步存放的內容。
簡潔的接口使得它的使用也很簡單,只需將需要作并發保護的變量讀取和賦值操作用Load()和Store()代替就行了。
下面是一個常見的使用場景:應用程序定期的從外界獲取最新的配置信息,然后更改自己內存中維護的配置變量。工作線程根據最新的配置來處理請求。
(源碼中的Config可自行替換成map[string]string,或者自己寫一個Config.go)
? package mainimport ("sync/atomic""time""log" )func loadConfig() *Config {//從數據庫或者文件系統中讀取配置信息,然后以map的形式存放在內存里c,_:=newConfig("config.toml")return c }func requests() chan int {// 將從外界中接受到的請求放入到channel里return make(chan int) }func main() {// config變量用來存放該服務的配置信息var config atomic.Valueconfig.Store(loadConfig())go func() {// 每10秒定時拉取最近的配置信息,并且更新到config變量里for {time.Sleep(10 * time.Second)// 對應于賦值操作 config = loadConfig()config.Store(loadConfig())}}()log.Println(config.Load().(*Config))// 創建工作線程,每個工作線程都會根據它所讀取到最新的配置信息來處理請求for {time.Sleep(10 * time.Second)go func() {//ch := requests()// 對應于取值操作 c := config// 由于Load() 返回的是一個interface{} 類型,所以要先強制轉換一下//for r := range ch {c := config.Load().(*Config)// 這里根據配置信息處理請求的邏輯...//_,_ = r,clog.Println(*c)//}}()}time.Sleep(30 * time.Second) }?內部實現
數據結構
atomic.Value被設計用來存儲任意類型的數據,所以它內部的字段是一個interface{}類型,非常的簡單粗暴。
// A Value provides an atomic load and store of a consistently typed value. // The zero value for a Value returns nil from Load. // Once Store has been called, a Value must not be copied. // // A Value must not be copied after first use. type Value struct {v interface{} }除了Value外,這個文件里還定義了一個ifaceWords類型,這其實是一個空interface (interface{})的內部表示格式(參見runtime/runtime2.go中eface的定義)。它的作用是將interface{}類型分解,得到其中的兩個字段。
// ifaceWords is interface{} internal representation. type ifaceWords struct {typ unsafe.Pointerdata unsafe.Pointer }寫入(Store)操作
在介紹寫入之前,我們先來看一下 Go 語言內部的unsafe.Pointer類型。
unsafe.Pointer
出于安全考慮,Go 語言并不支持直接操作內存,但它的標準庫中又提供一種不安全(不保證向后兼容性)?的指針類型unsafe.Pointer,讓程序可以靈活的操作內存。
unsafe.Pointer的特別之處在于,它可以繞過 Go 語言類型系統的檢查,與任意的指針類型互相轉換。也就是說,如果兩種類型具有相同的內存結構(layout),我們可以將unsafe.Pointer當做橋梁,讓這兩種類型的指針相互轉換,從而實現同一份內存擁有兩種不同的解讀方式。
比如說,[]byte和string其實內部的存儲結構都是一樣的,但 Go 語言的類型系統禁止他倆互換。如果借助unsafe.Pointer,我們就可以實現在零拷貝的情況下,將[]byte數組直接轉換成string類型。
bytes?:=?[]byte{104,?101,?108,?108,?111}p?:=?unsafe.Pointer(&bytes)?//強制轉換成unsafe.Pointer,編譯器不會報錯 str?:=?*(*string)(p)?//然后強制轉換成string類型的指針,再將這個指針的值當做string類型取出來 fmt.Println(str)?//輸出?"hello"知道了unsafe.Pointer的作用,我們可以直接來看代碼了:
?func?(v?*Value)?Store(x?interface{})?{if?x?==?nil?{panic("sync/atomic:?store?of?nil?value?into?Value")}vp?:=?(*ifaceWords)(unsafe.Pointer(v))??//?Old?valuexp?:=?(*ifaceWords)(unsafe.Pointer(&x))?//?New?valuefor?{typ?:=?LoadPointer(&vp.typ)if?typ?==?nil?{//?Attempt?to?start?first?store.//?Disable?preemption?so?that?other?goroutines?can?use//?active?spin?wait?to?wait?for?completion;?and?so?that//?GC?does?not?see?the?fake?type?accidentally.runtime_procPin()if?!CompareAndSwapPointer(&vp.typ,?nil,?unsafe.Pointer(^uintptr(0)))?{runtime_procUnpin()continue}//?Complete?first?store.StorePointer(&vp.data,?xp.data)StorePointer(&vp.typ,?xp.typ)runtime_procUnpin()return}if?uintptr(typ)?==?^uintptr(0)?{//?First?store?in?progress.?Wait.//?Since?we?disable?preemption?around?the?first?store,//?we?can?wait?with?active?spinning.continue}//?First?store?completed.?Check?type?and?overwrite?data.if?typ?!=?xp.typ?{panic("sync/atomic:?store?of?inconsistently?typed?value?into?Value")}StorePointer(&vp.data,?xp.data)return} }?大概的邏輯:
-
第5~6行 - 通過unsafe.Pointer將現有的和要寫入的值分別轉成ifaceWords類型,這樣我們下一步就可以得到這兩個interface{}的原始類型(typ)和真正的值(data)。
-
從第7行開始就是一個無限 for 循環。配合CompareAndSwap【忙等】使用,可以達到樂觀鎖的功效。
-
第8行,我們可以通過LoadPointer這個原子操作拿到當前Value中存儲的類型。下面根據這個類型的不同,分3種情況處理。
第一次寫入(第9~24行) - 一個Value實例被初始化后,它的 [type]字段會被設置為指針的零值 nil,所以第9行先判斷如果 [typ] 是 nil ,? 那就證明這個 [Value] 還未被寫入過數據。那之后就是一段初始寫入的操作:
-
runtime_procPin()這是runtime中的一段函數,具體的功能我不是特別清楚,也沒有找到相關的文檔。這里猜測一下,一方面它禁止了調度器對當前 goroutine 的搶占(preemption),使得它在執行當前邏輯的時候不被打斷,以便可以盡快地完成工作,因為別人一直在等待它。另一方面,在禁止搶占期間,GC 線程也無法被啟用,這樣可以防止 GC 線程看到一個莫名其妙的指向^uintptr(0)的類型(這是賦值過程中的中間狀態)。
-
使用CAS操作,先嘗試將typ設置為^uintptr(0)這個中間狀態。如果失敗,則證明已經有別的線程搶先完成了賦值操作,那它就解除搶占鎖,然后重新回到 for 循環第一步。
-
如果設置成功,那證明當前線程搶到了這個"樂觀鎖”,它可以安全的把v設為傳入的新值了(19~23行)。注意,這里是先寫data字段,然后再寫typ字段。因為我們是以typ字段的值作為寫入完成與否的判斷依據的。
第一次寫入還未完成(第25~30行)- 如果看到typ字段還是^uintptr(0)這個中間類型,證明剛剛的第一次寫入還沒有完成,所以它會繼續循環,“忙等"到第一次寫入完成。
第一次寫入已完成(第31行及之后) - 首先檢查上一次寫入的類型與這一次要寫入的類型是否一致,如果不一致則拋出異常。反之,則直接把這一次要寫入的值寫入到data字段。
這個邏輯的主要思想就是,為了完成多個字段的原子性寫入,我們可以抓住其中的一個字段,以它的狀態來標志整個原子寫入的狀態。這個想法我在?TiDB 的事務[4]實現中看到過類似的,他們那邊叫Percolator模型,主要思想也是先選出一個primaryRow,然后所有的操作也是以primaryRow的成功與否作為標志。嗯,果然是太陽底下沒有新東西。
如果沒有耐心看代碼,沒關系,這兒還有個簡化版的流程圖:
atomic.Value Store 流程
讀取(Load)操作
先上代碼:
func?(v?*Value)?Load()?(x?interface{})?{vp?:=?(*ifaceWords)(unsafe.Pointer(v))typ?:=?LoadPointer(&vp.typ)if?typ?==?nil?||?uintptr(typ)?==?^uintptr(0)?{//?First?store?not?yet?completed.return?nil}data?:=?LoadPointer(&vp.data)xp?:=?(*ifaceWords)(unsafe.Pointer(&x))xp.typ?=?typxp.data?=?datareturn }讀取相對就簡單很多了,它有兩個分支:
如果當前的typ是 nil 或者^uintptr(0),那就證明第一次寫入還沒有開始,或者還沒完成,那就直接返回 nil (不對外暴露中間狀態)。
否則,根據當前看到的typ和data構造出一個新的interface{}返回出去。
總結
本文從郵件列表中的一段討論開始,介紹了atomic.Value的被提出來的歷史緣由。然后由淺入深的介紹了它的使用姿勢,以及內部實現。讓大家不僅知其然,還能知其所以然。
另外,再強調一遍,原子操作由底層硬件支持,而鎖則由操作系統的調度器實現。鎖應當用來保護一段邏輯,對于一個變量更新的保護,原子操作通常會更有效率,并且更能利用計算機多核的優勢,如果要更新的是一個復合對象,則應當使用atomic.Value封裝好的實現。
使用規則:
建議:不要把內部使用的atomic.Value原子值暴露給外界,如果非要暴露也要通過API封裝形式,做嚴格的check。
出處:https://mp.weixin.qq.com/s/9aNfjX2UMQKLLgSW037_uQ
參考資料
[1]這段討論:?https://groups.google.com/forum/#!msg/golang-dev/SBmIen68ys0/WGfYQQSO4nAJ
[2]Dmitry Vyukov:?https://github.com/dvyukov
[3]TiDB 的事務:?https://pingcap.com/blog-cn/percolator-and-txn/
[4]喵叔:?https://blog.betacat.io/
[5]CC BY-NC-ND 4.0:?https://creativecommons.org/licenses/by-nc-nd/4.0/
?
總結
以上是生活随笔為你收集整理的Go 语言标准库中 atomic.Value的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: kafka如何彻底删除topic及数据
- 下一篇: mmap 源码分析