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

MingsonZheng發表於2021-11-17

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

知識共享許可協議

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

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

相關文章