聊聊cpu內存一致性

前言: 
作爲一個java攻程獅,本來是不需要瞭解到cpu cache這麼底層的東西的。然而,如果想更好的理解java多線程的各種坑 ,瞭解java多線程的精髓,又不得不瞭解一下,結合本人這陣子的學習,此文試圖從一個小問題出發,講講多線程下cpu cache可能會帶給我們什麼“驚喜”!而爲了解決這些驚喜,cpu又同時給我們提供了什麼手段。
如果你想更好的瞭解java內存可見性,更好理解volatile,不妨花幾分鐘看一看本篇文章。
溫馨提示: 不要太過於糾結本文講的細節對與錯,本文只想表達現實真的很複雜!這些內容也不是由我捏造出來的,是我在看了多方面的資料後總結出來的。
問題: java中,有一個實例變量,如 public int count,然後有兩個線程A和B,它們各自執行了一些動作,如下:
線程A: count += 5;
線程B: count += 2;
那麼count最終結果會是7麼?
腦洞大開:
1. 咱們先回到原始社會,看看cpu可能是怎麼工作的:
(1) 假設線程A先幹完了活,然後線程B纔開始幹活。線程A從內存中把 count=0 加載到核內(比如寄存器),一切準備就緒了,線程A要開始幹活了。
(2) 線程A開始幹活,把count變爲5,通過總線把值存回內存。
(3)我們前面說過,A幹完了活,就輪到B幹活了,B把值加載到右核中。

(4)線程B也把活也幹完了,最終就是如下效果:

到這裏,內存中值爲7,已經達到大家的期望了。有沒有感覺到一切好像太順利了,好吧,我隨口一問,如果左覈計算完count=5後不把值存入內存中,還是放在寄存器中,這時候右核進行計算,還能夠得出結論爲7嗎?

2. 社會總在進步,特別是左右兩核,一直在競爭,進步特別快,計算能力加強得特別誇張。然而內存這小子進步太慢了,爲了達到一種平衡,這會多了一個叫cache的小夥伴,我們來看看。這一次,咱們假設線程A和線程B同時幹活了。
(1) 首先,兩個核都想加載count的值,它們各自去黃色塊cache中找count,發現找不到,結果都去內存中加載count=0,並把值存儲到黃色cache中(cache讀起來非常快)。
(2) 爲了一切更加順利的進行,便於我們說明問題,我們假設線程B這時候休息了一下,線程A開始幹活,

(3)等A幹完活後,我們看到,左核的cache和內存的值全部變爲 5了,一切看起來挺好的,接着,B也幹活了,我們看看B的勞動成果,如下:

一不小心,B把A乾的活給覆蓋了,真心酸。

3. 到這裏,你是不是想說,右核太傻了,內存值都被改爲5了,你就不可以把cache廢除,重新加載 count=5再做計算麼?這樣結果會是7,不就OK了。爲了滿足這個小小的要求,於是乎歷史再次上演了。
(1) 我們依然假定線程A和線程B同時執行,起初,他們又同時把count=0加載到核內,同時放入cache中。

(2)爲了一切更加順利的進行,便於我們說明問題,我們假設線程B這時候休息了一下,線程A開始幹活,

這時候,跟以前不一樣了,左核的cache在改了count的值後,順便通知了一下右核的cache,然後右核把cache中的count值作廢了。
(3)又輪到線程B上場了,B發現cache中的count失效了,於是乎從內存中加載了最新的count=5,

(4)最後,B終於不辭勞苦把活幹完了,順便也通知了線程A,count值已經改了。

4. 初步看,cache間加上了通知機制(實則爲類MESI緩存一致性協議),一切好像朝着美好的方向前進着,然而,偉大的設計師們覺得通知來通知去,效率太低了,繼續改改改,於是乎咱們就再來一次吧。
(1) 我們依然假定線程A和線程B同時執行,起初,他們又同時把count=0加載到自己的緩存中。

(2)爲了一切更加順利的進行,便於我們說明問題,我們假設線程B這時候休息了一下,線程A開始幹活,

這一次,左核支出新招了,整了個叫store buffer的寫緩存區,爲啥呢?寫操作太慢了唄,受不了啦。每當有內存寫操作,它通知一下右核,比如,它告訴右核,count值我改了,你的cache失效了,右核聽到消息後,會說,好的,你放心幹吧。然後左核把寫操作放到異步隊列中,異步慢慢的執行。而右核呢,也耍小聰明瞭,它並沒有直接把它cache中的count立即改爲失效,它只是把這個動作放到一個稱爲invalid queue的隊列,等着異步再慢慢執行。
(3) 就是因爲大家都耍了小把戲,結果你看到了,事情已經很複雜了。不管怎樣,線程B還是得接着幹活。

