線程基礎:多任務處理——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小節描述的內容)。

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類型的變量,就是用來進行修改的

* 為了簡單起見,這裡就直接關掉了變量的修飾符

*/

// 屏蔽以下兩句註解將得到不一樣的工作效率

@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”。

---------------------

喜歡的朋友點點關注 後臺私信回覆“Java”即可免費獲取資料


分享到:


相關文章: