線程基礎:多任務處理——MESI協議以及帶來的問題:僞共享 原

1、概述
 本文和後續文章將着眼CPU的工作原理闡述僞共享的解決方法和volatile關鍵字的應用。

2、複習CPU工作原理
2.1、CPU工作原理
要清楚理解本文後續內容,就需要首先重新概述一下JVM的內存工作原理。當然JVM的內存模型是一個可以專門作爲另一個專題的較複雜知識點,所以這裏我們只描述對下文介紹的僞共享、volatile關鍵字相關聯的一些要點。這裏我們不討論JVM的內存模型,因爲本專題之前的內容有過相關討論(本專題後續還會討論),也因爲JVM內存模型的操作最終會轉換成如下圖所示的在內存、寄存器、CPU內核中的操作過程。

圖片摘自網絡

如上圖所示,當一個JVM線程進入“運行”狀態後(這個狀態的實際切換由操作系統進行控制),這個線程使用的變量(實際上存儲的可能是某個變量實際的值,也可能是某個對象的內存地址)將基於緩存行的概念被換入CPU緩存(既L1、L2緩存)。通常情況下CPU在工作中將優先嚐試命中L1、L2緩存中的數據,如果沒有命中纔會到主存中重新讀取最新的數據,這是因爲從L1、L2緩存中讀取數據的時間遠遠小於從主存中讀取數據的時間,且由於邊際效應的原因,往往L1、L2中的數據命中率都很高(參見下表)


(上表中時間的單位是納秒。1秒=1000000000納秒,也就是說1納米極其短暫,短到光在1納秒的時間內只能前進30釐米)。

請注意:每一個CPU物理內核都有其獨立使用的L1、    L2緩存,一些高級的CPU中又存在可供多核共享的L3緩存,以便MESI的工作過程中能在L3中進行數據可見性讀取。另外請注意,當CPU內核對數據進行修改時,通常來說被修改的數據不會立即回存到主存中(但最終會回寫到主存中)。

那麼當某一個數據(對象)A在多個處於“運行”狀態的線程中進行讀寫共享時(例如ThreadA、ThreadB和ThreadC),就可能出現多種問題:首先是多個線程可能在多個獨立的CPU內核中“同時”修改數據A,導致系統不知應該以哪個數據爲準;又或者由於ThreadA進行數據A的修改後沒有即時寫會內存ThreadB和ThreadC也沒有即時拿到新的數據A,導致ThreadB和ThreadC對於修改後的數據不可見。

2.2、MESI 協議及 RFO 請求
爲了解決這個問題,CPU工程師設計了一套數據狀態的記錄和更新協議——MESI(中文名:CPU緩存一致性協議)。這個規則實際上由四種數據狀態的描述構成,如下圖所示:

圖片摘自網絡

(圖片摘自網絡)其中:

M(修改,Modified):本地處理器已經修改緩存行,即是髒行,它的內容與內存中的內容不一樣,並且此 cache 只有本地一個拷貝(專有);
E(專有,Exclusive):緩存行內容和內存中的一樣,而且其它處理器都沒有這行數據;
S(共享,Shared):緩存行內容和內存中的一樣, 有可能其它處理器也存在此緩存行的拷貝;
I(無效,Invalid):緩存行失效, 不能使用。
這裏請注意一個關鍵點:CPU對於緩存狀態的記錄是以“緩存行”爲單位。舉個例子,一個CPU獨立使用的一級緩存的大小爲32KB,如果按照標準的一個“緩存行”爲64byte計算,這個一級緩存最大容納的“緩存行”爲512行。一個緩存行中可能存儲了多個變量值(例如一個64byte的緩存行理論上可以存儲64 / 8 = 8個long型變量的值),那麼只要這8個long型變量的任何一個的值發生了變化,都會導致該“緩存行”的狀態發生變化(造成的其中一種後果請參見本文後續2.3小節描述的內容)。

CPU從本地寄存器讀取數據:從本地寄存器讀取數據時,可能遇到緩存行分別爲M、E、S、I四種狀態,實際上處理“I”狀態以外的其它狀態在進行讀本地寄存器操作是,其狀態標記都不會發生任何變化。而讀取狀態爲“I”的緩存行時,由於緩存行已經失效,所以最終會在主存上讀取數據並重新加載。

