原文地址:https://blog.fanscore.cn/p/34/linux
本文以go1.14 darwin/amd64中的原子操做爲例,探究原子操做的彙編實現,引出LOCK
指令前綴、可見性、MESI協議、Store Buffer、Invalid Queue、內存屏障,經過對CPU體系結構的探究,從而理解以上概念,並在最終給出一些事實。c++
咱們以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
。算法
在早期CPU上LOCK指令會鎖總線,即其餘核心不能再經過總線與內存通信,從而實現該核心對內存的獨佔。緩存
這種作法雖然解決了問題可是性能太差,因此在Intel P6 CPU(P6是一個架構,並不是具體CPU)引入一個優化:若是數據已經緩存在CPU cache中,則鎖緩存,不然仍是鎖總線。注意,這裏所說的緩存鎖實際是緩存一致性的效果,下面咱們先講下緩存一致性的問題再回頭看緩存一致性是如何實現緩存鎖的效果的。架構
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狀態的簡稱:
核心之間協商通訊須要如下消息機制:
這裏有個存疑的地方:CPU從內存中讀到數據I狀態是轉移到S仍是E,查資料時兩種說法都有。我的認爲應該是E,由於這樣另一個核心要加載副本時只須要去當前核心上取就好了不須要讀內存,性能會更高些,若是你有不一樣見解歡迎在評論區交流。
Invalidate ACK
來接收反饋Invalidate
消息,收到Invalidate ACK
後修改狀態爲M;若是狀態爲I(包括cache miss)則須要發出Read Invalidate
當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中讀。
這卻是解決了上面的漏洞,可是還存在另一個問題,咱們看下面這段代碼:
a = 0 flag = false func runInCpu0() { a = 1 flag = true } func runInCpu1() { while (!flag) { continue } print(a) }
對於上面的代碼咱們假設有以下執行步驟:
從代碼角度看,咱們的代碼好像變成了
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中,以後再刷後面的條目。
上文經過寫屏障解決了僞重排序的問題後,還要思考另外一個問題,那就是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獨佔
爲了解決上面的問題,CPU提出了讀屏障指令,linux將其封裝爲了smp_rwm()函數。放到咱們的代碼中就是這樣:
... func runInCpu1() { while (!flag) { continue } smp_rwm() print(a) }
當CPU執行到smp_rwm()時,會將Invalid Queue中的數據處理完成後再執行屏障後面的讀取操做,這就解決了上面的問題了。
除了上面提到的讀屏障和寫屏障外,還有一種全屏障,它實際上是讀屏障和寫屏障的綜合體,兼具兩種屏障的做用,在linux中它是smp_mb()函數。
文章開始提到的LOCK指令其實兼具了內存屏障的做用。
如今想必你已經理解了緩存一致性,那麼咱們梳理一下在遵循了MESI協議有Store Buffer和Invalid Queue的CPU上緩存一致性是如何實現緩存鎖的效果的。
假設CPU0 cache中存在i,CPU0要執行i++,那麼有如下幾種可能性:
答:
read-modify-write 內存
的指令不是原子性的,以INC mem_addr
爲例,咱們假設數據已經緩存在了cache上,指令的執行須要先將數據從cache讀到執行單元中,再執行+1,而後寫回到cache。咱們看一個讀取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會保證順序一致性。
仍然以寫一個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的效果,因此仍是原子性並且可見的。
這篇文章花費了我大量的時間與精力,主要緣由是剛開始以爲原子性只是個小問題,可是隨着不斷的深刻挖掘,翻閱無數資料,才發現底下潛藏了無數的坑,面對浩瀚的計算機世界,深感本身的眇小與無知。
因爲精力緣由本文還有一些很重要的點沒有講到,好比acquire/release 語義等等。
另外客觀講本文問題不少,較真的話可能會對您形成必定的困擾,這裏表示抱歉,建議您能夠將本文做爲您研究計算機底層架構的一個契機,自行研究這方面的技術。