從老版glibc的一個bug說開來之前我在公眾號中,,發(fā)表過這么一篇文章: 這篇文章里面提到了老版本的glibc(2.13以前)中的排序函數(shù)qsort()有一個在并發(fā)時會出現(xiàn)core dump的bug,。那段代碼如下: void qsort_r(void* b, size_t n, size_t s, __compar_d_fn_t cmp, void* arg) { core dump原因就是用到了兩個static的變量:
static變量默認會初始化成0,。 if (size / pagesize > (size_t) phys_pages) { 這種除法操作并發(fā)的時候會出現(xiàn)除0,,導(dǎo)致coredump。也就是pagesize = 0,,而單看串行邏輯,,上述代碼沒有問題。因為前面pagesize已經(jīng)被賦值了:
這個就是讀取系統(tǒng)配置,,獲取頁的大小賦值給pagesize,。 那么為什么會core呢?首要原因當(dāng)然是pagesize的賦值的if其判斷條件不是pagesize為0,而是phys_pages 是否為0,。因此存在race condition,,phys_pages被賦值,但是pagesize還沒走到賦值的位置的時候,,其他線程開始做qsort排序,,導(dǎo)致跳過了這個if,直接去做了那個除法操作,。 所以見過一些老代碼在服務(wù)初始化的時候,,先用qsort()給隨機數(shù)做一下排序,目的就是給這兩個static變量初始化,。 為此,,有人給老版的glibc提過修復(fù)的merge request: https:///bugzilla/show_bug.cgi?id=11655 主要改動的diff是: - if (phys_pages == 0) 修改判斷條件,將兩個靜態(tài)變量是否為0都判斷了一遍,。很簡單易懂是吧,,看著也能解決這個并發(fā)的bug,但是最終glibc沒有合入這個修改,。而這其中的原因呢,,就要引出今天我們的議題了:編譯器和CPU會對指令進行重排序! 先看下最終glibc的修改版(glibc 2.13開始)是這樣:
這段代碼和之前版本的主要diff有二,,第一是if條件中改為直接判斷pagesize,,沒有用 || 去判斷兩個static變量是否為0;第二呢就是在pagesize真正被賦值之前加入了一個atomic_write_barrier() 后面會講到,。 劇透一下,,這段代碼的含義就是用匯編語言,在這里加入了一個內(nèi)存屏障,。好了,,開始講講什么是指令重排序,什么是內(nèi)存屏障吧,! 指令重排序編譯器為了提高程序的性能,,有時不會按照程序代碼對應(yīng)的指令順序來執(zhí)行,而是亂序執(zhí)行(Out-of-order execution),。比如我們用gcc編譯器都用過O2參數(shù),。當(dāng)然了說亂序有點夸張,它是在保證程序結(jié)果不變的情況下,,對看似沒有關(guān)聯(lián)的語句進行重排序,。然而它的重排序有個弊病,就是它僅能從單線程的串行邏輯角度去判斷兩個語句有沒有依賴關(guān)系,。不能判斷多線程環(huán)境下的依賴關(guān)系,。因而會導(dǎo)致問題。 當(dāng)然不僅編譯器,,CPU也會對程序進行優(yōu)化,,從而導(dǎo)致指令的重排。 前文所述的那個沒被合入的merge request,,如果合入則最終代碼如下: if (phys_pages == 0 || pagesize == 0) { 在第一個if塊中,,其實phys_pages和pagesize是沒有依賴關(guān)系的,所以直接可能被優(yōu)化成這樣執(zhí)行:
這樣又會產(chǎn)生一種新的race condition,,那就是某個線程中的qsort其pagesize和phys_pages都通過__sysconf()賦值完成了,,但是phys_pages /=4;還沒有被執(zhí)行,彼時另外一個線程又在執(zhí)行qsort,,導(dǎo)致它判斷pagesize和phys_pages都不等于0了,,就跳過了if直接執(zhí)行: if (size / pagesize > (size_t) phys_pages) { 這個時候的phys_pages是還沒有除以4的,所以這個除法雖然不core了,,但整個表達式的邏輯也不正確,! 內(nèi)存屏障內(nèi)存屏障(memory barrier)又叫內(nèi)存柵欄(memory fence),其目的就是用來阻擋CPU對指令的重排序,。我們再看下glibc最終修改后的代碼,。
atomic_write_barrier(),顧名思義就是加一個”寫類型“的內(nèi)存屏障,,其實它是一個宏,,展開為: __asm('':::'memory') 這個就是通過嵌入?yún)R編代碼的方式加了一個內(nèi)存屏障。讓phys_pages成功寫入之后再去給pagesize賦值(根據(jù)注釋也可見一斑),。 此外前面我有提到,,編譯器和CPU都會導(dǎo)致指令的重排序。這里的 __asm('':::'memory') 其實加的是編譯器的內(nèi)存屏障(也叫優(yōu)化屏障),,也就是說它能阻止編譯器不會對這段代碼重排序,,并不會阻止CPU的重排序。那么CPU不需要管嗎,? X86的內(nèi)存模型在談及CPU時,,通常會把變量的讀操作稱為load,變量的寫操作稱為store,。兩兩組合因而會出現(xiàn)4類讀寫操作:
對于我們常見的x86 架構(gòu)的CPU來說,它有一個相對強大的內(nèi)存模型,。它能直接保證前面三種屏障,,也就是說不需要去寫匯編指令去阻止CPU對前面三種類型讀寫操作的重排。但x86 CPU無法保證StoreLoad類型的屏障,。對于我前面所講的qsort的例子,,這個場景并不屬于StoreLoad,。貌似是StoreStore,先后對兩個變量進行寫入,。所以不需要給CPU加內(nèi)存屏障,。 當(dāng)然如果要加的話,也有辦法是這樣寫:
mfence是針對CPU的內(nèi)存屏障,。 內(nèi)存屏障與MESI看完前面的內(nèi)容,,相信你已經(jīng)認識到內(nèi)存屏障對于阻止編譯器和CPU指令重排序的作用,但其實CPU的內(nèi)存屏障卻不止如此,,還記得本系列的上一篇文章介紹了CPU的緩存一致性協(xié)議MESI嗎,? 其實內(nèi)存屏障與MESI也有關(guān)系。 CPU的內(nèi)存屏障如果只是保證指令順序不會亂,,也未必會讓程序執(zhí)行符合預(yù)期,。因為MESI為了提升性能,引入了Store Buffer和Invalidate Queue,。所以內(nèi)存屏障還有其他功能: 寫類型的內(nèi)存屏障還能觸發(fā)內(nèi)存的強制更新,,讓Store Buffer中的數(shù)據(jù)立刻回寫到內(nèi)存中。讀類型的內(nèi)存屏障會讓Invalidate Queue中的緩存行在后面的load之前全部標(biāo)記為失效,。 順帶一提,,X86 CPU是沒有實現(xiàn)Invalidate Queue的。 參考資料
|
|
來自: 一本正經(jīng)地胡鬧 > 《底層》