CodeGym /コース /C# SELF /Race Conditions の深掘り

Race Conditions の深掘り

C# SELF
レベル 57, レッスン 0
使用可能

1. より複雑な Race Condition の例

Race Condition は最もタチの悪いバグの一つです。なぜか? それは通常まれにしか起きず、CPU の速さ、OS の遅延、スレッド間のランダムな切り替えに依存し、しかも発生したときにはもうあなたは帰り支度をしていることが多いからです。静的解析ツールはこれを捕まえられません。ユニットテストも(あなたが超能力者でない限り)捕まえられません。でも一度... 本番で出てきます。

こういった嫌なサプライズを避けるには、Race Conditions がどう発生するか、そして対処法をよく理解する必要があります。

例1. 銀行倫理のない銀行口座

とてもシンプルな口座クラスを考えます:

public class Account
{
    public int Balance = 0;

    public void Deposit(int amount)
    {
        Balance += amount;
    }

    public void Withdraw(int amount)
    {
        Balance -= amount;
    }
}

2つのスレッドが同時に口座に 100 と 200 ユーロを入金し、その後それぞれが順に 50 ユーロを引き出すとします。シングルスレッドなら常に残高は (0 + 100 + 200 - 50 - 50) = 200 になります。

ではマルチスレッドの現実ではどうなるでしょうか? 実験してみます:

var acc = new Account();

var t1 = new Thread(() => {
    acc.Deposit(100);
    acc.Withdraw(50);
});
var t2 = new Thread(() => {
    acc.Deposit(200);
    acc.Withdraw(50);
});

t1.Start(); t2.Start();
t1.Join(); t2.Join();

Console.WriteLine(acc.Balance); // ここは実行ごとに違う結果になるかもしれません!

説明:
もし両方のスレッドが Balance を 0 と読んで、その後一方が 100 を書き込み、もう一方が 200 を書き込んだとして、間のスケジューリング次第で残高は「お金を失ったり」あるいは「余分についたり」します。ロックなしの銀行は詐欺師の夢です!

例2. フラグ変数

よくあるミスはフラグ変数を状態のインジケータとして使うことです:

bool isReady = false;

void Worker()
{
    while (!isReady)
    {
        // 待ってる...
    }
    // 何かをする
}

他のスレッドが isReady = true を書き込むかもしれません。一見安全そうに見えます:何がまずいのか? 実は、ブール変数の読み書きでさえ安全でないことがあります!理由はコンパイラの最適化、CPU のキャッシュ、マルチプロセッサでの命令のリオーダリングです。

何が起こり得るか?
あるスレッドは変更を見落として無限ループを回り続けるかもしれません。別スレッドは既に isReady = true にしているのに。スレッド間で「フラグ」を渡すときは必ず専用のプリミティブを使ってください(volatile、Interlocked、イベントなど — 詳しくは 公式ドキュメント を参照)。

例3. null チェックとオブジェクトの生成

シングルトンのようなクラスを想像してください:

public class Singleton
{
    private static Singleton _instance;

    public static Singleton Instance
    {
        get
        {
            if (_instance == null)
            {
                _instance = new Singleton();
            }
            return _instance;
        }
    }
}

このコードは単純に見えますが、もし複数のスレッドが同時に Instance を呼ぶと、複数のインスタンスが作られてしまう可能性があります!これは同期なしの double-check パターンの古典的な失敗例です:フィールド _instance はレースで初期化される可能性があり、lock で保護する必要があります。

2. コード中で Race Condition がどう現れるか

「読む-修正-書き戻す」操作 — 悪の解剖学

もう一度カウンタのインクリメントを見ましょう: counter++

CPU 的に言うとこれは:

  1. メモリから読む: register = counter;
  2. 増やす: register = register + 1;
  3. 書き戻す: counter = register;

もし2つのスレッドがほぼ同時にこのブロックを実行したらどうなるでしょうか?

  • 両方のスレッドが counter = 0 を読む。
  • 両方ともレジスタを 1 に増やす。
  • 両方が 1 を書き戻す。

結果:2 回のインクリメントで値は 1 しか増えない! 1 回分が「食われる」 — しかも誰も気づかないことが多いです。

2つのスレッドの「衝突」可視化


スレッド A        |   スレッド B        |   counter-------------------------------------------
読む (0)          |                     |   0
                  |   読む (0)          |   0
インクリメント (1)|                    |   0
                  |   インクリメント (1)|   0
書き込み (1)      |                     |   1
                  |   書き込み (1)      |   1  <-- おっと!2を期待したが1になった

なぜ Race Condition を捕まえるのが難しいのか?

  • Race Condition は「隠れる」。Console.WriteLine を数個入れただけでバグが消えることがある。スレッドのスケジュールが変わるからです。
  • バグはコア数、負荷、OSや .NET のバージョンに依存する。昨日は動いていて今日壊れる、なんてことが起こる。
  • 結果が不定:バグは常に出るわけではなく、「特定の条件」でだけ出る。
  • デバッグは一筋縄ではいかない。

