天天看點

Go語言核心36講(Go語言實戰與應用七)--學習筆記

29 | 原子操作(上)

我們在前兩篇文章中讨論了互斥鎖、讀寫鎖以及基于它們的條件變量,先來總結一下。

互斥鎖是一個很有用的同步工具,它可以保證每一時刻進入臨界區的 goroutine 隻有一個。讀寫鎖對共享資源的寫操作和讀操作則差別看待,并消除了讀操作之間的互斥。

條件變量主要是用于協調想要通路共享資源的那些線程。當共享資源的狀态發生變化時,它可以被用來通知被互斥鎖阻塞的線程,它既可以基于互斥鎖,也可以基于讀寫鎖。當然了,讀寫鎖也是一種互斥鎖,前者是對後者的擴充。

通過對互斥鎖的合理使用,我們可以使一個 goroutine 在執行臨界區中的代碼時,不被其他的 goroutine 打擾。不過,雖然不會被打擾,但是它仍然可能會被中斷(interruption)。

前導内容:原子性執行與原子操作

我們已經知道,對于一個 Go 程式來說,Go 語言運作時系統中的排程器會恰當地安排其中所有的 goroutine 的運作。不過,在同一時刻,隻可能有少數的 goroutine 真正地處于運作狀态,并且這個數量隻會與 M 的數量一緻,而不會随着 G 的增多而增長。

是以,為了公平起見,排程器總是會頻繁地換上或換下這些 goroutine。換上的意思是,讓一個 goroutine 由非運作狀态轉為運作狀态,并促使其中的代碼在某個 CPU 核心上執行。

換下的意思正好相反,即:使一個 goroutine 中的代碼中斷執行,并讓它由運作狀态轉為非運作狀态。

這個中斷的時機有很多,任何兩條語句執行的間隙,甚至在某條語句執行的過程中都是可以的。

即使這些語句在臨界區之内也是如此。是以,我們說,互斥鎖雖然可以保證臨界區中代碼的串行執行,但卻不能保證這些代碼執行的原子性(atomicity)。

在衆多的同步工具中,真正能夠保證原子性執行的隻有原子操作(atomic operation)https://baike.baidu.com/item/%E5%8E%9F%E5%AD%90%E6%93%8D%E4%BD%9C/1880992?fr=aladdin 。原子操作在進行的過程中是不允許中斷的。在底層,這會由 CPU 提供晶片級别的支援,是以絕對有效。即使在擁有多 CPU 核心,或者多 CPU 的計算機系統中,原子操作的保證也是不可撼動的。

這使得原子操作可以完全地消除競态條件,并能夠絕對地保證并發安全性。并且,它的執行速度要比其他的同步工具快得多,通常會高出好幾個數量級。不過,它的缺點也很明顯。

更具體地說,正是因為原子操作不能被中斷,是以它需要足夠簡單,并且要求快速。

你可以想象一下,如果原子操作遲遲不能完成,而它又不會被中斷,那麼将會給計算機執行指令的效率帶來多麼大的影響。是以,作業系統層面隻對針對二進制位或整數的原子操作提供了支援。

Go 語言的原子操作當然是基于 CPU 和作業系統的,是以它也隻針對少數資料類型的值提供了原子操作函數。這些函數都存在于标準庫代碼包sync/atomic中。

我一般會通過下面這道題初探一下應聘者對sync/atomic包的熟悉程度。

我們今天的問題是:sync/atomic包中提供了幾種原子操作?可操作的資料類型又有哪些?

這裡的典型回答是:

sync/atomic包中的函數可以做的原子操作有:加法(add)、比較并交換(compare and swap,簡稱 CAS)、加載(load)、存儲(store)和交換(swap)。

這些函數針對的資料類型并不多。但是,對這些類型中的每一個,sync/atomic包都會有一套函數給予支援。這些資料類型有:int32、int64、uint32、uint64、uintptr,以及unsafe包中的Pointer。不過,針對unsafe.Pointer類型,該包并未提供進行原子加法操作的函數。

此外,sync/atomic包還提供了一個名為Value的類型,它可以被用來存儲任意類型的值。

問題解析

這個問題很簡單,因為答案是明擺在代碼封包檔裡的。不過如果你連文檔都沒看過,那也可能回答不上來,至少是無法做出全面的回答。

我一般會通過此問題再衍生出來幾道題。下面我就來逐個說明一下。

第一個衍生問題 :我們都知道,傳入這些原子操作函數的第一個參數值對應的都應該是那個被操作的值。比如,atomic.AddInt32函數的第一個參數,對應的一定是那個要被增大的整數。可是,這個參數的類型為什麼不是int32而是*int32呢?

回答是:因為原子操作函數需要的是被操作值的指針,而不是這個值本身;被傳入函數的參數值都會被複制,像這種基本類型的值一旦被傳入函數,就已經與函數外的那個值毫無關系了。

是以,傳入值本身沒有任何意義。unsafe.Pointer類型雖然是指針類型,但是那些原子操作函數要操作的是這個指針值,而不是它指向的那個值,是以需要的仍然是指向這個指針值的指針。

隻要原子操作函數拿到了被操作值的指針,就可以定位到存儲該值的記憶體位址。隻有這樣,它們才能夠通過底層的指令,準确地操作這個記憶體位址上的資料。

第二個衍生問題: 用于原子加法操作的函數可以做原子減法嗎?比如,atomic.AddInt32函數可以用于減小那個被操作的整數值嗎?

回答是:當然是可以的。atomic.AddInt32函數的第二個參數代表差量,它的類型是int32,是有符号的。如果我們想做原子減法,那麼把這個差量設定為負整數就可以了。

對于atomic.AddInt64函數來說也是類似的。不過,要想用atomic.AddUint32和atomic.AddUint64函數做原子減法,就不能這麼直接了,因為它們的第二個參數的類型分别是uint32和uint64,都是無符号的,不過,這也是可以做到的,就是稍微麻煩一些。

例如,如果想對uint32類型的被操作值18做原子減法,比如說差量是-3,那麼我們可以先把這個差量轉換為有符号的int32類型的值,然後再把該值的類型轉換為uint32,用表達式來描述就是uint32(int32(-3))。

不過要注意,直接這樣寫會使 Go 語言的編譯器報錯,它會告訴你:“常量-3不在uint32類型可表示的範圍内”,換句話說,這樣做會讓表達式的結果值溢出。不過,如果我們先把int32(-3)的結果值賦給變量delta,再把delta的值轉換為uint32類型的值,就可以繞過編譯器的檢查并得到正确的結果了。

最後,我們把這個結果作為atomic.AddUint32函數的第二個參數值,就可以達到對uint32類型的值做原子減法的目的了。

還有一種更加直接的方式。我們可以依據下面這個表達式來給定atomic.AddUint32函數的第二個參數值:

^uint32(-N-1))
           

其中的N代表由負整數表示的差量。也就是說,我們先要把差量的絕對值減去1,然後再把得到的這個無類型的整數常量,轉換為uint32類型的值,最後,在這個值之上做按位異或操作,就可以獲得最終的參數值了。

這麼做的原理也并不複雜。簡單來說,此表達式的結果值的補碼,與使用前一種方法得到的值的補碼相同,是以這兩種方式是等價的。我們都知道,整數在計算機中是以補碼的形式存在的,是以在這裡,結果值的補碼相同就意味着表達式的等價。

package main

import (
	"fmt"
	"sync/atomic"
	"time"
)

func main() {

	// 第二個衍生問題的示例。
	num := uint32(18)
	fmt.Printf("The number: %d\n", num)
	delta := int32(-3)
	atomic.AddUint32(&num, uint32(delta))
	fmt.Printf("The number: %d\n", num)
	atomic.AddUint32(&num, ^uint32(-(-3)-1))
	fmt.Printf("The number: %d\n", num)

	fmt.Printf("The two's complement of %d: %b\n", delta, uint32(delta)) // -3的補碼。
	fmt.Printf("The equivalent: %b\n", ^uint32(-(-3)-1)) // 與-3的補碼相同。
	fmt.Println()
}
           

總結

今天,我們一起學習了sync/atomic代碼包中提供的原子操作函數和原子值類型。原子操作函數使用起來都非常簡單,但也有一些細節需要我們注意。我在主問題的衍生問題中對它們進行了逐一說明。

在下一篇文章中,我們會繼續分享原子操作的衍生内容。

筆記源碼

https://github.com/MingsonZheng/go-core-demo

Go語言核心36講(Go語言實戰與應用七)--學習筆記

本作品采用知識共享署名-非商業性使用-相同方式共享 4.0 國際許可協定進行許可。

歡迎轉載、使用、重新釋出,但務必保留文章署名 鄭子銘 (包含連結: http://www.cnblogs.com/MingsonZheng/ ),不得用于商業目的,基于本文修改後的作品務必以相同的許可釋出。