1. 程式人生 > >執行緒基礎:多工處理(18)——MESI協議以及帶來的問題:偽共享

執行緒基礎:多工處理(18)——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中的資料命中率都很高(參見下表)

裝置型別 操作時間範圍
CPU L1 快取 1ns—3ns
CPU L2 快取 2ns—10ns
CPU L3 快取 10ns左右
接入北橋的主存 80ns—100ns

(上表中時間的單位是納秒。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小節描述的內容)。

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

    如果CPU中的暫存器控制器發現當前已經有其它暫存器擁有了該資料,則讀取後將快取行狀態置為“S”,否則將該快取行狀態置為“E”。

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

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

  4. 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註解”的區別,顯示出較大的效能差異。如下表所示:

沒有使用Contended註解 使用了Contended註解
執行完第0次
執行完第1次
執行完第2次
time arra = 159132(毫秒)
執行完第0次
執行完第1次
執行完第2次
time arra = 88716(毫秒)

注意,整個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個長整型變數就可以全部佔滿),雖然這些變數中只有一個長整型在使用,但沒有關係,因為保證了所有可能存在偽共享風險的變數肯定在不同的快取行。如下程式碼示例:

// ......
public final static class VolatileLong {
  public volatile long value = 0L;
  // 這是6個佔位長整型變數,暫用快取行上多餘的位置
  // 保證各個VolatileLong型別例項肯定不會在同一快取行上
  public long p1, p2, p3, p4, p5, p6;
}
// ......

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

而JDK1.8以及以後的版本,提供了一個註解“@sun.misc.Contended”來表示一個類的變數需要啟用避免“偽共享”的配置。但是該註解預設情況下只用於JDK的原生包,如果需要在自己的程式碼中使用該註解,就需要在在啟動時程式時攜帶JVM引數“-XX:-RestrictContended”。

==============================================
(接後文)