問: 我在《程序員自我修養(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) { 為了配合這個(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) { 但有些情況下,這個(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) { 問題圓滿解決。 然后,多線程時(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遍) { 正常生成的、無優(yōu)化的匯編偽代碼應(yīng)該是: loop: //循環(huán)1000遍 很明顯,循環(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值一次 在循環(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 它對應(yīng)的指令是: //thread begin 僅僅內(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è)在看你最好看 - |
|