天天看點

并發程式設計中原子性、可見性、有序性這些特性的起源?

作者:不才陳某

著作權歸作者所有。商業轉載請聯系作者獲得授權,非商業轉載請注明出處。

前言

在高并發的情況下,你的程式是不是經常出現一些詭異的BUG,每次都是花費大量時間排查,但是你有沒有思考過這一切罪惡的源頭是什麼呢?

幕後那些事

CPU、記憶體、I/O裝置的速度差異越來越大,這也是程式性能的瓶頸,根據木桶理論,最終決定程式的整體性能取決于最慢的操作-讀寫I/O裝置,單方面的提高CPU的性能是無用的。為了平衡三者的差距,大牛前輩們不斷努力,最終做出了卓越的貢獻:CPU增加了緩存,平衡與記憶體之間的速度差異作業系統增加了程序、線程,以分時複用 CPU,進而均衡 CPU 與 I/O 裝置的速度差異;編譯程式優化指令執行次序,使得緩存能夠得到更加合理地利用。注意:正是硬體前輩們做的這些貢獻,額外的後果需要軟體工程師來承擔,太坑了。

坑一:CPU緩存導緻的可見性問題

在單核CPU的時代,所有的線程都在單個CPU上執行,不存在CPU資料和記憶體的資料的一緻性。 一個線程對共享變量的修改,另外一個線程能夠立刻看到,我們稱為可見性。 因為所有的線程都是在同一個CPU緩存中讀寫資料,一個線程對緩存的寫,對于另外一個線程肯定是可見的。如下圖:

并發程式設計中原子性、可見性、有序性這些特性的起源?

從上圖可以很清楚的了解,線程A對于變量的修改都是在同一個CPU緩存中,則線程B肯定是可見的。但是多核時代的到來則意味着每個CPU上都有一個獨立的緩存,資訊不再互通了,此時保證記憶體和CPU緩存的一緻性就很難了。如下圖:

并發程式設計中原子性、可見性、有序性這些特性的起源?

從上圖可以很清楚的了解,線程A和線程B對變量A的改變是不可見的,因為是在兩個不同的CPU緩存中。最簡單的證明方式則是在多核CPU的電腦上跑一個循環相加的方法,同時開啟兩個線程運作,最終得到的結果肯定不是正确的,如下:

public class TestThread {
    private Long total=0L;
    //循環一萬次相加
    private void add(){
        for (int i = 0; i < 10000; i++) {
            total+=1;
        }
    }

    //開啟兩個線程相加
    public static void calc() throws InterruptedException {
        TestThread thread=new TestThread();
        //建立兩個線程
        Thread thread1=new Thread(thread::add);
        Thread thread2=new Thread(thread::add);

        //啟動線程
        thread1.start();
        thread2.start();

        //阻塞主線程
        thread1.join();
        thread2.join();
        System.out.println(thread.total);
    }      

上述代碼在單核CPU的電腦上運作的結果肯定是20000,但是在多核CPU的電腦上運作的結果則是在1000020000之間,為什麼呢?原因很簡單,第一次在兩個線程啟動後,會将total=0讀取到各自的CPU緩存中,執行total+1=0後,各自将得到的結果total=1寫入到記憶體中(理想中應該是total=2),由于各自的CPU緩存中都有了值,是以每個線程都是基于各自CPU緩存中的值來計算,是以最終導緻了寫入記憶體中的值是在1000020000之間。注意:如果循環的次數很少,這種情況不是很明顯,如果次數設定的越大,則結果越明顯,因為兩個線程不是同時啟動的。

坑 二:線程切換導緻的原子性問題

早期的作業系統是基于程序排程CPU,不同程序間是共享記憶體空間的,比如你在IDEA寫代碼的同時,能夠打開QQ音樂,這個就是多程序。作業系統允許某個程序執行一段時間,比如40毫秒,過了這個時間則會選擇另外一個程序,這個過程稱之為任務切換,這個40毫秒稱之為時間片,如下圖:

并發程式設計中原子性、可見性、有序性這些特性的起源?

在一個時間片内,如果一個程序進行IO操作,比如讀檔案,這個時候該程序可以把自己标記為休眠狀态并讓出CPU的使用權,待檔案讀進記憶體,作業系統會将這個休眠的程序喚醒,喚醒後的程序就有機會重新獲得CPU的使用權。現代的作業系統更加輕量級了,都是基于線程排程,現在提到的任務切換大都訓示線程切換。注意:作業系統進行任務切換是基于CPU指令。 基于CPU指令是什麼意思呢?Java作為進階程式設計語言,一條簡單的語句可能底層就需要多條CPU指令,例如total+=1這條語句,至少需要三條CPU指令,如下:指令1:将total從記憶體讀到CPU寄存器中指令2:在寄存器中執行+1指令3:将結果寫入記憶體(緩存機制可能導緻寫入的是CPU緩存而不是記憶體) 基于CPU指令是什麼意思呢?簡單的說就是任務切換的時機可能是上面的任何一條指令完成之後。 我們假設線上程A執行了指令1後做了任務切換,此時線程B執行,雖然執行了total+1=1,但是最終的結果卻不是2,如下圖:

并發程式設計中原子性、可見性、有序性這些特性的起源?

我們把一個或者多個操作在CPU執行過程中不被中斷的特性稱之為原子性。 注意:CPU僅僅能保證CPU指令執行的原子性,并不能保證進階語言的單條語句的原子性。 此處分享一道經典的面試題:Long類型的資料在32位作業系統中加減是否存在并發問題?答案:是,因為Long類型是64位,在32位的作業系統中執行加減肯定是要拆分成多個CPU指令,是以無法保證加減的原子性。

坑三:編譯優化帶來的有序性問題

編譯優化算是最詭異的一個難題了,雖然進階語言規定了代碼的執行順序,但是編譯器有時為了優化性能,則會改變代碼執行的順序,比如a=4;b=3;,在代碼中可能給人直覺的感受是a=4先執行,b=3後執行,但是編譯器可能為了優化性能,先執行了b=3,這種對于我們肉眼是不可見的,上面例子中雖然不影響結果,但是有時候編譯器的優化可能導緻意想不到的BUG。雙重校驗鎖實作單例不知大家有沒有聽說過,代碼如下:

public class Singleton {
  static Singleton instance;
  static Singleton getInstance(){
    if (instance == null) {
      synchronized(Singleton.class) {
        if (instance == null)
          instance = new Singleton();
        }
    }
    return instance;
  }
}      

這裡我去掉了volatile關鍵字,那麼此時這個代碼在并發的情況下有問題嗎?上述代碼看上去很完美,但是最大的問題就在new Singleton();這行代碼上,預期中的new操作順序如下:配置設定一塊記憶體N在記憶體N上初始化Singleton對象将記憶體N的位址指派給instance變量但是實際上編譯優化後的執行順序如下:配置設定一塊記憶體N将記憶體N的位址指派給instance變量在記憶體N上初始化Singleton對象很多人問了,優化後影響了什麼?将記憶體N的位址提前指派給instance變量意味着instance!=null是成立的,一旦是高并發的情況下,線程A執行第二步發生了任務切換,則線程B執行到了if (instance == null)這個判斷,此時不成立,則直接傳回了instance,但是此時的instance并沒有初始化過,如果此時通路其中的成員變量則會發生空指針異常,執行流程如下圖:

總結