如果CPU中的寄存器控制器發現當前已經有其它寄存器擁有了該數據,則讀取後將緩存行狀態置爲“S”,否則將該緩存行狀態置爲“E”。

CPU從遠程寄存器讀取數據:什麼時候CPU會從遠程寄存器上讀取數據呢?就是上一步進行“I”狀態緩存行讀取時,如果寄存器控制器發現本數據已經存在於其它寄存器中的時候,就會發起遠程讀。在進行遠程讀操作時,遠程緩存行可能處於“S”狀態或者“E”狀態,但無論處於哪種狀態,在遠程讀操作成功後本地緩存行和所有遠程緩存行的狀態都將變成“S”狀態。

CPU進行本地寄存器寫操作:當CPU進行本地寄存器上指定緩存行的寫操作時,這個緩存行可能處於M、E、S、I的任何狀態。但是由於是進行了本地寫操作,所以無論處於什麼狀態,操作成功後本地緩存行的最終狀態都是“M”(這個情況是很好理解的)。我們先來討論兩種比較簡單的情況,既是操作前這個緩存行的狀態爲“M”或者爲“E”,這種狀態下,由於不涉及其它寄存器的狀態變化,所以只需要直接更改數據後,將狀態變爲“M”即可;接着討論一種較複雜的情況,既是操作前緩存行的狀態爲“S”,這種情況下,說明在其它寄存器中也同時存在相同數據,這時需要首先將本地寄存器中的緩存行狀態更改爲“E”,並將其它寄存器中相同緩存行狀態更改爲“I”,再進行操作,且在操作後將本地寄存器的狀態更改爲“M”;最後說一說操作前本地緩存行狀態爲“I”的情況,這種情況下,說明緩存行已經過期,那麼首先需要通過寄存器控制器重新讀取數據,那麼讀取後的緩存行狀態可能爲“E”也可能爲"S",當讀取成功後,再視情況執行寫操作,最終將該緩存行的狀態更改爲“M”。

CPU進行遠程寄存器寫操作:這裏要明確一個概念,從上文已經描述的三個操作來看,CPU都是將操作數據通過寄存器控制器防止到本地寄存器中,再進行讀/寫操作。而寄存器控制器可能讀取的是主存信息,也可能讀取的是另外某個遠程寄存器上讀取。所以按照這樣的描述就不應該有遠程寫的概念,那麼這裏的遠程寫又是什麼意思呢?

實際上這裏說的遠程寫,並不是真正意義上的直接將數據寫到遠程寄存器,而是說本地寄存器通過寄存器控制器讀取了遠程寄存器的數據後並不用於本地讀操作,而是用於本地寫的操作。也就是上文所述第“3”小點中,本地指定緩存行狀態爲“I”,且寄出器控制器從其它寄存器讀取緩存行到本地緩存行的情況。

這種情況下,本地緩存行將會通過寄存器控制器向遠程擁有相同緩存行的寄存器發送一個RFO請求(Request For Owner),要求其它所有寄存器將指定緩存行變更爲“I”狀態(實際上需要其它遠程寄存器變更緩存行狀態的需求,都會發送RFO請求)。

2.3、MESI 協議存在的問題
上述內容就是MESI狀態變化的主要過程,請注意這裏提到的RFO請求過程放在計算機的整個計算過程中來看,市是極爲短暫的,但如果放在寄存器工作環境下來看,則是比較耗費時間的(單位在100納秒以上)。在高併發情況下MESI協議還存在一些問題:

由於寄存器中處於“M”狀態的數據不會立即更新到主存(雖然最終會寫入主存),那麼就導致在其它寄存器中的相同數據會出現短暫的數值差異。這個短暫的時間真的是非常短——一個納秒級別的時間,但是在高併發情況下,依然會出現偶發性問題。也就是說在某一個變量值被多個線程共享的情況下,當某一個線程改變了這個變量的值,由於MESI協議的固有問題,另一個線程在很短暫的時間內是無法知曉值的變化的(但最終會知曉)。

要解決這個問題,其中一種方式就是使用鎖(java中的synchronized方式或者lock鎖的方式都行),但是這種解決方式又比較“重量級”,因爲實際上這種場景下我們並不需要保證操作的原子性,所以還有一種更“輕量級”的解決方法,就是使用volatile關鍵字(這是volatile關鍵字的主存一致性場景,將在後面一篇文章中專門介紹)。

