Go 的 Atomic.Value 為什么不加鎖也能保證數(shù)據(jù)線程安全?
有些朋友可能沒有注意過,在 Go(甚至是大部分語言)中,一條普通的賦值語句其實不是一個原子操作。例如,在32位機器上寫int64類型的變量就會有中間狀態(tài),因為它會被拆成兩次寫操作(匯編的MOV指令)——寫低 32 位和寫高 32 位,如下圖所示:
32機器上對int64進行賦值
如果一個線程剛寫完低32位,還沒來得及寫高32位時,另一個線程讀取了這個變量,那它得到的就是一個毫無邏輯的中間變量,這很有可能使我們的程序出現(xiàn)Bug。
這還只是一個基礎類型,如果我們對一個結(jié)構(gòu)體進行賦值,那它出現(xiàn)并發(fā)問題的概率就更高了。很可能寫線程剛寫完一小半的字段,讀線程就來讀取這個變量,那么就只能讀到僅修改了一部分的值。這顯然破壞了變量的完整性,讀出來的值也是完全錯誤的。
面對這種多線程下變量的讀寫問題,Go給出的解決方案是atomic.Value登場了,它使得我們可以不依賴于不保證兼容性的unsafe.Pointer類型,同時又能將任意數(shù)據(jù)類型的讀寫操作封裝成原子性操作。
之前我在文章Golang 五種原子性操作的用法詳解里,詳細介紹過它的用法,下面我們先來快速回顧一下atomic.Value的使用方式
atomic.Value的使用方式
atomic.Value類型對外提供了兩個讀寫方法:
- v.Store(c) - 寫操作,將原始的變量c存放到一個atomic.Value類型的v里。
- c := v.Load() - 讀操作,從線程安全的v中讀取上一步存放的內(nèi)容。
下面是一個簡單的例子演示atomic.Value的用法。
- type Rectangle struct {
- length int
- width int
- }
- var rect atomic.Value
- func update(width, length int) {
- rectLocal := new(Rectangle)
- rectLocal.width = width
- rectLocal.length = length
- rect.Store(rectLocal)
- }
- func main() {
- wg := sync.WaitGroup{}
- wg.Add(10)
- // 10 個協(xié)程并發(fā)更新
- for i := 0; i < 10; i++ {
- go func() {
- defer wg.Done()
- update(i, i+5)
- }()
- }
- wg.Wait()
- _r := rect.Load().(*Rectangle)
- fmt.Printf("rect.width=%d\nrect.length=%d\n", _r.width, _r.length)
- }
你也可以試試,不用atomic.Value,直接給Rectange類型的指針變量賦值,對比一下兩者結(jié)果的區(qū)別。
你可能會好奇,為什么atomic.Value在不加鎖的情況下就提供了讀寫變量的線程安全保證,接下來我們就一起看看其內(nèi)部實現(xiàn)。
atomic.Value的內(nèi)部實現(xiàn)
atomic.Value被設計用來存儲任意類型的數(shù)據(jù),所以它內(nèi)部的字段是一個interface{}類型。
- type Value struct {
- v interface{}
- }
除了Value外,atomic包內(nèi)部定義了一個ifaceWords類型,這其實是interface{}的內(nèi)部表示 (runtime.eface),它的作用是將interface{}類型分解,得到其原始類型(typ)和真正的值(data)。
- // ifaceWords is interface{} internal representation.
- type ifaceWords struct {
- typ unsafe.Pointer
- data unsafe.Pointer
- }
寫入線程安全的保證
在介紹寫入之前,我們先來看一下 Go 語言內(nèi)部的unsafe.Pointer類型。
unsafe.Pointer
出于安全考慮,Go 語言并不支持直接操作內(nèi)存,但它的標準庫中又提供一種不安全(不保證向后兼容性) 的指針類型unsafe.Pointer,讓程序可以靈活的操作內(nèi)存。
unsafe.Pointer的特別之處在于,它可以繞過 Go 語言類型系統(tǒng)的檢查,與任意的指針類型互相轉(zhuǎn)換。也就是說,如果兩種類型具有相同的內(nèi)存結(jié)構(gòu)(layout),我們可以將unsafe.Pointer當做橋梁,讓這兩種類型的指針相互轉(zhuǎn)換,從而實現(xiàn)同一份內(nèi)存擁有兩種不同的解讀方式。
比如說,[]byte和string其實內(nèi)部的存儲結(jié)構(gòu)都是一樣的,他們在運行時類型分別表示為reflect.SliceHeader和reflect.StringHeader
- type SliceHeader struct {
- Data uintptr
- Len int
- Cap int
- }
- type StringHeader struct {
- Data uintptr
- Len int
- }
但 Go 語言的類型系統(tǒng)禁止他倆互換。如果借助unsafe.Pointer,我們就可以實現(xiàn)在零拷貝的情況下,將[]byte數(shù)組直接轉(zhuǎn)換成string類型。
- bytes := []byte{104, 101, 108, 108, 111}
- p := unsafe.Pointer(&bytes) //將 *[]byte 指針強制轉(zhuǎn)換成unsafe.Pointer
- str := *(*string)(p) //將 unsafe.Pointer再轉(zhuǎn)換成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 value
- xp := (*ifaceWords)(unsafe.Pointer(&x)) // New value
- for {
- 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
- }
- }
大概的邏輯:
- 通過unsafe.Pointer將現(xiàn)有的和要寫入的值分別轉(zhuǎn)成ifaceWords類型,這樣我們下一步就可以得到這兩個interface{}的原始類型(typ)和真正的值(data)。
- 開始就是一個無限 for 循環(huán)。配合CompareAndSwap使用,可以達到樂觀鎖的效果。
- 通過LoadPointer這個原子操作拿到當前Value中存儲的類型。下面根據(jù)這個類型的不同,分3種情況處理。
第一次寫入 - 一個atomic.Value實例被初始化后,它的typ字段會被設置為指針的零值 nil,所以先判斷如果typ是nil 那就證明這個Value實例還未被寫入過數(shù)據(jù)。那之后就是一段初始寫入的操作:
- runtime_procPin()這是runtime中的一段函數(shù),一方面它禁止了調(diào)度器對當前 goroutine 的搶占(preemption),使得它在執(zhí)行當前邏輯的時候不被打斷,以便可以盡快地完成工作,因為別人一直在等待它。另一方面,在禁止搶占期間,GC 線程也無法被啟用,這樣可以防止 GC 線程看到一個莫名其妙的指向^uintptr(0)的類型(這是賦值過程中的中間狀態(tài))。
- 使用CAS操作,先嘗試將typ設置為^uintptr(0)這個中間狀態(tài)。如果失敗,則證明已經(jīng)有別的線程搶先完成了賦值操作,那它就解除搶占鎖,然后重新回到 for 循環(huán)第一步。
- 如果設置成功,那證明當前線程搶到了這個"樂觀鎖”,它可以安全的把v設為傳入的新值了。注意,這里是先寫data字段,然后再寫typ字段。因為我們是以typ字段的值作為寫入完成與否的判斷依據(jù)的。
第一次寫入還未完成- 如果看到typ字段還是^uintptr(0)這個中間類型,證明剛剛的第一次寫入還沒有完成,所以它會繼續(xù)循環(huán),一直等到第一次寫入完成。
第一次寫入已完成 - 首先檢查上一次寫入的類型與這一次要寫入的類型是否一致,如果不一致則拋出異常。反之,則直接把這一次要寫入的值寫入到data字段。
這個邏輯的主要思想就是,為了完成多個字段的原子性寫入,我們可以抓住其中的一個字段,以它的狀態(tài)來標志整個原子寫入的狀態(tài)。
讀取(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 = typ
- xp.data = data
- return
- }
讀取相對就簡單很多了,它有兩個分支:
如果當前的typ是 nil 或者^uintptr(0),那就證明第一次寫入還沒有開始,或者還沒完成,那就直接返回 nil (不對外暴露中間狀態(tài))。
否則,根據(jù)當前看到的typ和data構(gòu)造出一個新的interface{}返回出去。
總結(jié)
本文由淺入深的介紹了atomic.Value的使用姿勢,以及內(nèi)部實現(xiàn)。讓大家不僅知其然,還能知其所以然。
另外,原子操作由底層硬件支持,對于一個變量更新的保護,原子操作通常會更有效率,并且更能利用計算機多核的優(yōu)勢,如果要更新的是一個復合對象,則應當使用atomic.Value封裝好的實現(xiàn)。
而我們做并發(fā)同步控制常用到的Mutex鎖,則是由操作系統(tǒng)的調(diào)度器實現(xiàn),鎖應當用來保護一段邏輯。