原子操作、MESI和內存屏障引起我對鎖理解的智障

前言:

先向已經貢獻大量公開文檔的前輩們致敬,不管是中文的、英文的;再鄙視一下ARM文檔中關於DMB之類的文字,赤裸裸的鄙視,我截一段大家感受下:“The DMB instruction ensures that all affected memory accesses by the PE executing the DMB instruction that appear in program order before the DMB instruction and those which originate from a different PE, to the extent required by the DMB options, which have been Observed-by the PE before the DMB instruction is executed, are Observed-by each PE, to the extent required by the DMB options, before any affected memory accesses that appear in program order after the DMB instruction are Observed-by that PE.”。寫這種臭長臭長的大長句+一堆特定含義詞,這是手冊中應該出現的嗎?!莫不是不死族爲轉化我人族大騎士設計的咒語吧!

氣哼哼的開始正文


一直以來,我自認爲對鎖的理解是比較深入且沒有錯誤的,知道最近遇到一串兒問題,我才發現,我對鎖的使用級理解沒有問題,但是原理級理解是存在智障(智力屏障)的。而再增加數據存儲屏障、指令屏障,引起我智障的鋪墊有很長,但我自己思考了一下,然後再次閱讀查找了之間查過的資料,最終找到了我的理解中存在的灰色地帶,我表述成幾個問題:

1. 保障了什麼就可以保障原子操作?

2. MESI能保障什麼,保障不了什麼?

3. 內存屏障要保障什麼?

4. 自旋鎖如何鎖得住?

所謂原子操作,只需要保障在多CPU之間對某一內存地址中的值的增減是有序的,即可實現原子計數,所謂的原子操作。這個影響範圍其實是非常小的,而且現代CPU對內存的訪問都會經過多級的cache,cacheline的大小一般都會超過或等於CPU的位寬(32位、64位CPU),所以,在所有CPU的cache之間,如果存在某種同步機制,那麼原子操作的也就會在極少範圍內、以極少的代價實現;而LDXRB/LDAXRB/LDXRH/LDXR/LDAXR/LDXP/LDAXP以及與這組指令成對使用的STXRB/...即用於此目的;關於這組寄存器只需要簡單說一下就能明白:

LD*X* addr

操作此addr中的數據

ST*X* addr

在多個CPU上如果同時執行這三行,只有最先ST*X*到此addr的那句生效,返回0,而其他ST*X* addr都會返回1,然後程序就可以知道自己的原子操作有沒有生效。 也就是說,LD*X*與ST*X*的使用能保障寫入順序,也只能保障寫入順序。 而保障了寫入順序,也就實現了原子操作。

而這種在所有CPU的cache之間做同步的一般都會是MESI,MESI的詳細內容不展開論述,僅只出一點:MESI實現多CPU間被cache內存的一致性的通知機制,但也僅實現通知機制,不會將不一致的cache的數據統一(也就是不傳輸數據)。這是什麼意思呢:當多個CPU共同操作內存中的某個地址,MESI不會保障單一CPU對此地址的寫入在其他CPU的cache生效,而是通過使單一CPU對此地址的寫入引起其他CPU的cacheline變髒,來實現這些CPU的cacheline的內容的一致性的保障,其他共享此內存的CPU的對應cacheline變髒,置爲髒,則對此cacheline的讀就需要重新從內存(也可能是下一級cache)加載此cacheline。

至此,原子操作和MESI就明確了,也就是第1、2問題算是得到回答。那麼MESI能保障了原子操作,那麼它能保障多核間對某臨界資源的訪問一致性嗎?答案是不能。可能我們假設實現一個原子計數,多核間對此數據的操作是對齊的,LD*X*得到0則嘗試ST*X*置1,返回0(成功)後即認爲成功搶到鎖,然後就可以開心的去加載臨界資源。到目前爲止,這個理解並沒有問題,但是隻怕遇到一種情況:當上一個釋放原子計數(置0)的兄弟A的指令是亂序執行的,釋放原子計數的操作被亂序執行在了操作某塊臨界資源之前,這時候,成功爭得原子計數的老兄B,也對這塊臨界資源發生了操作,若是B對臨界資源的操作在A之後生效了也就罷了,如果B兄弟的操作先生效了,這塊臨界資源自然就產生了混亂,所以,MESI保不住這塊臨界資源。