你看到了,結果會怎樣,我也搞不清楚了,我只想說,事情着實讓人傷神。
腦洞大開的後話:
你看到了,我作了很多的假設,就已經得出了這麼複雜的情況,如果映射到多種實際的平臺,那又會是怎樣混亂的場面呢。也許,有同學想反駁我,我講的也許不太對,但我想說的是,事實比我講的要更加多樣化、複雜化,我只是進行了幾種可能的抽象,爲了引出多線程中我們可能會遇到的棘手的問題。怎麼確保原子操作,怎麼確保共享變量在多線程中的可見性?顯然cpu的設計者們也知道多線程下有很多未知的可能,於是乎,他們提供了一些方案,用於控制這種混亂的局面。在這裏,我把它歸爲兩類:lock前綴和內存屏障。
lock前綴 和 內存屏障
關於這個話題,各種地方一搜,都有好多好多介紹的文章。所以本文不會羅列太多理論的東西。我們先來看看lock前綴。
現實中,在intel cpu中,是存在 lock前綴的,所謂 lock前綴指的是在某些特定彙編指令中,添加上一個lock標識,
就會擁有神奇的功能,比如:
1) 執行lock前綴彙編指令的cpu會對外宣稱指定內存地址的主權,如果有其它核也想操作指定內存地址,不好意思,請等待,先讓我忙完再說,而且總能拿到最新的內存的值。
2) 看看前面提到的store buffer,lock前綴能夠確保把 store buffer的指令全部執行完了,使其結果對其它核所見。這裏我理解是其它核不一定會立即把cache置爲失效,因爲有 invalid queue的存在。這也就是說 lock前綴並不是萬能的,還是可能會有共享變量可見性問題。這種情況得藉助牛B轟轟的內存屏障了。
3)lock前綴指令一出現,cpu便不會把lock前綴指令兩邊的指令進行重排,從而能夠保證一些可見性。

再來簡單看看內存屏障,內存屏障可以認爲是一些特殊的彙編指令,它有一些特殊的功效,簡要概括之:
1) 內存屏障有多種,咱們暫且歸爲 寫屏障,讀屏障兩種。
2) 寫屏障指令一出現,cpu必須確保屏障兩邊的寫指令不可以跨越屏障調整執行順序,而且有些寫屏障指令強大到可以號令cpu,在屏障執行完之前,store buffer的所有指令必須先執先完畢,這樣其他cpu cache纔可以感知到(由於invalid queue的存在,感知到卻不一定會立即處理),所以一般寫屏障得結合讀屏障纔有效果。
3)由2我們知道,即使有強大的寫屏障功能,但是類似invalid queue之類的存在,還是影響到其他cpu對共享變量最新值的獲取,那該怎麼辦呢?這時候強大的讀屏障指令出現了。讀屏障,你可以認爲有兩個功效,一個仍然是阻止cpu亂改讀指令的順序,另一個就是可以讓cpu把invalid queue的消息先處理完了,再繼續幹活。一旦cpu把invalid queue中的消息處理完了,即意味着該cpu會把該作廢的緩存作廢,下次讀一個新的共享變量時,就可以“見到”共享變量的最新值了。
4) 作爲java攻城獅,我想你一定會瞭解到什麼是volatile寫和volatile讀,你會發現跟內存讀寫屏障的語義真像。
由於cpu本身的不確定性和複雜性,在多線程模型下,很多時候,事情的發生往往出人意料。編譯器,CPU爲了優化,採用了各種我們難以察覺的措施,但是他們能夠承諾的是,如果我們的程序全部跑在單線程中,一切都會按我們所想所思去執行,但一旦涉及到多線程,不好意思,程序完全雜亂無章了。這時候,我們只能藉助如lock和內存屏障之類強大的武器來處理變量的可見性以及程序的原子性和正確性。

講得有點多了,爲了讓大家更好消化,什麼指令重排之類的就不再多說了,好多地方都有相應的參考資料,只不過很多時候,他們沒有把cpu cache這種隱藏得很深的問題給拋出來而已,所以這也是我寫本篇的目的,結合這陣子的學習,既是一個總結,也希望能夠給看客一點啓發和思路。
發佈了40 篇原創文章 · 獲贊 174 · 訪問量 22萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章