GO安全併發之無鎖原子操作

sunsky303發表於2017-08-30

 

宣告:本文是《Go併發程式設計實戰》的樣章,禁止以任何形式轉載此文。

摘要:

  我們已經知道,原子操作即是進行過程中不能被中斷的操作。也就是說,針對某個值的原子操作在被進行的過程當中,CPU絕不會再去進行其它的針對該值的操作。無論這些其它的操作是否為原子操作都會是這樣。為了實現這樣的嚴謹性,原子操作僅會由一個獨立的CPU指令代表和完成。只有這樣才能夠在併發環境下保證原子操作的絕對安全。
Go語言提供的原子操作都是非侵入式的。它們由標準庫程式碼包sync/atomic中的眾多函式代表。我們可以通過呼叫這些函式對幾種簡單的型別的值進行原子操作。這些型別包括int32、int64、uint32、uint64、uintptr和unsafe.Pointer型別,共6個。這些函式提供的原子操作共有5種,即:增或減、比較並交換、載入、儲存和交換。它們分別提供了不同的功能,且適用的場景也有所區別。下面,我們就根據這些種類對Go語言提供的原子操作進行逐一的講解。

 1. 增或減
  被用於進行增或減的原子操作(以下簡稱原子增/減操作)的函式名稱都以“Add”為字首,並後跟針對的具體型別的名稱。例如,實現針對uint32型別的原子增/減操作的函式的名稱為AddUint32。事實上,sync/atomic包中的所有函式的命名都遵循此規則。
顧名思義,原子增/減操作即可實現對被操作值的增大或減小。因此,被操作值的型別只能是數值型別。更具體的講,它只能是我們在前面提到的int32、int64、uint32、uint64和uintptr型別。例如,我們如果想原子的把一個int32型別的變數i32的值增大3的話,可以這樣做:

1 newi32 := atomic.AddInt32(&i32, 3)

  我們將指向i32變數的值的指標值和代表增減的差值3作為引數傳遞給了atomic.AddInt32函式。之所以要求第一個引數值必須是一個指標型別的值,是因為該函式需要獲得到被操作值在記憶體中的存放位置,以便施加特殊的CPU指令。從另一個角度看,對於一個不能被取址的數值,我們是無法進行原子操作的。此外,這類函式的第二個引數的型別被操作值的型別總是相同的。因此,在前面那個呼叫表示式被求值的時候,字面量3會被自動轉換為一個int32型別的值。函式atomic.AddInt32在被執行結束之時會返回經過原子操作後的新值。不過不要誤會,我們無需把這個新值再賦給原先的變數i32。因為它的值已經在atomic.AddInt32函式返回之前被原子的修改了。
與該函式類似的還有atomic.AddInt64函式、atomic.AddUint32函式、atomic.AddUint64函式和atomic.AddUintptr函式。這些函式也可以被用來原子的增/減對應型別的值。例如,如果我們要原子的將int64型別的變數i64的值減小3話,可以這樣編寫程式碼:

1 var i64 int64
2 atomic.AddInt64(&i64, -3)

  不過,由於atomic.AddUint32函式和atomic.AddUint64函式的第二個引數的型別分別是uint32和uint64,所以我們無法通過傳遞一個負的數值來減小被操作值。那麼,這是不是就意味著我們無法原子的減小uint32或uint64型別的值了呢?幸好,不是這樣。Go語言為我們提供了一個可以迂迴的達到此目的辦法。
如果我們想原子的把uint32型別的變數ui32的值增加NN(NN代表了一個負整數),那麼我們可以這樣呼叫atomic.AddUint32函式:

1 atomic.AddUint32(&ui32, ^uint32(-NN-1))

  對於uint64型別的值來說也是這樣。呼叫表示式

1 atomic.AddUint64(&ui64, ^uint64(-NN-1))

  表示原子的把uint64型別的變數ui64的值增加NN(或者說減小-NN)。
  之所以這種方式可以奏效,是因為它利用了二進位制補碼的特性。我們知道,一個負整數的補碼可以通過對它按位(除了符號位之外)求反碼並加一得到。我們還知道,一個負整數可以由對它的絕對值減一併求補碼後得到的數值的二進位制表示來代表。例如,如果NN是一個int型別的變數且其值為-35,那麼表示式

1 uint32(int32(NN))

1 ^uint32(-NN-1)

的結果值就都會是11111111111111111111111111011101。由此,我們使用^uint32(-NN-1)和^uint64(-NN-1)來分別表示uint32型別和uint64型別的NN就順理成章了。這樣,我們就可以合理的繞過uint32型別和uint64型別對值的限制了。
以上是官方提供一種通用解決方案。除此之外,我們還有兩個非通用的方案可供選擇。首先,需要明確的是,對於一個代表負數的字面常量來說,它們是無法通過簡單的型別轉換將其轉換為uint32型別或uint64型別的值的。例如,表示式uint32(-35)和uint64(-35)都是不合法的。它們都不能通過編譯。但是,如果我們事先把這個字面量賦給一個變數然後再對這個變數進行型別轉換,那麼就可以得到Go語言編譯器的認可。我們依然以值為-35的變數NN為例,下面這條語句可以通過編譯並被正常執行:

1 fmt.Printf("The variable: %b.
"
, uint32(NN))

其輸出內容為:

1 The variable: 11111111111111111111111111011101.

可以看到,表示式uint32(NN)的結果值的二進位制表示與前面的uint32(int32(NN))表示式以及^uint32(-NN-1)表示式的結果值是一致的。它們都可以被用來表示uint32型別的-35。因此,我們也可以使用下面的呼叫表示式來原子的把變數ui32的值減小-NN:
atomic.AddUint32(&ui32, uint32(NN))
不過,這樣的編寫方式僅在NN是數值型別的變數的時候才可以通過編譯。如果NN是一個常量,那麼也會使表示式uint32(NN)不合法並無法通過編譯。它與表示式uint32(-35)造成的編譯錯誤是一致的。在這種情況下,我們可以這樣來達到上述目的:

1 atomic.AddUint32(&ui32, NN&math.MaxUint32)

其中,我們用到了標準庫程式碼包math中的常量MaxUint32。math.MaxUint32常量表示的是一個32位的、所有二進位制位上均為1的數值。我們把NN和math.MaxUint32進行按位與操作的意義是使前者的值能夠被視為一個uint32型別的數值。實際上,對於表示式NN&math.MaxUint32來說,其結果值的二進位制表示與前面uint32(int32(NN))表示式以及^uint32(-NN-1)表示式的結果值也是一致的。
我們在這裡介紹的這兩種非官方的解決方案是不能混用的。更具體地說,如果NN是一個常量,那麼表示式uint32(NN)是無法通過編譯的。而如果NN是一個變數,那麼表示式NN&math.MaxUint32就無法通過編譯。前者的錯誤在於代表負整數的字面常量不能被轉換為uint32型別的值。後者的錯誤在於這個按位與運算的結果值的型別不是uint32型別而是int型別,從而導致資料溢位的錯誤。相比之下,官方給出的那個解決方案的適用範圍更廣。
有些讀者可能會有這樣的疑問:為什麼如此曲折的實現這一功能?直接宣告出atomic.SubUint32()函式和atomic.SubUint64()函式不好嗎?作者理解,不這樣做是為了讓這些原子操作的API可以整齊劃一,並且避免在擴充它們的時候使sync/atomic包中宣告的程式實體成倍增加。(作者向Go語言官方提出了這個問題並引發了一些討論,他們也許會使用投票的方式來選取更好一些的方案)
注意,並不存在名為atomic.AddPointer的函式,因為unsafe.Pointer型別值之間既不能被相加也不能被相減。
2. 比較並交換
有些讀者可能很熟悉比較並交換操作的英文稱謂——Compare And Swap,簡稱CAS。在sync/atomic包中,這類原子操作由名稱以“CompareAndSwap”為字首的若干個函式代表。
我們依然以針對int32型別值的函式為例。該函式名為CompareAndSwapInt32。其宣告如下:

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

