解密詭異併發問題的幕後黑手:可見性問題

華為雲開發者社群發表於2021-10-22
摘要:可見性問題還是由CPU的快取導致的,而快取導致的可見性問題是導致諸多詭異的併發程式設計問題的“幕後黑手”之一。

本文分享自華為雲社群《【高併發】一文解密詭異併發問題的第一個幕後黑手——可見性問題》,作者:冰 河。

併發程式設計一直是很讓人頭疼的問題,因為多執行緒環境下不太好定位問題,它不像一般的業務程式碼那樣打個斷點,debug一下基本就能夠定位問題所在。併發程式設計中,出現的問題往往都是很詭異的,而且大多數情況下,問題也不是每次都會重現的。那麼,我們如何才能夠更好的解決併發問題呢?這就需要我們瞭解造成這些問題的“幕後黑手”究竟是什麼!

可見性

對於什麼是可見性,比較官方的解釋就是:一個執行緒對共享變數的修改,另一個執行緒能夠立刻看到。

說的直白些,就是兩個執行緒共享一個變數,無論哪一個執行緒修改了這個變數,則另外的一個執行緒都能夠看到上一個執行緒對這個變數的修改。這裡的共享變數,指的是多個執行緒都能夠訪問和修改這個變數的值,那麼,這個變數就是共享變數。

例如,執行緒A和執行緒B,它們都是直接修改主記憶體中的共享變數,無論是執行緒A修改了共享變數,還是執行緒B修改了共享變數,則另一個執行緒從主記憶體中讀取出來的變數值,一定是修改過的值,這就是執行緒的可見性。

解密詭異併發問題的幕後黑手:可見性問題

可見性問題

可見性問題,可以這樣理解:一個執行緒修改了共享變數,另一個執行緒不能立刻看到,這是由CPU新增了快取導致的問題。

理解了什麼是可見性,再來看可見性問題就比較好理解了。既然可見性是一個執行緒修改了共享變數後,另一個執行緒能夠立刻看到對共享變數的修改,如果不能立刻看到,這就會產生可見性的問題。

單核CPU不存在可見性問題

理解可見性問題我們還需要注意一點,那就是 在單核CPU上不存在可見性問題。 這是為什麼呢?

因為在單核CPU上,無論建立了多少個執行緒,同一時刻只會有一個執行緒能夠獲取到CPU的資源來執行任務,即使這個單核的CPU已經新增了快取。這些執行緒都是執行在同一個CPU上,操作的是同一個CPU的快取,只要其中一個執行緒修改了共享變數的值,那另外的執行緒就一定能夠訪問到修改後的變數值。

解密詭異併發問題的幕後黑手:可見性問題

多核CPU存在可見性問題

單核CPU由於同一時刻只會有一個執行緒執行,而每個執行緒執行的時候操作的都是同一個CPU的快取,所以,單核CPU不存在可見性問題。但是到了多核CPU上,就會出現可見性問題了。

這是因為在多核CPU上,每個CPU的核心都有自己的快取。當多個不同的執行緒執行在不同的CPU核心上時,這些執行緒操作的是不同的CPU快取。一個執行緒對其繫結的CPU的快取的寫操作,對於另外一個執行緒來說,不一定是可見的,這就造成了執行緒的可見性問題。

解密詭異併發問題的幕後黑手:可見性問題

例如,上面的圖中,由於CPU是多核的,執行緒A操作的是CPU-01上的快取,執行緒B操作的是CPU-02上的快取,此時,執行緒A對變數V的修改對執行緒B是不可見的,反之亦然。

Java中的可見性問題

使用Java語言編寫併發程式時,如果執行緒使用變數時,會把主記憶體中的資料複製到執行緒的私有記憶體,也就是工作記憶體中,每個執行緒讀寫資料時,都是操作自己的工作記憶體中的資料。

解密詭異併發問題的幕後黑手:可見性問題

此時,Java中執行緒讀寫共享變數的模型與多核CPU類似,原因是Java併發程式執行在多核CPU上時,執行緒的私有記憶體,也就是工作記憶體就相當於多核CPU中每個CPU核心的快取了。

由上圖,同樣可以看出,執行緒A對共享變數的修改,執行緒B不一定能夠立刻看到,這也就會造成可見性的問題。

程式碼示例

我們使用一個Java程式來驗證多執行緒的可見性問題,在這個程式中,定義了一個long型別的成員變數count,有一個名稱為addCount的方法,這個方法中對count的值進行加1操作。同時,在execute方法中,分別啟動兩個執行緒,每個執行緒呼叫addCount方法1000次,等待兩個執行緒執行完畢後,返回count的值,程式碼如下所示。

package io.mykit.concurrent.lab01;

/**
 * @author binghe
 * @version 1.0.0
 * @description 測試可見性
 */
public class ThreadTest {

    private long count = 0;

    private void addCount(){
        count ++;
    }

    public long execute() throws InterruptedException {
        Thread threadA = new Thread(() -> {
            for(int i = 0; i < 1000; i++){
                addCount();
            }
        });

        Thread threadB = new Thread(() -> {
            for(int i = 0; i < 1000; i++){
                addCount();
            }
        });

        //啟動執行緒
        threadA.start();
        threadB.start();

        //等待執行緒執行完成
        threadA.join();
        threadB.join();
        return count;
    }

    public static void main(String[] args) throws InterruptedException {
        ThreadTest threadTest = new ThreadTest();
        long count = threadTest.execute();
        System.out.println(count);
    }
}

我們執行下這個程式,結果如下圖所示。

解密詭異併發問題的幕後黑手:可見性問題

可以看到這個程式的結果是1509,而不是我們期望的2000。這是為什麼呢?讓我們一起來分析下這個程式。

首先,變數count屬於ThreadTest類的成員變數,這個成員變數對於執行緒A和執行緒B來說,是一個共享變數。假設執行緒A和執行緒B同時執行,它們同時將count=0讀取到各自的工作記憶體中,每個執行緒第一次執行完count++操作後,同時將count的值寫入記憶體,此時,記憶體中count的值為1,而不是我們想象的2。而在整個計算的過程中,執行緒A和執行緒B都是基於各自工作記憶體中的count值進行計算。這就導致了最終的count值小於2000。

歸根結底:可見性的問題是CPU的快取導致的。

總結

可見性是一個執行緒對共享變數的修改,另一個執行緒能夠立刻看到,如果不能立刻看到,就可能會產生可見性問題。在單核CPU上是不存在可見性問題的,可見性問題主要存在於執行在多核CPU上的併發程式。歸根結底,可見性問題還是由CPU的快取導致的,而快取導致的可見性問題是導致諸多詭異的併發程式設計問題的“幕後黑手”之一。我們只有深入理解了快取導致的可見性問題,並在實際工作中時刻注意避免可見性問題,才能更好的編寫出高併發程式。

 

點選關注,第一時間瞭解華為雲新鮮技術~

相關文章