可見性、原子性和有序性問題


可見性、原子性和有序性問題

並發編程背景

核心矛盾

這些年,我們的 CPU、內存、I/O 設備都在不斷迭代,不斷朝着更快的方向努力。但是,在這個快速發展的過程中,有一個核心矛盾一直存在,就是這三者的速度差異
我形象的描述了一下這三者的速度上的差異:所謂天上一天地上一年(愛因斯坦的相對論是有合理解釋的),CPU和內存之間的速度差異就是CPU天上一天,內存地上一年(假設 CPU 執行一條普通指令需要一天,那么 CPU 讀寫內存得等待一年的時間)。內存和I/O設備的速度差異就更大了,內存是天上一天,I/O設備是地上十年。

木桶原理

一只水桶能裝多少水取決於它最短的那塊木塊
程序的大部分語句都要訪問內存,有些還要訪問I/O,根據木桶原理,程序整體的性能取決於最慢的操作,所以只是單方面的提高CPU的性能是無效的,才出現了一些提高CPU性能使用率的優化

如何提高CPU的性能?
  1. CPU增加了緩存,以均衡與內存的速度差異(計算機體系結構方面優化)
  2. 操作系統增加了進程、線程,以分時復用CPU,進而均衡CPU與I/O設備的速度差異(操作系統方面優化)
  3. 編譯程序優化指令執行次序,使得緩存能夠得到更加合理地利用(編譯程序帶來的優化)

很多時候人們總是會很開心享受眼前的好處,卻總是忽略了一句話:采用一項技術的同時,一定會產生一個新的問題,並發程序很多的詭異問題的根源也從這里開始

可見性

單核時期,所有的線程都是操作同一個CPU的,所以CPU的緩存也是線程之間可見的,如下圖所示:
線程1和線程2都是編輯同一個CPU里面的緩存,所以線程1更新了變量A的值,線程2之后再訪問變量A,得到的一定是A的最新值

一個線程對共享資源的修改,另一個線程能夠立刻看到,稱之為可見性
多核時代,每個CPU都有自己單獨的緩存,當多個線程在不同的CPU上執行時,這些線程操作的就是不同的CPU緩存了,如下圖所示:線程1在CPU-1的緩存上編輯變量A,線程2在CPU-2的緩存上編輯變量A,這個時候線程1對變量A的操作對於線程2來說就不具備可見性

二話不說,上代碼解釋是最直接的方式
下面這段代碼創建了兩個線程,每個線程都會調用一次updateVar的方法,都會循環10000次的sharedVariable += 1操作,然后打印出共享變量的結果。

/**
 * 可見性測試Demo
 *
 * @author BO
 * @date 2019-03-25
 */
public class VisibilityTest {

    private long sharedVariable = 0;

    private void updateVar() {
        int times = 0;
        while (times++ < 10000) {
            sharedVariable += 1;
        }
    }

    public static void main(String[] args) throws InterruptedException {
        final VisibilityTest test = new VisibilityTest();
        // 創建兩個線程,執行修改方法
        Thread thread1 = new Thread(() -> {
            test.updateVar();
        });
        Thread thread2 = new Thread(() -> {
            test.updateVar();
        });
        // 啟動線程
        thread1.start();
        thread2.start();
        // 等待兩個線程執行結束
        thread1.join();
        thread2.join();
        System.out.println("執行后共享變量的值為:" + test.sharedVariable);
    }
}

講道理,這里應該是要輸出執行后共享變量的值為:20000的,因為單線程里調用兩次updateVar方法,sharedVariable的值就是20000,但實際上,我執行了n次(手都要點的酸死),執行的結果是10000個到20000之間的隨機數。
我們假設線程1和線程2同時開始執行,那么第一次都會將sharedVariable = 0讀取到各自的CPU緩存里,執行了updateVar方法后,各自緩存中的sharedVariable的值都是1,同時寫入內存后,內存中的sharedVariable是1而不是我們講道理的2,這就是緩存的可見性問題
這里因為兩個線程的啟動時有時差的,假設兩個線程是同時執行的,這里返回的結果應該是10000

原子性

操作系統帶來的多進程大家都體驗過,我們可以一邊聽歌一邊聊天就是多進程帶來的好處

操作系統允許進程執行一段時間,假設100ms,過來100ms操作系統就會重新選擇進程來執行,這個就是上面提到的分時服用CPU的原理,操作系統以100ms作為時間片進行任務切換

在一個時間片內,如果一個進程進行一個IO操作,加入讀一個文件,這個時候進程可以把自己標記為“休眠狀態”並讓出CPU的使用權,等待文件讀進內存,操作系統會把這個休眠的進程喚醒,喚醒后的進程就有機會重新獲得CPU的使用權

這個就是分時復用CPU的過程,這樣很好的提高了CPU的使用率

但是由於進程之間是不能進行內存空間的共享的,所以進程要做任務切換就要切換內存映射地址,而一個進程創建的所有線程,都是共享一個內存空間的,所以線程做任務切換成本就很低了,我們接下來講的就是對於線程的任務切換,也就是線程切換

我們現在使用的都是高級編程語言,高級編程語言里一條語句需要多條CPU指令完成。舉個簡單的列子,上面的代碼中,sharedVariable += 1,在操作系統中至少需要三條CPU指令才能完成

  • sharedVariable從內存加載到CPU的寄存器;
  • 在寄存器中執行 +1 操作
  • 把結果寫入內存(緩存機制可能導致寫入到了CPU的緩存)

在操作系統中,進行任務切換會發生在任何一條CPU指令執行完,假設任務切換發生在第一步,如下圖就是兩個線程的執行過程

這個時候就導致本來是兩次 +1 操作,結果到內存的實際值仍然是1

在化學上我們稱化學反應不可再分的基本微粒成為原子,如果我們不熟悉操作系統的執行規則,我們會默認為sharedVariable += 1就是一個原子,可能知道有線程切換會發生在這個操作之前,也或者這個操作之后,就是不會發生在這個操作中間,這就是我們經常忽略的原子性問題

所謂原子性,我們把一個或者多個操作在CPU執行的過程中不被中斷的特性成為原子性,就像化學反應上的原子一樣,是最小的單位了,不可分割

有序性

編譯器為了性能的優化,有時候會改變程序中語句的先后順序,有序性指的是程序按照代碼的先后順序執行,可能大家很疑惑這個怎么也會導致並發問題呢?請看下面的一個實例

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

上面這段代碼是一個典型的雙重檢查創建實例對象,在獲取實例getInstance方法中,先判斷了實例instance == null,如果為空就鎖定Singleton.class類,然后再檢查實例是否為空,如果還為空就創建一個Singleton實例。

此時兩個線程1和2同時調用getInstance方法,它們會同時都發現了實例為空,於是同時對Singleton.class類進行了加鎖,這里JVM保證只有一個線程加鎖成功(不要問我為什么,就是這樣的),這里假設線程1成功獲取到了鎖,線程1就會去創建Singleton實例,然后釋放鎖,線程2檢查instance == null時發現不為空,就不會再實例化了,這是不是很完美的代碼。

但實際上,這里會出現一個很隱蔽的問題,問題就在這個new Singleton()中,按照實例化的順序,new操作是這樣的:

  1. 分配一塊內存區域M;
  2. 在內存M上初始化Singleton對象;
  3. 然后M的地址賦值給instance變量。

但是實際上編譯器認為第2步和第3步對結果並沒有影響,所以根據實際情況進行了優化,變成了:

  1. 分配一塊內存區域M;
  2. 然后M的地址賦值給instance變量。
  3. 在內存M上初始化Singleton對象;

這個時候如果線程1獲取到鎖后,開始實例化Singleton對象,進行第2步操作結束后,操作系統進行了任務切換,此時線程2在進行第一個instance == null檢查時,發現instance變量已經不為空,所以就直接返回了這個實例,但實際上這個實例還沒有進行初始化,當程序中使用這個實例進行獲取這個實例的成員變量時,就會出現NPE異常了,這個就是有序性導致的並發問題

總結

只要我們能夠深刻理解可見性、原子性、有序性在並發場景下的原理,很多並發Bug都是可以解決、可以診斷的

實戰

在 32 位的機器上對 long 型變量進行加減操作存在並發隱患,到底是不是這樣的呢?
因為long型變量是64位,在32位CPU上執行寫操作,會被分成兩次寫操作,所以這個操作並不是原子性的,如果線程1在完成第一次寫操作后,出現線程切換,線程2將這個變量進行其他操作,就會導致線程1的這次操作存在問題


免責聲明!

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



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