突然想聊聊這個話題,是因為知乎上的一個問題多次出現(xiàn)在了我的Timeline里:請問,多個線程可以讀一個變量,只有一個線程可以對這個變量進(jìn)行寫,到底要不要加鎖?可惜的是很多高票答案語焉不詳,甚至有所錯漏。所以我想在這篇文章里斗膽聊聊這個水挺深的問題。受限于個人水平,文章若有錯漏,還望讀者不吝賜教。
首先約定,由于CPU的架構(gòu)和設(shè)計浩如煙海,本文站在工程師的角度,只談IA32/AMD64(x86-64)架構(gòu),不討論其他架構(gòu)的細(xì)節(jié)和差異。并且文章中主要引用Intel的文檔予以佐證,不關(guān)注AMD在實現(xiàn)細(xì)節(jié)上的差異。
眾所周知,當(dāng)一個執(zhí)行中的程序的數(shù)據(jù)被多個執(zhí)行流并發(fā)訪問的時候,就會涉及到同步(Synchronization)的問題。同步的目的是保證不同執(zhí)行流對共享數(shù)據(jù)并發(fā)操作的一致性。早在單核時代,使用鎖或者原子變量就很容易達(dá)成這一目的。甚至因為CPU的一些訪存特性,對某些內(nèi)存對齊數(shù)據(jù)的讀或?qū)懸簿哂性拥奶匦浴?/p>
比如,在《Intel? 64 and IA-32 Architectures Software Developer’s Manual》的第三卷System Programming Guide的Chapter 8 Multiple-Processor Management里,就給出了這樣的說明:
也就是說,有些內(nèi)存對齊的數(shù)據(jù)的訪問在CPU層面就是原子進(jìn)行的(注意這里說的只是單次的讀或者寫,類似普通變量i的i++操作不止一次內(nèi)存訪問)。此時,環(huán)形隊列(Ring buffer)這種數(shù)據(jù)結(jié)構(gòu)在某些架構(gòu)的單核CPU上,只有一個Reader和一個Writer的情況下是不需要額外同步措施的。原因就是read_index和writer_index的寫操作在滿足對齊內(nèi)存訪問的情況下是原子的,不需要額外的同步措施。注意這里我加粗了單核CPU這個關(guān)鍵字,那么到了多核心處理器的今天,該操作就不是原子了嗎?不,依舊是原子的,但是出現(xiàn)了其他的干擾因素迫使可能需要額外的同步措施才能保證原本無鎖代碼的正確運行。
首先是現(xiàn)代編譯器的代碼優(yōu)化和編譯器指令重排可能會影響到代碼的執(zhí)行順序。編譯期指令重排是通過調(diào)整代碼中的指令順序,在不改變代碼語義的前提下,對變量訪問進(jìn)行優(yōu)化。從而盡可能的減少對寄存器的讀取和存儲,并充分復(fù)用寄存器。但是編譯器對數(shù)據(jù)的依賴關(guān)系判斷只能在單執(zhí)行流內(nèi),無法判斷其他執(zhí)行流對競爭數(shù)據(jù)的依賴關(guān)系。就拿無鎖環(huán)形隊列來說,如果Writer做的是先放置數(shù)據(jù),再更新索引的行為。如果索引先于數(shù)據(jù)更新,Reader就有可能會因為判斷索引已更新而讀到臟數(shù)據(jù)。
那禁止編譯器對該類變量的優(yōu)化,解決了編譯期的重排序就沒事了嗎?不,CPU還有亂序執(zhí)行(Out-of-Order Execution)的特性。流水線(Pipeline)和亂序執(zhí)行是現(xiàn)代CPU基本都具有的特性。機(jī)器指令在流水線中經(jīng)歷取指、譯碼、執(zhí)行、訪存、寫回等操作。為了CPU的執(zhí)行效率,流水線都是并行處理的,在不影響語義的情況下。處理器次序(Process Ordering,機(jī)器指令在CPU實際執(zhí)行時的順序)和程序次序(Program Ordering,程序代碼的邏輯執(zhí)行順序)是允許不一致的,即滿足As-if-Serial特性。顯然,這里的不影響語義依舊只能是保證指令間的顯式因果關(guān)系,無法保證隱式因果關(guān)系。即無法保證語義上不相關(guān)但是在程序邏輯上相關(guān)的操作序列按序執(zhí)行。從此單核時代CPU的Self-Consistent特性在多核時代已不存在,多核CPU作為一個整體看,不再滿足Self-Consistent特性。
簡單總結(jié)一下,如果不做多余的防護(hù)措施,單核時代的無鎖環(huán)形隊列在多核CPU中,一個CPU核心上的Writer寫入數(shù)據(jù),更新index后。另一個CPU核心上的Reader依靠這個index來判斷數(shù)據(jù)是否寫入的方式不一定可靠。index有可能先于數(shù)據(jù)被寫入,從而導(dǎo)致Reader讀到臟數(shù)據(jù)。
所有的麻煩到這里就結(jié)束了嗎?當(dāng)然不,還有Cache的問題。前文提到的都是順序一致性(Sequential Consistency)的問題,沒有涉及Cache一致性(Cache Coherence)的問題。雖然說一般情況下程序員只需要關(guān)注順序一致性即可,但是區(qū)分清楚這兩個概念也能更好的解釋內(nèi)存屏障(Memory Barrier)。
開始提到Cache一致性協(xié)議之前,先介紹兩個名詞:
Load/Read CPU讀操作,是指將內(nèi)存數(shù)據(jù)加載到寄存器的過程
Store/Write CPU寫操作,是指將寄存器數(shù)據(jù)寫回主存的過程
現(xiàn)代處理器的緩存一般分為三級,由每一個核心獨享的L1、L2 Cache,以及所有的核心共享L3 Cache組成:
由于Cache的容量很小,一般都是充分的利用局部性原理,按行/塊來和主存進(jìn)行批量數(shù)據(jù)交換,以提升數(shù)據(jù)的訪問效率。以前寫過一篇《淺析x86架構(gòu)中cache的組織結(jié)構(gòu)》,這里不再贅述。既然各個核心之間有獨立的Cache存儲器,那么這些存儲器之間的數(shù)據(jù)同步就是個比較復(fù)雜的事情。緩存數(shù)據(jù)的一致性由緩存一致性協(xié)議保證。這里比較經(jīng)典的當(dāng)屬MESI協(xié)議。Intel的處理器使用從MESI中演化出的MESIF協(xié)議,而AMD使用MOESI協(xié)議。緩存一致性協(xié)議的細(xì)節(jié)超出了本文的討論范圍,有興趣的讀者可以自行研究。
傳統(tǒng)的MESI協(xié)議中有兩個行為的執(zhí)行成本比較大。一個是將某個Cache Line標(biāo)記為Invalid狀態(tài),另一個是當(dāng)某Cache Line當(dāng)前狀態(tài)為Invalid時寫入新的數(shù)據(jù)。所以CPU通過Store Buffer和Invalidate Queue組件來降低這類操作的延時。如圖:
當(dāng)一個核心在Invalid狀態(tài)進(jìn)行寫入時,首先會給其它CPU核發(fā)送Invalid消息,然后把當(dāng)前寫入的數(shù)據(jù)寫入到Store Buffer中。然后異步在某個時刻真正的寫入到Cache Line中。當(dāng)前CPU核如果要讀Cache Line中的數(shù)據(jù),需要先掃描Store Buffer之后再讀取Cache Line(Store-Buffer Forwarding)。但是此時其它CPU核是看不到當(dāng)前核的Store Buffer中的數(shù)據(jù)的,要等到Store Buffer中的數(shù)據(jù)被刷到了Cache Line之后才會觸發(fā)失效操作。而當(dāng)一個CPU核收到Invalid消息時,會把消息寫入自身的Invalidate Queue中,隨后異步將其設(shè)為Invalid狀態(tài)。和Store Buffer不同的是,當(dāng)前CPU核心使用Cache時并不掃描Invalidate Queue部分,所以可能會有極短時間的臟讀問題。當(dāng)然這里的Store Buffer和Invalidate Queue的說法是針對一般的SMP架構(gòu)來說的,不涉及具體架構(gòu)。事實上除了Store Buffer和Load Buffer,流水線為了實現(xiàn)并行處理,還有Line Fill Buffer/Write Combining Buffer 等組件,參考文獻(xiàn)8-10給出了相關(guān)的資料可以進(jìn)一步閱讀。
-
寄存器
+關(guān)注
關(guān)注
31文章
5363瀏覽量
120911 -
cpu
+關(guān)注
關(guān)注
68文章
10899瀏覽量
212606 -
編譯器
+關(guān)注
關(guān)注
1文章
1642瀏覽量
49224
原文標(biāo)題:淺墨: 聊聊原子變量、鎖、內(nèi)存屏障那點事(1)
文章出處:【微信號:LinuxDev,微信公眾號:Linux閱碼場】歡迎添加關(guān)注!文章轉(zhuǎn)載請注明出處。
發(fā)布評論請先 登錄
相關(guān)推薦
評論