小故事
小艾吃飯路上碰上小牛,忙問:你昨天面大廠面的咋樣了?聽說他們最喜歡問多線程相關(guān)知識。
小牛說:對啊,第一個問題我就講了20分鐘,直接把面試官講服了。
小艾忙問:什么問題能講這么久?是不是問你情感經(jīng)歷了?
小牛說:…問的volatile關(guān)鍵字。
小艾說:volatile關(guān)鍵詞的作用一般有如下兩個:
可見性:當(dāng)一個線程修改了由volatile關(guān)鍵字修飾的變量的值時,其它線程能夠立即得知這個修改。
有序性:禁止編譯器關(guān)于操作volatile關(guān)鍵詞修飾的變量的指令重排序。
你說這兩個說了20分鐘?口吃?
小牛說:你知道volatile的實(shí)現(xiàn)原理嗎?
小艾說:緩存一致性協(xié)議嘛,這有啥?
小牛說:既然硬件保證了緩存一致性協(xié)議,無論該變量是否被volatile關(guān)鍵詞修飾,它都該滿足緩存一致性協(xié)議呀。你這說的有點(diǎn)自相矛盾哦。
小艾說:那volatile的實(shí)現(xiàn)原理是什么?
小牛說:且聽我慢慢道來。
緩存一致性協(xié)議
我們知道,現(xiàn)代CPU都是多核處理器。由于cpu核心(Kernel)讀取內(nèi)存數(shù)據(jù)較慢,于是就有了緩存的概念。我們希望針對頻繁讀寫的某個內(nèi)存變量,提升本核心的訪問速率。因此我們會給每個核心設(shè)計緩存區(qū)(Cache),緩存該變量。由于緩存硬件的讀寫速度比內(nèi)存快,所以通過這種方式可以提升變量訪問速度。
緩存的結(jié)構(gòu)可以如下設(shè)計:
緩存結(jié)構(gòu)圖
其中,一個緩存區(qū)可以分為N個緩存行(Cache line),緩存行是和內(nèi)存進(jìn)行數(shù)據(jù)交換的最小單位。每個緩存行包含三個部分,其中valid用于標(biāo)識該數(shù)據(jù)的有效性。如果有效位為false,CPU核心就從內(nèi)存中讀取,并將對應(yīng)舊的緩存行數(shù)據(jù)覆蓋,否則使用舊緩存數(shù)據(jù);tag用于指示數(shù)據(jù)對應(yīng)的內(nèi)存地址;block則用以存儲數(shù)據(jù),
多核緩存和內(nèi)存
但是,如果涉及到并發(fā)任務(wù),多個核心讀取同一個變量值,由于每個核心讀取的是自己那一部分的緩存,每個核心的緩存數(shù)據(jù)不一致將會導(dǎo)致一系列問題。緩存一致性的問題根源就在于,對于某個變量,好幾個核心對應(yīng)的緩存區(qū)都有,到底哪個是新的數(shù)據(jù)呢?如果只有一個CPU核心對應(yīng)的緩存區(qū)有該變量,那就沒事啦,該緩存肯定是新的。
所以為了保證緩存的一致性,業(yè)界有兩種思路:
寫失效(Write Invalidate):當(dāng)一個核心修改了一份數(shù)據(jù),其它核心如果有這份數(shù)據(jù),就把valid標(biāo)識為無效;
寫更新(Write update):當(dāng)一個核心修改了一份數(shù)據(jù),其它核心如果有這份數(shù)據(jù),就都更新為新值,并且還是標(biāo)記valid有效。
業(yè)界有多種實(shí)現(xiàn)緩存一致性的協(xié)議,諸如MSI、MESI、MOSI、Synapse、Firefly Dragon Protocol等,其中最為流行的是MESI協(xié)議。
MESI協(xié)議就是根據(jù)寫失效的思路,設(shè)計的一種緩存一致性協(xié)議。為了實(shí)現(xiàn)這個協(xié)議,原先的緩存行修改如下:
緩存結(jié)構(gòu)圖
原先的valid是一個比特位,代表有效/無效兩種狀態(tài)。在MESI協(xié)議中,該位改成兩位,不再只是有效和無效兩種狀態(tài),而是有四個狀態(tài),分別為:
M(Modified):表示核心的數(shù)據(jù)被修改了,緩存數(shù)據(jù)屬于有效狀態(tài),但是數(shù)據(jù)只處于本核心對應(yīng)的緩存,還沒有將這個新數(shù)據(jù)寫到內(nèi)存中。由于此時數(shù)據(jù)在各個核心緩存區(qū)只有唯一一份,不涉及緩存一致性問題;
E(Exclusive):表示數(shù)據(jù)只存在本核心對應(yīng)的緩存中,別的核心緩存沒這個數(shù)據(jù),緩存數(shù)據(jù)屬于有效狀態(tài),并且該緩存中的最新數(shù)據(jù)已經(jīng)寫到內(nèi)存中了。同樣由于此時數(shù)據(jù)在各個核心緩存區(qū)只有一份,也不涉及緩存一致性問題;
S(Shared):表示數(shù)據(jù)存于多個核心對應(yīng)的緩存中,緩存數(shù)據(jù)屬于有效狀態(tài),和內(nèi)存一致。這種狀態(tài)的值涉及緩存一致性問題;
I(Invalid):表示該核心對應(yīng)的緩存數(shù)據(jù)無效。
看到這里,大家想必知道為什么這個協(xié)議稱為MESI協(xié)議了吧,它的命名就是取了這四個狀態(tài)的首字母而已。
為了保證緩存一致性,每個核心要寫新數(shù)據(jù)前,需要確保其他核心已經(jīng)置同一變量數(shù)據(jù)的緩存行狀態(tài)位為Invalid后,再把新數(shù)據(jù)寫到自己的緩存行,并之后寫到內(nèi)存中。
MESI協(xié)議包含以下幾個行為:
讀(Read):當(dāng)某個核心需要某個變量的值,并且該核心對應(yīng)的緩存沒這個變量時,就會發(fā)出讀命令,希望別的核心緩存或者內(nèi)存能給該核心最新的數(shù)據(jù);
讀命令反饋(Read Response):讀命令反饋是對讀命令的回應(yīng),包含了之前讀命令請求的數(shù)據(jù)。舉例來說,Kernel0發(fā)送讀命令,請求變量a的值,Kernel1對應(yīng)的緩存區(qū)包含變量a,并且該緩存的狀態(tài)是M狀態(tài),所以Kernel1會給Kernel0的讀命令發(fā)送讀命令反饋,給出該值;
無效化(Invalidate):無效化指令是一條廣播指令,它告訴其他所有核心,緩存中某個變量已經(jīng)無效了。如果變量是獨(dú)占的,只存在某一個核心對應(yīng)的緩存區(qū)中,那就不存在緩存一致性問題了,直接在自己緩存中改了就行,也不用發(fā)送無效化指令;
無效化確認(rèn)(Invalidate Acknowledge):該指令是對無效化指令的回復(fù),收到無效化指令的核心,需要將自己緩存區(qū)對應(yīng)的變量狀態(tài)改為Invalid,并回復(fù)無效化確認(rèn),以此保證發(fā)送無效化確認(rèn)的緩存已經(jīng)無效了;
讀無效(Read Invalidate):這個命令是讀命令和無效化命令的綜合體。它需要接受讀命令反饋和無效化確認(rèn);
寫回(Writeback)這個命令的意思是將核心中某個緩存行對應(yīng)的變量值寫回到內(nèi)存中去。
下圖給了個一個應(yīng)用MESI讀寫數(shù)據(jù)的例子。在該圖中,假設(shè)CPU有兩個核心,Kernel0表示第一個核心,Kernel1表示第二個核心。這里給出了Kernel0想寫新數(shù)據(jù)到自己緩存的例子。
MESI工作原理
首先Kernel0先完成新數(shù)據(jù)的創(chuàng)建;
Kernel0向全體其他核心發(fā)送無效化指令,告訴其他核心其所對應(yīng)的緩存區(qū)中的這條數(shù)據(jù)已經(jīng)過期無效。本圖例中只有一個其他核心,為Kernel1;
其他核心收到廣播消息后,將自己對應(yīng)緩存的數(shù)據(jù)的標(biāo)志位記為無效,然后給Kernel0回確認(rèn)消息;
收到所有其他Kernel的確認(rèn)消息后,Kernel0才能將新數(shù)據(jù)寫回到它所對應(yīng)的緩存結(jié)構(gòu)中去。
根據(jù)上圖,我們可以發(fā)現(xiàn),影響MESI協(xié)議的時間瓶頸主要有兩塊:
無效化指令:Kernel0需要通知所有的核心,該變量對應(yīng)的緩存在其他核心中是無效的。在通知完之前,該核心不能做任何關(guān)于這個變量的操作。
確認(rèn)響應(yīng):Kernel0需要收到其他核心的確認(rèn)響應(yīng)。在收到確認(rèn)消息之前,該核心不能做任何關(guān)于這個變量的操作,需要持續(xù)等待其他核心的響應(yīng),直到所有核心響應(yīng)完成,將其對應(yīng)的緩存行標(biāo)志位設(shè)為Invalid,才能繼續(xù)其它操作。
針對這兩部分,我們可以進(jìn)一步優(yōu)化:
針對無效化指令的加速:在緩存的基礎(chǔ)上,引入Store Buffer這個結(jié)構(gòu)。Store Buffer是一個特殊的硬件存儲結(jié)構(gòu)。通俗的來講,核心可以先將變量寫入Store Buffer,然后再處理其他事情。如果后面的操作需要用到這個變量,就可以從Store Buffer中讀取變量的值,核心讀數(shù)據(jù)的順序變成Store Buffer → 緩存 → 內(nèi)存。這樣在任何時候核心都不用卡住,做不了關(guān)于這個變量的操作了。引入Store Buffer后的結(jié)構(gòu)如下所示:
Store Buffer結(jié)構(gòu)
針對確認(rèn)響應(yīng)的加速:在緩存的基礎(chǔ)上,引入Invalidate Queue這個結(jié)構(gòu)。其他核心收到Kernel0的Invalidate的命令后,立即給Kernel0回Acknowledge,并把Invalidate這個操作,先記錄到Invalidate Queue里,當(dāng)其他操作結(jié)束時,再從Invalidate Queue中取命令,進(jìn)行Invalidate操作。所以當(dāng)Kernel0收到確認(rèn)響應(yīng)時,其他核心對應(yīng)的緩存行可能還沒完全置為Invalid狀態(tài)。引入Invalidate Queue后的結(jié)構(gòu)如下所示:
Invalidate Queue結(jié)構(gòu)
緩存一致性協(xié)議優(yōu)化存在的問題
上一節(jié)講了兩種緩存一致性協(xié)議的加速方式。但是這兩個方式卻會對緩存一致性導(dǎo)致一定的偏差,下面我們來看一下兩個出錯的例子:
例子1:關(guān)于Store Buffer帶來的錯誤,假設(shè)CPU有兩個核心,Kernel0表示第一個核心,Kernel1表示第二個核心。
...
public void foo(){
a=1;
b=1;
}
public void bar(){
while(b==0) continue;
assert(a==1):"a has a wrong value!";
}
...
如果Kernel0執(zhí)行foo()函數(shù),Kernel1執(zhí)行bar()函數(shù),按照之前我們的理解,如果b變量為1了,那a肯定為1了,assert(a==1)肯定不會報錯。但是事實(shí)卻不是這樣的。
假設(shè)初始情況是這樣的:在執(zhí)行兩個函數(shù)前Kernel1的緩存包含變量a=0,不包含緩存變量b,Kernel0的緩存包含變量b=0,不包含緩存變量a。
Kernel0執(zhí)行foo()函數(shù),Kernel1執(zhí)行bar()函數(shù)時,。這樣的話計算機(jī)的指令程序可能會如下展開:
Kernel0執(zhí)行a=1。由于Kernel0的緩存行不包含變量a,因此Kernel0會將變量a的值存在Store Buffer中,并且向其他Kernel進(jìn)行read Invalidate操作,通知a變量緩存無效;
Kernel1執(zhí)行while(b==0),由于Kernel1的緩存沒有變量b,因此它需要發(fā)送一個讀命令,去找b的值;
2Kernel0執(zhí)行b=1,由于Kernel0的緩存中已經(jīng)有了變量b,而且別的核心沒有這個變量的緩存,所以它可以直接更改緩存b的值;
3Kernel0收到讀命令后,將最新的b的值發(fā)送給Kernel1,并且將變量b的狀態(tài)由E(獨(dú)占)改變?yōu)镾(共享);
4Kernel1收到b的值后,將其存到自己Kernel對應(yīng)的緩存區(qū)中;
5Kernel1接著執(zhí)行while(b==0),因?yàn)榇藭rb的新值為1,因此跳出循環(huán);
6Kernel1執(zhí)行assert(a==1),由于Kernel1緩存中a的值為0,并且是有效的,所以斷言出錯;
7Kernel1終于收到了第一步Kernel0發(fā)送的Invalidate了,趕緊將緩存區(qū)的a==1置為invalid,但是為時已晚。
8
所以我們看到,這個例子出錯的原因完全是由Store Buffer這個結(jié)構(gòu)引發(fā)的。如果規(guī)定將Store Buffer中數(shù)據(jù)完全刷入到緩存,才能執(zhí)行對應(yīng)變量寫操作的話,該錯誤也能避免了。
例子2:關(guān)于Invalidate Queue帶來的錯誤,同樣假設(shè)CPU有兩個核心,Kernel0表示第一個核心,Kernel1表示第二個核心。
...
public void foo(){
a=1;
b=1;
}
public void bar(){
while(b==0) continue;
assert(a==1):"a has a wrong value!";
}
...
Kernel0執(zhí)行foo()函數(shù),Kernel1執(zhí)行bar()函數(shù),猜猜看這次斷言會出錯嗎?
假設(shè)在初始情況是這樣的:變量a的值在Kernel0和Kernel1對應(yīng)的緩存區(qū)都有,狀態(tài)為S(共享),初值為0,變量b的值是0,狀態(tài)為E(獨(dú)占),只存在于Kernel1對應(yīng)的緩存區(qū),不存在Kernel0對應(yīng)的緩存區(qū)。假設(shè)Kernel0執(zhí)行foo()函數(shù),Kernel1執(zhí)行bar()函數(shù)時,程序執(zhí)行過程如下:
0
Kernel0執(zhí)行a=1,此時由于a變量被更改了,需要給Kernel1發(fā)送無效化命令,并且將a的值存儲在Kernel0的Store Buffer中;
1Kernel1執(zhí)行while(b==0),由于Kernel1對應(yīng)的緩存不包含變量b,它需要發(fā)出一個讀命令;
2Kernel0執(zhí)行b=1,由于是獨(dú)占的,因此它直接更改自己緩存的值;
3Kernel0收到讀命令,將最新的b的值發(fā)送給Kernel1,并且將變量b的狀態(tài)改變?yōu)镾(共享);
4Kernel1收到Kernel0在第一步發(fā)的無效化命令,將這個命令存到Invalidate Queue中,打算之后再處理,并且給Kernel0回確認(rèn)響應(yīng);
5Kernel1收到包含b值的讀命令反饋,把該值存到自己緩存下;
6Kernel1收到b的值之后,打破while循環(huán);
7Kernel1執(zhí)行assert(a==1),由于此時Invalidate Queue中的無效化a=0這個緩存值還沒執(zhí)行,因此Kernel1會接著用自己緩存中的a=1這個緩存值,這就出現(xiàn)了問題;
8Kernel1開始執(zhí)行Invalidate Queue中的命令,將a=0這個緩存值無效化。但這時已經(jīng)太晚了。
9
所以我們看到,這個例子出錯的原因完全是由Invalidate Queue這個結(jié)構(gòu)引發(fā)的。如果規(guī)定將Invalidate Queue中命令完全處理完,才能執(zhí)行對應(yīng)變量讀操作的話,該錯誤也能避免了。
內(nèi)存屏障
既然剛剛我們遇到了問題,那如何改正呢?這里就終于到了今天的重頭戲,內(nèi)存屏障了。內(nèi)存屏障簡單來講就是一行命令,規(guī)定了某個針對緩存的操作。這里我們來看一下最常見的寫屏障和讀屏障。
針對Store Buffer:核心在后續(xù)變量的新值寫入之前,把Store Buffer的所有值刷新到緩存;核心要么就等待刷新完成后寫入,要么就把后續(xù)的后續(xù)變量的新值放到Store Buffer中,直到Store Buffer的數(shù)據(jù)按順序刷入緩存。這種也稱為內(nèi)存屏障中的寫屏障(Store Barrier)。
針對Invalidate Queue:執(zhí)行后需等待Invalidate Queue完全應(yīng)用到緩存后,后續(xù)的讀操作才能繼續(xù)執(zhí)行,保證執(zhí)行前后的讀操作對其他CPU而言是順序執(zhí)行的。這種也稱為內(nèi)存屏障中的讀屏障(Load Barrier)。
volatile中的內(nèi)存屏障
對于JVM的內(nèi)存屏障實(shí)現(xiàn)中,也采取了內(nèi)存屏障。JVM的內(nèi)存屏障有四種,這四種實(shí)際上也是上述的讀屏障和寫屏障的組合。我們來看一下這四種屏障和他們的作用:
LoadLoad屏障:對于這樣的語句
第一大段讀數(shù)據(jù)指令;
LoadLoad;
第二大段讀數(shù)據(jù)指令;
LoadLoad指令作用:在第二大段讀數(shù)據(jù)指令被訪問前,保證第一大段讀數(shù)據(jù)指令執(zhí)行完畢
StoreStore屏障:對于這樣的語句
第一大段寫數(shù)據(jù)指令;
StoreStore;
第二大段寫數(shù)據(jù)指令;
StoreStore指令作用:在第二大段寫數(shù)據(jù)指令被訪問前,保證第一大段寫數(shù)據(jù)指令執(zhí)行完畢
LoadStore屏障:對于這樣的語句
第一大段讀數(shù)據(jù)指令;
LoadStore;
第二大段寫數(shù)據(jù)指令;
LoadStore指令作用:在第二大段寫數(shù)據(jù)指令被訪問前,保證第一大段讀數(shù)據(jù)指令執(zhí)行完畢。
StoreLoad屏障:對于這樣的語句
第一大段寫數(shù)據(jù)指令;
StoreLoad;
第二大段讀數(shù)據(jù)指令;
StoreLoad指令作用:在第二大段讀數(shù)據(jù)指令被訪問前,保證第一大段寫數(shù)據(jù)指令執(zhí)行完畢。
針對volatile變量,JVM采用的內(nèi)存屏障是:
針對volatile修飾變量的寫操作:在寫操作前插入StoreStore屏障,在寫操作后插入StoreLoad屏障;
針對volatile修飾變量的讀操作:在每個volatile讀操作前插入LoadLoad屏障,在讀操作后插入LoadStore屏障;
通過這種方式,就可以保證被volatile修飾的變量具有線程間的可見性和禁止指令重排序的功能了。
總結(jié)
講了這么多,我們來總結(jié)一下。
volatile關(guān)鍵字保證了兩個性質(zhì):
可見性:可見性是指當(dāng)多個線程訪問同一個變量時,一個線程修改了這個變量的值,其他線程能夠立即看得到修改的值。
有序性:對一個volatile變量的寫操作,執(zhí)行在任意后續(xù)對這個volatile變量的讀操作之前。
單單緩存一致性協(xié)議無法實(shí)現(xiàn)volatile。
緩存一致性可以通過Store Buffer和Invalidate Queue兩種結(jié)構(gòu)進(jìn)行加速,但這兩種方式會造成一系列不一致性的問題。
因此后續(xù)提出了內(nèi)存屏障的概念,分為讀屏障和寫屏障,以此修正Store Buffer和Invalidate Queu產(chǎn)生的問題。
通過讀屏障和寫屏障,又發(fā)展出了LoadLoad屏障,StoreStore屏障,LoadStore屏障,StoreLoad屏障JVM也是利用了這幾種屏障,實(shí)現(xiàn)volatile關(guān)鍵字。
巨人的肩膀
Java多線程編程核心指南
Paul E. McKenney Memory Barriers: a Hardware View for Software Hackers
好文推薦
你不好奇 CPU 是如何執(zhí)行任務(wù)的?
10 張圖打開 CPU 緩存一致性的大門
哈嘍,我是小林,一股清流,就愛圖解計算機(jī)基礎(chǔ)。
如果覺得文章對你有幫助,歡迎分享給你的朋友,也給小林點(diǎn)個「在看」,這對小林非常重要。謝謝你們,給各位小姐姐小哥哥們抱拳了,我們下次見!
免責(zé)聲明:本文內(nèi)容由21ic獲得授權(quán)后發(fā)布,版權(quán)歸原作者所有,本平臺僅提供信息存儲服務(wù)。文章僅代表作者個人觀點(diǎn),不代表本平臺立場,如有問題,請聯(lián)系我們,謝謝!