Go語言原子操作及互斥鎖,有什麼區別呢?

Go語言圈 發表於 2022-01-19
Go

文章來自微信公眾號:Go語言圈


原子操作就是不可中斷的操作,外界是看不到原子操作的中間狀態,要麼看到原子操作已經完成,要麼看到原子操作已經結束。在某個值的原子操作執行的過程中,CPU絕對不會再去執行其他針對該值的操作,那麼其他操作也是原子操作。

Go語言中提供的原子操作都是非侵入式的,在標準庫程式碼包sync/atomic中提供了相關的原子函式。


增或減
用於增或減的原子操作的函式名稱都是以”Add”開頭的,後面跟具體的型別名,比如下面這個示例就是int64型別的原子減操作

func main() {
   var  counter int64 =  23
   atomic.AddInt64(&counter,-3)
   fmt.Println(counter)
}
---output---
20

原子函式的第一個引數都是指向變數型別的指標,是因為原子操作需要知道該變數在記憶體中的存放位置,然後加以特殊的CPU指令,也就是說對於不能取得記憶體存放地址的變數是無法進行原子操作的。

第二個引數的型別會自動轉換為與第一個引數相同的型別。此外,原子操作會自動將操作後的值賦值給變數,無需我們自己手動賦值了。

對於 atomic.AddUint32()atomic.AddUint64() 的第二個引數為 uint32uint64,因此無法直接傳遞一個負的數值進行減法操作,Go語言提供了另一種方法來迂迴實現:使用二進位制補碼的特性

注意:unsafe.Pointer型別的值無法被加減。


比較並交換(Compare And Swap)
簡稱CAS,在標準庫程式碼包sync/atomic中以”Compare And Swap“為字首的若干函式就是CAS操作函式,比如下面這個

func CompareAndSwapInt32(addr *int32, old, new int32) (swapped bool)

第一個引數的值是這個變數的指標,第二個引數是這個變數的舊值,第三個引數指的是這個變數的新值。

執行過程:呼叫CompareAndSwapInt32 後,會先判斷這個指標上的值是否跟舊值相等,若相等,就用新值覆蓋掉這個值,若相等,那麼後面的操作就會被忽略掉。返回一個 swapped 布林值,表示是否已經進行了值替換操作。

與鎖有不同之處:鎖總是假設會有併發操作修改被操作的值,而CAS總是假設值沒有被修改,因此CAS比起鎖要更低的效能損耗,鎖被稱為悲觀鎖,而CAS被稱為樂觀鎖。

CAS的使用示例

var value int32
func AddValue(delta int32)  {
   for {
      v:= value
      if atomic.CompareAndSwapInt32(&value,v,(v+delta)) {
         break
      }
   }
}

由示例可以看出,我們需要多次使用for迴圈來判斷該值是否已被更改,為了保證CAS操作成功,僅在 CompareAndSwapInt32 返回為 true時才退出迴圈,這跟自旋鎖的自旋行為相似。


載入與儲存
對一個值進行讀或寫時,並不代表這個值是最新的值,也有可能是在在讀或寫的過程中進行了併發的寫操作導致原值改變。為了解決這問題,Go語言的標準庫程式碼包sync/atomic提供了原子的讀取(Load為字首的函式)或寫入(Store為字首的函式)某個值

將上面的示例改為原子讀取

var value int32
func AddValue(delta int32)  {
   for {
      v:= atomic.LoadInt32(&value)
      if atomic.CompareAndSwapInt32(&value,v,(v+delta)) {
         break
      }
   }
}

原子寫入總會成功,因為它不需要關心原值是什麼,而CAS中必須關注舊值,因此原子寫入並不能代替CAS,原子寫入包含兩個引數,以下面的StroeInt32為例:

//第一個引數是被操作值的指標,第二個是被操作值的新值
func StoreInt32(addr *int32, val int32) 

交換
這類操作都以”Swap“開頭的函式,稱為”原子交換操作“,功能與之前說的CAS操作與原子寫入操作有相似之處。

func SwapInt32(addr *int32, new int32) (old int32)

SwapInt32 為例,第一個引數是int32型別的指標,第二個是新值。原子交換操作不需要關心原值,而是直接設定新值,但是會返回被操作值的舊值。

原子值
Go語言的標準庫程式碼包sync/atomic中有一個叫做Value的原子值,它是一個結構體型別,用於儲存需要原子讀寫的值,結構體如下

// Value提供原子載入並儲存一致型別的值。
// Value的零值從Load返回nil。
//呼叫Store後,不得複製值。
//首次使用後不得複製值。
type Value struct {
   v interface{}
}

可以看出結構體內是一個 v interface{},也就是說 該Value原子值可以儲存任何型別的需要原子讀寫的值。

使用方式如下:

var Atomicvalue  atomic.Value

該型別有兩個公開的指標方法

//原子的讀取原子值例項中儲存的值,返回一個 interface{} 型別的值,且不接受任何引數。
//若未曾透過store方法儲存值之前,會返回nil
func (v *Value) Load() (x interface{})

//原子的在原子例項中儲存一個值,接收一個 interface{} 型別(不能為nil)的引數,且不會返回任何值
func (v *Value) Store(x interface{})

一旦原子值例項儲存了某個型別的值,那麼之後Store儲存的值就必須是與該型別一致,否則就會引發panic

嚴格來講,atomic.Value型別的變數一旦被宣告,就不應該被複制到其他地方。比如:作為源值賦值給其他變數,作為引數傳遞給函式,作為結果值從函式返回,作為元素值透過通道傳遞,這些都會造成值的複製。

但是atomic.Value型別的指標型別變數就不會存在這個問題,原因是對結構體的複製不但會生成該值的副本,還會生成其中欄位的副本,這樣那麼併發引發的值變化都與原值沒關係了。


看下面這個小示例

func main() {
   var Atomicvalue  atomic.Value
   Atomicvalue.Store([]int{1,2,3,4,5})
   anotherStore(Atomicvalue)
   fmt.Println("main: ",Atomicvalue)
}

func anotherStore(Atomicvalue atomic.Value)  {
   Atomicvalue.Store([]int{6,7,8,9,10})
   fmt.Println("anotherStore: ",Atomicvalue)
}
---output---
anotherStore:  {[6 7 8 9 10]}
main:  {[1 2 3 4 5]}


原子操作與互斥鎖的區別
互斥鎖是一種資料結構,使你可以執行一系列互斥操作。而原子操作是互斥的單個操作,這意味著沒有其他執行緒可以打斷它。那麼就Go語言裡atomic包裡的原子操作和sync包提供的同步鎖有什麼不同呢?

首先atomic操作的優勢是更輕量,比如CAS可以在不形成臨界區和建立互斥量的情況下完成併發安全的值替換操作。這可以大大的減少同步對程式效能的損耗。

原子操作也有劣勢。還是以CAS操作為例,使用CAS操作的做法趨於樂觀,總是假設被操作值未曾被改變(即與舊值相等),並一旦確認這個假設的真實性就立即進行值替換,那麼在被操作值被頻繁變更的情況下,CAS操作並不那麼容易成功。

而使用互斥鎖的做法則趨於悲觀,我們總假設會有併發的操作要修改被操作的值,並使用鎖將相關操作放入臨界區中加以保護。


所以總結下來原子操作與互斥鎖的區別有:

  • 互斥鎖是一種資料結構,用來讓一個執行緒執行程式的關鍵部分,完成互斥的多個操作。
  • 原子操作是針對某個值的單個互斥操作。

可以把互斥鎖理解為悲觀鎖,共享資源每次只給一個執行緒使用,其它執行緒阻塞,用完後再把資源轉讓給其它執行緒。

atomic包提供了底層的原子性記憶體原語,這對於同步演算法的實現很有用。這些函式一定要非常小心地使用,使用不當反而會增加系統資源的開銷,對於應用層來說,最好使用通道或sync包中提供的功能來完成同步操作。

本作品採用《CC 協議》,轉載必須註明作者和本文連結
歡迎關注微信公眾號:Go語言圈   點選加入:Go語言技術微信群