1. はじめに
マルチスレッドアプリケーションでの共有リソースとは、2つ以上のスレッドが同時にアクセスできるもの全部を指します。例えば以下のようなものです:
- 変数(例えば、グローバルなカウンターやリスト)。
- オブジェクト(例えば、ユーザーのコレクション)。
- ファイルやネットワークソケット。
- 複数のスレッドによって変更される任意のデータ構造。
コンソールアプリでは、たいてい変数やオブジェクトがスレッド間で「シェア」されている場面に遭遇します。
アナロジー
同じノートに二人が順番を決めずに同時に書こうとしている状況を想像してみてください。良い場合は殴り書きになり、悪い場合は誰かのデータが消えるかもしれません。プログラミングでも同じで、その「人」はスレッドです。
データレースが発生しやすい典型的なリソース
下の表は、複数スレッドからの同時アクセスに注意すべき典型的なリソースです:
| リソース | 問題のグループ | 例 |
|---|---|---|
| 型が int の変数 | 不正な増減 | カウンター、インデックス |
| 共有コレクション | 要素の消失/破損、例外発生 | 共有の注文リスト |
| オブジェクト | 状態の不整合な変更 | フラグ、プロパティ |
| ファイル | データ破損、不正な読み書き | ログファイル、設定 |
2. レースコンディション:どのように現れるか?
例:訪問カウンター
例えば、ユーザーがボタンを何回押したか(あるいはこの例だと複数のスレッドが変数をインクリメントした回数)を数えたいとします。シンプルなコード:
int counter = 0;
void Increment() {
counter++;
}
次に、それぞれのスレッドで Increment() を100000回呼ぶ2つのスレッドを作ると:
using System;
using System.Threading;
class Program
{
static int counter = 0;
static void Increment()
{
for (int i = 0; i < 100_000; i++)
{
counter++;
}
}
static void Main()
{
Thread t1 = new Thread(Increment);
Thread t2 = new Thread(Increment);
t1.Start();
t2.Start();
t1.Join();
t2.Join();
Console.WriteLine($"期待: 200000, 結果: {counter}");
}
}
論理的には counter は何回インクリメントされるべきでしょう? 200000! でもこのコードを何度か実行すると、ほとんど確実に異なる数が出ます:185000、192500、198765… なぜでしょうか?
3. なぜ counter++ はアトミックでないのか?
counter++ が実際にどう動くか
C# や他の高級言語では、プログラムは機械命令の集合に翻訳されます。残念ながら演算子 counter++ は「変数に1を足す」という魔法の1命令に変わるわけではありません。実際には次のような流れになります:
- スレッドがメモリから値を読み取る(counter)。
- その値を 1 増やす(CPUレジスタ上)。
- 新しい値をメモリに書き戻す(counter)。
もし2つのスレッドがほぼ同時にこれを実行すると、両方とも同じ古い値を読み、増やして、両方とも同じ結果を書き戻し、1つのインクリメントが失われてしまいます。
レースのシナリオ
例えば counter が 1000 だったとします。両方のスレッドがこの値を読み(ステップ1)、それぞれで 1001 に増やし(ステップ2)、そして両方が 1001 を書き戻します(ステップ3)。ひどいことに、1つのインクリメントが単に失われます!
レースの可視化
| 時間 | スレッド1 | スレッド2 | 値 counter |
|---|---|---|---|
| 1 | 1000 を読み取る | 1000 | |
| 2 | 1000 を読み取る | 1000 | |
| 3 | 1001 にインクリメント | 1001 にインクリメント | 1000(まだ書き込みなし) |
| 4 | 1001 を書き込む | 1001 | |
| 5 | 1001 を書き込む | 1001 |
結果として、2回のインクリメントで値は1しか増えていません!
4. さらに例:見えにくいバグ
数値以外でも race condition は起こるか?
複数のスレッドが同じリストに要素を追加するケースを考えてみましょう:
using System;
using System.Collections.Generic;
using System.Threading;
class Program
{
static List<int> numbers = new List<int>();
static void AddNumbers()
{
for (int i = 0; i < 10000; i++)
{
numbers.Add(i);
}
}
static void Main()
{
Thread t1 = new Thread(AddNumbers);
Thread t2 = new Thread(AddNumbers);
t1.Start();
t2.Start();
t1.Join();
t2.Join();
Console.WriteLine($"期待: 20000, 結果: {numbers.Count}");
}
}
このコードも実行するたびに違う結果になる可能性があります:時には例外で落ちることもあり、時には期待より少ない要素数になることもあります。
なぜでしょう?標準の List<T> はスレッドセーフではないからです。つまり2つのスレッドが同時に Add を呼ぶと、内部構造が破損する可能性があります。
5. アトミック操作について
アトミック操作とは?
アトミックな操作とは、途中で別のスレッドに中断されることなく一度に完了する操作のことです。トランザクションのように「全部成功するか、何も起こらないか」の性質を持ちます。
- int 型の代入(例: myVar = 42;)は、多くのプラットフォームでアトミックです(巨大なオブジェクトでない限り)。
- しかし counter++ はアトミックではありません — 3つの連続した操作だからです。
特別なアトミック操作
.NET にはアトミック操作のためのクラスがあります。例えば Interlocked。これは次のレクチャーで詳しく扱います。
Interlocked.Increment を使ったアトミックインクリメントの例:
using System.Threading;
int counter = 0;
Interlocked.Increment(ref counter); // アトミック操作!
6. なぜ race condition を見つけるのが難しいのか?
Race condition が危険なのは:
- 高負荷時にしか現れないことがある。
- 100% の確率で再現されず、5% や 0.01% の確率でしか起きないことがある。
- ランダムに落ち、誰も予期しない場所で発生する。
どうやって問題を疑うか?
プログラムを毎回実行するたびに結果が違う(そして間違っている)なら、データレースを疑いましょう。
プログラマーのジョーク
「バグがたまにしか出ず、Thread.Sleep(50) を入れると直るなら —— 問題は思ったより深刻だよ。」
7. 役に立つポイント
同期
クリティカルセクション(共有リソースを扱うコード領域)を保護するには同期が必要です。これは次回以降のレクチャーのトピックですが、まずは問題に気づき説明できることが重要です。
初心者が犯しがちなミス
多くの初心者は「counter++ — これで何がまずい?」と思いがちです。残念ながら、スレッドが2つ以上になった瞬間から何でも起こり得ます。変数の読み書き、リストへの追加、オブジェクトの状態変更など、一見単純な操作でも問題になります。
実際の開発でのデータレースの位置づけ
現代のマルチスレッドアプリケーション(サーバーAPI、ウェブリクエストの処理、ゲームやモバイルアプリなど)では、ほぼ必ず共有リソースがあります。同期がないと、注文処理の誤り、クラッシュ、メモリリーク、そしてデバッグの地獄につながります。
面接で middle/senior ポジションを受ける際には「レースコンディションとは何か?どう避けるか?」は必ず聞かれます。ここで示した例とメカニズムを説明できれば、面接官に良い印象を与えられます。
GO TO FULL VERSION