上文已經提到MESI協議的標記單位是“緩存行”,以一個一級緩存總容量爲32Kbyte的寄存器來說,如果每一個緩存行定義的大小爲64byte,那麼整個寄存器就有512個“緩存行”。如果進行對象地址的換算,一個支持64位尋址長度計算機系統中,可以使用8個byte指向一個明確的內存地址起始位,也就是一個緩存行理論上最多可以存儲8個對象的內存起始位置;如果再進行長整型換算(長整型爲64位,也就是8個byte),那麼可以得到一個緩存行可以存儲8個長整型的數值。

設想一下這樣的一個使用場景,某一“緩存行”中的多個變量(姑且認爲就是長整型變量)被多個線程共享,其中線程1對變量A的值進行了修改,這時即使在另一個CPU內核工作的線程2沒有對變量B進行修改,後者的“緩存行”也會被標記爲“I”,當線程2要對變量B的值進行修改時,就必須使用RFO請求,到前者的寄存器上調取“緩存行”,並將前者寄存器“緩存行”的狀態更改爲“I”。這就導致了線程A和線程B雖然沒有共享某一個數值對象,但是也出現了關聯的狀態強佔的“活鎖”情況。

3、僞共享及解決方法
上文2.3小節提到的多個CPU內核搶佔同一緩存行上的不相關變量所引起的“活鎖”情況,稱之爲僞共享。在高併發情況下,這種MESI協議引起的“活鎖”情況反而降低了整個系統的性能。並且由於CPU和寄存器的工作調配並不是由Java程序員直接負責,所以這種僞共享問題很難發現和排查。

3.1、僞共享示例
請看如下代碼片段:

package testCoordinate;

/**
 * 僞共享示例
 * @author yinwenjie
 */
public class FalseSharing1 {
  /**
   * 因爲筆者做測試的電腦是8核CPU。
   * 這裏我們不考慮多線程的狀態切換因素,只考慮多線程在同一時間的MESI狀態強佔因素
   */
  private static final int CORENUIMBER = 8;
  private static VolatileClass[] volatileObjects = new VolatileClass[CORENUIMBER];
  static {
    // 這裏不能使用Arrays.fill工具,原因自己去看
    for(int index = 0 ; index < CORENUIMBER ; index++) {
      volatileObjects[index] = new VolatileClass();
    }
  }
  public static void main(String[] args) throws Exception {
    /*
     * 測試過程爲:
     * 1、首先創建和CORENUIMBER數量一致的線程對象和VolatileClass對象。
     * 2、這些線程各自處理各自的對應的VolatileClass對象,
     * 處理過程很簡單,就是進行當前currentValue在二進制下的加法運算,當數值超過 達到2^32時終止
     * 3、記錄整個過程的完成時間,並進行比較
     * 
     * 我們主要來看,看似多個沒有關係的計算過程在不同代碼編輯環境下的時間差異
     * 看整個3次的總時間(你也可以根據自己的實際情況進行調整,次數越多平均時間越準確)
     * */
    long totalTimes = 0l;
    int maxTimes = 3;
    for(int times = 0 ; times < maxTimes ; times++) {
      long startTime = System.currentTimeMillis();
      Thread[] testThreads = new Thread[CORENUIMBER];
      for(int index = 0 ; index < CORENUIMBER ; index++) {
        testThreads[index] = new Thread(new Handler(volatileObjects , index));
        testThreads[index].start();
      }
      
      // 等到所有計算線程終止,才繼續了
      for(int index = 0 ; index < CORENUIMBER ; index++) {
        testThreads[index].join();
      }
      long endTime = System.currentTimeMillis();
      totalTimes += (endTime - startTime);
      
      System.out.println("執行完第" + times + "次");
    }
    
    System.out.println("time arra = " + (totalTimes / maxTimes));
  }
  /**
   * 該類就是模擬我們在緩存行中需要修改的數據對象
   * 其中有一個long類型的變量,就是用來進行修改的<br/>
   * 爲了簡單起見,這裏就直接關掉了變量的修飾符
   */
  // 屏蔽以下兩句註解將得到不一樣的工作效率
  @SuppressWarnings("restriction")
  cc
  private static class VolatileClass {
    long currentValue = 0l;
  }
  private static class Handler implements Runnable {
    private int index;
    private VolatileClass[] volatileObjects;
    public Handler(VolatileClass[] volatileObjects , int index) {
      this.index = index;
      this.volatileObjects = volatileObjects;
    }
    
