1. 競合状態(race condition)を理解する
まず 競合状態(race condition) を思い出しましょう。これは、プログラムの結果が、スレッドが共有データやリソースへどの順番でアクセスするかに依存してしまう状況です。実行順序が変わると結果は予測不能になります。ちょうど、あなたと友人が同時に同じドキュメントを編集しようとしているようなものです。速く書けたほうが勝ち、最終的な文章はとても奇妙になるかもしれません。
Java(およびマルチスレッドをサポートする他の言語)では、適切な同期なしに複数のスレッドが同時に同じ変数を読み書きすると、race condition が発生します。
なぜ race condition は発生するのか?
Java のスレッドは並行に動作します。2つのスレッドが同じ変数(たとえば共有カウンタ)に同時にアクセスすると、互いに「割り込んで」しまうことがあります。見た目は原子的な操作(たとえば counter++)でも、実際にはそうではありません。
どのように動くのか counter++?
インクリメントは次の複数のステップからなります。
- メモリから変数の現在値を読み出す。
- その値を 1 増やす。
- 新しい値を書き戻す。
このとき別のスレッドも counter++ を行うと、両方が同じ値を読み、両方が増やし、両方が同じ結果を書き込んでしまうかもしれません。その結果、インクリメントが 1 回「消えて」しまいます。
2. race condition の例:カウンタのインクリメント
複数のスレッドを起動し、各スレッドが共有カウンタを 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 を読み、増やし、書き戻すためです。2つのスレッドが同じ値を読み、両方が増やして両方が書き込むと、インクリメントが 1 回失われます。その結果、最終値は常に期待より小さくなります。
3. もう一つの例:同期なしの銀行口座
銀行口座があり、2 つのスレッドが同時に引き出しを行うとします。
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 の影響
競合状態は単なる「奇妙な」結果ではありません。プログラマにとっての本当の頭痛の種です。理由は次のとおりです。
- 不具合が毎回出るとは限らない。 正しく動くときもあれば、動かないときもあります。スレッドがどの順で動けたかに依存します。
- テストしても保証にならない。 何度も実行して問題が出なくても、突然壊れることがあります。
- 原因の特定が難しい。 振る舞いは CPU の速度、システム負荷、他のプロセスなどに左右されます。
- 重大な障害につながることがある。 データ消失、不正な計算結果、アプリケーションのクラッシュなど。
実例
- 金融アプリ: 残高の誤計算、二重引き落とし。
- サーバ: メッセージの消失、リクエストの不正処理。
- ゲーム: キャラクターの「テレポート」、スコアの誤加算。
なぜテストでは race condition を防げないのか?
Race condition は典型的な「Heisenbug」です(捕まえようとすると消えるバグ)。テストを何千回走らせて再現しなくても、存在しないとは言えません。OS がスレッドをどうスケジューリングするかに依存するからです。うまく行くときもあれば、スレッドが「衝突」して問題が出るときもあります。
race condition を避けるには?
- 同期: メソッドやコードブロックに synchronized を使い、ある瞬間に共有データを変更できるスレッドを 1 つに制限します。
- 原子操作: java.util.concurrent.atomic パッケージのクラス(例:AtomicInteger)を使い、明示的な同期なしで安全な操作を行います。
- イミュータブル設計: オブジェクトを変更不可にすれば、race condition は発生しません。
同期の例
public class SafeCounter {
private int counter = 0;
public synchronized void increment() {
counter++;
}
public int getValue() {
return counter;
}
}
これで複数のスレッドが increment() を呼んでも、任意の時点で実行できるのは 1 つのスレッドだけになります。
5. スレッドで共有変数を扱うときの典型的なミス
誤り 1: 単純な操作は安全だと素朴に信じてしまう。
多くの人は、counter++ は 1 回の操作で、悪いことは起きないと思いがちです。実際には 3 つの操作から成り、その合間に別スレッドが「割り込め」ます。
誤り 2: スレッド間のやり取りに通常の変数を使う。
複数のスレッドが同期なしで同じ変数を読み書きしているなら、こんにちは、race condition。
誤り 3: 不具合は常に再現すると期待する。
Race condition はときどきしか現れないため、とりわけ厄介です。テストで動いたからといって安心はできません。
誤り 4: コレクション操作で同期を無視する。
ArrayList のような通常のコレクションはスレッドセーフではありません。複数スレッドが要素の追加・削除を行うと、破損やアプリケーションのクラッシュが起こり得ます。
誤り 5: 遅延で race condition を「直そう」とする。
たとえば Thread.sleep(10) やその他の「魔法の」待機です。これは問題を解決せず、隠すだけです。本当の解決策は同期や原子操作です。
GO TO FULL VERSION