volatile 對可見性的保證並不是那么簡單


 

  數據一致性部分借用大神“耗叔”的博客:https://coolshell.cn/articles/20793.html

  總結:volatile 關鍵字通過內存屏障禁止了指令的重排序,並在單個核心中,強制數據的更新及時更新到緩存。在此基礎上,依靠多核心處理器的緩存一致性協議等機制,保證了變量的可見性。

  在學習 volatile 關鍵字時總是繞不開兩點,保證數據及時更新到內存和禁止指令重排序,基於上述兩點 volatile 關鍵字保證了共享變量在多個線程間的可見性。

  雖然說起來知識點不多,但實際上 volatile 的實現是及其復雜的。在 java5 之前 volatile 關鍵字會經常造成一些無法預料的錯誤,導致其保守詬病。直到 5 版本對 volatile 進行改進之后才重獲新生,官方版本經歷的這些波折足以證明其底層實現邏輯的復雜。

  我們重溫一下 volatile 關鍵字實現涉及到的知識點。從大的分類來說,其涉及兩個知識點:多核心中數據的一致性,禁止指令的亂序執行。我們一個一個來看。

  多核心中數據的一致性

  現代處理器為了提高內存數據的訪問速度,都會有自帶的多級緩存,其位置在內存與處理器之間。

  老的CPU會有兩級內存(L1和L2),新的CPU會有三級內存(L1,L2,L3 )。其中:

  • L1緩分成兩種,一種是指令緩存,一種是數據緩存。L2緩存和L3緩存不分指令和數據。
  • L1和L2緩存在每一個CPU核中,L3則是所有CPU核心共享的內存。
  • L1、L2、L3的越離CPU近就越小,速度也越快,越離CPU遠,速度也越慢。

  再往后面就是內存,內存的后面就是硬盤。我們來看一些他們的速度:

  • L1 的存取速度:4 個CPU時鍾周期
  • L2 的存取速度: 11 個CPU時鍾周期
  • L3 的存取速度:39 個CPU時鍾周期
  • RAM內存的存取速度:107 個CPU時鍾周期

  可以看到,離處理器越近的緩存存取速度越快,緩存的存在極大的加快了處理器訪問內存的速度。但事情總是有兩面性的,緩存的存在加快了堆內存的訪問速度,同時也帶來了一系列額外的復雜性。每個 CPU 緩存中有一份自己的內存副本,會帶來各個 CPU 在訪問同一塊內存的數據時,每個 CPU 緩存中的副本可能不一致的問題。

  一般來說,目前的 CPU 會有兩種方法解決緩存不一致的問題:

  • Directory 協議。這種方法的典型實現是要設計一個集中式控制器,它是主存儲器控制器的一部分。其中有一個目錄存儲在主存儲器中,其中包含有關各種本地緩存內容的全局狀態信息。當單個CPU Cache 發出讀寫請求時,這個集中式控制器會檢查並發出必要的命令,以在主存和CPU Cache之間或在CPU Cache自身之間進行數據同步和傳輸。
  • Snoopy 協議。這種協議更像是一種數據通知的總線型的技術。CPU Cache通過這個協議可以識別其它Cache上的數據狀態。如果有數據共享的話,可以通過廣播機制將共享數據的狀態通知給其它CPU Cache。這個協議要求每個CPU Cache 都可以窺探數據事件的通知並做出相應的反應。如下圖所示,有一個Snoopy Bus的總線。

 

  因為Directory協議是一個中心式的,會有性能瓶頸,而且會增加整體設計的復雜度。而Snoopy協議更像是微服務+消息通訊,所以,現在基本都是使用Snoopy的總線的設計。

  這里,我想多寫一些細節,因為這種微觀的東西,不自然就就會更分布式系統相關聯,在分布式系統中我們一般用Paxos/Raft這樣的分布式一致性的算法。而在CPU的微觀世界里,則不必使用這樣的算法,原因是因為CPU的多個核的硬件不必考慮網絡會斷會延遲的問題。所以,CPU的多核心緩存間的同步的核心就是要管理好數據的狀態就好了。

  這里介紹幾個狀態協議,先從最簡單的開始,MESI協議,這個協議跟那個著名的足球運動員梅西沒什么關系,其主要表示緩存數據有四個狀態:Modified(已修改), Exclusive(獨占的),Shared(共享的),Invalid(無效的)。

  MESI 這種協議在數據更新后,會標記其它共享的CPU緩存的數據拷貝為Invalid狀態,然后當其它CPU再次read的時候,就會出現 cache miss 的問題,此時再從內存中更新數據。從內存中更新數據意味着20倍速度的降低。我們能不能直接從我隔壁的CPU緩存中更新?是的,這就可以增加很多速度了,但是狀態控制也就變麻煩了。還需要多來一個狀態:Owner(宿主),用於標記,我是更新數據的源。於是,現了 MOESI 協議

  MOESI協議的狀態機和演示示例我就不貼了,我們只需要理解MOESI協議允許 CPU Cache 間同步數據,於是也降低了對內存的操作,性能是非常大的提升,但是控制邏輯也非常復雜。

  順便說一下,與 MOESI 協議類似的一個協議是 MESIF,其中的 F 是 Forward,同樣是把更新過的數據轉發給別的 CPU Cache 但是,MOESI 中的 Owner 狀態 和MESIF 中的 Forward 狀態有一個非常大的不一樣—— Owner狀態下的數據是dirty的,還沒有寫回內存,Forward狀態下的數據是clean的,可以丟棄而不用另行通知

  從上面我們可以看出,緩存一致協議很好的保證了多處理器間緩存一致性的問題。這樣看來,我們並沒有使用 volatile 關鍵字的必要,硬件層本身實現了多處理器間的緩存一致性並且對其上層是透明的。但事實並非如此,處理器除緩存外,計算單元與緩存系統間還隔着本地寄存器和緩沖區,處理器本身完成了計算但並沒有及時將新值刷新到緩存的話,緩存一致協議並不會起作用,數據的新值對其它處理器的可見性依然無法得到保證。

  將新值及時刷新到緩存,這是單個處理器自己需要處理的問題,其依賴了內存屏障機制,下面我們會接着說。

指令的亂序執行

  在程序運行時,為了提升指令的執行效率,編譯器或者CPU會對代碼結構進行重新排序,達到最佳效果。 

  指令的重排序分為編譯期重排和運行期重排。

  編譯期重排是編譯器依據對上下文的分析,對指令進行重排序,使其更適合於CPU的並行執行。

  運行期重排是指運行過程中,CPU動態分析各部件效能,對指令進行重排優化。

  編譯器重排序:

  CPU只讀一次的x和y值。不需反復讀取寄存器來交替x和y值。編譯器的重排序是為了更加高效的使用處理器。

  編譯器重排序時會考慮指令的依賴性:

  1. 數據依賴性
  如果兩個操作訪問同一個變量,且這兩個操作中有一個為寫操作,此時這兩個操作之間就存在數據依賴性。數據依賴分為下列3種類型,這3種情況,只要重排序兩個操作的執行順序,程序的執行結果就會被改變。

 

   2. 控制依賴性
  flag變量是個標記,用來標識變量a是否已被寫入,在use方法中比變量i依賴if (flag)的判斷,這里就叫控制依賴,如果發生了重排序,結果就不對了。

  由此提出了 as-if-serial 語義,不管如何重排序,都必須保證代碼在單線程下的運行正確,連單線程下都無法正確,更不用討論多線程並發的情況,所以就提出了一個as-if-serial的概念。

  as-if-serial 語義的意思是:不管怎么重排序(編譯器和處理器為了提高並行度),(單線程)程序的執行結果不能被改變。編譯器、runtime和處理器都必須遵守as-if-serial語義。為了遵守as-if-serial語義,編譯器和處理器不會對存在 數據依賴關系的操作做重排序,因為這種重排序會改變執行結果。但是,如果操作之間不存在數據依賴關系,這些操作依然可能被編譯器和處理器重排序。
  as-if-serial 並沒有禁止存在控制依賴的指令進行重排序,因為控制依賴會降低流水線的並行度,所以處理器層面在處理條件分支時會采用猜測執行/流水線冒險來對分支指令進行預測執行,並在執行完成后對分支條件進行檢查和校驗冒險結果。所以無論是否重排序,處理器都會對其正確性進行檢驗。

  而對於處理器執行指令時,為了使流水線的效率最大化,也會動態的根據依賴部件的效能對指令進行進一步的重排序。所以代碼順序並不是真正的執行順序,只要有空間提高性能,CPU和編譯器可以進行各種優化。

  比如在執行對兩個內存塊A和B的賦值時(A先於B),由於 A 所處緩存塊的地址處於 busy 狀態(比如超線程情況下兩個線程競爭同一個緩存地址),CPU 為了防止 cache wait ,會嘗試先對 B 賦值,這就改變了原指令的執行順序。

  同時緩存和主存的讀取會利用load, store和write-combining緩沖區來緩沖和重排。這些緩沖區是查找速度很快的關聯隊列,當一個后來發生的load需要讀取上一個store的值,而該值還沒有到達緩存,查找緩沖區是必需的,下圖描繪的是一個簡化的現代多核CPU,從下圖可以看出執行單元可以利用本地寄存器和緩沖區來管理和緩存子系統的交互。

   這種以提升執行效率為目的的重排序可能會帶來意想不到的后果。為了在必要的時候避免重排序的發生,處理器為我們提供了內存屏障機制。

  內存屏障提供了兩個功能。首先,它們通過確保從另一個CPU來看屏障的兩邊的所有指令都是正確的程序順序,而保持程序順序的外部可見性;其次它們可以實現內存數據可見性,確保內存數據會同步到CPU緩存子系統。

  大多數的內存屏障都是復雜的話題。在不同的CPU架構上內存屏障的實現非常不一樣。相對來說Intel CPU的強內存模型比DEC Alpha的弱復雜內存模型(緩存不僅分層了,還分區了)更簡單。下面以x86架構為例。

