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















 
 
 











 
 
 
 