Concurrency(三:競態條件和臨界區)

MenfreXu發表於2019-03-11

多個執行緒執行相同的程式本身不會有安全問題,問題在於訪問了相同的資源。資源可以是記憶體區域(變數,陣列,物件),系統(資料庫,web 服務)或檔案等。實際上多個執行緒讀取不會變化的資源也不會有問題,問題在於一到多個執行緒對資源進行寫操作。

以下給出累加器併發訪問例項,多個執行緒對同一個累加器進行累加操作。分別列印出各個執行緒執行中數值前後變化,以及在主執行緒暫停2s後,給出最終的結果以及預期結果。

執行緒安全問題例項

done like this:

public class ThreadSecurityProblem {
    // 累加器
    public static class Counter {
        private int count = 0;
		
        // 該方法將會產生競態條件(臨界區程式碼)
        public void add(int val) {
            int result = this.count + val;
            System.out.println(Thread.currentThread().getName() + "-" + "before: " + this.count);
            this.count = result;
            System.out.println(Thread.currentThread().getName() + "-" + "after: " + this.count);
        }

        public int getCount() {
            return this.count;
        }
    }

    // 執行緒排程程式碼
    public static class MyRunnable implements Runnable {
        private Counter counter;
        private int val;

        MyRunnable(Counter counter, int val) {
            this.counter = counter;
            this.val = val;
        }

        @Override
        public void run() {
            counter.add(val);
        }
    }

    public static void main(String[] args) throws InterruptedException {
        final Counter counter = new Counter();
        // 開啟5個執行緒,呼叫同一個累加器
        IntStream.range(1, 6)
                .forEach(i -> {
                    final MyRunnable myRunnable = new MyRunnable(counter, i);
                    new Thread(myRunnable, "thread-" + i)
                            .start();
                });
        Thread.sleep(2000L);
        System.out.println(Thread.currentThread().getName() + "-final: " + counter.getCount());
		
        // 預期值
        int normalResult = IntStream.range(1, 6)
                .sum();
        System.out.println(Thread.currentThread().getName() + "-expected: " + normalResult);
    }
}
複製程式碼

執行結果:

thread-1-before: 0
thread-3-before: 0
thread-2-before: 0
thread-2-after: 2
thread-3-after: 3
thread-1-after: 1
thread-4-before: 2
thread-4-after: 6
thread-5-before: 6
thread-5-after: 11
main-final: 11
main-expected: 15

如結果所示,執行緒1/2/3先後取得this.count的初始值0,同時進行累加操作(順序無法預估)。執行緒1/2/3中的最後一次累加賦值後this.count變為2,隨後第4個執行緒開始累加賦值this.count變為6,最後第5個執行緒累加賦值this.count變為11.所以5個執行緒執行完畢後的結果為11,並非預期的15.

例項片段

執行緒1/2/3在執行Counter物件的add()方法時,在沒有任何同步機制的情況下,無法預估作業系統與JVM何時會切換執行緒執行。此時程式碼的執行軌跡類似下面的順序:

從主存載入this.count的值放到各自的工作記憶體中
各自將工作記憶體中的值累加val
將各自工作記憶體中的值寫回主存
複製程式碼

執行緒1/2/3交替情況模擬:

this.count = 0;
執行緒1: 讀取this.count到工作記憶體中,此時this.count為0
執行緒2: 讀取this.count到工作記憶體中,此時this.count為0
執行緒3: 讀取this.count到工作記憶體中,此時this.count為0
執行緒3: cpu將工作記憶體的值更新為3
執行緒2: cpu將工作記憶體的值更新為2
執行緒1: cpu將工作記憶體的值更新為1
執行緒3: 回寫工作記憶體中的值到主存,此時主存中this.count為3
執行緒1: 回寫工作記憶體中的值到主存,此時主存中this.count為1
執行緒2: 回寫工作記憶體中的值到主存,此時主存中this.count為2
最終主存中的this.count被更新為2
複製程式碼

三個執行緒執行完畢後,this.count最後寫回記憶體的值為最後一個執行緒的累加值(例項中為執行緒2,最後回寫到記憶體的值為2)。

總結

多執行緒訪問順序敏感的區域稱為臨界區,該區域程式碼會形成競態條件。如例項ThreadSecurityProblem中的Counter物件的add()方法。對於臨界區的程式碼不加上適當的同步措施將會形成競態條件,其執行結果完全無法預估。

相關文章