一区二区三区日韩精品-日韩经典一区二区三区-五月激情综合丁香婷婷-欧美精品中文字幕专区

分享

為什么編譯器過度優(yōu)化導(dǎo)致線程安全問題?

 半佛肉夾饃 2023-10-20 發(fā)布于河南

問:

我在《程序員自我修養(yǎng)》的書中看到這樣的內(nèi)容,過度優(yōu)化的第一個(gè)例子,下面是數(shù)據(jù)分析實(shí)際的執(zhí)行步驟,讓我不明白的是 為什么R[1]寄存器還沒有回寫到x,Thread2 就在可以執(zhí)行鎖block內(nèi)的操作了?難道此時(shí)Thread1已經(jīng)unlock了么?

我的理解是:由于編譯優(yōu)化讓Thread1的寄存器R[1]延遲回寫到x,所以在畫紅圈圈??的地方其實(shí)Thread1已經(jīng)執(zhí)行完unlock操作了,這時(shí)Thread2 就可以獲取鎖并且操作鎖block的操作了。

我同事的理解是:在這種優(yōu)化下,因?yàn)?x 被放到各自線程的寄存器了,所以鎖其實(shí)是鎖在了寄存器的操作上,而實(shí)際上兩個(gè)寄存器是相互獨(dú)立的,R2用了把新鎖。兩個(gè)寄存器 兩把鎖

我理解的是,Thread1正在使用鎖,Thread2就無法獲取鎖。

補(bǔ)充一個(gè)問題: 從書中介紹我知道,當(dāng)出現(xiàn)編譯器過度優(yōu)化就要使用volatile標(biāo)識符來避免這個(gè)問題,我想知道的是,編譯器過度優(yōu)化會(huì)在哪些場景呢?比如我是應(yīng)用開發(fā),編寫端的應(yīng)用,在工作中從來沒有想過還會(huì)有這種情況的存在,所以我想知道哪些場景編譯器會(huì)過度優(yōu)化,我需要考慮這個(gè)問題呢?

答:

這本書就屬于“以其昏昏,使人昭昭”的典型了。

先說鎖。

鎖的原理和作用其他答案已經(jīng)提到了,它實(shí)質(zhì)上是用一個(gè)原子操作指令來保護(hù)另外的一堆非原子指令,從而使它們也得到“原子性”。

所謂“原子性”,你可以理解為“做這些事時(shí),內(nèi)存只有我一個(gè)人能改,從而使得我的動(dòng)作完全體現(xiàn)我的意圖,要么完全成功要么完全失敗,不會(huì)出現(xiàn)第三種狀態(tài)”。

典型的原子操作指令如CPU提供的test & set類指令:先測試內(nèi)存中某個(gè)位置存儲的值是否符合條件(比如為0表示未上鎖),若符合條件則執(zhí)行set操作(把指定值寫入內(nèi)存);否則不執(zhí)行任何操作。

這個(gè)過程中,指令執(zhí)行過程就需要禁止內(nèi)存訪問。不然另一個(gè)test&set指令就可能讀到頭一個(gè)test&set指令即將寫入的單元的原始值,從而造成“臟讀”。

類似的,我們可以用test&set指令維護(hù)一個(gè)內(nèi)存單元的內(nèi)容,用它作為旗標(biāo)(flag);當(dāng)我們需要讀寫另一塊內(nèi)存之前,先檢查并設(shè)置旗標(biāo)——當(dāng)每個(gè)訪問這塊內(nèi)存的操作都先檢查和設(shè)置旗標(biāo)、發(fā)現(xiàn)旗標(biāo)狀態(tài)不對就主動(dòng)避讓時(shí),我們就說“這塊內(nèi)存被鎖保護(hù)起來了,現(xiàn)在對它的操作都是獨(dú)占的”。

顯而易見,你完全可以不去請求鎖(檢查旗標(biāo)、主動(dòng)避讓),那么鎖對你就是完全沒有強(qiáng)制力的。

換句話說,“鎖”其實(shí)是個(gè)“君子協(xié)定”。

“我”害怕這塊內(nèi)存臟讀臟寫,使“我”的程序出現(xiàn)bug,所以“我”才主動(dòng)調(diào)用鎖,發(fā)現(xiàn)鎖條件不滿足時(shí)主動(dòng)等待(除了自旋鎖。鎖一般是OS提供的,因此請求鎖失敗OS馬上就會(huì)知道,就會(huì)暫時(shí)中止線程執(zhí)行,直到鎖被釋放才會(huì)重新把它調(diào)度到待執(zhí)行隊(duì)列)。

但如果寫程序的是個(gè)野生的二蛋,他壓根就不知道臟讀臟寫這回事……那么所謂的“鎖保護(hù)”自然就不存在了(所以我喜歡把共享資源封裝成個(gè)類,各種訪問都必須通過接口進(jìn)行)。

明白了這個(gè),自然就該知道了:鎖是(程序員自己選擇的)主動(dòng)退避行為,疏忽了或者學(xué)藝不精就不會(huì)知道退避,并不存在“鎖什么什么位置”的說法。

或者,簡單說,在這個(gè)例子里,lock/unlock之間的代碼,無論是讀寫內(nèi)存也好、訪問磁盤也罷,它們一定是串行的。絕對不存在A lock了、還沒unlock呢,B居然能搶在A unlock之前執(zhí)行一說。

寫編譯器的不是傻子。鎖相關(guān)的指令是很特殊的(往往會(huì)帶lock前綴、或者設(shè)置內(nèi)存屏障,視不同CPU不同);遇到這種指令,用腳趾頭想也該知道接下來那塊代碼不能亂來——絕對不可能把鎖相關(guān)指令之前/之后的其他指令提到它之前/之后執(zhí)行。

類似的,C/C++的函數(shù)調(diào)用往往伴隨著無數(shù)(發(fā)生在內(nèi)存或其他地方的)副作用。沒有哪個(gè)傻蛋敢把函數(shù)調(diào)用位置前后的指令隨意調(diào)換位置的。這本書的說法純屬無稽之談。

再說volatile。

volatile其實(shí)是這么一回事:在過去那個(gè)還沒搞出線程的黑暗年代里,C/C++語言的使用者發(fā)現(xiàn),編譯器優(yōu)化有時(shí)候會(huì)搞砸他們的程序。

這件事情的緣由是這么來的:讀寫內(nèi)存的時(shí)間代價(jià)非常非常昂貴。比如在奔三CPU上,一次訪存同時(shí)又cache未命中引起的開銷是70個(gè)時(shí)鐘周期(來自我的記憶,數(shù)字未必正確);而讀取寄存器幾乎沒有開銷。

因此,編譯器必然傾向于“盡量壓縮掉所有不必要的內(nèi)存訪問指令”。這個(gè)技術(shù)被稱為“常量優(yōu)化”或者“常量分析”;用人話說就是“觀察一段子程序,盡量把'內(nèi)存變量的讀寫’去掉,替換為'只往寄存器加載一次,然后一直用寄存器內(nèi)容’;但同時(shí)要保證程序語義等價(jià)”。

void fun(int &arg1) {
  //一些操作,此時(shí)arg1可以全部優(yōu)化為寄存器訪問
  ....
  //這個(gè)調(diào)用把a(bǔ)rg1的地址傳給了另一個(gè)函數(shù)fun2
  //此時(shí)不得不把寄存器內(nèi)的arg1值寫入,否則fun2就會(huì)'臟讀’
  fun2(&arg1);
  //由于fun2可能修改了arg1,因此這里必須重新讀取arg1的新值
  ...
}

為了配合這個(gè)優(yōu)化,現(xiàn)代CPU內(nèi)部制造了大量的寄存器(組),方便程序使用;同時(shí),C++引入了新的const關(guān)鍵字——過去,你傳址一個(gè)變量到另一個(gè)函數(shù)中,那么這個(gè)變量很可能就會(huì)被這個(gè)函數(shù)修改;那么當(dāng)函數(shù)執(zhí)行返回后,你就不得不重新讀一下內(nèi)存中這個(gè)變量的值,這樣才能保證寄存器里面的值是最新的。

