1. はじめに
マルチスレッドアプリケーションにおいて、race condition があるかどうかは「もし」ではなく「いつ」起きるかの問題です。コードが堅牢だと思っていて、「ちょっとだけスレッドが二つあるだけ」で「全部明白で簡単」だとしても、レースは一番無害に見えるロジックの箇所に潜んでいることがあります。
そもそも race condition って何でそんなに怖いのか? 二人が同じ紙を同時に編集しているところを想像してみてください — 一人が書き、もう一人が消す。時々は問題ないけど、時々めちゃくちゃ読めないものができる。プログラムではもっと面白い結果になることがあります:バグは常に出るわけではなく、特定の、ほとんどランダムな条件でだけ現れます。
Race condition (レースコンディション) は、プログラムの実行結果がどのスレッドが先にリソースへアクセスしたか、あるいはどのスレッドが先に操作を行ったかに依存する状況です。この問題は並行(マルチスレッド)アクセスがある場合にのみ発生し、2つ以上のスレッドが共有データやリソースにアクセスするときに起きます。
レースでは何が起きるのか?
簡単な図です。スレッドが2つ、共有リソースが1つ(例えば変数 X)あると想像してください:
+---------+ +---------+
| スレッド1 | | スレッド2 |
+----+----+ +----+----+
| |
| Xを読む |
| <-------------------|
| |
| Xを増やす |
|-------------------> |
| |
| Xに書き込む |
| <-------------------|
もし両方のスレッドが同時に変数 X を読み、増やして、書き戻すと、誰かが他人の変更を「上書き」してしまい、増分の合計が期待値と一致しなくなります。
2. 古典的な Race Condition の例
例を見てみましょう。例えば、別々のスレッドからのボタン押下回数や処理済みタスク数を数えたいとします。
単純な変数とそれを増やす複数のスレッドを考えます:
using System;
using System.Threading;
class Program
{
static int counter = 0; // 共有リソース
static void Main()
{
Thread t1 = new Thread(IncrementCounter);
Thread t2 = new Thread(IncrementCounter);
t1.Start();
t2.Start();
t1.Join();
t2.Join();
Console.WriteLine("期待値: 200000");
Console.WriteLine("実際の値: " + counter);
}
static void IncrementCounter()
{
for (int i = 0; i < 100_000; i++)
{
counter++; // << ここで問題が発生する可能性がある!
}
}
}
何を期待するか?
各スレッドが counter をそれぞれ 100000 回増やすので、最終値が 200000 になると期待します。
実際はどうなるか?
時々は確かに 200000 になります。しかし多くの場合はそれより小さく、時にはかなり小さくなります。実験を繰り返すと結果がばらつきます!
なぜ?
操作 counter++ は原子的ではありません。実際は次のように(単純化して)実行されます:
- 現在の counter の値を読む(例えば 0)
- 1だけ増やす(1 になる)
- 書き戻す(counter = 1)
もし二つのスレッドが古い値を同時に読んだら、両方とも同じ新しい値を書き戻し、実際には一回しか増えていないことになります。
二つのスレッドの例での可視化:
例えば、counter = 0 のとき。
- スレッド1: 0 を読む
- スレッド2: 0 を読む
- スレッド1: 0 + 1 = 1 を計算
- スレッド2: 0 + 1 = 1 を計算
- スレッド1: 1 を書き込む
- スレッド2: 1 を書き込む(スレッド1のインクリメントを上書きしてしまう)
おめでとう、1つの増分を失いました!何千、何百万回の操作があると結果は大きくばらつきます。
3. さらに例:インクリメントだけじゃない
キッチンのドタバタ
イメージしやすくするために、小さなカフェを想像してみてください。二人の料理人が同じフライパンでオムレツを作っていて、行動を調整していない:
- 一人がオムレツを一つ置くと、もう一人がすぐに自分のを上に置く — 互いに混ざっちゃう;
- 一人は「もう二つ置いた」と思い、もう一人も同じことを思っているが、実際はフライパンに三つしかないのに四つだと思っている;
- 混乱が始まる...
プログラミングでも race condition は同じように「混乱」をもたらします:結果は高速で制御されていない操作の順序に依存します。
スレッドが干渉する時:データへの同時アクセス
例えば銀行アプリを実装していて、顧客が同じ口座に対して同時に入金と引き出しを別スレッドで行ったとします(例:一つはオンライン送金、もう一つは窓口):
account.Balance += 500; // スレッド1: 入金
account.Balance -= 300; // スレッド2: 引き出し
これらの操作に保護がなければ、最終残高が不正確になることがあります:操作の一部が単に「消える」ことがあります、スレッドが同時に動作しているときに。
4. 便利なニュアンス
なぜ race condition が問題なのか?
捕まえにくく、再現しにくい。 バグは高負荷のマシンや稀な条件でしか現れないことがあります。
デバッグが難しい。 デバッガで実行するとスレッドのタイミングが変わり、バグが消えることがあります。
データの整合性破壊。 不正確で壊れたデータが得られ、時にはまったく気づかれないこともあります。
セキュリティ。 クリティカルなアプリでは race condition が情報漏洩、データ破壊、さらには脆弱性につながることがあります。
「レースのタイミング」図
+-----------------------+ +-----------------------+
| スレッド1 | | スレッド2 |
+-----------------------+ +-----------------------+
| 1. counter を読む | | |
| 2. counter を増やす | | |
| (ただし書き戻さない) | | |
| | | 1. counter を読む |
| | | 2. counter を増やす |
| | | 3. counter を書き戻す |
| | | (counter = 1) |
| 3. counter を書き戻す | | |
| (counter = 1) | | |
+-----------------------+ +-----------------------+
両方のスレッドがインクリメントを行ったが、最終的に記録されたのは一つだけ!
レースがよく発生する場所
- 複数スレッドがアクセスするグローバルまたは static な変数。
- 複数スレッドから同時に追加されるリスト、キュー、コレクション。
- イベントやデリゲートの登録/解除が同時に行われる場合(例:UI とバックグラウンドタスク)。
- キャッシュ、辞書、接続管理。
- トランザクションやロックなしでのファイル、ログ、データベースへのアクセス全般。
race condition を避ける方法:簡単な導入
- 同期化!(詳細は次の講義で)。
- 言語やライブラリが提供する専用の構文やツールを使う:lock, Monitor, mutex, semaphore 等。
- 単純な操作には原子的メソッドを使う(Interlocked.Increment など)。
- スレッドセーフなコレクションを使う(ConcurrentBag, ConcurrentDictionary)。
- 常に「もし二つの関数が同時に呼ばれたらどうなるか?」と考える習慣を持つ。
5. 役立つアドバイス
レースの発見と診断のコツ
- 複数スレッドを使っているなら、最も単純な操作(インクリメント ++、代入)でさえ信用しないでください。
- 可能なら共有変数へのアクセスを避ける。
- 再現が難しいバグや挙動が「ふらつく」場合はレースを疑う。
- スレッド解析ツールを使う(dotTrace, Concurrency Visualizer, Thread Sanitizer)。
- 負荷テストを行う — スレッド数や操作数を増やすほどバグが見つかりやすくなる。
同期なしで何が許されて何がダメか
| 操作 | マルチスレッド環境で安全? | 説明 |
|---|---|---|
| 代入 int | 🟩 時々* | 一つのスレッドだけが書き込み、他は読み取りのみなら安全。そうでなければレース。 |
| インクリメント (++/--) | 🟥 ダメ | 原子ではない! Race Condition の原因。 |
| 読み取り string | 🟩 時々* | 作成後に変更されないなら安全。 |
| オブジェクトの代入 | 🟩 時々* | 同時書き込みが無ければ。 |
| List に追加 (List<T> に追加) | 🟥 ダメ | List<T> はスレッドセーフではない。 |
|
🟩 はい | 専用の原子メソッド。 |
— 「時々」は、もし一つのスレッドだけが書き込みを行い、他は読み取りだけなら安全という意味です。複数スレッドが同時に書き込み得るなら常にレースが生じます。
6. よくあるミスと落とし穴
上のデモコードでは counter++ が問題でした。もう一つの落とし穴は、値の増加や条件チェックでのタイミングです。
例:『最初の起動』での面白いバグ
if (!alreadyStarted)
{
alreadyStarted = true;
// 初期化を行う...
}
この条件を複数スレッドが同時に評価すると、それぞれが alreadyStarted == false を見て中に入る可能性があります!結果として初期化が二回走り、故障につながることがあります。
GO TO FULL VERSION