Java併發程式設計(Java Concurrency)(8)- 競爭與臨界區(Race Conditions and Critical Sections)

JenningLang發表於2016-11-17

原文連結:http://tutorials.jenkov.com/java-concurrency/race-conditions-and-critical-sections.html

摘要:這是翻譯自一個大概30個小節的關於Java併發程式設計的入門級教程,原作者Jakob Jenkov,譯者Zhenning Lang,轉載請註明出處,thanks and have a good time here~~~(希望自己不要留坑)

“競爭”是可能發生在“臨界區”內的一種特使情況。臨界區的含義是:如果多個執行緒訪問一個程式碼段,同時這些執行緒的執行順序將影響總得執行效果,那麼這個程式碼端就叫做臨界區。

反言之,如果臨界區的執行結果受到多執行緒執行順序的影響,那麼就說存在競爭。競爭比喻了不同的執行緒互相爭搶臨界區的程式碼,並且爭搶的結果也將影響臨界區的執行結果。

如果上面過於抽象難懂,下面的例子將幫助理解競爭與臨界區的含義。

1 臨界區

當多個執行緒執行其內部程式碼的時候,其本身不會引起任何問題;引起問題的原因是對共享資源的使用。例如共用的記憶體(變數、陣列和物件等)、系統資源(資料庫、網路服務等)或者是檔案。

跟進一步,事實上只有寫共享資源操作才會引發問題。只要內容不變,讓多個執行緒讀取相同的資源是安全的。

下面是一個臨界區的 Java 程式碼示例,例子中如果多個執行緒同時執行這段程式碼就會發生失敗:

public class Counter {

    protected long count = 0;

    public void add(long value){
        this.count = this.count + value;
    }
}

現在假設兩個執行緒 A 和 B 在對相同的 Counter 類的例項進行 add 操作,沒有辦法知道作業系統何時在兩個執行緒間切換。Java 虛擬機器無法像對待單一“原子”一樣處理 add() 函式中的程式碼(就是沒辦法一下子執行完程式碼中的全部內容,而是按步驟執行的),事實上 add() 中的程式碼好像被一系列更小的指令執行:

  1. 將 this.count 從記憶體中讀到暫存器中
  2. 將 value 與暫存器中的資料相加並存入暫存器
  3. 將暫存器資料寫入記憶體

那麼現在考慮如下的執行緒 A 和 B 的執行情況:

this.count = 0;
B: 將 this.count 讀入一個暫存器 (0) (
A: 將 this.count 讀入一個暫存器 (0)
B: 將暫存器中的資料 +2 (2)
B: 將暫存器中的資料 (2) 寫回記憶體,此時 this.count 等於 2
A: 將暫存器中的資料 +3 (3)
A: 將暫存器中的資料 (3) 寫回記憶體,此時 this.count 等於 3

這兩個執行緒的本來目的是將 2 和 3 加到 counter 上,所以期待的結果應該是 5。然而實際執行中執行緒發生了交錯,導致結果與預期不同。上例中,兩個執行緒都將 0 從記憶體中讀出並且加上了 2 和 3,然後將其寫回記憶體。所以最後的結果取決於誰最後將結果寫回記憶體(2 和 3 都有可能)。

2 臨界區中的競爭

前面例子的 add() 方法中存在著臨界區,所以當多執行緒執行臨界區程式碼的時候,競爭發生了。

對於臨界區和競爭更正規的定義是:如果兩個執行緒爭奪共享資源,並且資源被獲取的時機(順序)將對結果產生影響,這種情況叫做競爭;引發競爭的程式碼段被稱作鄰接區

3 阻止競爭的發生

為了阻止競爭的發生,臨界區的程式碼必須以“原子”的模式被執行 —— 即一旦一個執行緒開始執行臨界區的程式碼,直到其執行完臨界區,其他的執行緒就無法執行臨界區。

通過對臨界區程式碼設定合理的執行緒同步(thread synchronization)機制,競爭就可以被阻止。而 Java 中的執行緒同步的一種實現方式是同步程式碼塊(a synchronized block of Java code)。其他的實現途徑還有鎖(locks)、原子變數(atomic variables)(例如 java.util.concurrent.atomic.AtomicInteger)。

4 臨界區的吞吐量

相比於定義分散的小的臨界區,將所有程式碼定義成一個大的臨界區也可以讓程式碼正常運作。但將大的臨界區拆封成小的臨界區將帶來更多的好處,因為這樣的話不同的執行緒可以同時執行這些小的臨界區程式碼,從而減少了其相互之間對資源的爭奪,增加了整個臨界區吞吐量。

為了解釋這一點,下面是一個非常簡單的示例:

public class TwoSums {

    private int sum1 = 0;
    private int sum2 = 0;

    public void add(int val1, int val2){
        synchronized(this){
            this.sum1 += val1;   
            this.sum2 += val2;
        }
    }
}

上例中 add() 函式嘗試著將 val1 和 val2 加到 sum1 和 sum2 上。同時為了防止競爭的發生,這段程式碼被放在了 Java 同步程式碼塊中,這樣同一時間只能有一個執行緒執行 add() 中的具體加操作。

然而,由於例子中的 sum1 和 sum2 變數是相互獨立,完全可以分開在兩個同步塊中執行二者的加操作,如下:

public class TwoSums {

    private int sum1 = 0;
    private int sum2 = 0;

    private Integer sum1Lock = new Integer(1);
    private Integer sum2Lock = new Integer(2);

    public void add(int val1, int val2){
        synchronized(this.sum1Lock){
            this.sum1 += val1;   
        }
        synchronized(this.sum2Lock){
            this.sum2 += val2;
        }
    }
}

現在兩個執行緒可以同時執行 add() 方法中的程式碼。一個執行緒執行 sum1 的加操作,而另一個執行緒執行 sum2 的加操作。由於兩個同步塊被兩個不同的物件同步(this.sum1Lock 和 this.sum2Lock),這樣一來執行 add() 方法的多個執行緒相互等待的時間就被減少了。

當然上例是非常簡單的,而實際中對於臨界區的拆分可能更加複雜,並且需要仔細考量執行緒執行順序以及其結果的可能。

相關文章