Store Barrier

  Store屏障,是x86的”sfence“指令,強制所有在store屏障指令之前的store指令,都在該store屏障指令執行之前被執行,並把store緩沖區的數據都刷到CPU緩存。這會使得程序狀態對其它CPU可見,這樣其它CPU可以根據需要介入。

Load Barrier

  Load屏障,是x86上的”ifence“指令,強制所有在load屏障指令之后的load指令,都在該load屏障指令執行之后被執行,並且一直等到load緩沖區被該CPU讀完才能執行之后的load指令。這使得從其它CPU暴露出來的程序狀態對該CPU可見,這之后CPU可以進行后續處理。

       畢竟多核心之間有緩存一致性協議,但並沒有緩沖區一致性協議,這些CPU本身攜帶的緩沖到緩存讀寫的小部件中很可能存在臟數據。而處理器每次讀緩存之前都會先嘗試讀取這些緩沖區中的數據,所以我們需要讀完其中的值防止數據的污染。

      多提一句緩沖區。緩存到內存我們會有合並寫,緩沖區到緩存也會有合並寫,合理的寫代碼去充分的利用它們將會大大提升程序的效率。比如如果一個核心有四個寫緩沖區,而我們的一個循環中只改變四個變量的值,那么一直到循環結束,cpu可能只與緩存打了兩次交道,一次讀入初始數據,一次合並寫。其它的運算全部是在緩沖區中進行的,少了到緩存的讀寫,效率可想而知。

Full Barrier

  Full屏障,是x86上的”mfence“指令,復合了load和save屏障的功能。

  volatile變量在寫操作之后會插入一個store屏障,在讀操作之前會插入一個load屏障。一個類的final字段會在初始化后插入一個store屏障,來確保final字段在構造函數初始化完成並可被使用時可見。

       通過內存屏障,保證了對volatile 的寫操作一定會及時刷新到 CPU 緩存系統,通過 CPU 的緩存一致性協議,進而保證了多核環境下 volatile 的 happen-before 原則,一個對 volatile 的寫操作只要發生在讀操作之前,寫的結果一定對讀操作可見。

  但可見性並不等於原子性,其只是原子性的必要條件。比如兩個線程同時對一個變量進行寫操作,依然會造成變量污染,而且這並不違反 happen-before 原則。

        總的來說,CPU與編譯器的指令重排序總會保證程序的串行語義,也就是在單線程環境下的正確性。維持這種串行語義的依據便是指令間的數據依賴關系。但是在多線程的情況下,不同線程之間的指令是不存在數據依賴關系的(或者說機器無法分析出來不同線程中的數據依賴關系),但是我們的線程在進行協作時不可避免的會產生這種依賴關系。比如線程a對共享變量賦值與線程a在為共享變量賦值后喚醒線程b的兩個指令從單線程的角度看是不存在數據依賴關系的,可以對其進行重排序。但從多線程角度來說,線程b的運行是基於線程a改變共享變量的值這個動作的結果的。

        所以我們必須保證線程a改變共享變量的結果對下一條指令,也就是喚醒線程b可見。那么我們需要禁止這兩條指令的重排序,並且保證共享變量在運行a.b線程的處理器中的一致性。這時我們便可以采用內存屏障技術,禁止其重排序,並保證數據的改變可以及時flush。

        還有一點需要注意的是,說了這么多多內存屏障,內存屏障是處理器為我們提供的機制,其保證的是處理器層面指令在屏障前后的執行順序。至於編譯期重排序保證串行化語義以及特定多線程情況下不打破指令的數據依賴,是由編譯器來保證的。java在 編譯時通過遵循happen before原則來保證了這一點。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM