天天看點

深入了解并發程式設計藝術之記憶體模型

作者:騰訊技術工程

作者:ehtan 騰訊背景開發工程師

随着硬體技術的飛速發展,多核處理器已經成為計算裝置的标配,這使得開發人員需要掌握并發程式設計的知識和技巧,以充分發揮多核處理器的潛力。然而并發程式設計并非易事,它涉及到許多複雜的概念和原理。為了更好地了解并發程式設計的内在機制,需要深入研究記憶體模型及其在并發程式設計中的應用。本文将主要以 Java 記憶體模型來探讨并發程式設計中 BUG 的源頭和處理這些問題的底層實作原理,助你更好地把握并發程式設計的内在機制。

随着硬體技術的飛速發展,多核處理器已經成為計算裝置的标配,這使得開發人員需要掌握并發程式設計的知識和技巧,以充分發揮多核處理器的潛力。然而并發程式設計并非易事,它涉及到許多複雜的概念和原理。為了更好地了解并發程式設計的内在機制,需要深入研究記憶體模型及其在并發程式設計中的應用。本文将主要以 Java 記憶體模型來探讨并發程式設計中 BUG 的源頭和處理這些問題的底層實作原理,助你更好地把握并發程式設計的内在機制。

并發程式設計問題-可見性和有序性

private int a, b;
    private int x, y;

    public void test() {
        Thread t1 = new Thread(() -> {
            a = 1;
            x = b;
        });

        Thread t2 = new Thread(() -> {
            b = 2;
            y = a;
        });
        // ...start啟動線程,join等待線程
        assert x == 2;
        assert y == 1;
    }
           

首先我們先看一段代碼,這裡定義了兩個共享變量 x 和 y,在兩個線程中分别對 x 和 y 指派,當同時開啟兩個線程并等待線程執行完成,最終結果是否是共享變量 x 等于 2 并且 y 等于 1 呢?答案是未可知,即共享變量 x 和 y 可能存在多種執行結果。可以看到在并發程式設計中,常常會遇到一些與預期不符的結果,導緻程式邏輯的失敗。這樣的異常問題,會讓開發人員感到困惑。但是如果細細探究這些問題的根源,發現是有迹可循的。

這個問題的原因主要是兩點:一是處理器和記憶體對共享變量的處理的速度差異。二是編譯優化和處理器優化造成代碼指令重排序。前者導緻可見性問題,後者導緻有序性問題。

處理器緩存導緻的可見性問題

深入了解并發程式設計藝術之記憶體模型

如上圖所示,由于處理器和記憶體的速度差距太大。為了提高處理速度,處理器不直接和記憶體進行通信,而是先将系統記憶體的資料讀到内部緩存(L1,L2 或其他)後再進行操作。基于局部性原理,處理器在讀取記憶體資料時,是一塊塊地讀取,每一小塊資料也叫緩存行(cache line)。當處理器操作完資料,也不直接寫回記憶體,而且先寫入緩存中,并将目前緩存标記為髒(dirty)。等到目前緩存被替換時,才将資料寫回記憶體。這個過程叫寫回政策(write-back)。

同時為了提高效率,處理器使用寫緩存區(store buffer)臨時儲存向記憶體寫入的資料。寫緩沖區可以保證指令流水線的持續運作,同時合并寫緩沖區中對同一記憶體位址的多次寫,減少記憶體總線的占用。但是由于緩沖區的資料并非及時寫回記憶體,且寫緩沖區僅對自己的處理器可見,其他處理器無法感覺目前共享變量已經變更。處理器的讀寫順序與記憶體實際操作的讀寫順序可能存在不一緻。

深入了解并發程式設計藝術之記憶體模型

現在再回來看上面代碼,那麼可以得到四種結果:

1)假設處理器 A 對變量 a 指派,但沒及時回寫記憶體。處理器 B 對變量 b 指派,且及時回寫記憶體。處理器 A 從記憶體中讀到變量 b 最新值。那麼這時結果是:x 等于 2,y 等于 0。

2)假設處理器 A 對變量 a 指派,且及時回寫記憶體。處理器 B 從記憶體中讀到變量 a 最新值。處理器 B 對變量 b 指派,但沒及時回寫記憶體。那麼這時結果是:x 等于 0,y 等于 1。

3)假設處理器 A 和 B,都沒及時回寫變量 a 和 b 值到記憶體。那麼這時結果是:x 等于 0,y 等于 0。

4)假設處理器 A 和 B,都及時回寫變量 a 和 b 值到記憶體,且從記憶體中讀到變量 a 和 b 的最新值。那麼這時結果是:x 等于 2,y 等于 1。

從上面可發現除了第四種情況,其他三種情況都存在對共享變量的操作不可見。所謂可見性,便是當一個線程對某個共享變量的操作,另外一個線程立即可見這個共享變量的變更。

而從上面推論可以發現,要達到可見性,需要處理器及時回寫共享變量最新值到記憶體,也需要其他處理器及時從記憶體中讀取到共享變量最新值。

是以也可以說隻要滿足上述兩個條件。那麼就可以保證對共享變量的操作,在并發情況下是線程安全的。在 Java 語言中,是通過 volatile 關鍵字實作。volatile 關鍵字并不是 Java 語言的特産,古老的 C 語言裡也有,它最原始的意義就是禁用 CPU 緩存。

對如下共享變量:

// instance是volatile變量
  volatile Singlenton instance = new Singlenton();
           

轉換成彙編代碼,如下:

0x01a3de1d: movb 5 0 x 0, 0 x 1104800(% esi);
0x01a3de24: lock addl $ 0 x 0,(% esp);
           

可以看到 volatile 修飾的共享變量會多出第二行彙編變量,并且多了一個 LOCK 指令。LOCK 字首的指令在多核處理器會引發兩件事:

1)将目前處理器緩存行的資料寫回到系統記憶體。

2)這個寫回記憶體的操作會使在其他 CPU 裡緩存了該記憶體位址的資料無效。

上述的操作是通過總線嗅探和總線仲裁來實作。而基于總線嗅探和總線仲裁,現代處理器逐漸形成了各種緩存一緻性協定,例如 MESI 協定。

深入了解并發程式設計藝術之記憶體模型

總之作業系統便是基于上述實作,從底層來保證共享變量在并發情況下的線程安全。而對開發人員,隻需要在恰當時候加上 volatile 關鍵字就可以。

除了 volatile,也可以使用 synchronized 關鍵字來保證可見性。關于 volatile 和 synchronized 的具體實作,會在下篇文章詳細闡述。

編譯優化導緻的有序性問題

前面講到通過緩存一緻性協定,來保障共享變量的可見性。那麼是否還有其他情況,導緻對共享變量操作不符合預期結果。可以看下面的代碼:

private int a, b;
    private int x, y;

    public void test() {
        Thread t1 = new Thread(() -> {
            x = b;
            a = 1;
        });

        Thread t2 = new Thread(() -> {
            y = a;
            b = 2;
        });
        // ...start啟動線程,join等待線程
        assert x == 2;
        assert y == 1;
    }
           

假設将線程 t1 的代碼塊從 a = 1;x = b;改成 x = b;a = 1; 。将線程 t2 的代碼塊從 b = 2;y = a;改成 y = a;b = 2;。

對于線程 t1 和 t2 自己來說,代碼的重排序,不會影響目前線程執行。但是在多線程并發執行下,會出現如下情況:

1)假設處理器 A 先将變量 b=0 指派給 x,再将變量 a 指派 1。處理器 B 先将變量 a=0 指派給 y,再将變量 b 指派 2。那麼這時結果是:x 等于 0,y 等于 0。

可見代碼的重排序也會影響到程式最終結果。

代碼和指令的重排序的主要原因有三個,分别為編譯器的重排序,處理器的亂序執行,以及記憶體系統的重排序。後面兩點是處理器優化。

深入了解并發程式設計藝術之記憶體模型

重排序是指編譯器和處理器為了優化程式性能而對指令序列進行重新排序的一種手段。重排序需要遵守兩點:

1)資料依賴性:如果兩個操作之間存在資料依賴,那麼編譯器和處理器不能調整它們的順序。

// 寫後讀
a = 1;
b = a;
// 寫後寫
a = 1;
a = 2;
// 讀後寫
a = b;
b = 1;
           

上面 3 種情況,編譯器和處理器不能調整它們的順序,否則将會造成程式語義的改變。

2)as-if-serial 語義:即給程式一個順序執行的假象。即經過重排序的執行結果要與順序執行的結果保持一緻。

a = 1;
b = 2;
c = a * b;
           

如上對變量 a 的指派和對變量 b 的指派,不存在資料依賴關系。是以對變量 a 和 b 重排序不會影響變量 c 的結果。

但資料依賴性和 as-if-serial 語義隻保證單個處理器中執行的指令序列和單個線程中執行的操作,并不考慮多處理器和多線程之間的資料依賴情況。是以在多線程程式中,對存在資料依賴的操作重排序,可能會改變程式的執行結果。是以要避免程式的錯誤的執行,便是需要禁止這種編譯和處理器優化導緻的重排序。

這種方式叫做記憶體屏障(memory barriers)。記憶體屏障是一組處理器指令,使用者實作對記憶體操作的順序限制。以我們日常接觸的 X86_64 架構來說,讀讀(loadload)、讀寫(loadstore)以及寫寫(storestore)記憶體屏障是空操作(no-op),隻有寫讀(storeload)記憶體屏障會被替換成具體指令。

在 Java 語言中,記憶體屏障通過 volatile 關鍵字實作,禁止被它修飾的變量發生指令重排序操作:

1)不允許 volatile 字段寫操作之前的記憶體通路被重排序至其之後。

2)不允許 volatile 字段讀操作之後的記憶體通路被重排序至其之前。

//  變量a,b通過volatile修飾
    private volatile int a, b;
    private int x, y;

    public void test() {
        Thread t1 = new Thread(() -> {
            a = 1;
            // 編譯器插入storeload記憶體屏障指令
            // 1)禁止代碼和指令重排序
            // 2)強制重新整理變量a的最新值到記憶體
            x = b;
            // 1)強制從記憶體中讀取變量b的最新值
        });

        Thread t2 = new Thread(() -> {
            b = 2;
            // 編譯器插入storeload記憶體屏障指令
            // 1)禁止代碼和指令重排序
            // 2)強制重新整理變量b的最新值到記憶體
            y = a;
            // 1)強制從記憶體中讀取變量a的最新值
        });
        // ...start啟動線程,join等待線程
        assert x == 2;
        assert y == 1;
    }
           

可以看到通過 volatile 修飾的變量通過 LOCK 指令和記憶體屏障,實作共享變量的可見性和避免代碼和指令的重排序,最終保障了程式在多線程情況下的正常執行。

并發程式設計問題-原子性

private int count = 0;

    public void test() {
        List<Thread> ts = new ArrayList<>();
        for (int i = 0; i < 100; i++) {
            Thread t = new Thread(() -> {
                for (int j = 0; j < 10000; j++) {
                    count += 1;
                }
            });
            ts.add(t);
        }
        // ...start啟動線程,join等待線程
        assert count == 100 * 10000;
    }
           

對于 Java 這樣的進階語言,一條語句最終會被轉換成多條 CPU 指令完成,例如上面代碼中的 count+=1,至少需要三條 CPU 指令:

1)指令 1:把變量 count 從記憶體加載到 CPU 的寄存器;

2)指令 2:在寄存器中執行+1 操作;

3)指令 3:将結果寫入記憶體(緩存機制導緻可能寫入的是處理器緩存而不是記憶體)。

那麼假設有兩個線程 A 和 B,同時執行 count+=1,可能存在如下情況:

1)線程 A 從記憶體加載 count 并執行 count+=1,同時線程 B 從記憶體加載 count 并執行 count+=1,并同時寫回記憶體。那麼這時結果是:count = 1。

2)線程 A 從記憶體加載 count 并執行 count+=1,并将 count 結果寫回記憶體。線程 B 再從記憶體加載 count 并執行 count+=1。那麼這時結果是:count = 2。

可以看到如果要 count 結果正确,要保證 count 讀取,操作,寫入三個過程不被中斷。這個過程,可以稱之為原子操作。原子 (atomic)本意是“不能被進一步分割的最小粒子”,而原子操作 (atomicoperation) 意為“不可被中斷的一個或一系列操作”。

處理器主要使用基于對緩存加鎖或總線加鎖的方式來實作原子操作:

1)總線加鎖:通過 LOCK#信号鎖住總線 BUS,使目前處理器獨享記憶體空間。但是此時其他處理器都不能通路記憶體其他位址,效率低。

深入了解并發程式設計藝術之記憶體模型

2)緩存加鎖:緩存一緻性協定(MESI)。強制目前處理器緩存行失效,并從記憶體讀取其他處理器回寫的資料。當有些無法被緩存或跨域多個緩存行的資料,依然需要使用總線鎖。

深入了解并發程式設計藝術之記憶體模型

對于以上兩個機制,處理器底層提供了很多指令來實作,其中最重要的是 CMPXCHG 指令。但 CMPXCHG 隻在單核處理器下有效,多核處理器依然要加上 LOCK 字首(LOCK CMPXCHG)。利用 CMPXCHG 指令可以通過循環 CAS 方式來實作原子操作。

// 判斷目前機器是否是多核處理器
int mp = os::is MP();
_asm {
    mov edx, dest
    mov ecx, exchange value
    mov eax, compare_value
    // 這裡需要先進行判斷是否為多核處理器
    LOCK IF MP(mp)
    // 如果是多核處理器就會在這行指令前加Lock标記
    cmpxchg dword ptr [edx],ecx
}
           

CAS 即 Compare and Swap。CAS 操作需要輸入兩個數值,一個舊值 (期望操作前的值)和一個新值,在操作期間先比較舊值有沒有發生變化,如果沒有發生變化,才交換成新值,發生了變化則不交換。

Java 語言提供了大量的原子操作類,來實作對應的 CAS 操作。比如 AtomicBoolean,AtomicInteger,AtomicLong 等。

private AtomicInteger count = new AtomicInteger(0);

    public void test() {
        List<Thread> ts = new ArrayList<>();
        for (int i = 0; i < 100; i++) {
            Thread t = new Thread(() -> {
                for (int j = 0; j < 10000; j++) {
                   count.incrementAndGet();
                }
            });
            ts.add(t);
        }
        // ...start啟動線程,join等待線程
        assert count.get() == 100 * 10000;
    }
           

CAS 雖然很高效解決了原子操作,但是 CAS 也存在一些問題,比如 ABA 問題,循環時間長開銷大,隻能保障一個共享變量的原子操作。關于如上問題解決和 Atomic 包介紹,會在下篇文章詳細闡述。

記憶體模型與 happens-before 關系

前面說到處理器提供了一些特殊指令比如 LOCK,CMPXCHG,記憶體屏障等來保障多線程情況下的程式邏輯正常執行。但這依然存在幾個問題:

1)處理器底層指令實作細節複雜難懂,開發人員需要付出巨大的學習成本。

2)不同的硬體和作業系統,對指令的支援和實作不一樣,需要考慮跨平台的相容性。

3)程式業務邏輯複雜多變,處理器和線程之間的資料操作依賴關系也相應更複雜。

是以進階語言會提供一種抽象的記憶體模型,用于描述多線程環境下的記憶體通路行為。 無需關心底層硬體和作業系統的具體實作細節,就可以編寫出高效、可移植的并發程式。對于 Java 語言,這種記憶體模型便是 Java 記憶體模型(Java Memory Model,簡稱 JMM)。

Java 記憶體模型主要特性是提供了 volatile、synchronized、final 等同步原語,用于實作原子性、可見性和有序性。另一個重要的概念便是 happens-before 關系,用來描述并發程式設計中操作之間的偏序關系。除了 Java 語言,包括 golang,c++,rust 等進階語言也實作了自己的 happens-before 關系。

Java 記憶體模型的 happens-before 關系是用來描述兩個線程操作的記憶體可見性。需注意這裡的可見性,并不代表 A 線程的執行順序一定早于 B 線程, 而是 A 線程對某個共享變量的操作對 B 線程可見。即 A 線程對變量 a 進行寫操作,B 線程讀取到是變量 a 的變更值。

Java 記憶體模型定義了主記憶體(main memory),本地記憶體(local memory),共享變量等抽象關系,來決定共享變量在多線程之間通信同步方式,即前面所說兩個線程操作的記憶體可見性。其中本地記憶體,涵蓋了緩存,寫緩沖區,寄存器以及其他硬體和編譯器優化等概念。

深入了解并發程式設計藝術之記憶體模型

如圖所示,如果線程 A 與線程 B 之間要通信的話,必須要經曆下面 2 個步驟:

1)線程 A 把本地記憶體 A 中更新過的共享變量重新整理到主記憶體中

2)線程 B 到主記憶體中去讀取線程 A 之前已更新過的共享變量

盡管定義這樣的資料通信方式,但實際程式的資料依賴操作是複雜多變的。為了進一步抽象這種線程間的資料同步方式,Java 記憶體模型定義了下述線程間的 happens-before 關系:

1)程式順序規則:單線程内,每個操作 happens-before 于該線程中的任意後續操作。

2)螢幕鎖規則:解鎖操作 happens-before 之後對同一把鎖的加鎖操作。

3)volatile 變量規則:volatile 字段的寫操作 happens-before 之後對同一字段的讀操作。

4)傳遞性規則:如果 A happens-before B,且 B happens-before C,那麼 A happens-before C。

5)start()規則:如果線程 A 執行操作 ThreadB.start(),那麼 A 線程的 ThreadB.start()操作 happens-before 于線程 B 中的任意操作。

6)join()規則:如果線程 A 執行操作 ThreadB.join()并成功傳回,那麼線程 B 中的任意操作 happens-before 線程 A 從 ThreadB.join()操作成功傳回。

與開發人員密切相關的是 1、2、3、4 四個規則。其中規則 1 滿足了 as-if-serial 語義,即 Java 記憶體模型允許代碼和指令重排序,隻要不影響程式執行結果。規則 2 和 3 是通過 synchronized、volatile 關鍵字實作。結合規則 1、2、3 來看看規則 4 的具體使用,可以看到如下的代碼,程式最終執行且得到正确結果:

// x, y, z被volatile關鍵字修飾
    private volatile int x, y, z;

    public void test() {
        Thread a = new Thread(() -> {
            // 基于程式順序規則
            // 沒有資料依賴關系,可以重排序下面代碼
            int i = 0;
            x = 2;
            // 基于volatile變量規則
            // 編譯器插入storeload記憶體屏障指令
            // 1)禁止代碼和指令重排序
            // 2)強制重新整理變量x的最新值到記憶體
        });

        Thread b = new Thread(() -> {
            int i = 0;
            // 存在資料依賴關系,無法重排序下面代碼
            // 強制從主記憶體中讀取變量x的最新值
            y = x;
            // 基于volatile變量規則
            // 編譯器插入storeload記憶體屏障指令
            // 1)禁止代碼和指令重排序
            // 2)強制重新整理變量y的最新值到記憶體
            // 3)y = x;可能會被編譯優化去除
            y = 3;
            // 編譯器插入storeload記憶體屏障指令
            // 1)禁止代碼和指令重排序
            // 2)強制重新整理變量y的最新值到記憶體
        });

        Thread c = new Thread(() -> {
            // 基于程式順序規則
            // 沒有資料依賴關系,可以重排序下面代碼
            int i = 0;
            // 基于volatile變量規則
            // 強制從主記憶體中讀取變量x和y的最新值
            z = x * y;
            // 編譯器插入storeload記憶體屏障指令
            // 1)禁止代碼和指令重排序
            // 2)強制重新整理變量z的最新值到記憶體
        });
        // ...start啟動線程,join等待線程
        assert z == 6;
        // 可以看到a線程對變量x變更,b線程對變量y變更,最終對線程c可見
        // 即滿足傳遞性規則
    }
  private  int x, y, z;

    public void test() {
        Thread a = new Thread(() -> {
           // synchronized,同步原語,程式邏輯将順序串行執行
            synchronized (this){
                // 基于程式順序規則
                // 沒有資料依賴關系,可以重排序下面代碼
                int i = 0;
                x = 2;
                // 基于螢幕鎖規則
                // 強制重新整理變量x的最新值到記憶體
            }
        });

        Thread b = new Thread(() -> {
           // synchronized,同步原語,程式邏輯将順序串行執行
            synchronized (this) {
                int i = 0;
                // 存在資料依賴關系,無法重排序下面代碼
                // 強制從主記憶體中讀取變量x的最新值
                y = x;
                // 基于螢幕鎖規則
                // 1)強制重新整理變量y的最新值到記憶體
                // 2)y = x;可能會被編譯優化去除
                y = 3;
                // 強制重新整理變量y的最新值到記憶體
            }
        });

        Thread c = new Thread(() -> {
           // synchronized,同步原語,程式邏輯将順序串行執行
            synchronized (this) {
                // 基于程式順序規則
                // 沒有資料依賴關系,可以重排序下面代碼
                int i = 0;
                // 基于螢幕鎖規則
                // 強制從主記憶體中讀取變量x和y的最新值
                z = x * y;
                // 強制重新整理變量z的最新值到記憶體
            }
        });
        // ...start啟動線程,join等待線程
        assert z == 6;
        // 可以看到a線程對變量x變更,b線程對變量y變更,最終對線程c可見
        // 即滿足傳遞性規則
    }
           

記憶體模型綜述

在本文中,我們對 Java 記憶體模型進行了全面的概述。Java 記憶體模型是 Java 虛拟機規範的一部分,為 Java 開發人員提供了一種抽象的記憶體模型,用于描述多線程環境下的記憶體通路行為。

jJava 記憶體模型關注并發程式設計中的原子性、可見性和有序性問題,并提供了一系列同步原語(如 volatile、synchronized 等)來實作這些原則。此外,還定義 happens-before 關系,用于描述操作之間的偏序關系,進而確定記憶體通路的正确性和一緻性。

Java 記憶體模型的主要優勢在于它為并發程式設計提供了基礎,簡化了複雜性。屏蔽不同處理器差異性,在不同的處理器平台之上呈現了一緻的記憶體模型,并允許一定程度的性能優化。這些優勢使得 Java 開發人員可以更容易地編寫出正确、高效、可移植的并發程式。

了解 Java 記憶體模型的原理和實踐對于編寫高品質的 Java 并發程式至關重要。希望本文能為您提供有關 Java 記憶體模型的有用資訊,幫助您更好地了解并發程式設計的内在機制,以及在實際項目中選擇合适的同步原語和政策。

繼續閱讀