    @Override
    public void run() {
      Long number = 0l;
      while(number++ < 0xFFFFFFFFL) {
        volatileObjects[index].currentValue = number;
      }
    }
  }
}

以上代碼在所描述的工作場景實際上在很多介紹僞共享的文章中都可以找到,筆者只是基於易讀的目的出發進行了一些調整:代碼中描述了N個線程(例如8個),每個線程持有獨立的VolatileClass類的實例(注意,是“獨立的”),每一個VolatileClass類的實例示例中只包括了一個長整型變量“currentValue ”,接下來我們讓這些線程工作起來,各自對各自持有的currentValue 變量進行累加,直到達到0xFFFFFFFF這個上限值(注意,這裏是位運算並不代表32位整形的最小值)。

那麼整個代碼在運行時就擁有了8個完全獨立的currentValue工作在8個獨立線程中,但是看似沒有關聯的8個變量賦值過程,卻因爲“有沒有使用Contended註解”的區別,顯示出較大的性能差異。如下表所示:


注意,整個JDK使用的版本是JDK 8.0+,因爲在不同的低版本的JDK版本下,體現性能差異的方式是不一樣的;另外執行時,需要攜帶JVM參數“-XX:-RestrictContended”,這樣Contended註解才能起作用。

3.2、性能差異原因
那麼我們基於寄存器對多個currentValue變量在緩存行的存取方式,結合上文提到的MESI協議狀態的變化,來解釋一下爲什麼執行結果上會有這樣的性能差異:

當沒有增加“Contended”註解的時候,由於每個VolatileClass類的實例中只有一個長整型變量“currentValue”,再加上實例對象本身8byte的描述信息,所以總共是16byte,遠遠沒有達到單緩存行64byte的大小限制。

再加上這些VolatileClass類的實例又是使用一個數組進行連續描述的,所以就出現了多個VolatileClass類的實例再計算過程中被放到了一個緩存行上(不一定是上文示例代碼中8個VolatileClass對象都被放到了同一緩存行,而是說肯定有多個VolatileClass對象被放在了同一緩存行上)。

這個時候雖然多個線程使用了不同的VolatileClass對象(中的變量),但是都會導致同一緩存行的狀態發生了變化,緩存行的無效狀態變化將會非常頻繁,導致了較高的無效性能消耗。

3.3、特別說明
當筆者寫作本篇文章的時候,查閱網絡上的一些資料。但是發現其中一些文章對於僞共享的代碼示意存在一些描述不完整的問題。當然這些問題都是可以理解的,因爲有的文章發表時間都已經是3、4年前的事情了。很多文章中對於不同JDK處理“僞共享”的機制,沒有分別進行說明。

上文已經提到有一種處理“僞共享”的方式,叫做“佔位”。這種方式很好理解,就是將一個緩存行使用8個長整型變量全部佔滿(以單緩存行64byte爲單位,其中一個對象的頭描述戰友8byte,所以實際上只需要7個長整型變量就可以全部佔滿),雖然這些變量中只有一個長整型在使用,但沒有關係,因爲保證了所有可能存在僞共享風險的變量肯定在不同的緩存行。如下代碼示例:


以上代碼當然可以達到佔位的目的,但實際上只能在JDK 1.7版本之前使用,因爲JDK 1.7以及之後的版本會在Java文件的編譯期將無用的變量自動忽略掉,這樣就導致了設計失效。

而JDK1.8以及以後的版本,提供了一個註解“@sun.misc.Contended”來表示一個類的變量需要啓用避免“僞共享”的配置。但是該註解默認情況下只用於JDK的原生包,如果需要在自己的代碼中使用該註解,就需要在在啓動時程序時攜帶JVM參數“-XX:-RestrictContended”。
--------------------- 
出處:http://mp.toutiao.com/preview_article/?pgc_id=6616230919214744078

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