解密詭異並發問題的幕后黑手:可見性問題


摘要:可見性問題還是由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的緩存導致的,而緩存導致的可見性問題是導致諸多詭異的並發編程問題的“幕后黑手”之一。我們只有深入理解了緩存導致的可見性問題,並在實際工作中時刻注意避免可見性問題,才能更好的編寫出高並發程序。

 

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


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM