CodeGym /課程 /JAVA 25 SELF /競態條件(race condition)

競態條件(race condition)

JAVA 25 SELF
等級 51 , 課堂 4
開放

1. 認識競態條件(race condition)

讓我們回顧一下 競態條件(race condition)——當程式的結果取決於執行緒取得共享資料或資源的順序。若執行順序改變,結果就會變得不可預測。這很像你和朋友同時編輯同一份文件:誰比較快寫入,誰就「贏」,而最後的文字可能非常奇怪。

在 Java(以及任何支援多執行緒的語言)中,當多個執行緒同時讀取與/或修改同一個變數且缺乏適當同步時,就會出現 race condition。

為什麼會產生 race condition?

Java 執行緒是並行運作的。如果兩個執行緒同時操作同一個變數(例如遞增共享計數器),它們可能會彼此「踩到」。即使某個操作看起來是原子的(例如 counter++),其實並不是!

counter++ 如何運作?

遞增操作包含幾個步驟:

  1. 從記憶體讀取變數的目前值。
  2. 將此值加一。
  3. 把新值寫回記憶體。

如果此時另一個執行緒也在做 counter++,它們可能都讀到相同的值、都把它加一並各自寫回相同的結果——最後就有一次遞增被「吃掉」了。

2. 競態條件示例:計數器遞增

我們來寫個簡單的程式,啟動多個執行緒,每個都把共享計數器加上 1。看起來如果我們啟動了 1000 個執行緒,最終的計數應該是 1000。來驗證看看!

public class RaceConditionDemo {
    static int counter = 0;

    public static void main(String[] args) throws InterruptedException {
        int threads = 1000;
        Thread[] threadArray = new Thread[threads];

        for (int i = 0; i < threads; i++) {
            threadArray[i] = new Thread(() -> {
                counter++; // 危險的操作!
            });
            threadArray[i].start();
        }

        // 等待所有執行緒結束
        for (int i = 0; i < threads; i++) {
            threadArray[i].join();
        }

        System.out.println("預期: " + threads);
        System.out.println("實際: " + counter);
    }
}

預期輸出:

預期: 1000
實際: 843

每次執行的數值可能不同:有時 900、有時 700,有時甚至 1000——但非常罕見。

為什麼會這樣?

執行緒同時讀取 counter 的值,將其遞增,然後寫回。如果兩個執行緒讀到了同一個值、都將它加一並都寫回——就會有一次遞增遺失。結果最終值總是小於預期。

3. 另一個例子:沒有同步的銀行

想像我們有個銀行帳戶,兩個執行緒同時提領現金。

public class BankAccount {
    private int balance = 100;

    public void withdraw(int amount) {
        if (balance >= amount) {
            // 模擬耗時工作
            try { Thread.sleep(1); } catch (InterruptedException ignored) {}
            balance -= amount;
        }
    }

    public int getBalance() {
        return balance;
    }
}

public class BankDemo {
    public static void main(String[] args) throws InterruptedException {
        BankAccount account = new BankAccount();

        Thread t1 = new Thread(() -> account.withdraw(100));
        Thread t2 = new Thread(() -> account.withdraw(100));

        t1.start();
        t2.start();
        t1.join();
        t2.join();

        System.out.println("預期: 0 或 100");
        System.out.println("實際餘額: " + account.getBalance());
    }
}

有時兩個執行緒都會看到帳上有 100,於是兩邊都成功提款。結果餘額會變成 -100!(現實生活不會這樣,但在程式裡很容易發生。)

4. 一些有用的細節

競態條件的後果

競態條件不只是「奇怪」的結果。對程式設計師來說,它是真正的頭痛,因為:

  • 錯誤不一定會出現。 有時程式運作正確,有時則不然。一切取決於執行緒「趕上」動作的時序。
  • 測試不保證成功。 你可以多次執行程式——一切都看似正常,但某次卻突然壞掉。
  • 錯誤很難捕捉。 行為取決於處理器速度、系統負載、以及其他正在執行的程式。
  • 可能引發嚴重故障: 資料遺失、不正確的計算、應用程式當機。

真實範例

  • 金融應用: 餘額計算錯誤、重複扣款。
  • 伺服器: 訊息遺失、請求處理不正確。
  • 遊戲: 角色「瞬移」、分數計算錯誤。

為什麼測試無法避免 race condition?

競態條件是典型的「Heisenbug」(當你試圖捕捉它時就會消失的 bug)。即使你把測試跑上一千次都沒看到錯誤——也不代表它不存在!一切取決於作業系統如何排程執行緒。有時一切順利,有時執行緒會「撞在一起」而出現問題。

如何避免 race condition?

  • 同步: 使用 synchronized 關鍵字修飾方法或程式碼區塊,讓同一時間只有一個執行緒可以修改共享資料。
  • 原子操作: 使用 java.util.concurrent.atomic 套件中的類別(例如 AtomicInteger),在不需顯式同步的情況下提供安全操作。
  • 不可變性: 如果物件不可被修改,就不會產生競態條件。

同步的範例

public class SafeCounter {
    private int counter = 0;

    public synchronized void increment() {
        counter++;
    }

    public int getValue() {
        return counter;
    }
}

現在,如果多個執行緒呼叫 increment(),在任一時刻都只有一個執行緒能執行此方法。

5. 在執行緒中處理共享變數的常見錯誤

錯誤 1:對簡單操作安全性的天真自信。
許多人以為 counter++ 就是一個操作,不會出事。其實它由三個步驟組成,而在這些步驟之間,其他執行緒可能會「插進來」。

錯誤 2:使用普通變數在執行緒間交換資料。
如果多個執行緒在沒有同步的情況下,對同一個變數進行讀寫——恭喜,你遇到競態條件了!

錯誤 3:以為錯誤會一直重現。
競態條件可能只偶爾出現,這使它特別狡猾。別指望測試都通過就代表一切沒問題。

錯誤 4:在使用集合時忽略同步。
ArrayList 這樣的普通集合並非執行緒安全。如果多個執行緒同時新增或刪除元素——可能發生故障,甚至導致程式崩潰。

錯誤 5:試圖用延遲來「修」競態條件。
例如透過 Thread.sleep(10) 或其他「魔法」暫停。這種作法無法解決問題,只是把它掩蓋起來。真正的解法是同步或原子操作。

1
問卷/小測驗
多執行緒,等級 51,課堂 4
未開放
多執行緒
多執行緒基礎
留言
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION