深刻理解原子操做的本質

原文地址:https://blog.fanscore.cn/p/34/linux

引言

本文以go1.14 darwin/amd64中的原子操做爲例,探究原子操做的彙編實現,引出LOCK指令前綴可見性MESI協議Store BufferInvalid Queue內存屏障,經過對CPU體系結構的探究,從而理解以上概念,並在最終給出一些事實。c++

Go中的原子操做

咱們以atomic.CompareAndSwapInt32爲例,它的函數原型是:git

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

對應的彙編代碼爲:github

// sync/atomic/asm.s 24行
TEXT ·CompareAndSwapInt32(SB),NOSPLIT,$0
	JMP	runtime∕internal∕atomic·Cas(SB)

經過跳轉指令JMP跳轉到了runtime∕internal∕atomic·Cas(SB),因爲架構的不一樣對應的彙編代碼也不一樣,咱們看下amd64平臺對應的代碼:golang

// runtime/internal/atomic/asm_amd64.s 17行
TEXT runtime∕internal∕atomic·Cas(SB),NOSPLIT,$0-17
	MOVQ	ptr+0(FP), BX // 將函數第一個實參即addr加載到BX寄存器
	MOVL	old+8(FP), AX // 將函數第二個實參即old加載到AX寄存器
	MOVL	new+12(FP), CX // // 將函數第一個實參即new加載到CX寄存器
	LOCK // 本文關鍵指令,下面會詳述
	CMPXCHGL	CX, 0(BX) // 把AX寄存器中的內容(即old)與BX寄存器中地址數據(即addr)指向的數據作比較若是相等則把第一個操做數即CX中的數據(即new)賦值給第二個操做數
	SETEQ	ret+16(FP) // SETEQ與CMPXCHGL配合使用,在這裏若是CMPXCHGL比較結果相等則設置本函數返回值爲1,不然爲0(16(FP)是返回值即swapped的地址)
	RET // 函數返回

從上面代碼中能夠看到本文的關鍵:LOCK。它實際是一個指令前綴,它後面必須跟read-modify-write指令,好比:ADD, ADC, AND, BTC, BTR, BTS, CMPXCHG, CMPXCH8B, CMPXCHG16B, DEC, INC, NEG, NOT, OR, SBB, SUB, XOR, XADD, XCHG算法

LOCK實現原理

在早期CPU上LOCK指令會鎖總線,即其餘核心不能再經過總線與內存通信,從而實現該核心對內存的獨佔。緩存

這種作法雖然解決了問題可是性能太差,因此在Intel P6 CPU(P6是一個架構,並不是具體CPU)引入一個優化:若是數據已經緩存在CPU cache中,則鎖緩存,不然仍是鎖總線。注意,這裏所說的緩存鎖實際是緩存一致性的效果,下面咱們先講下緩存一致性的問題再回頭看緩存一致性是如何實現緩存鎖的效果的。架構

Cache Coherency

CPU Cache與False Sharing 一文中詳細介紹了CPU緩存的結構,CPU緩存帶來了一致性問題,舉個簡單的例子:併發

// 假設CPU0執行了該函數
var a int = 0
go func fnInCpu0() {
    time.Sleep(1 * time.Second)
    a = 1 // 2. 在CPU1加載完a以後CPU0僅修改了本身核心上的cache可是沒有同步給CPU1
}()
// CPU1執行了該函數
go func fnInCpu1() {
    fmt.Println(a) // 1. CPU1將a加載到本身的cache,此時a=0
    time.Sleep(3 * time.Second)
    fmt.Println(a) // 3. CPU1從cache中讀到a=0,但此時a已經被CPU0修改成0了
}()

上例中因爲CPU沒有保證緩存的一致性,致使了兩個核心之間的同一數據不可見從而程序出現了問題,因此CPU必須保證緩存的一致性,下面將介紹CPU是如何經過MESI協議作到緩存一致的。app

MESI是如下四種cacheline狀態的簡稱:

  • M(Modified):此狀態爲該cacheline被該核心修改,而且保證不會在其餘核心的cacheline上
  • E(Exclusive):標識該cacheline被該核心獨佔,其餘核心上沒有該行的副本。該核心可直接修改該行而不用通知其餘核心。
  • S(Share):該cacheline存在於多個核心上,可是沒有修改,當前核心不能直接修改,修改該行必須與其餘核心協商。
  • I(Invaild):該cacheline無效,cacheline的初始狀態,說明要麼不在緩存中,要麼內容已過期。