可以看到,CompareAndSwapInt32函式接受三個引數。第一個引數的值應該是指向被操作值的指標值。該值的型別即為*int32。後兩個引數的型別都是int32型別。它們的值應該分別代表被操作值的舊值和新值。CompareAndSwapInt32函式在被呼叫之後會先判斷引數addr指向的被操作值與引數old的值是否相等。僅當此判斷得到肯定的結果之後,該函式才會用引數new代表的新值替換掉原先的舊值。否則,後面的替換操作就會被忽略。這正是“比較並交換”這個短語的由來。CompareAndSwapInt32函式的結果swapped被用來表示是否進行了值的替換操作。
與我們前面講到的鎖相比,CAS操作有明顯的不同。它總是假設被操作值未曾被改變(即與舊值相等),並一旦確認這個假設的真實性就立即進行值替換。而使用鎖則是更加謹慎的做法。我們總是先假設會有併發的操作要修改被操作值,並使用鎖將相關操作放入臨界區中加以保護。我們可以說,使用鎖的做法趨於悲觀,而CAS操作的做法則更加樂觀。
CAS操作的優勢是,可以在不形成臨界區和建立互斥量的情況下完成併發安全的值替換操作。這可以大大的減少同步對程式效能的損耗。當然,CAS操作也有劣勢。在被操作值被頻繁變更的情況下,CAS操作並不那麼容易成功。有些時候,我們可能不得不利用for迴圈以進行多次嘗試。示例如下:

1 var value int32
2 func addValue(delta int32) {
3 for {
4 v := value
5 if atomic.CompareAndSwapInt32(&value, v, (v + delta)) {
6 break
7 }
8 }
9 }

可以看到,為了保證CAS操作的成功完成,我們僅在CompareAndSwapInt32函式的結果值為true時才會退出迴圈。這種做法與自旋鎖的自旋行為相似。addValue函式會不斷的嘗試原子的更新value的值,直到這一操作成功為止。操作失敗的緣由總會是value的舊值已不與v的值相等了。如果value的值會被併發的修改的話,那麼發生這種情況是很正常的。
CAS操作雖然不會讓某個Goroutine阻塞在某條語句上,但是仍可能會使流程的執行暫時停滯。不過,這種停滯的時間大都極其短暫。
請記住,當想併發安全的更新一些型別(更具體的講是,前文所述的那6個型別)的值的時候,我們總是應該優先選擇CAS操作。
與此對應,被用來進行原子的CAS操作的函式共有6個。除了我們已經講過的CompareAndSwapInt32函式之外,還有CompareAndSwapInt64、CompareAndSwapPointer、CompareAndSwapUint32、CompareAndSwapUint64 和CompareAndSwapUintptr函式。這些函式的結果宣告列表與CompareAndSwapInt32函式的完全一致。而它們的引數宣告列表與後者也非常類似。雖然其中的那三個引數的型別不同,但其遵循的規則是一致的,即:第二個和第三個引數的型別均為與第一個引數的型別(即某個指標型別)緊密相關的那個型別。例如,如果第一個引數的型別為*unsafe.Pointer,那麼後兩個引數的型別就一定是unsafe.Pointer。這也是由這三個引數的含義決定的。
3. 載入
在前面示例的for迴圈中,我們使用語句v := value為變數v賦值。但是,要注意,其中的讀取value的值的操作並不是併發安全的。在該讀取操作被進行的過程中,其它的對此值的讀寫操作是可以被同時進行的。它們並不會受到任何限制。
在第7章的第1節的最後,我們舉過這樣一個例子:在32位計算架構的計算機上寫入一個64位的整數。如果在這個寫操作未完成的時候有一個讀操作被併發的進行了,那麼這個讀操作很可能會讀取到一個只被修改了一半的資料。這種結果是相當糟糕的。
為了原子的讀取某個值,sync/atomic程式碼包同樣為我們提供了一系列的函式。這些函式的名稱都以“Load”為字首,意為載入。我們依然以針對int32型別值的那個函式為例。
我們下面利用LoadInt32函式對上一個示例稍作修改:

1 func addValue(delta int32) {
2 for {
3 v := atomic.LoadInt32(&value)
4 if atomic.CompareAndSwapInt32(&value, v, (v + delta)) {
5 break
6 }
7 }
8 }

函式atomic.LoadInt32接受一個*int32型別的指標值,並會返回該指標值指向的那個值。在該示例中,我們使用呼叫表示式atomic.LoadInt32(&value)替換掉了識別符號value。替換後,那條賦值語句的含義就變為:原子的讀取變數value的值並把它賦給變數v。有了“原子的”這個形容詞就意味著,在這裡讀取value的值的同時,當前計算機中的任何CPU都不會進行其它的針對此值的讀或寫操作。這樣的約束是受到底層硬體的支援的。
注意,雖然我們在這裡使用atomic.LoadInt32函式原子的載入value的值,但是其後面的CAS操作仍然是有必要的。因為,那條賦值語句和if語句並不會被原子的執行。在它們被執行期間,CPU仍然可能進行其它的針對value的值的讀或寫操作。也就是說,value的值仍然有可能被併發的改變。
與atomic.LoadInt32函式的功能類似的函式有atomic.LoadInt64、atomic.LoadPointer、atomic.LoadUint32、atomic.LoadUint64和atomic.LoadUintptr。
4. 儲存
與讀取操作相對應的是寫入操作。而sync/atomic包也提供了與原子的值載入函式相對應的原子的值儲存函式。這些函式的名稱均以“Store”為字首。
在原子的儲存某個值的過程中,任何CPU都不會進行鍼對同一個值的讀或寫操作。如果我們把所有針對此值的寫操作都改為原子操作,那麼就不會出現針對此值的讀操作因被併發的進行而讀到修改了一半的值的情況了。
原子的值儲存操作總會成功,因為它並不會關心被操作值的舊值是什麼。顯然,這與前面講到的CAS操作是有著明顯的區別的。因此,我們並不能把前面展示的addValue函式中的呼叫atomic.CompareAndSwapInt32函式的表示式替換為對atomic.StoreInt32函式的呼叫表示式。
函式atomic.StoreInt32會接受兩個引數。第一個引數的型別是*int 32型別的,其含義同樣是指向被操作值的指標。而第二個引數則是int32型別的,它的值應該代表欲儲存的新值。其它的同類函式也會有類似的引數宣告列表。
5. 交換
在sync/atomic程式碼包中還存在著一類函式。它們的功能與前文所講的CAS操作和原子載入操作都有些類似。這樣的功能可以被稱為原子交換操作。這類函式的名稱都以“Swap”為字首。
與CAS操作不同,原子交換操作不會關心被操作值的舊值。它會直接設定新值。但它又比原子載入操作多做了一步。作為交換,它會返回被操作值的舊值。此類操作比CAS操作的約束更少,同時又比原子載入操作的功能更強。
以atomic.SwapInt32函式為例。它接受兩個引數。第一個引數是代表了被操作值的記憶體地址的*int32型別值,而第二個引數則被用來表示新值。注意,該函式是有結果值的。該值即是被新值替換掉的舊值。atomic.SwapInt32函式被呼叫後,會把第二個引數值置於第一個引數值所表示的記憶體地址上(即修改被操作值),並將之前在該地址上的那個值作為結果返回。其它的同類函式的宣告和作用都與此類似。
至此,我們快速且簡要地介紹了sync/atomic程式碼包中的所有函式的功能和用法。這些函式都被用來對特定型別的值進行原子性的操作。如果我們想以併發安全的方式操作單一的特定型別(int32、int64、uint32、uint64、uintptr或unsafe.Pointer)的值的話,應該首先考慮使用這些函式來實現。請注意,原子的減小一些特定型別(確切地說,是uint32型別和uint64型別)的值的實現方式並不那麼直觀。在Go語言官方對此進行改進之前,我們應該按照他們為我們提供的那種方式來進行此類操作。
6. 應用於實際
下面,我們就使用剛剛介紹的知識再次對在前面示例中建立的*myDataFile型別進行改造。在*myDataFile型別的第二個版本中,我們仍然使用兩個互斥鎖來對與roffset欄位和woffset欄位相關的操作進行保護。*myDataFile型別的方法中的絕大多數都包含了這些操作。
首先,我們來看對roffset欄位的操作。在*myDataFile型別的Read方法中有這樣一段程式碼:

1 // 讀取並更新讀偏移量
2 var offset int64
3 df.rmutex.Lock()
4 offset = df.roffset
5 df.roffset += int64(df.dataLen)
6 df.rmutex.Unlock()

這段程式碼的含義是讀取讀偏移量的值並把它存入到區域性變數中,然後增加讀偏移量的值以使其它的併發的讀操作能夠被正確、有效的進行。為了使程式能夠在併發環境下有序的對roffset欄位進行操作,我們為這段程式碼應用了互斥鎖rmutex。
欄位roffset和變數offset都是int64型別的。後者代表了前者的舊值。而欄位roffset的新值即為其舊值與dataLen欄位的值的和。實際上,這正是原子的CAS操作的適用場景。我們現在用CAS操作來實現該段程式碼的功能:

1 // 讀取並更新讀偏移量
2 var offset int64
3 for {
4 offset = df.roffset
5 if atomic.CompareAndSwapInt64(&df.roffset, offset,
6 (offset + int64(df.dataLen))) {
7 break
8 }
9 }

根據roffset和offset的型別,我們選用atomic.CompareAndSwapInt64來進行CAS操作。我們在呼叫該函式的時候傳入了三個引數,分別代表了被操作值的地址、舊值和新值。如果該函式的結果值是true,那麼我們就退出for迴圈。這時,變數offset即是我們需要的讀偏移量的值。另一方面,如果該函式的結果值是false,那麼就說明在從完成讀取到開始更新roffset欄位的值的期間內有其它的併發操作對該值進行了更改。當遇到這種情況,我們就需要再次嘗試。只要嘗試失敗,我們就會重新讀取roffset欄位的值並試圖對該值進行CAS操作,直到成功為止。具體的嘗試次數與具體的併發環境有關。
我們在前面說過,在32位計算架構的計算機上寫入一個64位的整數也會存在併發安全方面的隱患。因此,我們還應該將這段程式碼中的offset = df.roffset語句修改為offset = atomic.LoadInt64(&df.roffset)。
除了這裡,在*myDataFile型別的Rsn方法中也有針對roffset欄位的讀操作:

1 df.rmutex.Lock()
2 defer df.rmutex.Unlock()
3 return df.roffset / int64(df.dataLen)

我們現在去掉施加在上面的鎖定和解鎖操作,轉而使用原子操作來實現它。修改後的程式碼如下:

1 offset := atomic.LoadInt64(&df.roffset)
2 return offset / int64(df.dataLen)

這樣,我們就在依然保證相關操作的併發安全的前提下去除了對互斥鎖rmutex的使用。對於欄位woffset和互斥鎖wmutex,我們也應該如法炮製。讀者可以試著按照上面的方法修改與之相關的Write方法和Wsn方法。
在修改完成之後,我們就可以把代表互斥鎖的rmutex欄位和wmutex欄位從*myDataFile型別的基本結構中去掉了。這樣,該型別的基本結構會顯得精簡了不少。
通過本次改造,我們減少了*myDataFile型別及其方法對互斥鎖的使用。這對該程度的效能和可伸縮性都會有一定的提升。其主要原因是,原子操作由底層硬體支援,而鎖則由作業系統提供的API實現。若實現相同的功能,前者通常會更有效率。讀者可以為前面展示的這三個版本的*myDataFile型別的實現編寫效能測試,以驗證上述觀點的正確性。
總之,我們要善用原子操作。因為它比鎖更加簡練和高效。不過,由於原子操作自身的限制,鎖依然常用且重要。

 
  另外,可以參考 非阻塞同步演算法與CAS(Compare and Swap)無鎖演算法(java內幕原理),加深理解 。

謀膽並重


相關文章