而const關(guān)鍵字相當(dāng)于告訴編譯器,這個(gè)函數(shù)并不會(huì)修改這個(gè)被聲明為const的引用變量;所以無需在本函數(shù)執(zhí)行返回后、強(qiáng)制調(diào)用者重新加載新值。

void fun(int &arg1) {
  //一些操作,注意此時(shí)arg1可以全部優(yōu)化為寄存器訪問
  ....
  //這個(gè)調(diào)用把a(bǔ)rg1的地址傳給了另一個(gè)函數(shù)fun2
  //此時(shí)不得不把寄存器內(nèi)的arg1值寫入,否則fun2就會(huì)'臟讀’
  fun2(arg1);
  //由于fun2的聲明為fun2(const &arg1),因此它不會(huì)修改arg1,所以無需重讀內(nèi)存中的arg1的值
  ...
}

但有些情況下,這個(gè)優(yōu)化會(huì)引起問題。

舉例來說,某些CPU上,外設(shè)和內(nèi)存是統(tǒng)一編址的。比如你把指針p指向內(nèi)存位置100H,在intel CPU上這是訪問內(nèi)存;但在其它CPU上,這可能是讀寫地址編碼為100H的那個(gè)外設(shè)的內(nèi)容(比如網(wǎng)卡)。

于是,當(dāng)它是內(nèi)存時(shí),只在開頭讀一次到寄存器、之后一直操作寄存器是正確的優(yōu)化;但如果它是外設(shè)……

因此,C/C++不得不增加了一個(gè)volatile關(guān)鍵字:這個(gè)變量的內(nèi)容隨時(shí)可能因不明原因改變,因此不要對它執(zhí)行常量優(yōu)化。

void read_nic(char *buf, size_t len) {
   volatile int *nic_port;
   nic_port=100H;
   for(size_t i = 0; i < len; i++) {
       //必須有volatile聲明,否則buf內(nèi)容就可能是從100H讀進(jìn)來的第一個(gè)值的len次重復(fù)
       buf[i] = *nic_port;
   }
}

問題圓滿解決。

然后,多線程時(shí)代到來。

多線程允許一個(gè)程序內(nèi)部同時(shí)存在多個(gè)執(zhí)行緒;這時(shí)候,哪怕變量指向內(nèi)存,它的內(nèi)容也可能隨時(shí)改變了——只要它被共享給另一個(gè)線程。

于是,為了圖方便,人們直接“挪用”了volatile關(guān)鍵字——這個(gè)變量的內(nèi)容隨時(shí)可能改變,因此不要對它做常量優(yōu)化。

事情似乎解決了。

但是,volatile這個(gè)關(guān)鍵字太容易誤導(dǎo)初學(xué)者:

1、因?yàn)関olatile的意思是“可變”,所以既然我都這么聲明了,編譯器一定能安排的妥妥貼貼——比如多線程程序里,只要把一個(gè)變量聲明為volatile就能保證數(shù)據(jù)安全。

但實(shí)際上,對一個(gè)volatile變量的訪問指令未必是原子的;volatile僅保證了“每次都從內(nèi)存取數(shù)據(jù)”,并不能保證數(shù)據(jù)安全(無法避免臟讀/臟寫)。你必須自己想辦法保護(hù)共享數(shù)據(jù)、確保對它們訪問的原子性。

2、既然volatile保證不了數(shù)據(jù)安全,我用鎖保護(hù)它總行了吧?順便的,反正volatile也沒用,統(tǒng)統(tǒng)刪了算了……

錯(cuò)。哪怕用了鎖,volatile仍然有用。它的作用是阻止編譯器的常量優(yōu)化。多線程訪問的數(shù)據(jù)的確不能常量優(yōu)化,所以你還必須寫上它——換句話說,需要阻止常量優(yōu)化的地方,你仍然需要自己明確聲明。

然后呢,你提到的“利用volatile避免過度優(yōu)化”這個(gè)說法,又是一個(gè)典型的、更淺薄的錯(cuò)誤理解——volatile的作用很精確,就是阻止常量優(yōu)化;如果編譯器的常量優(yōu)化沒問題,用它就是典型的“負(fù)優(yōu)化”;如果編譯器真優(yōu)化出了問題,用它也并不能阻止其他方面的優(yōu)化。

正本清源之后,這個(gè)問題就很容易回答了。

1、編譯器的確會(huì)“通過調(diào)整代碼執(zhí)行次序優(yōu)化程序效率”,甚至CPU自己都會(huì)通過“亂序執(zhí)行”來優(yōu)化自己內(nèi)部資源的利用效率;但這個(gè)優(yōu)化必然是嚴(yán)格同義的——絕對絕對不可能出現(xiàn)“兩個(gè)線程被鎖保護(hù)部分的指令交錯(cuò)執(zhí)行”這么奇葩的事情

換句話說,這本書的作者壓根沒搞明白鎖究竟是什么、背后是什么機(jī)制。

2、真正惹禍的是“常量優(yōu)化”

編譯器一“看”,你的程序僅執(zhí)行了x++,沒把它傳給別人;那好,x++就可以做常量優(yōu)化——于是thread1就再也看不見thread2的修改了。

甚至于,它還可以“聰明”的發(fā)現(xiàn),其實(shí)函數(shù)內(nèi)部也用不著真從內(nèi)存讀x值,直接讀取參數(shù)值——然后只需在函數(shù)末尾寫一次內(nèi)存,節(jié)約一次內(nèi)存讀取,豈不美哉?

如果你在調(diào)用其它函數(shù)前操作x的話,它甚至還能貼心的直接用指令中的立即數(shù)0代替x,幫你把效率優(yōu)化到極致!

而volatile的作用正是“阻止常量優(yōu)化”——所以它立即解決了問題。

但請注意,問題是“多余的常量優(yōu)化”,并不是什么“交換指令執(zhí)行順序”。

3、書上的示例是一個(gè)無效示例

你照這樣寫一個(gè)程序,反復(fù)執(zhí)行一千萬遍也不可能出現(xiàn)臟讀臟寫問題。

這是因?yàn)?,“常量?yōu)化”說白了是一種“相信X這個(gè)變量在我控制之下,因此沒必要執(zhí)行那么多'多余的讀寫’”這樣過于樂觀的假定。

因此,對這段代碼:

for(循環(huán)1000遍) {
   lock();
   x++;
   unlock();
}

正常生成的、無優(yōu)化的匯編偽代碼應(yīng)該是:

loop: //循環(huán)1000遍
CALL lock
LOAD x to EAX //每次循環(huán)都要從內(nèi)存載入x值
inc EAX
SAVE EAX to x //每次循環(huán)都要把x值寫回內(nèi)存
//判斷循環(huán)次數(shù)是否足夠
... //代碼略
JNZ loop //返回loop標(biāo)簽,重復(fù)執(zhí)行如上動(dòng)作

很明顯,循環(huán)1000遍,那么就要執(zhí)行1000次LOAD x to EAX 和 SAVE EAX to x;期間執(zhí)行權(quán)變動(dòng)可能引起cache失效,每次cache失效都需要至少70個(gè)時(shí)鐘周期訪問DDR……

注意,x86匯編支持在指令中訪問內(nèi)存,并不需要像某些RISC機(jī)一樣使用獨(dú)立的load/save指令訪存。但拆開寫有助于理解這里的實(shí)際執(zhí)行過程,因此這里我把它寫成了三條指令。

一旦打開優(yōu)化,編譯器會(huì)優(yōu)化成這樣:

LOAD x to EAX //在整個(gè)函數(shù)中僅載入x值一次

loop: //循環(huán)1000遍
CALL lock
inc EAX
//判斷循環(huán)次數(shù)是否足夠
... //代碼略
JNZ loop //返回loop標(biāo)簽,重復(fù)執(zhí)行如上動(dòng)作

SAVE EAX to x //不再讀寫x之后、函數(shù)返回之前,把x寫回內(nèi)存

在循環(huán)開始之前執(zhí)行LOAD x to EAX把x內(nèi)容載入EAX,然后一直操縱EAX;直到循環(huán)結(jié)束、線程返回前,這才調(diào)用SAVE EAX to x,把EAX內(nèi)容寫回x。

