天天看點

1. 可見性、原子性和有序性問題:并發程式設計bug源頭 - 理論基礎1. 并發程式的幕後故事2. 問題1:緩存導緻的可見性問題3. 問題2:線程切換帶來的原子性問題4. 問題3:編譯優化帶來的有序性問題5.總結6. 問題

文章目錄

  • 1. 并發程式的幕後故事
  • 2. 問題1:緩存導緻的可見性問題
  • 3. 問題2:線程切換帶來的原子性問題
  • 4. 問題3:編譯優化帶來的有序性問題
  • 5.總結
  • 6. 問題

1. 并發程式的幕後故事

核心沖突: CPU,記憶體,I/O裝置三者的速度差異.

形象比喻:

項目 CPU 記憶體 I/O裝置
速度比較 一天 一年 幾千年

為了提高CPU使用率,平衡三者的速度差異,計算機體系機構,作業系統,編譯程式分别做以下貢獻:

  • CPU增加CPU緩存(不同于記憶體,以下簡稱緩存),以均衡和記憶體的速度差異;
  • 作業系統增加程序和線程,分時複用CPU,均衡CPU和I/O裝置速度差異;
  • 編譯程式優化指令執行次序,更加合理利用緩存。

有利就有弊,由此帶來可見性、原子性和有序性問題。

2. 問題1:緩存導緻的可見性問題

可見性: 一個線程對共享變量的修改,另外一個線程能立刻看到。

  1. 單核時代,不存在可見性問題

所有線程都在單個CPU裡面執行,一個線程對緩存的改寫,另外一個線程是一定可見的。線程A改寫了緩存的V,線程B可以獲得V的最新值。

1. 可見性、原子性和有序性問題:并發程式設計bug源頭 - 理論基礎1. 并發程式的幕後故事2. 問題1:緩存導緻的可見性問題3. 問題2:線程切換帶來的原子性問題4. 問題3:編譯優化帶來的有序性問題5.總結6. 問題

2. 多核時代,可見性問題

多個線程在不同的CPU執行,緩存變量V分散在不同的CPU緩存上,存在可見性問題。

如下圖,線程 A 操作的是 CPU-1 上的緩存,而線程 B 操作的是 CPU-2 上的緩存,很明顯,這個時候線程 A 對變量 V 的操作對于線程 B 而言就不具備可見性了。

1. 可見性、原子性和有序性問題:并發程式設計bug源頭 - 理論基礎1. 并發程式的幕後故事2. 問題1:緩存導緻的可見性問題3. 問題2:線程切換帶來的原子性問題4. 問題3:編譯優化帶來的有序性問題5.總結6. 問題

3. 驗證可見性問題

直覺感覺count是20000,實際是介于10000到20000. 假設線程A和B同時執行,第一次把count= 0 讀取到各自緩存,count+=1之後,同時寫到記憶體,發現count是1,而不是2, 之後兩個線程在各自緩存計算count,是以最終結果不是20000. 循環一億次,資料更接近一億,不是兩億。

public class TestVisibility {
	private static long count = 0;

	private void add10k() {
		int idx = 0;
		while (idx++ < 10000) {
			count += 1;
		}
	}

	private static long calc() throws InterruptedException {
		final TestVisibility testVisibility = new TestVisibility();
		// 建立兩個線程,執行add10()
		Thread thread1 = new Thread(() -> {
			testVisibility.add10k();
		});
		Thread thread2 = new Thread(() -> {
			testVisibility.add10k();
		});
		// 啟動線程
		thread1.start();
		thread2.start();
		// 等待線程結束
		thread1.join();
		thread2.join();
		return count;
	}

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

3. 問題2:線程切換帶來的原子性問題

原子性: 一個或多個操作在CPU執行過程中不被中斷的特性。

  1. 線程切換
    1. 可見性、原子性和有序性問題:并發程式設計bug源頭 - 理論基礎1. 并發程式的幕後故事2. 問題1:緩存導緻的可見性問題3. 問題2:線程切換帶來的原子性問題4. 問題3:編譯優化帶來的有序性問題5.總結6. 問題
  2. 多線程的任務切換帶來原子性問題的原因。

首先, 進階語言的一條語句往往需要多條CPU指令完成,例如count += 1,至少需要三條CPU指令:

  • 指令1,需要把變量count從記憶體加載到CPU寄存器上;
  • 指令2,在寄存器執行+1操作;
  • 指令3,将結果寫入記憶體(緩存機制導緻可能寫到CPU緩存而不是記憶體)。

其次, 作業系統做任務切換,可以發現在任務一條CPU指令,注意是CPU指令,不是進階語言的一條語句。

例如下圖,線程A在指令1之後就切換到線程B,等線程B執行上述三條指令,再回來執行指令。我們最後得到的結果是1,而不是2。

CPU 能保證的原子操作是 CPU 指令級别的,而不是進階語言的操作符,這是違背我們直

覺的地方。是以,很多時候我們需要在進階語言層面保證操作的原子性。

1. 可見性、原子性和有序性問題:并發程式設計bug源頭 - 理論基礎1. 并發程式的幕後故事2. 問題1:緩存導緻的可見性問題3. 問題2:線程切換帶來的原子性問題4. 問題3:編譯優化帶來的有序性問題5.總結6. 問題

4. 問題3:編譯優化帶來的有序性問題

有序性: 程式按照代碼的先後次序執行。

經典案例:雙重檢查建立單利對象

public class Singleton {

	private static Singleton instance;

	private Singleton() {
	}

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

想象中的流程是這樣的:

  1. 假設線程A、B同時調用getInstance(), 他們同時發現instance==null,他們同時加鎖,而JVM隻能保證一個加鎖成功,假設A成功加鎖,B在等待;
  2. 線程A建立一個執行個體後,釋放鎖;
  3. 鎖釋放後,B喚醒,加鎖成功,此時B檢查instance==null,已經有執行個體了,B不建立新的執行個體。

看似完美,問題出在 new Singleton() 身上:

我們認為new操作如下:

  • 1.配置設定一塊記憶體M;
  • 2.在記憶體M初始化Singleton對象;
  • 3.然後M的位址指派給instance對象。

實際上優化後的執行路徑是這樣的:

  • 1.配置設定一塊記憶體M;
  • 2.M的位址指派給instance對象;
  • 3.在記憶體M初始化Singleton對象。

我們假設線程A執行到步驟2時,任務切換到線程B,B檢查intance!=null,就傳回執行個體,此時調用intance的成員變量可能出發空指針異常。

1. 可見性、原子性和有序性問題:并發程式設計bug源頭 - 理論基礎1. 并發程式的幕後故事2. 問題1:緩存導緻的可見性問題3. 問題2:線程切換帶來的原子性問題4. 問題3:編譯優化帶來的有序性問題5.總結6. 問題

問題:線程A在對象還沒有初始化之前怎麼就釋放鎖了?B怎麼可能拿到鎖?

解答:實際上線程B是拿不到鎖的,問題在于時間切片後,線程B進入第一個if後,此時instance不為空,線程B拿到還沒有初始化的instance,調用其成員變量可能會報異常。

5.總結

cpu緩存帶來可見性問題,線程切換帶來原子性問題,編譯優化帶來有序性問題。原本緩存,線程切換,編譯優化是為了提高性能,一個技術解決一個問題,可能帶來其他問題,要懂得如何規避。

6. 問題

在 32 位的機器上對 long 型變量進行加減操作存在并發隐患,到底是不是這樣呢?

繼續閱讀