看懂這篇,才能說了解併發底層技術!

零開局

在多線程併發的世界裏synchronized、volatile、JMM是我們繞不過去的技術坎,而重排序、可見性、內存屏障又有時候搞得你一臉懵逼。有道是知其然知其所以然,瞭解了底層的原理性問題,不論是日常寫BUG還是面試都是必備神器了。

 

先看幾個問題點:1、處理器與內存之間是怎麼交互的?2、什麼是緩存一致性協議?3、高速緩存內的消息是怎麼更新變化的?4、內存屏障又和他們有什麼關係?如果上面的問題你都能倒背如流,那就去看看電影放鬆下吧!

一、高速緩存

目前的處理器的處理能力要遠遠的勝於主內存(DRAM)訪問的效率,往往主內存執行一次讀寫操作所需的時間足夠處理器執行上百次指令。所以爲了填補處理器與主內存之間的差距,設計者們在主內存和處理器直接引入了高速緩存(Cache)。如圖:

 

 

 

其實在現代處理器中,會有多級高速緩存。一般我們會成爲一級緩存(L1 Cache)、二級緩存(L2 Cache)、三級緩存(L3 Cache)等,其中一級緩存一般會被集成在CPU內核中。如圖:

 

 

 

 

內部結構

高速緩存存在於每個處理器內,處理器在執行讀、寫操作的時候並不需要直接與內存交互,而是通過高速緩存進行。高速緩存內其實就是爲應用程序訪問的變量保存了一個數據副本。高速緩存相當於一個容量極小的散列表(Hash Table),其鍵是一個內存地址,值是內存數據的副本或是我們準備寫入的數據。從其內部來看,其實相當於一個拉鍊散列表,也就是包含了很多桶,每個桶上又可以包含很多緩存條目(想想HashMap),如圖:

 

 

 

 

緩存條目

在每個緩存條目中,其實又包含了Tag、Data Block、Flag三個部分,咱們來個小圖:

 

 

 

 

  • **Data Block : **也就是我們常常叨叨的緩存行(Cache Line),她其實是高速緩存與主內存間進行數據交互的最小單元,裏面存儲着我們需要的變量數據。
  • **Tag : **包含了緩存行中數據內存地址的信息(其實是內存地址的高位部分的比特)
  • Flag : 標識了當前緩存行的狀態(MESI咯)

那麼,我們的處理器又是怎麼尋找到我們需要的變量呢?不多說,上圖:

 

 

 

其實,在處理器執行內存訪問變量的操作時,會對內存地址進行解碼的(由高速緩存控制器執行)。而解碼後就會得到tag、index 、offset三部分數據。index : 我們知道高速緩存內的結構是一個拉鍊散列表,所以index就是爲了幫我們來定位到底是哪個緩存條目的。tag : 很明顯和我們緩存條目中的Tag 一樣,所以tag 相當於緩存條目的編號。主要用於,在同一個桶下的拉鍊中來尋找我們的目標。offset : 我們要知道一個前提,就是一個緩存條目中的緩存行是可以存儲很多變量的,所以offset的作用是用來確定一個變量在緩存行中的起始位置。所以,在如果在高速緩存內能找到緩存條目並且定位到了響應的緩存行,而此時緩存條目的Flag標識爲有效狀態,這時候也就是我們所說的緩存命中(Cache Hit),否則就是緩存未命中(Cache Miss)。緩存未命又包括讀未命中(Read Miss)和寫未命中(Write Miss)兩種,對應着對內存的讀寫操作。而在讀未命中(Read Miss) 產生時,處理器所需要的數據會從主內存加載並被存入高速緩存對應的緩存行中,此過程會導致處理器停頓(Stall)而不能執行其他指令。

二、緩存一致性協議

在多線程進行共享變量訪問時,因爲各個線程執行的處理器上的高速緩存中都會保存一份變量的副本數據,這樣就會有一個問題,那當一個副本更新後怎麼保證其它處理器能馬上的獲取到最新的數據。這其實就是緩存一致性的問題,其本質也就是怎麼防止數據的髒讀。爲了解決這個問題,處理器間出現了一種通信機制,也就是緩存一致性協議(Cache Coherence Protocol)。

MESI是什麼

緩存一致性協議有很多種,MESI(Modified-Exclusive-Shared-Invalid)協議其實是目前使用很廣泛的緩存一致性協議,x86處理器所使用的緩存一致性協議就是基於MESI的。我們可以把MESI對內存數據訪問理解成我們常用的讀寫鎖,它可以使對同一內存地址的讀操作是併發的,而寫操作是獨佔的。所以在任何時刻寫操作只能有一個處理器執行。而在MESI中,一個處理器要向內存寫數據時必須持有該數據的所有權。MESI將緩存條目的狀態分爲了Modified、Exclusive、Shared、Invalid四種,並在此基礎上定義了一組消息用於處理器的讀、寫內存操作。如圖:

 

 

 

MESI的四種狀態

所以MESI其實就是使用四種狀態來標識了緩存條目當前的狀態,來保證了高速緩存內數據一致性的問題。那我們來仔細的看下四種狀態Modified :表示高速緩存中相應的緩存行內的數據已經被更新了。由於MESI協議中任意時刻只能有一個處理器對同一內存地址對應的數據進行更新,也就是說再多個處理器的高速緩存中相同Tag值的緩存條目只能有一個處於Modified狀態。處於此狀態的緩存條目中緩存行內的數據與主內存包含的數據不一致。Exclusive:表示高速緩存相應的緩存行內的數據副本與主內存中的數據一樣。並且,該緩存行以獨佔的方式保留了相應主內存地址的數據副本,此時其他處理上高速緩存當前都不保留該數據的有效副本。Shared:表示當前高速緩存相應緩存行包含相應主內存地址對應的數據副本,且與主內存中的數據是一致的。如果緩存條目狀態是Shared的,那麼其他處理器上如果也存在相同Tag的緩存條目,那這些緩存條目狀態肯定也是Shared。Invalid:表示該緩存行中不包含任何主內存中的有效數據副本,這個狀態也是緩存條目的初始狀態。

MESI處理機制

前面說了那麼多,都是MESI的基礎理論,那麼,MESI協議到底是怎麼來協調處理器進行內存的讀寫呢?

 

 

 其實,想協調處理必然需要先和各個處理器進行通信。所以MESI協議定義了一組消息機制用於協調各個處理器的讀寫操作。我們可以參考HTTP協議來進行理解,可以將MESI協議中的消息分爲請求和響應兩類。處理器在進行主內存讀寫的時候會往總線(Bus)中發請求消息,同時每個處理器還會嗅探(Snoop)總線中由其他處理器發出的請求消息並在一定條件下往總線中回覆響應消息。針對於消息的類型,有如下幾種:

  • Read : 請求消息,用於通知其他處理器、主內存,當前處理器準備讀取某個數據。該消息內包含待讀取數據的主內存地址。
  • Read Response: 響應消息,該消息內包含了被請求讀取的數據。該消息可能是主內存返回的,也可能是其他高速緩存嗅探到Read 消息返回的。
  • Invalidate: 請求消息,通知其他處理器刪除指定內存地址的數據副本。其實就是告訴他們你這個緩存條目內的數據無效了,刪除只是邏輯上的,其實就是更新下緩存條目的Flag.
  • Invalidate Acknowledge: 響應消息,接收到Invalidate消息的處理器必須回覆此消息,表示已經刪除了其高速緩存內對應的數據副本。
  • Read Invalidate: 請求消息,此消息爲Read 和 Invalidate消息組成的複合消息,作用主要是用於通知其他處理器當前處理器準備更新一個數據了,並請求其他處理器刪除其高速緩存內對應的數據副本。接收到該消息的處理器必須回覆Read Response 和 Invalidate Acknowledge消息。
  • Writeback: 請求消息,消息包含了需要寫入主內存的數據和其對應的內存地址。

瞭解完了基礎的消息類型,那麼我們就來看看MESI協議是如何協助處理器實現內存讀寫的,看圖說話:舉例:假如內存地址0xxx上的變量s 是CPU1 和CPU2共享的我們先來說下CPU上讀取數據s高速緩存內存在有效數據時:

 

 

 CPU1會根據內存地址0xxx在高速緩存找到對應的緩存條目,並讀取緩存條目的Tag和Flag值。如果此時緩存條目的Flag 是M、E、S三種狀態的任何一種,那麼就直接從緩存行中讀取地址0xxx對應的數據,不會向總線中發送任何消息。高速緩存內不存在有效數據時:

 

 

 1、如CPU2 高速緩存內找到的緩存條目狀態爲I時,則說明此時CPU2的高速緩存中不包含數據s的有效數據副本。