3. コレクションや .NET クラスでの Race Condition

Race Condition は単なる変数だけで起きるわけではありません。コレクション、キュー、標準の .NET クラスも必ずしも安全ではありません。

例: List<T> はスレッドセーフではない

もし2つのスレッドが同時に通常の List<T> に .Add() を呼ぶと、結果は悲惨になりえます:

var list = new List<int>();
var tasks = new List<Task>();

for (int t = 0; t < 10; t++)
{
    int threadNum = t;
    tasks.Add(Task.Run(() => {
        for (int i = 0; i < 1000; i++)
            list.Add(threadNum * 1000 + i);
    }));
}
Task.WaitAll(tasks.ToArray());
Console.WriteLine(list.Count); // ほぼ確実に < 10000 になる

ここでは Add() の内部で Race Condition が起きます。内部配列を拡張して要素をコピーしている最中に別スレッドが追加を行うと... データが失われたり、例外が投げられたり、コレクションが壊れたりします。

例: 複数スレッドが同じインデクサを使う

var array = new int[10];
void Worker(int index, int value)
{
    array[index] = value;
}

Parallel.Invoke(
    () => Worker(5, 1),
    () => Worker(5, 2)
);

結果として array[5] には 1 か 2 のどちらかが入ります — どちらが最後に書いたか次第です。時にはこれが望ましい挙動でもありますが、多くの場合は微妙で見つけにくいバグの原因になります。

4. 役に立つ注意点

Race Condition と非アトミック操作

操作が「小さい」ように見えてもアトミックでない場合は特に注意してください。

  • i++, i--
  • a = b
  • myObject.Property = value
  • 「フラグを確認してから値を変更する」
  • 「オブジェクトを取得してからその内部状態を変更する」

これらは単体ではアトミックではありません。マイクロステップの間に他のスレッドに「奪われる」可能性があります。

Race Condition の神話と落とし穴

神話 1: 「変数が int なら安全、プリミティブだから」。
現実: intvolatile 指定や専用 API を使わない限りアトミックが保証されるとは限りません。

神話 2: 「一方が書き込み、他方が読み込みだけなら大丈夫」。
現実: いいえ! .NET のコレクションは読み書きが同時に行われる場合に整合性を保証しないので、壊れたオブジェクトや奇妙な例外が発生する可能性があります。

神話 3: 「Race Condition は高負荷サービスだけの話」。
現実: 小さいユーティリティやゲーム、デスクトップアプリでも発生します。ただ頻度が低いか、すぐに出ないだけです。

どう守るか?

  • 同期プリミティブを使う(lock、Mutex、Monitor など)。
  • 数値の増減など基本操作には Interlocked を使う(ドキュメント参照)。
  • コレクションにはスレッドセーフなクラスを使う(ConcurrentBag、ConcurrentDictionary など — 詳細は ドキュメント)。
  • 保護なしでフラグや状態を使わない(volatile、イベント、同期プリミティブを使う)。

いつ Race Condition が発生するか

シナリオ Race Condition の可能性? どう守るか
複数のスレッドがデータを読む いいえ(変更がなければ) -
1つのスレッドが書き込み、他が読み込み はい
lock/volatile
複数スレッドが書き込み はい
lock/Interlocked
複数スレッドがコレクションを変更 はい スレッドセーフなコレクション
複数スレッドがフラグを使う はい volatile、lock、イベント

アトミック性は万能ではない

時にはアトミックな操作であっても論理的な「競合」から守ってくれないことがあります。

例:

if (!cache.ContainsKey(key))
{
    cache[key] = GetData(key);
}

もし2つのスレッドが同時にこの if を通過すると、両方ともキーが無いと判断して両方が値を生成し、後から来た方が前の結果を上書きしてしまいます! ここでは intInterlocked は役に立ちません — 完全なロックか、あるいは ConcurrentDictionary の GetOrAdd のような専用機能が必要です。

4. 学生が Race Condition を扱うときの典型的なミス

ミス #1: lock は「大きなもの」のためだけに必要だと思っている。
実際には単純な変数でも保護なしで壊れます。

ミス #2: 間違ったオブジェクトでロックしている。
例えば string やクラス外部からアクセス可能なオブジェクトをロックしてしまうと、同期が意図した通りに働かないことがあります。

ミス #3: 「ほとんどの場合正しい」結果に頼る。
バグがまれにしか出ないからといってリソースを保護しなくて良いわけではありません。

ミス #4: 非同期メソッドを同期せずに使う。
「なんとかなるだろう」と期待して、予期せぬ箇所でランダムなバグに遭遇します。

ミス #5: double-check の Singleton を lock なしで書く。
メモリ上に複数のインスタンスが生まれ、デバッグが地獄になります。

2
タスク
C# SELF, レベル 57, レッスン 0
ロック未解除
Monitorを使ったリソースへのアクセスの同期
Monitorを使ったリソースへのアクセスの同期
コメント
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION