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

29 | 原子操作(上)

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

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

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

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

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

我們已經知道,對於一個 Go 程序來說,Go 語言運行時系統中的調度器會恰當地安排其中所有的 goroutine 的運行。不過,在同一時刻,只可能有少數的 goroutine 真正地處於運行狀態,並且這個數量只會與 M 的數量一致,而不會隨着 G 的增多而增長。

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

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

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

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

在眾多的同步工具中,真正能夠保證原子性執行的只有原子操作(atomic operation)//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代碼包中提供的原子操作函數和原子值類型。原子操作函數使用起來都非常簡單,但也有一些細節需要我們注意。我在主問題的衍生問題中對它們進行了逐一說明。

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

筆記源碼

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

知識共享許可協議

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

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