在Go語言標準庫中,
sync/atomic
包將底層硬件提供的原子操作封裝成了Go的函數。但是這些操作支持幾種基本數據類型,因此爲了擴大原子操作的適用範圍,Go語言在1.4版本的時候向sync/atomic
包中添加了一個新的類型Value
。此類型的值相當於一個容器,可以被用來"原子的"存儲(store)和加載(load)任意類型的值。
歷史起源
我在golang-dev
郵件列表中翻到了14年的這段討論,有用戶報告了encoding/gob
包在多核機器上(80-core)
上的性能問題,認爲encoding/gob
之所以不能完全利用到多核的特性是因爲它裏面使用了大量的互斥鎖(mutex
),如果把這些互斥鎖換成用atomic.LoadPointer/StorePointer
來做併發控制,那性能將能提升20倍。
針對這個問題,有人提議在已有的atomic
包的基礎上封裝出一個atomic.Value
類型,這樣用戶就可以在不依賴Go內部類型unsafe.Point
的情況下使用到atomic
提供的原子操作。所以我們現在看到的atomic
包中除了atomic.Value
外,其餘都是早期由彙編寫成的,並且atomic.Value
類型的底層實現也是建立在已有的atomic
包的基礎上。
那爲什麼在上面的場景中,atomic
會比mutex
性能好很多呢?作者 Dmitry Vyukov 總結了這兩者的一個區別:
Mutexes do no scale. Atomic loads do.
Mutex
由操作系統實現,而atomic
包中的原子操作則由底層硬件直接提供支持。在 CPU 實現的指令集裏,有一些指令被封裝進了atomic
包,這些指令在執行的過程中是不允許中斷(interrupt
)的,因此原子操作可以在lock-free
的情況下保證併發安全,並且它的性能也能做到隨CPU
個數的增多而線性擴展。
好了,說了這麼多的原子操作,我們先來看看什麼樣的操作能被叫做原子操作 。
原子性
一個或者多個操作在 CPU 執行的過程中不被中斷的特性,稱爲原子性(atomicity) 。這些操作對外表現成一個不可分割的整體,他們要麼都執行,要麼都不執行,外界不會看到他們只執行到一半的狀態。而在現實世界中,CPU 不可能不中斷的執行一系列操作,但如果我們在執行多個操作時,能讓他們的中間狀態對外不可見,那我們就可以宣稱他們擁有了"不可分割”的原子性。
有些朋友可能不知道,在 Go(甚至是大部分語言)中,一條普通的賦值語句其實不是一個原子操作。例如,在32位機器上寫int64
類型的變量就會有中間狀態,因爲它會被拆成兩次寫操作(MOV
)——寫低 32 位和寫高 32 位,如下圖所示:
如果一個線程剛寫完低32位,還沒來得及寫高32位時,另一個線程讀取了這個變量,那它得到的就是一個毫無邏輯的中間變量,這很有可能使我們的程序出現詭異的 Bug。
這還只是一個基礎類型,如果我們對一個結構體進行賦值,那它出現併發問題的概率就更高了。很可能寫線程剛寫完一小半的字段,讀線程就來讀取這個變量,那麼就只能讀到僅修改了一部分的值。這顯然破壞了變量的完整性,讀出來的值也是完全錯誤的。
面對這種多線程下變量的讀寫問題,我們的主角——automic.Value
登場了,它使得我們可以不依賴於不保證兼容性的unsafe.Pointer
類型,同時又能將任意數據類型的讀寫操作封裝成原子性操作(讓中間狀態對外不可見)
使用姿勢
atomic.Value
類型對外暴露的方法有兩個:
v.Store(c)
——寫操作,將原始的變量c
存放到一個atomic.Value
類型的v
裏面。c = v.load()
——讀操作,從線程安全的v
中讀取上一步存放的內容。
間接的接口使得它的使用也很簡單,只需要激昂需要作併發保存的變量讀取和賦值作用load()
和store()
代替就行了。
下面時一個常見的使用場景:應用程序定期的從外界讀取最新的配置信息,然後更改自己內存中維護的配置變量。工作線程根據最新的配置來處理請求。
import (
"sync/atomic"
"time"
)
func loadConfig() map[string]string {
// 從數據庫或者文件系統中讀取配置信息,然後以map的形式存放在內存裏
return make(map[string]string)
}
func requests() chan int {
// 將從外界中接受到的請求放入到channel裏
return make(chan int)
}
func main() {
// config變量用來存放該服務的配置信息
var config atomic.Value
// 初始化時從別的地方加載配置文件,並存到config變量裏
config.Store(loadConfig())
go func() {
// 每10秒鐘定時的拉取最新的配置信息,並且更新到config變量裏
for {
time.Sleep(10 * time.Second)
// 對應於賦值操作 config = loadConfig()
config.Store(loadConfig())
}
}()
// 創建工作線程,每個工作線程都會根據它所讀取到的最新的配置信息來處理請求
for i := 0; i < 10; i++ {
go func() {
for r := range requests() {
// 對應於取值操作 c := config
// 由於Load()返回的是一個interface{}類型,所以我們要先強制轉換一下
c := config.Load().(map[string]string)
// 這裏是根據配置信息處理請求的邏輯...
_, _ = r, c
}
}()
}
}
內部實現
數據結構
atomic.Value
被設計用來存儲任意類型的數據,所以它內部的字段是一個interface{}
類型,非常的簡單粗暴
type Value struct {
v interface{}
}
除了value
之外,這個文件裏面還定義了一個ifaceWords
類型,這其實是一個空interface(interface{}
)的內部表示格式(參見runtime/runtime2.go中eface的定義)。它的作用是將interface{}
類型分解,得到其中的兩個字段:
type ifaceWords struct {
typ unsafe.Pointer // 原始類型
data unsafe.Pointer // 真實值
}
寫入(Store)操作
在介紹寫入之前,我們先來看一下 Go 語言內部的unsafe.Pointer
類型。
unsafe.Pointer
知道了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
}
}
大概的邏輯:
-
第5~6行 - 通過
unsafe.Pointer
將現有的和要寫入的值分別轉成ifaceWords
類型,這樣我們下一步就可以得到這兩個interface{}
的原始類型(typ)和真正的值(data)。
-
從第7行開始就是一個無限 for 循環。配合
CompareAndSwap
食用,可以達到樂觀鎖的功效。 -
第8行,我們可以通過
LoadPointer
這個原子操作拿到當前Value
中存儲的類型。下面根據這個類型的不同,分3種情況處理。 -
第一次寫入(第9~24行) - 一個
Value
實例被初始化後,它的typ
字段會被設置爲指針的零值 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
字段
這個邏輯的主要思想就是,爲了完成多個字段的原子性寫入,我們可以抓住其中的一個字段,以它的狀態來標誌整個原子寫入的狀態。
讀取(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
(不對外暴露中間狀態)。
- 否則,根據當前看到的
typ
和data
構造出一個新的interface{}
返回出去。
總結
本文從郵件列表中的一段討論開始,介紹了·atomic.Value
的被提出來的歷史緣由。然後由淺入深的介紹了它的使用姿勢,以及內部實現。讓大家不僅知其然,還能知其所以然。
另外,再強調一遍,原子操作由底層硬件支持,而鎖則由操作系統的調度器實現。鎖應當用來保護一段邏輯,對於一個變量更新的保護,原子操作通常會更有效率,並且更能利用計算機多核的優勢,如果要更新的是一個複合對象,則應當使用atomic.Value
封裝好的實現。