核心之間協商通訊須要如下消息機制:

  • Read: CPU發起數據讀取請求,請求中包含數據的地址
  • Read Response: Read消息的響應,該消息有多是內存響應的,有多是其餘核心響應的(即該地址存在於其餘核心上cacheline中,且狀態爲Modified,這時必須返回最新數據)
  • Invalidate: 核心通知其餘核心將它們本身核心上對應的cacheline置爲Invalid
  • Invalidate ACK: 其餘核心對Invalidate通知的響應,將對應cacheline置爲Invalid以後發出該確認消息
  • Read Invalidate: 至關於Read消息+Invalidate消息,即當前核心要讀取數據並修改該數據。
  • Write Back: 寫回,即將Modified的數據寫回到低一級存儲器中,寫回會盡量地推遲內存更新,只有當替換算法要驅逐更新過的塊時才寫回到低一級存儲器中。

手畫狀態轉移圖

image.png

這裏有個存疑的地方:CPU從內存中讀到數據I狀態是轉移到S仍是E,查資料時兩種說法都有。我的認爲應該是E,由於這樣另一個核心要加載副本時只須要去當前核心上取就好了不須要讀內存,性能會更高些,若是你有不一樣見解歡迎在評論區交流。

一些規律

  1. CPU在修改cacheline時要求其餘持有該cacheline副本的核心失效,並經過Invalidate ACK來接收反饋
  2. cacheline爲M意味着內存上的數據不是最新的,最新的數據在該cacheline上
  3. 數據在cacheline時,若是狀態爲E,則直接修改;若是狀態爲S則須要廣播Invalidate消息,收到Invalidate ACK後修改狀態爲M;若是狀態爲I(包括cache miss)則須要發出Read Invalidate

Store Buffer

當CPU要修改一個S狀態的數據時須要發出Invalidate消息並等待ACK才寫數據,這個過程顯然是一個同步過程,但這對於對計算速度要求極高的CPU來講顯然是不可接受的,必須對此優化。
所以咱們考慮在CPU與cache之間加一個buffer,CPU能夠先將數據寫入到這個buffer中併發出消息,而後它就能夠去作其餘事了,待消息響應後再從buffer寫入到cache中。但這有個明顯的邏輯漏洞,考慮下這段代碼:

a = 1
b = a + 1

假設a初始值爲0,而後CPU執行a=1,數據被寫入Store Buffer尚未落地就緊接着執行了b=a+1,這時因爲a尚未修改落地,所以CPU讀到的仍是0,最終計算出來b=1。

爲了解決這個明顯的邏輯漏洞,又提出了Store Forwarding:CPU能夠把Buffer讀出來傳遞(forwarding)給下面的讀取操做,而不用去cache中讀。
image.png

這卻是解決了上面的漏洞,可是還存在另一個問題,咱們看下面這段代碼:

a = 0
flag = false
func runInCpu0() {
    a = 1
    flag = true
}

func runInCpu1() {
    while (!flag) {
   	continue
    }
    print(a)
}

對於上面的代碼咱們假設有以下執行步驟:

  1. 假定當前a存在於cpu1的cache中,flag存在於cpu0的cache中,狀態均爲E。
  2. cpu1先執行while(!flag),因爲flag不存在於它的cache中,因此它發出Read flag消息
  3. cpu0執行a=1,它的cache中沒有a,所以它將a=1寫入Store Buffer,併發出Invalidate a消息
  4. cpu0執行flag=true,因爲flag存在於它的cache中而且狀態爲E,因此將flag=true直接寫入到cache,狀態修改成M
  5. cpu0接收到Read flag消息,將cache中的flag=true發回給cpu1,狀態修改成S
  6. cpu1收到cpu0的Read Response:flat=true,結束while(!flag)循環
  7. cpu1打印a,因爲此時a存在於它的cache中a=0,因此打印出來了0
  8. cpu1此時收到Invalidate a消息,將cacheline狀態修改成I,但爲時已晚
  9. cpu0收到Invalidate ACK,將Store Buffer中的數據a=1刷到cache中

從代碼角度看,咱們的代碼好像變成了

func runInCpu0() {
    flag = true
    a = 1
}

好像是被從新排序了,這實際上是一種 僞重排序,必須提出新的辦法來解決上面的問題

寫屏障

CPU從軟件層面提供了 寫屏障(write memory barrier) 指令來解決上面的問題,linux將CPU寫屏障封裝爲smp_wmb()函數。寫屏障解決上面問題的方法是先將當前Store Buffer中的數據刷到cache後再執行屏障後面的寫入操做。

SMP: Symmetrical Multi-Processing,即多處理器。

這裏你可能好奇上面的問題是硬件問題,CPU爲何不從硬件上本身解決問題而要求軟件開發者經過指令來避免呢?其實很好回答:CPU不能爲了這一個方面的問題而拋棄Store Buffer帶來的巨大性能提高,就像CPU不能由於分支預測錯誤會損耗性能增長功耗而放棄分支預測同樣。

仍是以上面的代碼爲例,前提保持不變,這時咱們加入寫屏障:

a = 0
flag = false
func runInCpu0() {
    a = 1
    smp_wmb()
    flag = true
}

func runInCpu1() {
    while (!flag) {
   	continue
    }
    print(a)
}

當cpu0執行flag=true時,因爲Store Buffer中有a=1尚未刷到cache上,因此會先將a=1刷到cache以後再執行flag=true,當cpu1讀到flag=true時,a也就=1了。

有文章指出CPU還有一種實現寫屏障的方法:CPU將當前store buffer中的條目打標,而後將屏障後的「寫入操做」也寫到Store Buffer中,cpu繼續幹其餘的事,當被打標的條目所有刷到cache中,以後再刷後面的條目。

Invalid Queue

上文經過寫屏障解決了僞重排序的問題後,還要思考另外一個問題,那就是Store Buffer size是有限的,當Store Buffer滿了以後CPU仍是要卡住等待Invalidate ACK。Invalidate ACK耗時的主要緣由是CPU須要先將本身cacheline狀態修改I後才響應ACK,若是一個CPU很繁忙或者處於S狀態的副本特別多,可能全部CPU都在等它的ACK。

CPU優化這個問題的方式是搞一個Invalid Queue,CPU先將Invalidate消息放到這個隊列中,接着就響應Invalidate ACK。然而這又帶來了新的問題,仍是以上面的代碼爲例

a = 0
flag = false
func runInCpu0() {
    a = 1
    smp_wmb()
    flag = true
}

func runInCpu1() {
    while (!flag) {
   	continue
    }
    print(a)
}

咱們假設a在CPU0和CPU1中,且狀態均爲S,flag由CPU0獨佔

  1. CPU0執行a=1,由於a狀態爲S,因此它將a=1寫入Store Buffer,併發出Invalidate a消息
  2. CPU1執行while(!flag),因爲其cache中沒有flag,因此它發出Read flag消息
  3. CPU1收到CPU0的Invalidate a消息,並將此消息寫入了Invalid Queue,接着就響應了Invlidate ACK
  4. CPU0收到CPU1的Invalidate ACK後將a=1刷到cache中,並將其狀態修改成了M
  5. CPU0執行到smp_wmb(),因爲Store Buffer此時爲空因此就往下執行了
  6. CPU0執行flag=true,由於flag狀態爲E,因此它直接將flag=true寫入到cache,狀態被修改成了M
  7. CPU0收到了Read flag消息,由於它cache中有flag,所以它響應了Read Response,並將狀態修改成S
  8. CPU1收到Read flag Response,此時flag=true,因此結束了while循環
  9. CPU1打印a,因爲a存在於它的cache中且狀態爲S,因此直接將cache中的a打印出來了,此時a=0,這顯然發生了錯誤。
  10. CPU1這時才處理Invalid Queue中的消息將a狀態修改成I,但爲時已晚

爲了解決上面的問題,CPU提出了讀屏障指令,linux將其封裝爲了smp_rwm()函數。放到咱們的代碼中就是這樣:

...
func runInCpu1() {
    while (!flag) {
   	continue
    }
    smp_rwm()
    print(a)
}

當CPU執行到smp_rwm()時,會將Invalid Queue中的數據處理完成後再執行屏障後面的讀取操做,這就解決了上面的問題了。

除了上面提到的讀屏障和寫屏障外,還有一種全屏障,它實際上是讀屏障和寫屏障的綜合體,兼具兩種屏障的做用,在linux中它是smp_mb()函數。
文章開始提到的LOCK指令其實兼具了內存屏障的做用。

回頭看LOCK指令

如今想必你已經理解了緩存一致性,那麼咱們梳理一下在遵循了MESI協議有Store Buffer和Invalid Queue的CPU上緩存一致性是如何實現緩存鎖的效果的。