顯然,如此一來,當(dāng)某個(gè)單線程進(jìn)程執(zhí)行這段循環(huán)時(shí),它只會(huì)操縱自己那個(gè)執(zhí)行現(xiàn)場的EAX;不僅少執(zhí)行了1998條訪存指令,還可以借助現(xiàn)代CPU海量的寄存器/寄存器窗口,獲得極致的執(zhí)行效率。

但是,很明顯的,把這段代碼丟線程里執(zhí)行時(shí),里面的lock/unlock其實(shí)是沒有實(shí)際作用的。因?yàn)閮蓚€(gè)線程都僅僅讀寫了一次內(nèi)存中的x,之后的執(zhí)行完全是各做各的。這樣顯然是要出大事的。

換句話說,常量優(yōu)化錯(cuò)誤的刪除了循環(huán)中的訪存指令,這才是bug出現(xiàn)的原因。

注意這不是簡單的“調(diào)整指令執(zhí)行次序”,而是“把訪存操作從循環(huán)中去掉、在使用之前載入一次,然后在函數(shù)返回之前寫回一次”——用對術(shù)語有助于理解問題。

把變量x聲明為volatile,就可以避免編譯器錯(cuò)誤的刪除這1998次訪存操作,這樣里面的加解鎖操作才有意義,才能保證執(zhí)行結(jié)果正確。

那么,回過頭來,我們看書上的示例??闯鰡栴}了嗎?

沒錯(cuò),這個(gè)線程太簡單了:

//thread begin
lock();
x++;
unlock();
//thread end

它對應(yīng)的指令是:

//thread begin
CALL lock
LOAD x to EAX
inc EAX
SAVE EAX to x
CALL unlock
//thread end

僅僅內(nèi)存讀寫各一次,這還怎么做“常量優(yōu)化”?

既然沒法做常量優(yōu)化,那么你盡管放心,編譯器又不是神經(jīng)病,不會(huì)無意義的改動(dòng)你的代碼。作者臆想中的bug并不會(huì)出現(xiàn)。

因此,這本書的作者顯然從未真正實(shí)驗(yàn)過自己寫的東西,完全是基于一知半解在胡說八道。

很簡單點(diǎn)事;但如果真跟著它的思路,肯定越學(xué)越糊涂。

耗費(fèi)了N倍的精力,反而構(gòu)建了一個(gè)錯(cuò)誤的知識體系;然后一步錯(cuò),步步錯(cuò),深陷泥潭無法脫出……這就是垃圾書的危害。

RECOMMEND

- 點(diǎn)個(gè)在看你最好看 -

    轉(zhuǎn)藏 分享 獻(xiàn)花(0

    0條評論

    發(fā)表

    請遵守用戶 評論公約

    類似文章 更多

    国产又猛又黄又粗又爽无遮挡| 亚洲高清欧美中文字幕| 国产欧美韩日一区二区三区| 麻豆印象传媒在线观看| 日韩精品一级一区二区| 美女被草的视频在线观看| 国产精品日韩欧美一区二区| 亚洲专区一区中文字幕| 欧美在线观看视频免费不卡| 九九蜜桃视频香蕉视频| 欧美午夜视频免费观看| 一区二区三区亚洲天堂| 五月天丁香亚洲综合网| 欧美性猛交内射老熟妇| 久久福利视频在线观看| 激情三级在线观看视频| 在线免费国产一区二区| 91久久精品在这里色伊人| 欧美黑人精品一区二区在线| 日韩人妻一区二区欧美| 国产成人精品在线播放| 殴美女美女大码性淫生活在线播放| 国产不卡最新在线视频| 国产在线一区二区免费| 免费福利午夜在线观看| 国产精品日韩欧美一区二区| 国产成人精品午夜福利| 国产福利一区二区三区四区| 日本婷婷色大香蕉视频在线观看| 欧美自拍系列精品在线| 精品欧美在线观看国产| 国产成人综合亚洲欧美日韩| 91精品国产综合久久不卡| 国产一区二区熟女精品免费| 国产精品成人又粗又长又爽| 国产午夜精品在线免费看| 午夜视频成人在线免费| 中日韩美女黄色一级片| 国产精品免费视频视频| 精品国产成人av一区二区三区| 欧美日韩精品一区二区三区不卡 |