徹底搞懂內存屏障,讓程序運行更有序
在多線程編程的世界中,內存訪問就像是一場繁忙的交通。多個線程如同路上的車輛,它們頻繁地讀取和寫入內存中的數據。在這種情況下,內存屏障就像是交通信號燈,起著至關重要的作用。它確保了不同線程對內存的訪問是有序的,避免了數據不一致和其他潛在的問題。沒有內存屏障,線程可能會以意想不到的順序訪問內存,就像沒有信號燈的路口,車輛可能會發生碰撞和混亂。
而有了內存屏障,線程在訪問內存時就有了明確的規則和順序,就像車輛按照信號燈的指示有序通行,從而保證了多線程程序的正確性和穩定性。所以,內存屏障在多線程編程中,是保障程序正常運行的關鍵因素。
一、內存訪問的 “亂序” 困境
1.1CPU 的 “小算盤”:亂序執行
為了提高執行效率,現代 CPU 采用了亂序執行技術。在傳統的順序執行中,CPU 按照指令在程序中的順序依次執行,一條指令執行完成后才會執行下一條指令。然而,在實際運行中,很多指令之間并沒有嚴格的依賴關系 ,比如下面這兩條指令:
int a = 5;
int b = 3;這兩條指令之間沒有數據依賴,它們的執行順序并不影響最終的結果。如果按照順序執行,當遇到一些長延遲操作,如內存訪問時,CPU 就會處于空閑等待狀態,這會浪費大量的時間。而亂序執行則打破了這種傳統的順序限制,允許 CPU 在遇到某些指令依賴未解決時,先執行其他不相關的指令。
亂序執行的實現依賴于復雜的硬件機制。CPU 內部有一個指令調度器,它會分析指令流,找出可以并行執行的指令,并重新排序以最大化資源利用率。同時,處理器還需要維護一個寄存器重命名機制,避免數據沖突和錯誤。此外,亂序執行還依賴于分支預測等輔助技術,以確保指令流的正確性。例如,在一個多任務環境下,當某個程序卡頓或等待 I/O 操作完成時,亂序執行能夠動態調整各個進程之間的資源分配,確保其他程序仍可繼續運行而不受影響,從而顯著提升了計算機系統的響應速度。
1.2緩存惹的 “禍”:數據一致性問題
CPU 緩存的出現是為了解決 CPU 與內存之間速度不匹配的問題。由于 CPU 的運行速度遠遠快于內存的訪問速度,如果 CPU 每次都直接從內存中讀取數據,會大大降低系統的性能。因此,在 CPU 和內存之間引入了高速緩存(Cache),它作為一個高速的臨時存儲區域,存放著 CPU 近期可能會訪問的數據。
CPU 緩存通常分為多級,如 L1、L2 和 L3 緩存,其中 L1 緩存速度最快但容量最小,L3 緩存容量最大但速度相對較慢。當 CPU 需要讀取一個數據時,它會首先在緩存中查找,如果找到(稱為緩存命中),則直接從緩存中讀取數據,這樣可以大大提高訪問速度;如果沒有找到(稱為緩存未命中),則需要從內存中讀取數據,并將該數據所在的數據塊調入緩存中,以便后續訪問。
在多核心 CPU 系統中,每個核心都有自己的緩存,這就可能引發數據一致性問題。當多個核心同時訪問共享內存中的數據時,如果一個核心修改了其緩存中的數據,而其他核心的緩存中仍然保存著舊數據,就會導致數據不一致。例如,假設有兩個線程分別在不同的核心上運行,它們都訪問同一個共享變量 x。線程 1 讀取 x 的值為 1,然后對其進行加 1 操作,得到 x 的值為 2,并將其寫回緩存,但尚未寫回內存。此時,線程 2 從自己的緩存中讀取 x 的值,由于其緩存中的數據尚未更新,仍然讀取到的值為 1,這就導致了數據不一致的問題。
為了解決緩存一致性問題,現代計算機系統采用了多種技術,如緩存一致性協議(如 MESI 協議)和總線嗅探機制。緩存一致性協議通過定義緩存狀態和狀態轉換規則,確保各個核心的緩存數據始終保持一致;總線嗅探機制則通過讓每個核心監聽總線上的內存訪問請求,及時更新自己緩存中的數據。
1.3編譯器的 “優化陷阱”
編譯器為了提高程序的執行效率,會對源代碼進行各種優化,其中指令重排是一種常見的優化手段。指令重排是指編譯器在不改變單線程程序語義的前提下,對指令的執行順序進行重新排列,以充分利用 CPU 的資源和提高程序的性能。
考慮下面這段代碼:
int a = 0;
int b = 0;
// 線程1執行
a = 1;
b = 2;
// 線程2執行
if (b == 2) {
assert(a == 1);
}在單線程環境下,無論 a 和 b 的賦值順序如何,都不會影響程序的正確性。因此,編譯器可能會對這兩條賦值指令進行重排,將其變為:
b = 2;
a = 1;在多線程環境下,這種指令重排可能會導致問題。如果線程 2 在 b 被賦值為 2 之后,但 a 還未被賦值為 1 時執行if (b == 2)條件判斷,那么assert(a == 1)就會失敗,因為此時 a 的值仍然為 0。這就是編譯器優化帶來的多線程隱患,它破壞了程序在多線程環境下的正確性。
為了避免這種問題,程序員需要使用一些同步機制,如內存屏障(Memory Barrier)來告訴編譯器哪些指令不能被重排,從而保證多線程程序的正確性。
二、Linux內存屏障詳解
2.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,能讓寫入緩存中的最新數據更新寫入主內存,讓其他線程可見。
2.2為什么會出現內存屏障?
由于現在計算機存在多級緩存且多核場景,為了保證讀取到的數據一致性以及并行運行時所計算出來的結果一致,在硬件層面實現一些指令,從而來保證指定執行的指令的先后順序。比如上圖:雙核cpu,每個核心都擁有獨立的一二級緩存,而緩存與緩存之間需要保證數據的一致性所以這里才需要加添屏障來確保數據的一致性。三級緩存為各CPU共享,最后都是主內存,所以這些存在交互的CPU都需要通過屏障手段來保證數據的唯一性。
內存屏障存在的意義就是為了解決程序在運行過程中出現的內存亂序訪問問題,內存亂序訪問行為出現的理由是為了提高程序運行時的性能,Memory Bariier能夠讓CPU或編譯器在內存訪問上有序。
(1)運行時內存亂序訪問
運行時,CPU本身是會亂序執行指令的。早期的處理器為有序處理器(in-order processors),總是按開發者編寫的順序執行指令, 如果指令的輸入操作對象(input operands)不可用(通常由于需要從內存中獲取), 那么處理器不會轉而執行那些輸入操作對象可用的指令,而是等待當前輸入操作對象可用。
相比之下,亂序處理器(out-of-order processors)會先處理那些有可用輸入操作對象的指令(而非順序執行) 從而避免了等待,提高了效率。現代計算機上,處理器運行的速度比內存快很多, 有序處理器花在等待可用數據的時間里已可處理大量指令了。即便現代處理器會亂序執行, 但在單個CPU上,指令能通過指令隊列順序獲取并執行,結果利用隊列順序返回寄存器堆,這使得程序執行時所有的內存訪問操作看起來像是按程序代碼編寫的順序執行的, 因此內存屏障是沒有必要使用的(前提是不考慮編譯器優化的情況下)。
(2)SMP架構需要內存屏障的進一步解釋:
從體系結構上來看,首先在SMP架構下,每個CPU與內存之間,都配有自己的高速緩存(Cache),以減少訪問內存時的沖突采用高速緩存的寫操作有兩種模式:
(1). 穿透(Write through)模式,每次寫時,都直接將數據寫回內存中,效率相對較低;
(2). 回寫(Write back)模式,寫的時候先寫回告訴緩存,然后由高速緩存的硬件再周轉復用緩沖線(Cache Line)時自動將數據寫回內存,
或者由軟件主動地“沖刷”有關的緩沖線(Cache Line)。出于性能的考慮,系統往往采用的是模式2來完成數據寫入;正是由于存在高速緩存這一層,正是由于采用了Write back模式的數據寫入,才導致在SMP架構下,對高速緩存的運用可能改變對內存操作的順序。
已上面的一個簡短代碼為例:
// thread 0 -- 在CPU0上運行
x = 42;
ok = 1;
// thread 1 – 在CPU1上運行
while(!ok);
print(x);假設,正好CPU0的高速緩存中有x,此時CPU0僅僅是將x=42寫入到了高速緩存中,另外一個ok也在高速緩存中,但由于周轉復用高速緩沖線(Cache Line)而導致將ok=1刷會到了內存中,此時CPU1首先執行對ok內存的讀取操作,他讀到了ok為1的結果,進而跳出循環,讀取x的內容,而此時,由于實際寫入的x(42)還只在CPU0的高速緩存中,導致CPU1讀到的數據為x(17)。
程序中編排好的內存訪問順序(指令序:program ordering)是先寫入x,再寫入y。而實際上出現在該CPU外部,即系統總線上的次序(處理器序:processor ordering),卻是先寫入y,再寫入x(這個例子中x還未寫入)。
在SMP架構中,每個CPU都只知道自己何時會改變內存的內容,但是都不知道別的CPU會在什么時候改變內存的內容,也不知道自己本地的高速緩存中的內容是否與內存中的內容不一致。
反過來,每個CPU都可能因為改變了內存內容,而使得其他CPU的高速緩存變的不一致了。在SMP架構下,由于高速緩存的存在而導致的內存訪問次序(讀或寫都有可能書序被改變)的改變很有可能影響到CPU間的同步與互斥。
因此需要有一種手段,使得在某些操作之前,把這種“欠下”的內存操作(本例中的x=42的內存寫入)全都最終地、物理地完成,就好像把欠下的債都結清,然后再開始新的(通常是比較重要的)活動一樣。這種手段就是內存屏障,其本質原理就是對系統總線加鎖。
回過頭來,我們再來看看為什么非SMP架構(UP架構)下,運行時內存亂序訪問不存在。
在單處理器架構下,各個進程在宏觀上是并行的,但是在微觀上卻是串行的,因為在同一時間點上,只有一個進程真正在運行(系統中只有一個處理器)。
在這種情況下,我們再來看看上面提到的例子:
線程0和線程1的指令都將在CPU0上按照指令序執行。thread0通過CPU0完成x=42的高速緩存寫入后,再將ok=1寫入內存,此后串行的將thread0換出,thread1換入,及時此時x=42并未寫入內存,但由于thread1的執行仍然是在CPU0上執行,他仍然訪問的是CPU0的高速緩存,因此,及時x=42還未寫回到內存中,thread1勢必還是先從高速緩存中讀到x=42,再從內存中讀到ok=1。
綜上所述,在單CPU上,多線程執行不存在運行時內存亂序訪問,我們從內核源碼也可得到類似結論(代碼不完全摘錄)
#define barrier() __asm__ __volatile__("": : :"memory")
#define mb() alternative("lock; addl $0,0(%%esp)", "mfence", X86_FEATURE_XMM2)
#define rmb() alternative("lock; addl $0,0(%%esp)", "lfence", X86_FEATURE_XMM2)
#ifdef CONFIG_SMP
#define smp_mb() mb()
#define smp_rmb() rmb()
#define smp_wmb() wmb()
#define smp_read_barrier_depends() read_barrier_depends()
#define set_mb(var, value) do { (void) xchg(&var, value); } while (0)
#else
#define smp_mb() barrier()
#define smp_rmb() barrier()
#define smp_wmb() barrier()
#define smp_read_barrier_depends() do { } while(0)
#define set_mb(var, value) do { var = value; barrier(); } while (0)
#endif這里可看到對內存屏障的定義,如果是SMP架構,smp_mb定義為mb(),mb()為CPU內存屏障(接下來要談的),而非SMP架構時(也就是UP架構),直接使用編譯器屏障,運行時內存亂序訪問并不存在。
(3)為什么多CPU情況下會存在內存亂序訪問?
我們知道每個CPU都存在Cache,當一個特定數據第一次被其他CPU獲取時,此數據顯然不在對應CPU的Cache中(這就是Cache Miss)。
這意味著CPU要從內存中獲取數據(這個過程需要CPU等待數百個周期),此數據將被加載到CPU的Cache中,這樣后續就能直接從Cache上快速訪問。
當某個CPU進行寫操作時,他必須確保其他CPU已將此數據從他們的Cache中移除(以便保證一致性),只有在移除操作完成后,此CPU才能安全地修改數據。
顯然,存在多個Cache時,必須通過一個Cache一致性協議來避免數據不一致的問題,而這個通信的過程就可能導致亂序訪問的出現,也就是運行時內存亂序訪問。
受篇幅所限,這里不再深入討論整個細節,有興趣的讀者可以研究《Memory Barriers: a Hardware View for Software Hackers》這篇文章,它詳細地分析了整個過程。
現在通過一個例子來直觀地說明多CPU下內存亂序訪問的問題:
volatile int x, y, r1, r2;
//thread 1
void run1()
{
x = 1;
r1 = y;
}
//thread 2
void run2
{
y = 1;
r2 = x;
}變量x、y、r1、r2均被初始化為0,run1和run2運行在不同的線程中。
如果run1和run2在同一個cpu下執行完成,那么就如我們所料,r1和r2的值不會同時為0,而假如run1和run2在不同的CPU下執行完成后,由于存在內存亂序訪問的可能,這時r1和r2可能同時為0。我們可以使用CPU內存屏障來避免運行時內存亂序訪問(x86_64):
void run1()
{
x = 1;
//CPU內存屏障,保證x=1在r1=y之前執行
__asm__ __volatile__("mfence":::"memory");
r1 = y;
}
//thread 2
void run2
{
y = 1;
//CPU內存屏障,保證y = 1在r2 = x之前執行
__asm__ __volatile__("mfence":::"memory");
r2 = x;
}2.3為什么要有內存屏障?
為了解決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重排序指令引起的。重排序問題無時無刻不在發生,主要源自以下幾種場景:
- 編譯器編譯時的優化;
- 處理器執行時的多發射和亂序優化;
- 讀取和存儲指令的優化;
- 緩存同步順序(導致可見性問題)。
2.4內存屏障的分類與作用
在 Linux 內核中,常見的內存屏障操作分為讀屏障(Read Barriers)、寫屏障(Write Barriers)和全屏障(Full Barriers)。這些屏障操作通過插入特定的匯編指令,確保內存訪問按預定順序執行。
(1)讀屏障(Load Barrier)
讀屏障確保在屏障之后的所有讀操作不會排在屏障之前的讀操作之后執行。也就是說,屏障確保了它后面的所有讀取操作在屏障之前的讀取操作完成之后才開始執行。
假設我們有兩個變量 a 和 b,以及兩個線程 Thread1 和 Thread2,代碼如下:
// 全局變量
int a = 0;
int b = 0;
// Thread1執行
a = 1;
rmb(); // 讀屏障
b = 2;
// Thread2執行
if (b == 2) {
assert(a == 1);
}在這個例子中,讀屏障rmb()保證了在b = 2之前,a = 1的讀操作已經完成。因此,當 Thread2 執行if (b == 2)時,a的值一定是 1,assert(a == 1)不會失敗。
(2)寫屏障(Store Barrier)
寫屏障保證在屏障之前的所有寫操作會在屏障之后的寫操作之前執行。具體來說,屏障確保了它前面所有的寫入操作在屏障指令執行之前完成。通過寫屏障,內核可以強制保證 “先寫后讀” 或 “先寫再寫” 的順序。
還是以上面的代碼為例,如果我們將讀屏障換成寫屏障:
// 全局變量
int a = 0;
int b = 0;
// Thread1執行
a = 1;
wmb(); // 寫屏障
b = 2;
// Thread2執行
if (b == 2) {
assert(a == 1);
}寫屏障wmb()保證了a = 1的寫操作在b = 2的寫操作之前完成,并且對其他線程可見。這樣,當 Thread2 執行if (b == 2)時,它能看到a已經被賦值為 1,從而assert(a == 1)不會失敗。
(3)全屏障(Full Barrier)
全屏障是一種同時包含讀屏障和寫屏障的屏障操作,確保所有在屏障之前的讀寫操作都會在屏障之后的讀寫操作之前執行。全屏障是最嚴格的屏障,它禁止亂序執行。
同樣的代碼,使用全屏障:
// 全局變量
int a = 0;
int b = 0;
// Thread1執行
a = 1;
mb(); // 全屏障
b = 2;
// Thread2執行
if (b == 2) {
assert(a == 1);
}全屏障mb()不僅保證了a = 1的寫操作在b = 2的寫操作之前完成,還保證了在b = 2之前,a = 1的讀操作也已經完成。這意味著,無論是讀操作還是寫操作,都嚴格按照代碼順序執行,從而最大程度地保證了多線程環境下數據的一致性和程序的正確性。
三、內存屏障核心原理
3.1編譯器優化與優化屏障
在程序編譯階段,編譯器為了提高代碼的執行效率,會對代碼進行優化,其中指令重排是一種常見的優化手段。例如,對于下面的 C 代碼:
int a = 1;
int b = 2;在沒有數據依賴的情況下,編譯器可能會將其編譯成匯編代碼時,交換這兩條指令的順序,先執行b = 2,再執行a = 1。在單線程環境下,這種重排通常不會影響程序的最終結果。但在多線程環境中,當多個線程共享數據時,這種重排可能會導致數據一致性問題 。
為了禁止編譯器對特定指令進行重排,Linux 內核提供了優化屏障機制。在 Linux 內核中,通過barrier()宏來實現優化屏障 。barrier()宏的定義如下:
#define barrier() __asm__ __volatile__("" ::: "memory")__asm__表示這是一段匯編代碼,__volatile__告訴編譯器不要對這段代碼進行優化,即不要改變其前后代碼塊的順序 。"memory"表示內存中的變量值可能會發生變化,編譯器不能使用寄存器中的值來優化,而應該重新從內存中加載變量的值。這樣,在barrier()宏之前的指令不會被移動到barrier()宏之后,之后的指令也不會被移動到之前,從而保證了編譯器層面的指令順序。
3.2CPU 執行優化與內存屏障
現代 CPU 為了提高執行效率,采用了超標量體系結構和亂序執行技術。CPU 在執行指令時,會按照程序順序取出一批指令,分析找出沒有依賴關系的指令,發給多個獨立的執行單元并行執行,最后按照程序順序提交執行結果,即 “順序取指令,亂序執行,順序提交執行結果” 。
例如,當 CPU 執行指令A需要從內存中讀取數據,而這個讀取操作需要花費較長時間時,CPU 不會等待指令A完成,而是會繼續執行后續沒有數據依賴的指令B、C等,直到指令A的數據讀取完成,再繼續執行指令A的后續操作 。
雖然 CPU 的亂序執行可以提高執行效率,但在某些情況下,這種亂序執行可能會導致問題。比如,在多處理器系統中,一個處理器修改數據后,可能不會把數據立即同步到自己的緩存或者其他處理器的緩存,導致其他處理器不能立即看到最新的數據。為了解決這個問題,需要使用內存屏障來保證 CPU 執行指令的順序 。
內存屏障確保在屏障原語前的指令完成后,才會啟動原語之后的指令操作。在不同的 CPU 架構中,有不同的指令來實現內存屏障的功能。例如,在 X86 系統中,以下這些匯編指令可以充當內存屏障:
- 所有操作 I/O 端口的指令;
- 前綴lock的指令,如lock;addl $0,0(%esp),雖然這條指令本身沒有實際意義(對棧頂保存的內存地址內的內容加上 0),但lock前綴對數據總線加鎖,從而使該條指令成為內存屏障;
- 所有寫控制寄存器、系統寄存器或 debug 寄存器的指令(比如,cli和sti指令,可以改變eflags寄存器的IF標志);
- lfence、sfence和mfence匯編指令,分別用來實現讀內存屏障、寫內存屏障和讀 / 寫內存屏障;
- 特殊的匯編指令,比如iret指令,可以終止中斷或異常處理程序。
在 ARM 系統中,則使用ldrex和strex匯編指令實現內存屏障。這些內存屏障指令能夠阻止 CPU 對指令的亂序執行,確保內存操作的順序性和可見性,從而保證多線程環境下程序的正確執行。
3.3內存屏障的工作過程
內存屏障在工作時,就像是一個嚴格的 “柵欄”,對內存操作進行著有序的管控。以下通過一段簡單的偽代碼示例,來詳細描述內存屏障的工作過程:
// 定義共享變量
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,從而斷言不會失敗 。
通過這個例子可以看出,內存屏障就像一個堅固的 “柵欄”,將內存操作有序地分隔開來,確保了內存操作的順序性和數據的可見性,有效地避免了多線程環境下由于指令重排序和緩存不一致等問題導致的數據錯誤和程序邏輯混亂 。
四、內存屏障的應用實例
4.1多線程數據共享
在多線程編程中,數據共享是常見的場景。假設我們有一個多線程程序,其中一個線程負責寫入數據,另一個線程負責讀取數據。代碼示例如下:
#include <stdio.h>
#include <pthread.h>
#include <stdatomic.h>
// 共享變量
atomic_int shared_variable = 0;
// 寫線程函數
void* writer(void* arg) {
for (int i = 0; i < 1000000; ++i) {
shared_variable = i;
// 使用寫屏障,確保數據寫入主內存
atomic_thread_fence(memory_order_release);
}
return NULL;
}
// 讀線程函數
void* reader(void* arg) {
for (int i = 0; i < 1000000; ++i) {
// 使用讀屏障,確保從主內存讀取最新數據
atomic_thread_fence(memory_order_acquire);
int value = shared_variable;
// 處理讀取到的數據
}
return NULL;
}
int main() {
pthread_t writer_thread, reader_thread;
// 創建寫線程
pthread_create(&writer_thread, NULL, writer, NULL);
// 創建讀線程
pthread_create(&reader_thread, NULL, reader, NULL);
// 等待寫線程結束
pthread_join(writer_thread, NULL);
// 等待讀線程結束
pthread_join(reader_thread, NULL);
return 0;
}在這個例子中,writer線程負責向shared_variable寫入數據,reader線程負責讀取數據。atomic_thread_fence(memory_order_release)是一個寫屏障,它確保在屏障之前對shared_variable的寫操作完成后,才允許其他線程進行讀操作。atomic_thread_fence(memory_order_acquire)是一個讀屏障,它確保在讀取shared_variable之前,所有對shared_variable的寫操作都已經完成并對當前線程可見。通過使用內存屏障,我們保證了多線程環境下數據共享的一致性和正確性。
4.2雙重檢查鎖定(DCL)
雙重檢查鎖定(Double-Checked Locking)是一種常見的設計模式,用于在多線程環境下實現延遲初始化。其基本思想是在獲取實例時,先進行一次快速檢查,判斷實例是否已經創建,如果未創建,則進入同步塊進行二次檢查并創建實例。這樣可以避免每次獲取實例時都進行同步操作,從而提高性能。然而,在沒有正確使用內存屏障的情況下,雙重檢查鎖定可能會出現問題。
以 C++語言為例,以下是一個錯誤的雙重檢查鎖定實現:
#include <mutex>
class Singleton {
private:
static Singleton* instance; // 缺少適當的內存可見性保證
static std::mutex mtx;
Singleton() {} // 私有構造函數
public:
static Singleton* getInstance() {
if (instance == nullptr) { // 第一次檢查(無鎖)
std::lock_guard<std::mutex> lock(mtx); // 加鎖
if (instance == nullptr) { // 第二次檢查(持有鎖)
instance = new Singleton(); // 問題所在
}
}
return instance;
}
};
// 靜態成員初始化
Singleton* Singleton::instance = nullptr;
std::mutex Singleton::mtx;錯誤原因分析:
①指令重排序問題:instance = new Singleton() 可分解為三步:
- 分配內存
- 構造對象(初始化)
- 將指針指向指向內存地址
在缺乏內存屏障的情況下,編譯器或 CPU 可能重排序后兩步,導致其他線程在對象未完全構造時就看到 instance 非空并嘗試使用,引發未定義行為。
②內存可見性問題:多個線程可能看不到其他線程對 instance 的修改,因為缺少強制內存同步的機制。
C++11 及以上的正確實現:需結合 std::atomic 確保內存可見性和禁止重排序
#include <mutex>
#include <atomic>
class Singleton {
private:
static std::atomic<Singleton*> instance; // 原子變量保證可見性
static std::mutex mtx;
Singleton() {}
public:
static Singleton* getInstance() {
Singleton* temp = instance.load(std::memory_order_acquire); // 原子加載
if (temp == nullptr) {
std::lock_guard<std::mutex> lock(mtx);
temp = instance.load(std::memory_order_relaxed);
if (temp == nullptr) {
temp = new Singleton();
instance.store(temp, std::memory_order_release); // 原子存儲
}
}
return temp;
}
};
// 靜態成員初始化
std::atomic<Singleton*> Singleton::instance(nullptr);
std::mutex Singleton::mtx;std::atomic 的內存序(memory_order_acquire/release)確保了:
- 其他線程能看到 instance 的最新值
- 禁止 new 操作的指令重排序,保證對象完全構造后才被其他線程可見
4.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 協議)協同工作,有效地解決了緩存不一致的問題,確保了多處理器系統中數據的正確性和可靠性 。
五、使用注意事項與性能考量
5.1避免過度使用
雖然內存屏障是解決多線程環境下內存一致性問題的有力工具,但過度使用會對系統性能產生負面影響 。內存屏障會阻止 CPU 和編譯器對指令進行重排序,這在一定程度上限制了它們的優化能力,從而增加了指令執行的時間 。在一些不必要的場景中使用內存屏障,會導致性能下降 。
例如,在單線程環境中,由于不存在多線程并發訪問共享數據的問題,使用內存屏障是完全沒有必要的,這只會浪費系統資源 。在多線程環境中,如果共享數據的訪問沒有數據競爭問題,也不應隨意使用內存屏障 。比如,在一個多線程程序中,多個線程只是讀取共享數據,而不進行寫操作,此時使用內存屏障并不能帶來任何好處,反而會降低性能 。因此,在使用內存屏障時,需要仔細分析代碼的執行邏輯和數據訪問模式,確保只在必要的地方使用內存屏障,以避免不必要的性能損失 。
5.2選擇合適的屏障類型
不同類型的內存屏障在功能和適用場景上有所不同,因此根據具體的場景選擇合適的內存屏障類型至關重要 。如果只需要保證讀操作的順序,那么使用讀內存屏障(rmb)即可;如果只需要保證寫操作的順序,使用寫內存屏障(wmb)就足夠了 。在一些復雜的場景中,可能需要同時保證讀寫操作的順序,這時就需要使用通用內存屏障(mb)或讀寫內存屏障 。
例如,在一個多線程程序中,線程 A 需要先讀取共享變量x,再讀取共享變量y,并且要求這兩個讀操作按照順序進行,此時就可以在讀取x和y之間插入讀內存屏障 。如果線程 A 需要先寫入共享變量x,再寫入共享變量y,并且要求其他線程能夠按照這個順序看到更新后的值,那么就應該在寫入x和y之間插入寫內存屏障 。在一些涉及復雜數據結構讀寫的場景中,可能需要使用通用內存屏障來保證讀寫操作的順序 。
比如,在一個多線程程序中,線程 A 需要先寫入數據到共享鏈表,然后讀取鏈表中的其他部分,線程 B 則需要先讀取線程 A 寫入的數據,然后再寫入新的數據,這種情況下就可以使用通用內存屏障來確保線程 A 和線程 B 的讀寫操作按照預期的順序進行 。因此,在使用內存屏障時,需要根據具體的場景和需求,選擇合適的內存屏障類型,以充分發揮內存屏障的作用,同時避免不必要的性能開銷 。
5.3性能監測與優化
為了確保內存屏障的使用不會對系統性能造成過大的影響,使用工具監測內存屏障對性能的影響,并根據監測結果進行優化是很有必要的 。在 Linux 系統中,可以使用 perf 工具來監測內存屏障對性能的影響 。perf 是一個性能分析工具,它可以收集系統的性能數據,包括CPU使用率、內存訪問次數等 。通過使用perf 工具,可以了解內存屏障的使用對系統性能的影響,從而找到性能瓶頸,并進行優化 。
例如,可以使用 perf record 命令來收集性能數據,然后使用 perf report 命令來查看性能報告 。在性能報告中,可以看到各個函數的 CPU 使用率、內存訪問次數等信息,從而找到內存屏障使用較多的函數,并分析其對性能的影響 。如果發現某個函數中內存屏障的使用導致了性能下降,可以嘗試優化該函數的代碼,減少內存屏障的使用,或者選擇更合適的內存屏障類型 。
除了使用 perf 工具外,還可以通過代碼優化、算法改進等方式來提高系統性能 。例如,可以減少不必要的內存訪問,優化數據結構,提高代碼的并行性等 。通過綜合使用這些方法,可以有效地提高系統性能,確保內存屏障的使用不會對系統性能造成過大的影響 。





























