解鎖Linux內存屏障:讓程序運行更有序
在當今的計算機世界里,多核心處理器已成為主流,無論是日常辦公的電腦,還是數據中心的超級服務器,它們內部的多個核心都在同時忙碌地工作著,力求高效地處理各種任務。在 Linux 系統中,這些核心共同協作,為無數的應用程序提供運行支撐。然而,這看似和諧的運行背后,實則隱藏著一個棘手的問題 —— 內存訪問的混亂。
你或許想象不到,在處理器內部,為了提升性能,CPU 常常會對指令進行亂序執行。與此同時,每個核心都配備了自己的高速緩存,數據在緩存與主內存之間頻繁穿梭,這就導致了不同核心對內存數據的訪問順序和時機變得難以捉摸。在單線程環境下,這種亂序執行或許不會引發明顯問題,但一旦進入多線程或多處理器協同工作的場景,問題就會接踵而至,數據不一致、程序運行結果與預期相悖等情況屢見不鮮,嚴重影響了程序的正確性和穩定性。
而Linux內存屏障,正是為解決這一系列問題而生的關鍵技術。它宛如一位公正的秩序維護者,巧妙地介入 CPU 與內存之間,通過特殊的指令或編譯器輔助手段,強制規定內存操作的先后順序,讓各個核心在訪問內存時能夠 “井然有序”,從而確保程序按照開發者預期的方式運行。接下來,就讓我們一同深入探索 Linux 內存屏障的奧秘,揭開它為程序運行保駕護航的神秘面紗 。
一、內存屏障簡介
1.1內存屏障概述
內存屏障,也叫內存柵欄(Memory Fence) ,是一種在多處理器系統中,用于控制內存操作順序的同步機制。它就像是一個 “關卡”,確保在它之前的內存讀寫操作,一定在它之后的內存讀寫操作之前完成 。在單核單線程的程序里,我們通常不用擔心指令執行順序的問題,因為 CPU 會按照代碼編寫的順序依次執行。但在多處理器或者多線程的環境下,情況就變得復雜起來。
現代處理器為了提高性能,會采用諸如指令亂序執行、緩存等技術。比如,當處理器執行一段代碼時,可能會根據自身的優化策略,將某些指令的執行順序進行調整,只要最終結果不受影響就行。在多線程場景中,如果多個線程同時訪問和修改共享內存,指令重排序就可能導致數據不一致的問題。內存屏障的出現,就是為了解決這類問題,它能夠阻止編譯器和處理器對特定內存操作的重排序,保證內存操作的順序性和數據的可見性。
大多數處理器提供了內存屏障指令:
- 完全內存屏障(full memory barrier)保障了早于屏障的內存讀寫操作的結果提交到內存之后,再執行晚于屏障的讀寫操作。
- 內存讀屏障(read memory barrier)僅確保了內存讀操作;
- 內存寫屏障(write memory barrier)僅保證了內存寫操作。
內核代碼里定義了這三種內存屏障,如x86平臺:arch/x86/include/asm/barrier.h
#define mb() asm volatile("mfence":::"memory")
#define rmb() asm volatile("lfence":::"memory")
#define wmb() asm volatile("sfence" ::: "memory")
個人理解:就類似于我們喝茶的時候需要先把水煮開(限定條件),然后再切茶,而這一整套流程都是限定特定環節的先后順序(內存屏障),保障切出來的茶可以更香。
硬件層的內存屏障分為兩種:Load Barrier 和 Store Barrier即讀屏障和寫屏障。
內存屏障有兩個作用:
- 阻止屏障兩側的指令重排序;
- 強制把寫緩沖區/高速緩存中的臟數據等寫回主內存,讓緩存中相應的數據失效。
對于Load Barrier來說,在指令前插入Load Barrier,可以讓高速緩存中的數據失效,強制從新從主內存加載數據;對于Store Barrier來說,在指令后插入Store Barrier,能讓寫入緩存中的最新數據更新寫入主內存,讓其他線程可見。
1.2不同場景內存屏障
(1)java內存屏障
- java的內存屏障通常所謂的四種即LoadLoad,StoreStore,LoadStore,StoreLoad實際上也是上述兩種的組合,完成一系列的屏障和數據同步功能。
- LoadLoad屏障:對于這樣的語句Load1; LoadLoad; Load2,在Load2及后續讀取操作要讀取的數據被訪問前,保證Load1要讀取的數據被讀取完畢。
- StoreStore屏障:對于這樣的語句Store1; StoreStore; Store2,在Store2及后續寫入操作執行前,保證Store1的寫入操作對其它處理器可見。
- LoadStore屏障:對于這樣的語句Load1; LoadStore; Store2,在Store2及后續寫入操作被刷出前,保證Load1要讀取的數據被讀取完畢。
- StoreLoad屏障:對于這樣的語句Store1; StoreLoad; Load2,在Load2及后續所有讀取操作執行前,保證Store1的寫入對所有處理器可見。它的開銷是四種屏障中最大的。在大多數處理器的實現中,這個屏障是個萬能屏障,兼具其它三種內存屏障的功能
(2)volatile語義中的內存屏障
- volatile的內存屏障策略非常嚴格保守,非常悲觀且毫無安全感的心態:
- 在每個volatile寫操作前插入StoreStore屏障,在寫操作后插入StoreLoad屏障;
- 在每個volatile讀操作前插入LoadLoad屏障,在讀操作后插入LoadStore屏障;
- 由于內存屏障的作用,避免了volatile變量和其它指令重排序、線程之間實現了通信,使得volatile表現出了鎖的特性。
(3)final語義中的內存屏障
對于final域,編譯器和CPU會遵循兩個排序規則:
新建對象過程中,構造體中對final域的初始化寫入和這個對象賦值給其他引用變量,這兩個操作不能重排序;
初次讀包含final域的對象引用和讀取這個final域,這兩個操作不能重排序;(意思就是先賦值引用,再調用final值)
總之上面規則的意思可以這樣理解,必需保證一個對象的所有final域被寫入完畢后才能引用和讀取。這也是內存屏障的起的作用:
- 寫final域:在編譯器寫final域完畢,構造體結束之前,會插入一個StoreStore屏障,保證前面的對final寫入對其他線程/CPU可見,并阻止重排序。
- 讀final域:在上述規則2中,兩步操作不能重排序的機理就是在讀final域前插入了LoadLoad屏障。
- X86處理器中,由于CPU不會對寫-寫操作進行重排序,所以StoreStore屏障會被省略;而X86也不會對邏輯上有先后依賴關系的操作進行重排序,所以LoadLoad也會變省略。
二、為什么需要內存屏障?
由于現在計算機存在多級緩存且多核場景,為了保證讀取到的數據一致性以及并行運行時所計算出來的結果一致,在硬件層面實現一些指令,從而來保證指定執行的指令的先后順序。比如上圖:雙核cpu,每個核心都擁有獨立的一二級緩存,而緩存與緩存之間需要保證數據的一致性所以這里才需要加添屏障來確保數據的一致性。三級緩存為各CPU共享,最后都是主內存,所以這些存在交互的CPU都需要通過屏障手段來保證數據的唯一性。
內存屏障存在的意義就是為了解決程序在運行過程中出現的內存亂序訪問問題,內存亂序訪問行為出現的理由是為了提高程序運行時的性能,Memory Bariier能夠讓CPU或編譯器在內存訪問上有序。
2.1內存屏障出現的背景(內存亂序是怎么出現的?)
早期的處理器為有序處理器(In-order processors),有序處理器處理指令通常有以下幾步:
- 指令獲取
- 如果指令的輸入操作對象(input operands)可用(例如已經在寄存器中了),則將此指令分發到適當的功能單元中。如果一個或者多個操 作對象不可用(通常是由于需要從內存中獲取),則處理器會等待直到它們可用
- 指令被適當的功能單元執行
- 功能單元將結果寫回寄存器堆(Register file,一個 CPU 中的一組寄存器)
相比之下,亂序處理器(Out-of-order processors),處理指令通常有以下幾步:
- 指令獲取
- 指令被分發到指令隊列(Invalidate Queues,后面會講到)
- 指令在指令隊列中等待,直到輸入操作對象可用(一旦輸入操作對象可用,指令就可以離開隊列,即便更早的指令未被執行)
- 指令被分配到適當的功能單元并執行
- 執行結果被放入隊列(放入到store buffer中,而不是直接寫到cache中,后面也會講到)
- 只有所有更早請求執行的指令的執行結果被寫入cache后,指令執行的結果才被寫入cache(執行結果重排序,讓執行看起來是有序的)
已經了解了cache的同學應該可以知道,如果CPU需要讀取的地址中的數據已經已經緩存在了cache line中,即使是cpu需要對這個地址重復進行讀寫,對CPU性能影響也不大,但是一旦發生了cache miss(對這個地址進行第一次寫操作),如果是有序處理器,CPU在從其他CPU獲取數據或者直接與主存進行數據交互的時候需要等待不可用的操作對象,這樣就會非常慢,非常影響性能。舉個例子:
如果CPU0發起一次對某個地址的寫操作,但是其local cache中沒有數據,這個數據存放在CPU1的local cache中。為了完成這次操作,CPU0會發出一個invalidate的信號,使其他CPU的cache數據無效(因為CPU0需要重新寫這個地址中的值,說明這個地址中的值將被改變,如果不把其他CPU中存放的該地址的值無效,那么就有可能會出現數據不一致的問題)。只有當其他之前就已經存放了改地址數據的CPU中的值都無效了后,CPU0才能真正發起寫操作。需要等待非常長的時間,這就導致了性能上的損耗。
但是亂序處理器山就不需要等待不可用的操作對象,直接把invalidate message放到invalidate queues中,然后繼續干其他事情,提高了CPU的性能,但也帶來了一個問題,就是程序執行過程中,可能會由于亂序處理器的處理方式導致內存亂序,程序運行結果不符合我們預期的問題。
2.2理解內存屏障
不少開發者并不理解一個事實 — 程序實際運行時很可能并不完全按照開發者編寫的順序訪問內存。例如:
x = r;
y = 1;
這里,y = 1很可能先于x = r執行。這就是內存亂序訪問。內存亂序訪問行為出現的理由是為了提升程序運行時的性能。編譯器和CPU都可能引起內存亂序訪問:
- 編譯時,編譯器優化進行指令重排而導致內存亂序訪問;
- 運行時,多CPU間交互引入內存亂序訪問。
編譯器和CPU引入內存亂序訪問通常不會帶來什么問題,但在一些特殊情況下(主要是多線程程序中),邏輯的正確性依賴于內存訪問順序,這時,內存亂序訪問會帶來邏輯上的錯誤,例如:
// thread 1
while(!ok);
do(x);
// thread 2
x = 42;
ok = 1;
Ok初始化為0, 線程1等待ok被設置為1后執行do函數。假如,線程2對內存的寫操作亂序執行,也就是x賦值晚于ok賦值完成,那么do函數接受的實參很有可能出乎開發者的意料,不為42。我們可以引入內存屏障來避免上述問題的出現。內存屏障能讓CPU或者編譯器在內存訪問上有序。一個內存屏障之前的內存訪問操作必定先于其之后的完成。
三、為什么要有內存屏障?
為了解決cpu,高速緩存,主內存帶來的的指令之間的可見性和重序性問題。
我們都知道計算機運算任務需要CPU和內存相互配合共同完成,其中CPU負責邏輯計算,內存負責數據存儲。CPU要與內存進行交互,如讀取運算數據、存儲運算結果等。由于內存和CPU的計算速度有幾個數量級的差距,為了提高CPU的利用率,現代處理器結構都加入了一層讀寫速度盡可能接近CPU運算速度的高速緩存來作為內存與CPU之間的緩沖:將運算需要使用
的數據復制到緩存中,讓CPU運算可以快速進行,計算結束后再將計算結果從緩存同步到主內存中,這樣處理器就無須等待緩慢的內存讀寫了。就像下面這樣:
圖片
每個CPU都會有自己的緩存(有的甚至L1,L2,L3),緩存的目的就是為了提高性能,避免每次都要向內存取,但是這樣的弊端也很明顯:不能實時的和內存發生信息交換,會使得不同CPU執行的不同線程對同一個變量的緩存值不同。用volatile關鍵字修飾變量可以解決上述問題,那么volatile是如何做到這一點的呢?那就是內存屏障,內存屏障是硬件層的概念,不同的硬件平臺實現內存屏障的手段并不是一樣,java通過屏蔽這些差異,統一由jvm來生成內存屏障的指令。
volatile的有序性和可見性
volatile的內存屏障策略非常嚴格保守,非常悲觀且毫無安全感的心態:在每個volatile寫操作前插入StoreStore屏障,在寫操作后插入StoreLoad屏障;在每個volatile讀操作前插入LoadLoad屏障,在讀操作后插入LoadStore屏障;由于內存屏障的作用,避免了volatile變量和其它指令重排序、實現了線程之間通信,使得volatile表現出了鎖的特性。
重排序:代碼的執行順序不按照書寫的順序,為了提升運行效率,在不影響結果的前提下,打亂代碼運行
int a=1;
int b=2;
int c=a+b;
int c=5;
這里的int c=5這個賦值操作可能發生在int a=1這個操作之前
內存屏障的引入,本質上是由于CPU重排序指令引起的。重排序問題無時無刻不在發生,主要源自以下幾種場景:
- 編譯器編譯時的優化;
- 處理器執行時的多發射和亂序優化;
- 讀取和存儲指令的優化;
- 緩存同步順序(導致可見性問題)。
3.1編譯器優化
編譯器在不改變單線程程序語義的前提下,也就是保證單線程程序執行結果正確的情況下,可以重新安排語句的執行順序。編譯器在優化的時候是不知道當前程序是在哪個線程中執行的,因此它只能保證單線程的正確性。
例如,有如下程序:
if (a)
b = a;
else
b = 42;
在經過編譯器優化后可能變成:
b = 42;
if (a)
b = a;
這種優化在單線程下是沒有問題的,但是如果有另外一個線程要讀取變量b的值時,有可能會有問題。前面的程序只有當變量a的值為0時,才會將b賦值42,后面的程序無論變量a的值是多少,都有一段時間會將b賦值為42。
造成這個問題的原因是,編譯器優化的時候只注重“結果”,不注重“過程”。這種優化在單線程程序中沒有問題,代碼一直都是自己運行,只要結果對就可以了,但是在多線程程序下,代碼執行過程中的某些狀態可能會對別的線程產生影響,這個是編譯器優化無法考慮到的。
3.2處理器執行時的多發射和亂序優化
現代處理器基本上都是支持多發射的,也就是在一個指令周期內可以同時執行多條指令。但是,處理器的資源就那么多,可能不能同時滿足處理這些指令的要求。比如,處理器就只有一個加法器,如果同時有兩條指令都需要算加法,那么有一條指令必須等待。如果這時候再下一條指令是讀取指令,并且和前兩條指令無關,那么這條指令將在前面某條加法指令之前完成。還有一種可能,就是前后指令之間具有相關性,比如對同一個地址先讀取再寫入,后面的寫入操作必須等待前面的讀取操作完成后才能執行。但是如果這時候第三條指令是寫入一個無關的地址,那它可以在前面的寫入操作之前被執行,執行順序再次被打亂了。
所以,一般情況下指令亂序并不是CPU在執行指令之前刻意去調整順序。CPU總是順序的去內存里面取指令,然后將其順序的放入指令流水線。但是指令執行時的各種條件,指令與指令之間的相互影響,可能導致順序放入流水線的指令,最終不是按照放入的順序執行完成,在外邊看起來仿佛是“亂序”一樣,這就是所謂的“順序流入,亂序流出”。
3.3讀取和存儲指令的優化
CPU有可能根據情況,將相臨的兩條讀取或寫入操作合并成一條。
例如,對于如下的兩條讀取操作:
X = *A; Y = *(A + 4);
可能被合并成一條讀取操作:
{X, Y} = LOAD {*A, *(A + 4) };
同樣的,對于如下兩條寫入操作:
*A = X; *(A + 4) = Y;
有可能會被合并成一條:
STORE {*A, *(A + 4) } = {X, Y};
以上這幾種情況,由于編譯器或CPU,出于“優化”的目的,按照某種規則將指令重新排序的行為稱作“真”重排序。不同的是,編譯器重排序是在編譯程序時進行的,一旦編譯成功后執行次序就定下來了。而后面幾種是在CPU運行程序時實時進行的,CPU架構不同可能起到的效果完全不同。
編譯器或CPU在執行各種優化的時候,都有一些必須的前提,就是至少在單一CPU上執行不能出現問題。有一些數據訪問明顯是相互依賴的,就不能打亂它們的執行順序。比如:
1)在一個給定的CPU上,有依賴的內存訪問:
比如如下兩條指令:
A = Load B;
C = Load *A
第二條加載指令的地址是由第一條指令加載的,第二條指令要能正確執行,必須要等到第一條指令執行完成后才行,也就是說第二條指令依賴于第一條指令。這種情況下,無論如何處理器是不會打亂這兩條指令的執行次序的。不過,有可能會在這兩條指令間插入別的指令,但必須保證第二條指令在第一條指令執行完后才能執行。
2)在一個給定的CPU上,交叉的加載和存儲操作,它們訪問的內存地址有重疊:
例如,先存儲后加載同一個內存地址上的內容:
Store *X = A;
B = Load *X;
或者先加載后讀取同一個內存地址上的內容:
A = Load *X;
Store *X = B;
對同一個內存地址的存取,如果兩條指令執行次序被打亂了,那肯定會發生錯誤。但是,如果是兩條加載或兩條存儲指令(中間沒有加載),哪怕是對同一個內存地址的操作,也可能由于優化產生變化。
有了上面兩條限制,很容易想到,那如果所有加載或存儲指令都沒有相關性呢?這時候就要看CPU的心情了,可以以任何次序被執行,可以完全不按照它們在程序中出現的次序。
3.4緩存同步順序
上面的幾種情況都比較好理解,最詭異的是所謂的緩存同步順序的問題。要把這個問題說清楚首先要說一下緩存是什么。
現代CPU的運算速度比現代內存系統的速度快得多,它們的速度差了幾個數量級,那怎么辦呢?硬件設計者想到了在內存和CPU之間加入一個速度足夠快,但空間不是很大的存儲空間,這個就是所謂的緩存。緩存的速度足夠快,但是它一般是某個或某些CPU核獨享的,而不像計算機的主存,一般認為是系統中所有CPU共享的。
圖片
一旦引入了緩存,就會引入多個地方存放同一個數據的問題,就有可能出現數據不一致的問題。假設變量X所在內存同時被兩個CPU都緩存了,但是這時候CPU0對變量X的值做出了修改,這之后CPU1如果試圖讀取變量X的值時,其實讀到的是老的值。
這個時候就需要所謂的緩存一致性協議了,一般常用的是MESI協議。MESI代表“Modified”、“Exclusive”、“Shared”和“Invalid”四種狀態的縮寫,特定緩存行可以處在該協議采用的這四種狀態上:
- 處于“Modified”狀態的緩存行:當前CPU已經對緩存行的數據進行了修改,但是該緩存行的內容并沒有在其它CPU的緩存中出現。因此,處于該狀態的緩存行可以認為被當前CPU所“擁有”。這就是所謂的“臟”行,它的內容和內存中的內容不一樣。由于只有當前CPU的緩存持有最新的數據,因此要么將“臟”數據寫回到內存,要么將該數據“轉移”給其它緩存。
- 處于“Exclusive”狀態的緩存行:該狀態非常類似于“Modified”狀態,緩存的內容確保沒有在其它CPU的緩存中出現。唯一的差別是,該緩存行還沒有被當前的CPU修改,也就是說緩存行內容和內存中的是一樣,是對內存數據的最新復制。但是,由于當前CPU能夠在任何時刻將數據存儲到該緩存行而不考慮其它CPU,因此處于“Exclusive”狀態的緩存行也可以認為被當前CPU所“擁有”。
- 處于“Shared”狀態的緩存行:表示緩存行的數據和主存中的一樣,并且可能已經被復制到至少一個其它CPU的緩存中。但是,在沒有得到其他CPU“許可”的情況下,任何CPU不能向該緩存行存儲數據。與“Exclusive”狀態相同,由于內存中的值是最新的,因此當需要丟棄該緩存行時,可以不用向內存回寫。
- 處于“Invalid”狀態的緩存行:表示該緩存行已經失效了,不能再被繼續使用了。當有新數據進入緩存時,它可以直接放置到一個處于“Invalid”狀態的緩存行上,不需要做其它的任何處理。
為了維護這個狀態機,需要各個CPU之間進行通信,會引入下面幾種類型的消息:
- 讀消息:該消息包含要讀取的緩存行的物理地址。
- 讀響應消息:該消息包含較早前的讀消息所請求的數據,這個讀響應消息要么由物理內存提供,要么由某一個其它CPU上的緩存提供。例如,如果某一個CPU上的緩存擁有處于“Modified”狀態的目標數據,那么該CPU上的緩存必須提供讀響應消息。
- 使無效消息:該消息包含要使無效的緩存行的物理地址,所有其它CPU上的緩存必須移除相應的數據并且響應此消息。
- 使無效應答消息:一個接收到使無效消息的CPU必須在移除指定數據后響應一個使無效應答消息。
- 讀使無效消息:該消息包含要被讀取的緩存行的物理地址,同時指示其它CPU上的緩存移除對應的數據。因此,正如名字所示,它將讀消息和使無效消息合并成了一條消息。讀使無效消息同時需要一個讀響應消息及一組使無效應答消息進行應答。
- 寫回消息:該包含要回寫到物理內存的地址和數據。這個消息允許緩存在必要時換出處于“Modified”狀態的數據,以便為其它數據騰出空間。
通過上面的介紹可以看到,MESI緩存一致性協議可以保證系統中的各個CPU核上的緩存都是一致的。但是也帶來了一個很大的問題,由于所有的操作都是“同步”的,必須要等待遠端CPU完成指定操作后收到響應消息才能真正執行對應的存儲或加載操作,這樣會極大降低系統的性能。比如說,如果CPU0和CPU1上同時緩存了同一段數據,如果CPU0想對其進行更改,那么必須先發送使無效消息給CPU1,等到CPU1真的將該緩存的數據段標記成“Invalid”狀態后,會向CPU0發送使無效應答消息,理論上只有CPU0收到這個消息后,才可以真的更改數據。但是,從要更改到真的能更改已經經過了好幾個階段了,這時CPU0只能等在那里。
魚和熊掌都兼得是不可能的,想提高性能,只能稍微放松一下對緩存一致性的要求。
具體的,會引入如下兩個模塊:
存儲緩沖:前面提到過,在寫數據之前我們先要得到緩存段的獨占權,如果當前CPU沒有獨占權,要先讓系統中別的CPU上緩存的同一段數據都變成無效狀態。為了提高性能,可以引入一個叫做存儲緩沖(Store Buffer)的模塊,將其放置在每個CPU和它的緩存之間。當前CPU發起寫操作,如果發現沒有獨占權,可以先將要寫入的數據放在存儲緩沖中,并繼續運行,仿佛獨占權瞬間就得到了一樣。當然,存儲緩沖中的數據最后還是會被同步到緩存中的,但就相當于是異步執行了,不會讓CPU等了。并且,當前CPU在讀取數據的時候應該首先檢查其是否存在于存儲緩沖中。
無效隊列:如果當前CPU上收到一條消息,要使某個緩存段失效,但是此時緩存正在處理其它事情,那這個消息可能無法在當前的指令周期中得到處理,而會將其放入所謂的無效隊列(Invalidation Queue)中,同時立即發送使無效應答消息。那個待處理的使無效消息將保存在隊列中,直到緩存有空為止。
圖片
加入了這兩個模塊之后,CPU的性能是提高了,但緩存一致性就遭到了一定程度的破壞。假設變量X所在內存同時被兩個CPU都緩存了,但是這時候CPU0對變量X的值做出了修改,這之后CPU1如果試圖讀取變量X的值時,有可能讀到的是老的值,當然也有可能讀到的是新的值。但是,在經過一段不確定的時間后,CPU1一定是可以讀到變量X新的值,可以理解為滿足所謂的最終一致性。
但這只是對單個變量來說的,如果程序中有多個變量,那么在其它CPU看來,它們之間的讀寫次序將完全無法得到保證。
假設有CPU0上要執行對三個變量的寫操作:
Store A = 1;
Store B = 2;
Store C = 3;
但是,這三個變量在緩存中的狀態不一樣,假設A變量和B變量在CPU0和CPU1中的緩存都存在,也就是處于“Shared”狀態,而C變量是CPU0獨占的,也就是處于“Exclusive”狀態。假設系統經歷了如下幾個步驟:
在對變量A和B賦值時,CPU0發現其實別的CPU也緩存了,因此會將它們臨時放到存儲緩沖中。
在對變量C賦值時,CPU0發現是獨占的,那么可以直接修改緩存的值,該緩存行的狀態被切換成了“Modified”。注意,這個時候,如果在CPU1上執行了讀取變量C的操作,其實已經可以讀到變量C的最新值了,CPU1發送讀消息,CPU0發送讀響應消息,包含最新的數據,同時將緩存行的狀態都切換成“Shared”。但是,如果這個時候如果CPU1嘗試讀取變量A或者變量B的數據,將會獲得老的數據,應為CPU1上對應變量A和B的緩存行的狀態仍然是“Shared”。
CPU0開始處理對應變量A和B的存儲緩沖,將它們更新進緩存,但之前必須要向CPU1發送使無效消息。這里再次假設變量A的緩存正忙,而變量B的可以立即處理。那么變量A的使無效消息將存放在CPU1的無效隊列中,而變量B的緩存行已經失效。這時,如果CPU1嘗試獲得變量B,是可以獲得最新的數據的,而變量A還是不行。
CPU1對應變量A的緩存已經空閑了,可以處理當前無效隊列的請求,因此變量A對應的緩存行將失效。直到這時CPU1才可以真正的讀到變量A的最新值。
通過以上的步驟可以看到,雖然在CPU0上是先對變量A賦值,接著對B賦值,最后對C賦值,但是在CPU1上“看到”的順序剛好是相反的,先“看到”C,接著“看到”B,最后看到“C”。在CPU1上會產生一種錯覺,方式CPU0是先對C賦值,再對B賦值,最后對A賦值一樣。這種由于緩存同步順序的問題,讓程序看起來好像指令被重排序了的情況,稱作“偽”重排序。
四、內存屏障的類型
在 Linux 系統中,根據其作用和功能,內存屏障主要分為以下三種類型:
4.1全屏障(Full Barrier)
全屏障,也稱作強內存屏障 ,它的功能最為強大。全屏障可以阻止屏障兩邊的讀寫操作進行重排序,確保在屏障之前的所有讀寫操作,都在屏障之后的讀寫操作之前完成。在 x86 架構中,全屏障的實現指令是mfence 。當 CPU 執行到mfence指令時,會將之前所有的存儲和加載操作都按順序完成,才會繼續執行后面的指令。例如:
// 線程1
x = 1; // 寫操作1
mfence(); // 全屏障
y = 2; // 寫操作2
// 線程2
if (y == 2) { // 讀操作1
assert(x == 1); // 讀操作2
}
在這個例子中,由于mfence全屏障的存在,線程 1 中x = 1的寫操作一定會在線程 2 讀取y的值之前完成,從而保證了線程 2 在讀取y為 2 時,x的值也已經被正確地更新為 1,避免了由于指令重排序導致的數據不一致問題。全屏障在需要嚴格保證內存操作順序的場景中非常有用,比如在實現一些關鍵的同步機制或者對共享資源的復雜操作時。
4.2讀取屏障(Read/Load Barrier)
讀取屏障的作用是確保在該屏障之前的所有讀取操作,必須在該屏障之后的讀取操作之前完成 。它主要用于控制讀取操作的順序,防止讀取操作的重排序。在 x86 架構中,讀取屏障對應的指令是lfence 。例如:
// 線程1
int a = shared_variable1; // 讀操作A
lfence(); // 讀取屏障
int b = shared_variable2; // 讀操作B
在上述代碼中,lfence讀取屏障保證了讀操作 A 一定會在讀操作 B 之前完成。即使處理器可能有優化策略,也不能將讀操作 B 提前到讀操作 A 之前執行。讀取屏障在多線程環境中,當讀取操作的順序對程序邏輯有重要影響時非常關鍵。比如在一些依賴于特定讀取順序的算法實現中,或者在讀取共享狀態變量時,為了確保獲取到正確的狀態信息,就需要使用讀取屏障來保證讀取操作的順序性 。
4.3寫入屏障(Write/Store Barrier)
一個寫內存屏障可以提供這樣的保證,站在系統中的其它組件的角度來看,在屏障之前的寫操作看起來將在屏障后的寫操作之前發生。
如果映射到上面的例子來說,首先,寫內存屏障會對處理器指令重排序做出一些限制,也就是在寫內存屏障之前的寫入指令一定不會被重排序到寫內存屏障之后的寫入指令之后。其次,在執行寫內存屏障之后的寫入指令之前,一定要保證清空當前CPU存儲緩沖中的所有寫操作,將它們全部“提交”到緩存中。這樣的話系統中的其它組件(包括別的CPU),就可以保證在看到寫內存屏障之后的寫入數據之前先看到寫內存屏障之前的寫入數據。
圖片
寫入屏障用于確保在該屏障之前的所有寫入操作,必須在該屏障之后的寫入操作之前完成 。它主要關注寫入操作的順序,防止寫入操作的重排序。在 x86 架構中,寫入屏障的指令是sfence 。例如:
// 線程1
shared_variable1 = 10; // 寫操作C
sfence(); // 寫入屏障
shared_variable2 = 20; // 寫操作D
這里,sfence寫入屏障確保了寫操作 C 一定會在寫操作 D 之前完成。無論編譯器如何優化或者處理器如何執行指令,都不會改變這兩個寫操作的順序。寫入屏障在多線程同時修改共享數據時非常重要,它可以保證數據的更新按照預期的順序進行,避免由于寫入順序混亂導致的數據不一致問題。比如在更新一些關聯的共享變量時,使用寫入屏障可以確保先更新的變量對其他線程可見后,再進行后續變量的更新 。
五、內存屏障的工作過程
內存屏障在工作時,就像是一個嚴格的 “柵欄”,對內存操作進行著有序的管控。以下通過一段簡單的偽代碼示例,來詳細描述內存屏障的工作過程:
// 定義共享變量
int shared_variable1 = 0;
int shared_variable2 = 0;
// 線程1執行的代碼
void thread1() {
shared_variable1 = 1; // 操作A:對共享變量1進行寫入
memory_barrier(); // 插入內存屏障
shared_variable2 = 2; // 操作B:對共享變量2進行寫入
}
// 線程2執行的代碼
void thread2() {
if (shared_variable2 == 2) { // 操作C:讀取共享變量2
assert(shared_variable1 == 1); // 操作D:讀取共享變量1并進行斷言
}
}
在上述示例中,當線程 1 執行時:
- 屏障前的操作:首先執行shared_variable1 = 1(操作 A),這個寫入操作會按照正常的流程進行,可能會被處理器優化執行,也可能會被暫時緩存在處理器的寫緩沖區或者緩存中 。此時,操作 A 可以自由執行和重排,只要最終的結果正確即可。
- 遇到屏障:當執行到memory_barrier()內存屏障指令時,處理器會暫停執行后續指令,直到操作 A 的寫入操作被完全確認完成 。這意味著,操作 A 的數據必須被寫入到主內存中,并且其他處理器的緩存也需要被更新(如果涉及到緩存一致性問題),以確保數據的可見性。只有在操作 A 的所有相關內存操作都完成之后,處理器才會繼續執行內存屏障后面的指令。
- 屏障后的操作:接著執行shared_variable2 = 2(操作 B),由于內存屏障的存在,操作 B 不能提前于操作 A 完成,它必須在操作 A 完全結束之后才能開始執行 。這樣就保證了操作 A 和操作 B 的執行順序是按照代碼編寫的順序進行的。
當線程 2 執行時:
- 先執行if (shared_variable2 == 2)(操作 C),讀取共享變量 2 的值。如果此時線程 1 已經執行完內存屏障以及后續的操作 B,那么線程 2 讀取到的shared_variable2的值就會是 2 。
- 接著執行assert(shared_variable1 == 1)(操作 D),讀取共享變量 1 的值并進行斷言。因為內存屏障保證了線程 1 中操作 A 先于操作 B 完成,并且操作 A 的結果對其他線程可見,所以當線程 2 讀取到shared_variable2為 2 時,shared_variable1的值必然已經被更新為 1,從而斷言不會失敗 。
通過這個例子可以看出,內存屏障就像一個堅固的 “柵欄”,將內存操作有序地分隔開來,確保了內存操作的順序性和數據的可見性,有效地避免了多線程環境下由于指令重排序和緩存不一致等問題導致的數據錯誤和程序邏輯混亂 。
六、內存屏障的實現原理
6.1存儲器一致性模型
存儲器一致性模型是處理器設計者定義的一種規則,用于描述處理器對內存操作的可見性和順序性 。它分為強一致性模型和弱一致性模型,不同的模型對內存屏障的必要性和類型有著重要影響。
在強一致性模型下,處理器嚴格按照程序代碼的指令次序來執行所有的存儲(Store)與加載(Load)指令 ,并且從其他處理器和內存的角度來看,感知到的數據變化也完全是按照指令執行的次序。這就好比在一個非常有序的隊列中,每個人都嚴格按照排隊順序依次進行操作,不存在插隊或者提前操作的情況。在這種模型下,內存操作的順序是非常明確的,程序不需要使用內存屏障來保證內存操作的正確性,因為處理器已經天然地保證了這一點。然而,這種模型雖然簡單直觀,但由于對處理器的限制較多,會在一定程度上影響處理器的性能。
弱一致性模型則相對寬松一些,它允許處理器對某些指令組合進行重排序 ,以提高處理器的執行效率。例如,在弱一致性模型中,可能會出現存儲 - 加載(Store - Load)指令重排序的情況,即如果第一條指令是存儲指令,第二條指令是加載指令,那么在程序執行時,加載指令可能會先于存儲指令執行。這種重排序在單線程環境下通常不會產生問題,因為單線程環境下程序的執行順序和結果是可預測的。但在多線程環境中,這種重排序可能會導致數據不一致的問題。
比如,一個線程對共享變量進行了修改(存儲操作),但由于重排序,另一個線程可能在這個修改還未完成時就讀取了這個變量(加載操作),從而獲取到舊的數據。為了解決弱一致性模型下多線程環境中的數據一致性問題,就需要使用內存屏障。不同類型的內存屏障可以針對不同的指令重排序情況進行約束,確保內存操作的順序性和數據的可見性。例如,在 x86 架構采用的完全存儲定序(TSO)模型下,允許 Store - Load 指令重排序,為了保證程序執行的正確性,就需要在適當的位置插入內存屏障指令,如mfence、lfence、sfence等 ,來確保在加載操作之前,所有的存儲操作都已經完成并對其他處理器可見。
6.2緩存一致性協議
在多核心處理器中,每個核心都有自己的高速緩存(如 L1、L2、L3 緩存) ,這些緩存可以大大提高處理器訪問數據的速度。但也帶來了緩存一致性的問題,即如何保證多個核心緩存中的數據與主內存以及其他核心緩存中的數據保持一致。MESI 協議是一種廣泛應用的緩存一致性協議,它通過對緩存行(Cache Line,通常為 64 字節)的狀態標記來實現緩存一致性。
MESI 協議中,緩存行有四種狀態:
- 已修改(Modified,M):表示緩存行中的數據已經被修改,并且與主內存中的數據不一致 。此時,該緩存行只存在于當前核心的緩存中,其他核心的緩存中沒有該緩存行的副本。在數據被寫回主內存之前,其他核心如果需要讀取該數據,會收到一個無效信號,然后從主內存中讀取最新的數據。
- 獨占(Exclusive,E):表示緩存行中的數據與主內存中的數據一致,并且只存在于當前核心的緩存中 ,其他核心的緩存中沒有該緩存行的副本。在這種狀態下,如果當前核心對緩存行中的數據進行修改,緩存行狀態會變為已修改(M);如果其他核心請求讀取該數據,緩存行狀態會變為共享(S)。
- 共享(Shared,S):表示緩存行中的數據與主內存中的數據一致,并且存在于多個核心的緩存中 。當一個核心修改共享狀態的緩存行時,會向總線上發送一個無效信號,通知其他核心將該緩存行的狀態標記為無效(Invalid,I),然后自己將緩存行狀態變為已修改(M)。這樣可以保證在同一時刻,只有一個核心能夠修改共享數據,從而維護數據的一致性。
- 無效(Invalid,I):表示緩存行中的數據已經無效,不能再被使用 。當一個核心收到其他核心發送的無效信號時,會將自己緩存中對應的緩存行狀態標記為無效。
內存屏障在 MESI 協議中起著關鍵的作用。當處理器執行內存屏障指令時,它會強制完成所有內存寫入操作,確保在屏障前的所有內存操作都能在屏障之后被其他執行上下文(線程或處理器)看到 。例如,在一個多核心處理器系統中,當一個核心執行寫入屏障指令時,它會確保之前的所有寫入操作都已經完成,并且將修改后的數據寫回主內存,同時通過 MESI 協議通知其他核心更新它們的緩存,使其他核心緩存中的相應數據也變為最新狀態。這樣,當其他核心執行讀取操作時,就能獲取到最新的數據,從而保證了緩存一致性。
6.3指令序列
內存屏障通常通過特殊指令序列來實現,這些指令會強制CPU等待所有內存操作完成,從而確保內存操作的順序性。以x86架構為例,常見的內存屏障指令有mfence(全屏障)、lfence(讀取屏障)和sfence(寫入屏障) 。
mfence指令是全屏障指令,它會阻止屏障兩邊的讀寫操作進行重排序 。當 CPU 執行到mfence指令時,會將之前所有的存儲和加載操作都按順序完成,才會繼續執行后面的指令。例如:
// 線程1
x = 1; // 寫操作1
mfence(); // 全屏障
y = 2; // 寫操作2
在這個例子中,mfence指令保證了寫操作 1(x = 1)一定會在寫操作 2(y = 2)之前完成,即使處理器可能有優化策略,也不能改變這兩個寫操作的執行順序。
lfence指令是讀取屏障指令,它確保在該屏障之前的所有讀取操作,必須在該屏障之后的讀取操作之前完成 。例如:
// 線程1
int a = shared_variable1; // 讀操作A
lfence(); // 讀取屏障
int b = shared_variable2; // 讀操作B
這里,lfence指令保證了讀操作 A 一定會在讀操作 B 之前完成,防止了讀取操作的重排序。
sfence指令是寫入屏障指令,它確保在該屏障之前的所有寫入操作,必須在該屏障之后的寫入操作之前完成 。例如:
// 線程1
shared_variable1 = 10; // 寫操作C
sfence(); // 寫入屏障
shared_variable2 = 20; // 寫操作D
在這個例子中,sfence指令保證了寫操作 C 一定會在寫操作 D 之前完成,避免了寫入操作的重排序。
這些內存屏障指令通過特殊的指令序列,利用 CPU 的硬件特性,實現了對內存操作順序的控制,從而有效地解決了多線程和多處理器環境下的內存一致性問題 。
七、內存屏障的應用場景
7.1多線程編程
在多線程編程中,內存屏障起著至關重要的作用,它能夠確保線程間數據的一致性和可見性。假設有兩個線程共享變量x和y,初始值都為 0,如下所示:
// 共享變量
int x = 0;
int y = 0;
// 線程1
void* thread1(void* arg) {
x = 1; // 寫操作1
// 這里插入內存屏障,假設為全屏障mfence()
y = 2; // 寫操作2
return NULL;
}
// 線程2
void* thread2(void* arg) {
if (y == 2) { // 讀操作1
// 這里可以根據需要插入內存屏障,假設為讀取屏障lfence()
assert(x == 1); // 讀操作2
}
return NULL;
}
在這個例子中,如果沒有內存屏障,線程 1 中的寫操作 1 和寫操作 2 可能會被重排序,導致線程 2 在讀取y為 2 時,x的值還未被更新為 1,從而使斷言失敗 。通過插入內存屏障,如線程 1 中的全屏障mfence(),可以確保寫操作 1 先于寫操作 2 完成,并且寫操作 1 的結果對其他線程可見 。線程 2 中的讀取屏障lfence()可以確保在讀取x之前,先讀取到y為 2 時,x的值已經被正確更新為 1,從而保證了線程間數據的一致性和可見性,避免了由于指令重排序導致的錯誤 。
7.2內存共享
在內存共享場景中,比如多個處理器同時訪問共享內存,內存屏障能夠確保各個處理器按照正確的順序訪問內存。以一個簡單的生產者 - 消費者模型為例,假設有兩個處理器,一個作為生產者,一個作為消費者,共享一個內存緩沖區和一個標志位flag :
// 共享內存緩沖區
int buffer = 0;
// 標志位,用于指示緩沖區是否有數據
int flag = 0;
// 生產者處理器執行的代碼
void producer() {
buffer = 10; // 向緩沖區寫入數據
// 插入寫入屏障sfence()
flag = 1; // 設置標志位,表示緩沖區有數據
}
// 消費者處理器執行的代碼
void consumer() {
if (flag == 1) { // 檢查標志位
// 插入讀取屏障lfence()
assert(buffer == 10); // 讀取緩沖區數據并進行斷言
}
}
在這個例子中,生產者處理器在向緩沖區寫入數據后,通過插入寫入屏障sfence(),確保寫操作完成并對其他處理器可見后,再設置標志位 。消費者處理器在檢查標志位后,通過插入讀取屏障lfence(),確保在讀取緩沖區數據之前,已經看到生產者設置的標志位,從而保證了緩沖區數據的一致性和正確訪問順序 。如果沒有這些內存屏障,可能會出現消費者處理器在標志位被設置之前就讀取緩沖區數據,或者生產者處理器設置標志位后,緩沖區數據還未被正確寫入的情況 。
7.3緩存一致性
在緩存一致性場景中,內存屏障可以保證各處理器緩存數據的一致。在一個多處理器系統中,每個處理器都有自己的緩存,當多個處理器同時訪問共享數據時,可能會出現緩存不一致的問題 。例如,處理器 A 修改了共享變量x的值,并將其緩存起來,此時處理器 B 的緩存中x的值還是舊的 。如果沒有內存屏障的控制,處理器 B 在讀取x時,可能會從自己的緩存中讀取到舊值,而不是處理器 A 修改后的新值 。
// 共享變量
int x = 0;
// 處理器A執行的代碼
void processorA() {
x = 1; // 修改共享變量x的值
// 插入全屏障mfence(),確保緩存一致性
}
// 處理器B執行的代碼
void processorB() {
// 插入全屏障mfence(),確保讀取到最新數據
assert(x == 1); // 讀取共享變量x的值并進行斷言
}
在這個例子中,處理器 A 在修改共享變量x的值后,通過插入全屏障mfence(),將修改后的數據寫回主內存,并通知其他處理器更新它們的緩存 。處理器 B 在讀取x的值之前,也插入全屏障mfence(),確保從主內存中讀取到最新的數據,從而保證了各處理器緩存數據的一致性 。內存屏障通過與緩存一致性協議(如 MESI 協議)協同工作,有效地解決了緩存不一致的問題,確保了多處理器系統中數據的正確性和可靠性 。