於是第3個問題就順理成章的被推到了前臺,要說內存屏障在保障什麼,首先肯定要提現在CPU的指令亂序且並行執行的原理,用一句話表述就是:有順序的彙編代碼在真實的單個CPU中,是會根據指令之間是否存在依賴性、指令執行所需時間發生重排以及併發執行的,並且執行結果很多時候都會不寫入(直接投入後續計算)。更粗魯一點說,其實一個CPU(PE)是可以在一個時鐘週期內同時執行多次1+1=2的計算的;對比不支持指令亂序並行執行的古代CPU(古代把能算出1+1=2的這段電路就算做一個CPU),現代每一個CPU核都是有一堆古代核(很不準確的說)。這樣的指令執行方式,極大加速了CPU效率,但是也帶來了一些問題,有些執行很慢的指令如:遇到冷cache,需要從主存加載數據;操作外設寄存器,外設反應遠慢與CPU;這樣的一段代碼大概率都會出現後快指令先於前慢指令執行的情況,這使“後指令非內存地址可感的對前指令操作結果存在依賴”的情況出現執行錯誤。還有很多時候一些指令沉積在流水線中,沒有達到提交點,而它的提交又是必然會發生的,此時cache中的數據沒變,但是卻必然會發生改變。當然,上面所說的這些並不是重點,重點是,內存屏障包括“DMB 作用域、DSB 作用域、ISB”等這些數據屏障,指令屏障能保障什麼:他們只能保障前文指令在DMB/DSB/ISB指令後的指令前完全給出結果(執行完畢),單核性質的。這麼說,屏障,能保障多核間對某臨界資源的訪問一致性嗎?很遺憾答案是不能,可能說,它能保障:“對於執行屏障的CPU 1,到屏障點後未開始下一條指令時,CPU 1流水線內所有計算結果都會顯化給屏障作用域內其他CPU看到”,但是看到也就是看到,並不知道是什麼時候看到的,跟多核並沒有幾毛錢的關係。

原子操作保得住多核對原子空間的有序操作,但保不住臨界資源,因爲亂序併發;然後又說了內存屏障包的住亂序併發,但跟多核並沒有幾毛錢關係;而第4個問題,自旋鎖如何鎖得住,就是綜合使用原子操作和內存屏障,才能保住臨界資源。天色已晚,該丟點代碼出來,下面是自旋鎖的獲取鎖:

/* Spinlock implementation. The memory barriers are implicit with the load-acquire and store-release instructions.*/

static inline void arch_spin_lock(arch_spinlock_t *lock){

unsigned int tmp;

arch_spinlock_t lockval, newval;

asm volatile( /* Atomically increment the next ticket. */

ARM64_LSE_ATOMIC_INSN( /* LL/SC */

" prfm pstl1strm, %3\n" /* 內存預加載 %3是 "+Q" (*lock) */

"1: ldaxr %w0, %3\n" /* 加載*lock,與ldxr的區別在於,load-aquire,隱含屏障 */

" add %w1, %w0, %w5\n"

" stxr %w2, %w1, %3\n" /* 搶鎖位(排位) */

" cbnz %w2, 1b\n", /* 搶到否? */

/* LSE atomics */

" mov %w2, %w5\n" /* */

" ldadda %w2, %w0, %3\n"

__nops(3))

/* Did we get the lock? */

" eor %w1, %w0, %w0, ror #16\n" /* 計算是否搶到鎖(owner是否爲自己)  */

" cbz %w1, 3f\n"

/* No: spin on the owner. Send a local event to avoid missing an unlock before the exclusive load.*/

" sevl\n"

"2: wfe\n"

" ldaxrh %w2, %4\n" /* 加載owner */

" eor %w1, %w2, %w0, lsr #16\n" /* 繼續算是否搶到鎖(owner是否爲自己) */

" cbnz %w1, 2b\n"

/* We got the lock. Critical section starts here. */

"3:"

: "=&r" (lockval), "=&r" (newval), "=&r" (tmp), "+Q" (*lock)

: "Q" (lock->owner), "I" (1 << TICKET_SHIFT)

: "memory");}

然後自旋鎖的釋放:

static inline void arch_spin_unlock(arch_spinlock_t *lock){

unsigned long tmp;

asm volatile(ARM64_LSE_ATOMIC_INSN(

/* LL/SC */

" ldrh %w1, %0\n" /* 加載owner */

" add %w1, %w1, #1\n" /* 要釋放鎖,owner增 */

" stlrh %w1, %0", /* 存出去,讓大家看到 */

/* LSE atomics */

" mov %w1, #1\n"

" staddlh %w1, %0\n"

__nops(1))

: "=Q" (lock->owner), "=&r" (tmp)

:  "memory");

}

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章