Golang 五種原子性操作的用法詳解
本文我們詳細聊一下Go
語言的原子操作的用法,啥是原子操作呢?顧名思義,原子操作就是具備原子性的操作... 是不是感覺說了跟沒說一樣,原子性的解釋如下:
一個或者多個操作在 CPU 執行的過程中不被中斷的特性,稱爲_原子性(atomicity)_ 。這些操作對外表現成一個不可分割的整體,他們要麼都執行,要麼都不執行,外界不會看到他們只執行到一半的狀態。
CPU
執行一系列操作時不可能不發生中斷,但如果我們在執行多個操作時,能讓他們的中間狀態對外不可見,那我們就可以宣稱他們擁有了 " 不可分割” 的原子性。
類似的解釋我們在數據庫事務的ACID
概念裏也聽過。
Go 語言提供了哪些原子操作
Go
語言通過內置包sync/atomic
提供了對原子操作的支持,其提供的原子操作有以下幾大類:
-
增減,操作的方法名方式爲
AddXXXType
,保證對操作數進行原子的增減,支持的類型爲int32
、int64
、uint32
、uint64
、uintptr
,使用時以實際類型替換前面我說的XXXType
就是對應的操作方法。 -
載入,保證了讀取到操作數前沒有其他任務對它進行變更,操作方法的命名方式爲
LoadXXXType
,支持的類型除了基礎類型外還支持Pointer
,也就是支持載入任何類型的指針。 -
存儲,有載入了就必然有存儲操作,這類操作的方法名以
Store
開頭,支持的類型跟載入操作支持的那些一樣。 -
比較並交換,也就是
CAS
(Compare And Swap),像Go
的很多併發原語實現就是依賴的CAS
操作,同樣是支持上面列的那些類型。 -
交換,這個簡單粗暴一些,不比較直接交換,這個操作很少會用。
互斥鎖跟原子操作的區別
平日裏,在併發編程裏,Go 語言sync
包裏的同步原語Mutex
是我們經常用來保證併發安全的,那麼他跟atomic
包裏的這些操作有啥區別呢?在我看來他們在使用目的和底層實現上都不一樣:
-
使用目的:互斥鎖是用來保護一段邏輯,原子操作用於對一個變量的更新保護。
-
底層實現:
Mutex
由操作系統的調度器實現,而atomic
包中的原子操作則由底層硬件指令直接提供支持,這些指令在執行的過程中是不允許中斷的,因此原子操作可以在lock-free
的情況下保證併發安全,並且它的性能也能做到隨CPU
個數的增多而線性擴展。
對於一個變量更新的保護,原子操作通常會更有效率,並且更能利用計算機多核的優勢。
比如下面這個,使用互斥鎖的併發計數器程序:
func mutexAdd() {
var a int32 = 0
var wg sync.WaitGroup
var mu sync.Mutex
start := time.Now()
for i := 0; i < 100000000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
mu.Lock()
a += 1
mu.Unlock()
}()
}
wg.Wait()
timeSpends := time.Now().Sub(start).Nanoseconds()
fmt.Printf("use mutex a is %d, spend time: %v\n", a, timeSpends)
}
把Mutex
改成用方法atomic.AddInt32(&a, 1)
調用,在不加鎖的情況下仍然能確保對變量遞增的併發安全。
func AtomicAdd() {
var a int32 = 0
var wg sync.WaitGroup
start := time.Now()
for i := 0; i < 1000000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
atomic.AddInt32(&a, 1)
}()
}
wg.Wait()
timeSpends := time.Now().Sub(start).Nanoseconds()
fmt.Printf("use atomic a is %d, spend time: %v\n", atomic.LoadInt32(&a), timeSpends)
}
可以在本地運行以上這兩段代碼,可以觀察到計數器的結果都最後都是1000000
,都是線程安全的。
需要注意的是,所有原子操作方法的被操作數形參必須是指針類型,通過指針變量可以獲取被操作數在內存中的地址,從而施加特殊的 CPU 指令,確保同一時間只有一個 goroutine 能夠進行操作。
上面的例子除了增加操作外我們還演示了載入操作,接下來我們來看一下CAS
操作。
比較並交換
該操作簡稱CAS
(Compare And Swap)。這類操作的前綴爲 CompareAndSwap
:
func CompareAndSwapInt32(addr *int32, old, new int32) (swapped bool)
func CompareAndSwapPointer(addr *unsafe.Pointer, old, new unsafe.Pointer) (swapped bool)
該操作在進行交換前首先確保被操作數的值未被更改,即仍然保存着參數 old
所記錄的值,滿足此前提條件下才進行交換操作。CAS
的做法類似操作數據庫時常見的樂觀鎖機制。
需要注意的是,當有大量的 goroutine 對變量進行讀寫操作時,可能導致CAS
操作無法成功,這時可以利用for
循環多次嘗試。
上面我只列出了比較典型的int32
和unsafe.Pointer
類型的CAS
方法,主要是想說除了讀數值類型進行比較交換,還支持對指針進行比較交換。
unsafe.Pointer 提供了繞過 Go 語言指針類型限制的方法,unsafe 指的並不是說不安全,而是說官方並不保證向後兼容。
// 定義一個struct類型P
type P struct{ x, y, z int }
// 執行類型P的指針
var pP *P
func main() {
// 定義一個執行unsafe.Pointer值的指針變量
var unsafe1 = (*unsafe.Pointer)(unsafe.Pointer(&pP))
// Old pointer
var sy P
// 爲了演示效果先將unsafe1設置成Old Pointer
px := atomic.SwapPointer(
unsafe1, unsafe.Pointer(&sy))
// 執行CAS操作,交換成功,結果返回true
y := atomic.CompareAndSwapPointer(
unsafe1, unsafe.Pointer(&sy), px)
fmt.Println(y)
}
上面的示例並不是在併發環境下進行的CAS
,只是爲了演示效果,先把被操作數設置成了Old Pointer
。
其實Mutex
的底層實現也是依賴原子操作中的CAS
實現的,原子操作的atomic
包相當於是sync
包裏的那些同步原語的實現依賴。
比如互斥鎖Mutex
的結構裏有一個state
字段,其是表示鎖狀態的狀態位。
type Mutex struct {
state int32
sema uint32
}
爲了方便理解,我們在這裏將它的狀態定義爲 0 和 1,0 代表目前該鎖空閒,1 代表已被加鎖,以下是sync.Mutex
中Lock
方法的部分實現代碼。
func (m *Mutex) Lock() {
// Fast path: grab unlocked mutex.
if atomic.CompareAndSwapInt32(&m.state, 0, mutexLocked) {
if race.Enabled {
race.Acquire(unsafe.Pointer(m))
}
return
}
// Slow path (outlined so that the fast path can be inlined)
m.lockSlow()
}
在atomic.CompareAndSwapInt32(&m.state, 0, mutexLocked)
中,m.state
代表鎖的狀態,通過CAS
方法,判斷鎖此時的狀態是否空閒(m.state==0
),是,則對其加鎖(mutexLocked
常量的值爲 1)。
atomic.Value 保證任意值的讀寫安全
atomic
包裏提供了一套Store
開頭的方法,用來保證各種類型變量的併發寫安全,避免其他操作讀到了修改變量過程中的髒數據。
func StoreInt32(addr *int32, val int32)
func StoreInt64(addr *int64, val int64)
func StorePointer(addr *unsafe.Pointer, val unsafe.Pointer)
...
這些操作方法的定義與上面介紹的那些操作的方法類似,我就不再演示怎麼使用這些方法了。
值得一提的是如果你想要併發安全的設置一個結構體的多個字段,除了把結構體轉換爲指針,通過StorePointer
設置外,還可以使用atomic
包後來引入的atomic.Value
,它在底層爲我們完成了從具體指針類型到unsafe.Pointer
之間的轉換。
有了atomic.Value
後,它使得我們可以不依賴於不保證兼容性的unsafe.Pointer
類型,同時又能將任意數據類型的讀寫操作封裝成原子性操作(中間狀態對外不可見)。
atomic.Value
類型對外暴露了兩個方法:
-
v.Store(c)
- 寫操作,將原始的變量c
存放到一個atomic.Value
類型的v
裏。 -
c := v.Load()
- 讀操作,從線程安全的v
中讀取上一步存放的內容。
1.17 版本我看還增加了Swap
和CompareAndSwap
方法。
簡潔的接口使得它的使用也很簡單,只需將需要做併發保護的變量讀取和賦值操作用Load()
和Store()
代替就行了。
由於Load()
返回的是一個interface{}
類型,所以在使用前我們記得要先轉換成具體類型的值,再使用。下面是一個簡單的例子演示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 個協程併發更新
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
類型的指針變量賦值,看看在併發條件下,兩個字段的值是不是能跟預期的一樣變成 10 和 15。
總結
本文詳細介紹了 Go 語言原子操作atomic
包中會被高頻使用的操作的使用場景和用法,當然我並沒有羅列atomic
包裏所有操作的用法,主要是考慮到有的用到的地方實在不多,或者是已經被更好的方式替代,還有就是覺得確實沒必要,看完本文的內容相信你已經完全具備自行探索atomic
包的能力了。
再強調一遍,原子操作由底層硬件支持,而鎖則由操作系統的調度器實現。鎖應當用來保護一段邏輯,對於一個變量更新的保護,原子操作通常會更有效率,並且更能利用計算機多核的優勢,如果要更新的是一個複合對象,則應當使用atomic.Value
封裝好的實現。
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/W48sjzxwjPYKgcY8DavBYA