假設CPU0 cache中存在i,CPU0要執行i++,那麼有如下幾種可能性:

  • cacheline狀態爲E,CPU0直接從cache中讀到i而後+1後寫回到cache,cacheline狀態修改成M。這種狀況下i爲CPU0獨佔,其餘要修改勢必要付出Invalidate消息,可是不會獲得ACK的,因此這個過程不受其餘核心影響,因此是「原子性」的。
  • cacheline狀態爲M,與上面相同
  • cacheline狀態爲S,CPU0要執行i++,可是讀到了LOCK指令,它有寫屏障的做用,因此不會寫到Store Buffer而是直接發出Invalidate i消息,這時若是其餘核心雖然有Invalid Queue可是由於LOCK指令具備讀屏障的做用因此也不會寫入到Invalid Queue中,而是直接將本身cache中的i狀態置爲S,而後返回Invalidate ACK。CPU0收到ACK執行i=0+1,將i寫回cache中,狀態修改成M。其餘核心若是也要修改的話會被總線給ban掉,因此這個過程也不會被其餘核心干擾,因此也是「原子性」的。
  • cacheline狀態爲I,狀態I等價於cache miss,因此不需考慮。

幾個問題

問題1: CPU採用MESI協議實現緩存同步,爲何還要LOCK

答:

  1. MESI協議只是一個協議,有些CPU可能沒有遵循這個協議,或者沒有實現強一致性只實現了最終一致性,就好比上文提到的Store Buffer、Invalid Queue的引入就致使由強一致性變成了最終一致性,所以須要經過LOCK指令告知CPU在這裏必須給我放棄性能考慮來保證強一致性。
  2. MESI協議只管緩存,可能還有其餘的組件影響了執行順序,好比由於CPU流水線氣泡的問題指令會亂序執行。

問題2: 一條彙編指令是原子性的嗎

  1. read-modify-write 內存的指令不是原子性的,以INC mem_addr爲例,咱們假設數據已經緩存在了cache上,指令的執行須要先將數據從cache讀到執行單元中,再執行+1,而後寫回到cache。
  2. 對於沒有對齊的內存,讀取內存可能須要屢次讀取,這不是原子性的。(在某些CPU上讀取未對齊的內存是不被容許的)
  3. 其餘未知緣由...

問題3: Go中的原子讀

咱們看一個讀取8字節數據的例子,直接看golang atomic.LoadUint64()彙編:

// uint64 atomicload64(uint64 volatile* addr);
1. TEXT runtime∕internal∕atomic·Load64(SB), NOSPLIT, $0-12
2.	MOVL	ptr+0(FP), AX // 將第一個參數加載到AX寄存器
3.	TESTL	$7, AX // 判斷內存是否對齊
4.	JZ	2(PC) // 跳到這條指令的下兩條處,即跳轉到第6行
5.	MOVL	0, AX // crash with nil ptr deref 引用0x0地址會觸發錯誤
6.	MOVQ	(AX), M0 // 將內存地址指向的數據加載到M0寄存器
7.	MOVQ	M0, ret+4(FP) // 將M0寄存器中數據(即內存指向的位置)給返回值
8.	EMMS // 清除M0寄存器
9.	RET

第3行TESTL指令對兩個操做數按位與,若是結果爲0,則將ZF設置爲1,不然爲0。因此這一行實際上是判斷傳進來的內存地址是否是8的整數倍。

第4行JZ指令判斷若是ZF即零標誌位爲1則執行跳轉到第二個操做數指定的位置,結合第三行就是若是傳入的內存地址是8的整數倍,即內存已對齊,則跳轉到第6行,不然繼續往下執行。

關於內存對齊能夠看下我這篇文章:理解內存對齊

雖然MOV指令是原子性的,可是彙編中貌似沒有加入內存屏障,那Golang是怎麼實現可見性的呢?我這裏也並無徹底的理解,不過大概意思是Golang的atomic會保證順序一致性。

問題4:Go中的原子寫

仍然以寫一個8字節數據的操做爲例,直接看golang atomic.LoadUint64()彙編:

TEXT runtime∕internal∕atomic·Store64(SB), NOSPLIT, $0-16
	MOVQ	ptr+0(FP), BX
	MOVQ	val+8(FP), AX
	XCHGQ	AX, 0(BX)
	RET

雖然沒有LOCK指令,但XCHGQ指令具備LOCK的效果,因此仍是原子性並且可見的。

總結

這篇文章花費了我大量的時間與精力,主要緣由是剛開始以爲原子性只是個小問題,可是隨着不斷的深刻挖掘,翻閱無數資料,才發現底下潛藏了無數的坑,面對浩瀚的計算機世界,深感本身的眇小與無知。
s70KdH.png

因爲精力緣由本文還有一些很重要的點沒有講到,好比acquire/release 語義等等。

另外客觀講本文問題不少,較真的話可能會對您形成必定的困擾,這裏表示抱歉,建議您能夠將本文做爲您研究計算機底層架構的一個契機,自行研究這方面的技術。

參考資料

相關文章
相關標籤/搜索