2、CPU2向總線發送Read消息來讀取地址0xxx對應的數據s.

3、CPU1(或主內存)嗅探到Read消息,則需要回復Read Response提供相應的數據。

4、CPU2接收到Read Response消息時,會將其中攜帶的數據s存入相應的緩存行並將對應的緩存條目狀態更新爲S。

從宏觀的角度看,就是上面的流程了,我們再繼續深入下,看看在緩存條目爲I的時候到底是怎麼進行消息處理的

 

 說完了讀取數據,我們就在說下CPU1是怎麼寫入一個地址爲0xxx的數據s的

 

 

 MESI協議解決了緩存一致性的問題,但其中有一個問題,那就是需要在等待其他處理器全部回覆後才能進行下一步操作,這種等待明顯是不能接受的,下面就繼續來看看大神們是怎麼解決處理器等待的問題的。

三、寫緩衝和無效化隊列

因爲MESI自身有個問題,就是在寫內存操作的時候必須等待其他所有處理器將自身高速緩存內的相應數據副本都刪除後,並接收到這些處理器回覆的Invalidate Acknowledge/Read Response消息後才能將數據寫入高速緩存。爲了避免這種等待造成的寫操作延遲,硬件設計引入了寫緩衝器和無效化隊列。

寫緩衝器(Store Buffer)

在每個處理器內都有自己獨立的寫緩衝器,寫緩衝器內部包含很多條目(Entry),寫緩衝器比高速緩存還要小點。

 

 

 那麼,在引入了寫緩衝器後,處理器在執行寫入數據的時候會做什麼處理呢?還會直接發送消息到BUS嗎?我們來看幾個場景:(注意x86處理器是不管相應的緩存條目是什麼狀態,都會直接將每一個寫操作結果存入寫緩衝器)1、如果此時緩存條目狀態是E或者M:代表此時處理器已經獲取到數據所有權,那麼就會將數據直接寫入相應的緩存行內,而不會向總線發送消息。2、如果此時緩存條目狀態是S

  • 此時處理器會將寫操作的數據存入寫緩衝器的條目中,併發送Invalidate消息。
  • 如果此時相應緩存條目的狀態是I ,那就稱之爲寫操作遇到了寫未命中(Write Miss),此時就會將數據先寫入寫緩衝器的條目中,然後在發送Read Invalidate來通知其他處理器我要進行數據更新了。
  • 處理器的寫操作其實在將數據寫入緩衝器時就完成了,處理器並不需要等待其他處理器返回Invalidate Acknowledge/Read Response消息
  • 當處理器接收到其他處理器回覆的針對於同一個緩存條目的Invalidate Acknowledge消息時,就會將寫緩衝內對應的數據寫入相應的緩存行中

通過上面的場景描述我們可以看出,寫緩衝器幫助處理器實現了異步寫數據的能力,使得處理器處理指令的能力大大提升。

無效化隊列(Invalidate Queue)

其實在處理器接到Invalidate類型的消息時,並不會刪除消息中指定地址對應的數據副本(也就是說不會去馬上修改緩存條目的狀態爲I),而是將消息存入無效化隊列之後就回復Invalidate Acknowledge消息了,主要原因還是爲了減少處理器等待的時間。所以不管是寫緩衝器還是無效化隊列,其實都是爲了減少處理器的等待時間,採用了空間換時間的方式來實現命令的異步處理。總之就是,寫緩衝器解決了寫數據時要等待其他處理器響應得問題,無效化隊列幫助解決了刪除數據等待的問題。但既然是異步的,那必然又會帶來新的問題 -- 內存重排序和可見性問題。所以,我們繼續接着聊。

存儲轉發(Store Fowarding)

通過上面內容我們知道了有了寫緩衝器後,處理器在寫數據時直接寫入緩衝器就直接返回了。那麼問題就來了,當我們寫完一個數據又要馬上進行讀取可咋辦呢?話不多說,咱們還是舉個例子來說,如圖:

 

 

 此時第一步處理器將變量S的更新後的數據寫入到寫緩衝器返回,接着馬上執行了第二步進行S變量的讀取。由於此時處理器對S變量的更新結果還停留在寫緩衝器中,因此從高速緩存緩存行中讀到的數據還是變量S的舊值。

爲了解決這種問題,存儲轉發(Store Fowarding)這個概念上線了。其理論就是處理器在執行讀操作時會先根據相應的內存地址從寫緩衝器中查詢。如果查到了直接返回,否則處理器纔會從高速緩存中查找,這種從緩衝器中讀取的技術就叫做存儲轉發。看圖:

 

 

內存重排序和可見性的問題

由於寫緩衝器和無效化隊列的出現,處理器的執行都變成了異步操作。緩衝器是每個處理器私有的,一個處理器所存儲的內容是無法被其他處理器讀取的。舉個例子:CPU1 更新變量到緩衝器中,而CPU2因爲無法讀取到CPU1緩衝器內容所以從高速緩存中讀取的仍然是該變量舊值。其實這就是寫緩衝器導致StoreLoad重排序問題,而寫緩衝器還會導致StoreStore重排序問題等。爲了使一個處理器上運行的線程對共享變量所做的更新被其他處理器上運行的線程讀到,我們必須將寫緩衝器的內容寫到其他處理器的高速緩存上,從而使在緩存一致性協議作用下此次更新可以被其他處理器讀取到。處理器在寫緩衝器滿、I/O指令被執行時會將寫緩衝器中的內容寫入高速緩存中。但從變量更新角度來看,處理器本身無法保障這種更新的”及時“性。爲了保證處理器對共享變量的更新可被其他處理器同步,編譯器等底層系統藉助一類稱爲內存屏障的特殊指令來實現。內存屏障中的存儲屏障(Store Barrier)會使執行該指令的處理器將寫緩衝器內容寫入高速緩存。內存屏障中的加載屏障(Load Barrier)會根據無效化隊列內容指定的內存地址,將相應處理器上的高速緩存中相應的緩存條目狀態標記爲I。

四、內存屏障

因爲說了存儲屏障(Store Barrier)和加載屏障(Load Barrier) ,所以這裏再簡單的提下內存屏障的概念。劃重點:(你細品)處理器支持哪種內存重排序(LoadLoad重排序、LoadStore重排序、StoreStore重排序、StoreLoad重排序),就會提供相對應能夠禁止重排序的指令,而這些指令就被稱之爲內存屏障(LoadLoad屏障、LoadStore屏障、StoreStore屏障、StoreLoad屏障)劃重點:如果用X和Y來代替Load或Store,這類指令的作用就是禁止該指令左側的任何 X 操作與該指令右側的任何 Y 操作之間進行重排序(就是交換位置),確保指令左側的所有 X 操作都優先於指令右側的Y操作。內存屏障的具體作用:

 

屏障名稱示例具體作用
StoreLoad Store1;Store2;Store3;StoreLoad;Load1;Load2;Load3 禁止StoreLoad重排序,確保屏障之前任何一個寫(如Store2)的結果都會在屏障後任意一個讀操作(如Load1)加載之前被寫入
StoreStore Store1;Store2;Store3;StoreStore;Store4;Store5;Store6 禁止StoreStore重排序,確保屏障之前任何一個寫(如Store1)的結果都會在屏障後任意一個寫操作(如Store4)之前被寫入
LoadLoad Load1;Load2;Load3;LoadLoad;Load4;Load5;Load6 禁止LoadLoad重排序,確保屏障之前任何一個讀(如Load1)的數據都會在屏障後任意一個讀操作(如Load4)之前被加載
LoadStore Load1;Load2;Load3;LoadStore;Store1;Store2;Store3 禁止LoadStore重排序,確保屏障之前任何一個讀(如Load1)的數據都會在屏障後任意一個寫操作(如Store1)的結果被寫入高速緩存(或主內存)前被加載

五、總結

其實從頭看到尾就會發現,一個技術點的出現往往是爲了填補另一個的坑。爲了解決處理器與主內存之間的速度鴻溝,引入了高速緩存,卻又導致了緩存一致性問題爲了解決緩存一致性問題,引入瞭如MESI等技術,又導致了處理器等待問題爲了解決處理器等待問題,引入了寫緩衝和無效化隊列,又導致了重排序和可見性問題爲了解決重排序和可見性問題,引入了內存屏障,舒坦。。。

 轉自:https://mp.weixin.qq.com/s/SZl2E5NAhpYM4kKv9gyQOQ 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章