マルチスレッドの実務ベストプラクティス .NET編 ── スレッドを増やす前に決めておくこと

· · Windows, マルチスレッド, C#, .NET, 業務アプリ, 不具合調査, 設計

「処理が遅いからスレッドを立てて並列にしたら、たまに集計結果がずれる」「バックグラウンド処理を足したら、月に一度アプリが固まるようになった」「デバッグ実行だと再現しないと言われても、客先では確かに起きている」── マルチスレッドプログラミングの怖さは、書いた直後には正しく動いて見えることです。競合のバグはタイミング依存で、テストをすり抜けて本番でだけ顔を出します。

一方で、マルチコアが当たり前になった今、業務アプリでも「UIを固めずに重い処理を回す」「複数の装置やファイルを並行して処理する」といった要求からマルチスレッドを避けて通れない場面は確実にあります。重要なのは、スレッドを増やす前に設計の原則を決めておくことです。マルチスレッドのバグはデバッグで潰すものではなく、設計で入り込む余地をなくすものだからです。

本記事はマルチスレッド実務シリーズの.NET編です。Windowsで業務アプリを開発していてマルチスレッド化が必要になった開発者を対象に、言語やOSを問わず通用する設計原則と、C#/.NETでの具体的な道具立てを、2026年8月時点の一次情報にもとづいて整理します。原則そのものはLinuxでもC++でも変わりません。ネイティブコードで書く場合は、同じ原則をそれぞれの言語の道具に落とした「C++編」「C言語編」を、Javaで書く場合は「Java編」を参照してください。

1. まず結論

  • スレッドを自分で作らないことが最初のベストプラクティスです。new Thread ではなく Task・スレッドプール・Parallel クラスなど上位のAPIに乗り、スレッド数の管理はランタイムに任せます。12
  • 並列化で最初に削るべきは「共有された可変状態」です。複数スレッドが同じ変数に書き込む場所が競合の発生源であり、ロックで守るより先に、データの分割・不変化・受け渡しで共有そのものを減らします。3
  • ロックには規律を持たせます。「どのデータをどのロックが守るか」を1対1で決め、ロック対象は外部に見えない専用オブジェクトにします。lock(this)lock(typeof(X)) は禁止です。.NET 9以降は専用の System.Threading.Lock 型を使います。4
  • スレッド間のデータ受け渡しはキューに寄せます。System.Threading.Channels や並行コレクションを使ったプロデューサー/コンシューマー構成は、ロックの張り巡らせより設計が単純で、境界も明確になります。56
  • 止め方は最初に設計します。停止は CancellationToken による協調キャンセルが唯一の正解で、Thread.Abort は .NET(Core系)では実行時例外になります。78
  • UIはUIスレッドの専有物です。WinFormsのコントロールもWPFの要素も、作成したスレッド以外から触ってはいけません。別スレッドからは Control.Invoke / Dispatcher 経由で依頼します。910
  • 「並列にすれば速い」とは限りません。1回あたりの仕事が小さいループは並列化のオーバーヘッドで逆に遅くなります。必ず測定してから採用します。3

2. なぜマルチスレッドは難しいのか ── 競合状態とデッドロック

マルチスレッドが導入する問題は、突き詰めると2種類です。4

競合状態(race condition)は、複数のスレッドがどの順序で特定のコードに到達するかによって結果が変わってしまうバグです。定番の例が共有カウンターのインクリメントで、count++ という1行は実際には「読み込み → 加算 → 書き戻し」の3ステップに分かれます。2つのスレッドが同時にこの3ステップを実行すると、一方の加算がもう一方の書き戻しで上書きされ、加算が失われます。実行するたびに結果が変わり、どの実行結果になるかは予測できません。4

スレッドB共有変数 countスレッドAスレッドB共有変数 countスレッドAcount = 102回加算したのに count = 11スレッドAの加算が失われた読み込み(10)読み込み(10)手元で加算(11)手元で加算(11)書き戻し(11)書き戻し(11)

図1: 共有カウンターで加算が失われる典型的な競合状態。count++ の3ステップの間に別スレッドが割り込むと、後から書き戻したほうが上書きする

デッドロックは、2つのスレッドが互いに相手の持っているロックを待ち合って、どちらも先へ進めなくなる状態です。スレッドAがロック1を持ってロック2を待ち、スレッドBがロック2を持ってロック1を待つ ── これだけで両者は永遠に止まります。4

ロック2の解放待ちロック1の解放待ちスレッドAロック1を保持中スレッドBロック2を保持中

図2: デッドロックの循環待ち。待ちの矢印が輪を作った瞬間、輪の中の全スレッドが永遠に停止する

厄介なのは、どちらもタイミング依存だという点です。開発機では数万回に1回しか当たりを引かないインターリーブ(実行順の組み合わせ)が、コア数もタイミングも違う客先マシンでは毎日起きる、ということが普通にあります。「デバッガーを付けると再現しない」「ログを足したら消えた」というのも、観測がタイミングを変えてしまうためで、競合バグの典型的な振る舞いです。

だからこそ、この後の原則はすべて一つの方向を向いています。「正しく同期する」より前に「同期が必要な場所を減らす」 ── これがマルチスレッド設計の背骨です。

3. 原則1: スレッドを自分で作らない

3.1. Task とスレッドプールに乗る

new Thread(...) でスレッドを直接作る書き方は、今日の .NET では例外的な最終手段です。.NET Framework 4 以降、マルチスレッド・並列コードの推奨手段は TPL(Task Parallel Library)、つまり Task を中心としたAPI群です。TPLは並列度を利用可能なプロセッサに合わせて動的に調整し、仕事の分割、スレッドプールへのスケジューリング、キャンセル対応、状態管理といった低レベルの面倒をすべて引き受けます。1

スレッドプールは .NET 自身が Task の実行、非同期I/Oの完了、タイマーコールバックなど広範に使っている基盤で、短い仕事を投げる限り、開発者がスレッドのライフサイクルを管理する必要はありません。2

// CPUを使う重い処理をバックグラウンドで
var result = await Task.Run(() => HeavyCalculation(input));

// 独立した複数の処理を並行で走らせて全部待つ(件数が少ないとき)
// ※ この形はProcessAsyncがI/O主体の非同期メソッドである場合の書き方。
//   WhenAllは「既に走っているTaskを待つ」だけなので、CPU計算を並行させたい
//   場合は各処理を Task.Run(() => Calc(x)) で包んでスレッドプールに載せる
var results = await Task.WhenAll(items.Select(x => ProcessAsync(x)));

// 件数が多いなら、同時実行数に上限を付けて流す
await Parallel.ForEachAsync(items,
    new ParallelOptions { MaxDegreeOfParallelism = 8, CancellationToken = callerCt },
    async (x, ct) => await ProcessAsync(x, ct));
// ポイントは2つ: 呼び出し側のトークンをParallelOptionsに接続すること
// (これを忘れると本体のctは常にNone)と、そのctを本体にも通すこと(捨てない)

注意点がひとつあります。Task.WhenAll(items.Select(...)) は、列挙した時点で全要素の処理を一斉に開始します。数件〜数十件の決まった仕事なら問題ありませんが、件数の多いコレクションに対して使うと、ソケット・DB接続・メモリを一度に使い果たします。件数が読めない処理は、上の Parallel.ForEachAsync のように同時実行数へ上限を付けるか、後述するboundedチャネルで流量を制御します。

自前スレッドが正当化されるのは、「専用のメッセージループを持つ」「スレッドのアパートメント(STA)を指定する必要がある」「アプリの生存期間ずっと走り続ける」といった、スレッドそのものの性質が要件になっている場合にほぼ限られます。

3.2. データ並列は Parallel.For / ForEach

「コレクションの各要素に同じ処理をして、全体を速くしたい」というデータ並列には、ループを自分でスレッドに割るのではなく Parallel.For / Parallel.ForEach を使います。データソースの分割(パーティショニング)と負荷の再配分はTPLが行い、基本的なループならロックも不要です。11

ただし、公式ドキュメントが明記する2つの落とし穴があります。3

  • 並列が常に速いとは思わないこと。反復回数が少ない、または1回の処理が軽いループは、並列化のオーバーヘッドが本体を上回って遅くなります。性能は要因が多いので、必ず測定して判断します。
  • 反復同士を待ち合わせないこと。Parallel.For の各反復が実際に並列実行される保証はありません。ある反復が別の反復のイベント設定を待つようなコードは、スケジューリング次第でデッドロックします。

3.3. 「待つ」処理はスレッドではなく非同期I/Oへ

ファイル・ネットワーク・DBなどI/O待ちが主体の処理は、スレッドを増やす対象ではありません。待っている間スレッドを1本占有するのは無駄なだけで、async/await による非同期I/Oなら待機中にスレッドを消費しません。この使い分け(CPUバウンドは並列化、I/Oバウンドは非同期化)はマルチスレッド設計の入り口で最初に引くべき線です。

I/O待ちが主体ファイル・ネットワーク・DBCPUを使う計算コレクションの全要素に同じ処理を適用独立したひとかたまりのバックグラウンド処理メッセージループやSTA指定などスレッド自体の性質が要件並行にしたい処理がある処理の主体は?async/await の非同期I/Oスレッドは増やさない仕事の形は?Parallel.For / ForEachTask.Run / Task.WhenAllnew Thread(例外的な最終手段)

図3: 「スレッドを立てる」前の分岐。ほとんどの業務処理は上3つの出口のどれかに落ち、new Thread に到達するのは例外的なケースだけ

async/await の実務判断は「C# async/await実務判断表」で、その足元でスレッドプールと非同期I/Oがどう繋がっているかは「IOCPと.NETスレッドプール」で詳しく扱っています。

4. 原則2: 共有可変状態を最小化する

競合は「複数のスレッド」と「共有された可変データ」が揃ったときにだけ起きます。スレッドの数は要件で決まるので、設計で削れるのは共有のほうです。手段は3つあります。

4.1. 分割する ── 各スレッドが自分のデータだけを触る

もっとも単純で強力なのは、データをスレッドごとに分けてしまうことです。並列ループの集計であれば、共有の合計変数に毎回書き込むのではなく、Parallel.For のスレッドローカル状態を受け取るオーバーロードを使って各スレッドが手元で小計を作り、最後に1回だけ合流させます。共有への書き込みが「反復のたび」から「スレッドごとに1回」まで減り、同期のコストも競合の窓も桁違いに小さくなります。3

long total = 0;
Parallel.For(0, items.Length,
    () => 0L,                                  // スレッドローカルの初期値
    (i, state, local) => local + Weigh(items[i]), // 各反復は自分のlocalにだけ加算
    local => Interlocked.Add(ref total, local));  // 合流はスレッドごとに1回
データ配列(処理対象)スレッド1担当分を処理して手元の小計にだけ加算スレッド2担当分を処理して手元の小計にだけ加算スレッド3担当分を処理して手元の小計にだけ加算合流: Interlocked.Add でスレッドごとに1回だけ合計に反映

図4: スレッドローカル集計。処理中は各スレッドが自分のデータしか触らないため競合の余地がなく、共有への書き込みは合流時のスレッドごとに1回だけになる

4.2. 不変にする ── 書き換えないものは共有してよい

読み取りしかされないデータは、何スレッドから同時に読んでも安全です。設定値・マスターデータ・計算の入力などは、構築後に書き換えない(イミュータブルにする)ことで、同期なしで自由に共有できます。C# なら record 型や init プロパティがこの設計を後押しします。「変更が必要になったら、書き換えるのではなく新しいインスタンスを作って差し替える」と決めるだけで、守るべき可変状態が一つ減ります。

ただし「読み取り専用に見える」と「不変である」は別物です。IReadOnlyList<T> のような読み取り専用インターフェイスは「そのインターフェイス経由では書き換えられない」だけで、裏にある List<T> を別の参照から書き換えることは防げません。record / init の保証も浅く、プロパティが参照する先のオブジェクトまでは守りません。スレッド間で本当に安全に共有したいデータは、ImmutableArray<T> など System.Collections.Immutable の不変コレクションを使うか、共有する時点でコピーを渡して、書き換え経路そのものを断ちます。このとき要素の型 T 自体も不変であることが条件です。不変コレクションが守るのは「並び」だけで、可変な要素オブジェクトへの参照は元のまま共有されるため、別の経路から要素の中身を書き換えられれば競合は残ります。オブジェクトグラフの末端まで不変にするか、深いコピーを渡してください。

4.3. 受け渡す ── 共有する代わりにキューで送る

それでもスレッド間でデータを動かす必要はあります。そのときは「共有変数を両側から触る」のではなく、片方が書き、片方が読むキューを間に置くプロデューサー/コンシューマー構成にします。

.NET での第一候補は System.Threading.Channels です。プロデューサーが非同期にデータを書き込み、コンシューマーが非同期に読み出すFIFOで、同期の面倒はすべてチャネルが管理します。5

var channel = Channel.CreateBounded<WorkItem>(100); // 容量100で背圧をかける

// プロデューサー側
await channel.Writer.WriteAsync(item, ct); // 満杯なら空きが出るまで待つ
// …すべてのプロデューサーが書き終えたら:
channel.Writer.Complete();   // 「もう来ない」を宣言。これがないと読み手のループが終われない

// コンシューマー側
await foreach (var item in channel.Reader.ReadAllAsync(ct))
{
    Process(item);
}
満杯なら書き込みを待たせる(背圧)空なら読み出しを待たせるプロデューサー1WriteAsyncboundedチャネル(容量100)FIFOキュー同期はチャネルが管理プロデューサー2WriteAsyncコンシューマー1ReadAllAsyncコンシューマー2ReadAllAsync

図5: チャネルを挟んだプロデューサー/コンシューマー構成。両側は共有変数を直接触らず、待ち合わせも容量制御もチャネルに任せる

実務で重要なのは容量に上限のある(bounded)チャネルを選ぶことです。上限に達したときの既定動作は「書き込み側が空きを待つ」で、これが自然な背圧(バックプレッシャー)になります。生産が消費より速い構成で無制限のキューを使うと、動いてはいるがメモリが増え続ける、という時限爆弾になります。5

同期的な世界でboundedチャネルと同じ役割を果たすのは、容量を指定した BlockingCollection<T> です。容量制限で生産側が消費側を追い越しすぎるのを防ぎ、空のときは消費側をブロックして待たせる、というブロッキングと容量制御を備えています。12 一方 ConcurrentQueue<T> / ConcurrentStack<T> は、ロックを使わず Interlocked 操作だけでスレッドセーフを実現した高速なコレクションですが6、容量制限も「空になったら待つ」仕組みも持たない、素のスレッドセーフキューです。仕事の受け渡しの主役ではなく部品と考えてください。なお BlockingCollection<T> は非同期アクセスを想定した設計ではないため、async/await と組み合わせるなら Channel<T> を選びます。12

なお、「辞書を ConcurrentDictionary に替えたからスレッドセーフ」という思い込みには注意が必要です。個々の操作がスレッドセーフでも、「存在確認してから追加」のような複合操作は依然として競合します(GetOrAdd など複合操作用のメソッドを使います)。その GetOrAdd にも but書きがあり、格納される値は1つに決まる一方で、値を作るファクトリ関数は競合時に複数回呼ばれることがあります。ファクトリに副作用(接続を開く、ファイルを作る等)を入れると重複実行で漏れが生じるため、副作用のない関数にするか、確実に1回にしたい初期化は Lazy<T> を値に格納する形にします。コレクションの型を替えることは、共有可変状態を減らすことの代わりにはなりません。

5. 原則3: ロックには規律を持たせる

共有可変状態を減らしても、ゼロにはできないことが多い。残った共有には排他制御(ロック)を使いますが、ロックは「とりあえず怪しい所を lock で囲む」道具ではありません。規律は4つです。

5.1. 「何を守るか」を決め、専用オブジェクトでロックする

ロックの単位は「コードの区間」ではなく「データ」で考えます。守りたい可変データの集合ごとにロックオブジェクトを1つ対応させ、そのデータに触るすべての場所で同じロックを取る ── この対応表が崩れているのが競合バグの実態です。

ロック対象のオブジェクトは、外部に公開しない専用のインスタンスにします。lock(this) は自分のインスタンスを参照できる外部コードと、lock(typeof(X)) はアプリケーションドメイン全体と、それぞれロックを共有してしまい、デッドロックの温床になります。.NET 9 / C# 13 以降では専用型 System.Threading.Lock のインスタンスをロックオブジェクトにするのが推奨です。4

public class OrderBook
{
    private readonly Lock _gate = new();          // .NET 9+(それ以前は readonly object)
    private readonly List<Order> _orders = [];    // _gate が守るデータ

    public void Add(Order order)
    {
        lock (_gate) { _orders.Add(order); }
    }
}

C# の lock ステートメントは、例外が起きてもロックを確実に解放することを保証します。展開のされ方はロックオブジェクトの型で変わり、通常のオブジェクトなら finally で Monitor.Exit を呼ぶ形に、Lock 型なら EnterScope() とその破棄を使う形になります。413 つまり Lock 型のフィールドは Monitor とは別の仕組みであり、一部のコードだけが Monitor.Enter(_gate) を手書きすると lock (_gate) との相互排他が成立しません。どちらの型でも、Monitor.Enter / Exit の手書きはやめて常に lock 構文に統一するのが安全です。4

5.2. ロック中に「時間のかかること」「外部のこと」をしない

ロックを持っている時間は短いほどよく、ロック中にしてよいのは守っているデータの読み書きだけです。ロックを保持したままI/Oを行う、イベントやコールバックで外部のコードを呼ぶ、といった書き方は、保持時間を引き延ばすだけでなく、呼び出した先が別のロックを取ろうとしてデッドロックする経路を作ります。ロックの外で準備し、ロックの中では差し替えだけ、が基本形です。

なお lock の中で await することはできません(コンパイルエラーになります)。これは制限ではなく保護で、Monitor はロックを取ったスレッドが解放しなければならないスレッド親和性を持つため、await の前後でスレッドが替わりうる非同期コードとは両立しないのです。非同期コードでの排他には SemaphoreSlim を初期カウント1で使います。14

private readonly SemaphoreSlim _asyncGate = new(1, 1);

public async Task SaveAsync(Data data, CancellationToken ct)
{
    await _asyncGate.WaitAsync(ct);
    try   { await WriteToFileAsync(data, ct); }
    finally { _asyncGate.Release(); }
}

5.3. 複数ロックは常に同じ順序で

ロックが2つ以上あるとき、取得順序がスレッドによって入れ替わるのがデッドロックの古典的なパターンです。対策は単純で、全スレッドが同じ順序でロックを取ることをルール化します。順序を保証できない箇所には Monitor.TryEnter のタイムアウト付きオーバーロードを使い、取れなければ手放してやり直す(あるいは異常を記録する)ことで、永遠のハングを検出可能な失敗に変えられます。4

5.4. 単純な更新は Interlocked、読み取りが多いなら ReaderWriterLockSlim

カウンターの増減やフラグの入れ替えのような単一変数の原子的更新は、lock より Interlocked クラス(Increment / Add / CompareExchange)が高速です。競合がなければ単一のCPU命令プレフィックスで済みます。4 逆に、Interlocked でできるのはそこまでで、複数の変数をまとめて整合させる用途には使えません。volatile と組み合わせた自前のロックフリー構造は、メモリモデルの深い理解を要する上級者の道具であり、業務アプリで書くべきものではありません。

「読み取りは頻繁だが書き込みはまれ」という共有データには、書き込みだけを排他し読み取りは同時に通す ReaderWriterLockSlim という選択肢もあります。13

6. 原則4: 止め方を最初に設計する

マルチスレッドの設計レビューで最初に聞くべき質問は「これはどうやって止まるのですか」です。動き出すコードは書けても、安全に止まるコードは設計しないと生まれません。

6.1. 協調キャンセル(CancellationToken)が唯一の正解

.NET の停止モデルは 協調キャンセルに統一されています。止める側が CancellationTokenSource を作り、その Token を各処理に渡す。止めたくなったら Cancel() を呼ぶ。処理側はトークンを監視して、自分のきりのよいところで後片付けをして終わる ── 強制ではなく協調なので、処理側は一貫した状態を保ったまま終了できます。7

private CancellationTokenSource? _cts;
private Task? _worker;

public void Start()
{
    if (_worker is { IsCompleted: false })    // 動いている間の二重Startを拒否する
        throw new InvalidOperationException("ワーカーは既に実行中です。");
    if (_worker is { IsFaulted: true })       // 前回の失敗を握りつぶしたまま作り直さない
        throw new InvalidOperationException("前回のワーカーが失敗しています。", _worker.Exception);
    _cts = new CancellationTokenSource();
    var token = _cts.Token;   // 停止後の再Startと競合しないよう、先にローカルへ捕まえる
    _worker = Task.Run(() => WorkLoop(token), token);
}

private void WorkLoop(CancellationToken ct)
{
    while (!ct.IsCancellationRequested)   // ポーリングで監視
    {
        ProcessNextItem(ct);              // ブロックする呼び出しにはctを渡して即時中断
    }
}

public async Task StopAsync()
{
    var cts = _cts;          // 待っている間にフィールドが差し替わっても、
    var worker = _worker;    // 止める対象を取り違えないようローカルに固定する
    if (cts is null || worker is null) return;

    Exception? cancelFailure = null;
    try { cts.Cancel(); }    // トークンに登録されたコールバックが例外を投げることがある
    catch (Exception ex) { cancelFailure = ex; }   // 捕まえておき、合流を果たしてから報告する

    try
    {
        try { await worker; }    // Cancelの成否に関わらず合流は必ず果たし、途中の失敗も観測する
        catch (OperationCanceledException ex) when (ex.CancellationToken == cts.Token)
        { }                      // こちらが要求した停止だけを「正常」扱いにする
        catch (Exception ex) when (cancelFailure is not null)
        {
            throw new AggregateException(cancelFailure, ex);  // 両方の失敗をどちらも失わない
        }
    }
    finally
    {
        cts.Dispose();           // 合流が済んだソースは破棄する(WaitHandle等のOS資源の解放)。
        if (ReferenceEquals(_cts, cts))
        {
            _cts = null;         // 破棄済みソースを後続のStopAsyncに使わせない
            _worker = null;
        }
    }
    if (cancelFailure is not null)
        throw new AggregateException(cancelFailure);
}

なお、この Start / StopAsync同一スレッド(UIスレッドなど)から順に呼ばれる前提の最小構成です。複数のスレッドが同時にライフサイクルを操作しうるなら、Start / StopAsync 自体を SemaphoreSlim などで直列化してください ── ワーカーを守る前に、ワーカーの管理操作そのものが競合しては本末転倒です。

この小さなサンプルにも、実務で効く工夫を入れてあります。まず Start は実行中の二重呼び出しを拒否します。無条件に _cts_worker を上書きすると、前のワーカーへの参照が失われ、止めることも合流することもできない「はぐれスレッド」が併走します。ライフサイクルAPI(Start/Stop)は「同時に1つ」を自分で守らせるのが基本です。加えて3点。第一に、停止APIは完了を待ちますCancel() はキャンセルを「要求」するだけで、戻った瞬間にはワーカーがまだ ProcessNextItem の途中かもしれません。要求だけして戻る Stop() にすると、呼び出し側が後片付けを始めたときにワーカーがまだ動いている、という新しい競合を作ります。第二に、Taskを捨てずに保持します_ = Task.Run(...) と投げ捨てると、ワーカーが例外で死んでも誰も気づけません。第三に、トークンは _cts.Token をラムダ内で参照するのではなくローカル変数に捕まえてから渡します。ラムダ内で参照すると評価されるのは実行時で、停止直後に再Startされた場合に古いワーカーが新しいトークンを掴む取り違えが起きます。あわせてそのトークンを Task.Run の第2引数にも渡しておくと、処理側が ThrowIfCancellationRequested やキャンセル対応APIの OperationCanceledException で終わった場合に、Taskが「失敗(Faulted)」ではなく「キャンセル(Canceled)」として分類されます(この例のようにループ条件で普通に抜けた場合は正常完了扱いです)。もうひとつ、StopAsync の catch は when フィルターで自分のトークン由来のキャンセルだけを握っています。OperationCanceledException を無条件に握りつぶすと、処理内部の別トークン(要素ごとのタイムアウトなど)が投げた本物の失敗まで「停止したから正常」に見えてしまうためです。なお、このトークン一致での識別は WorkLoop の内部でリンクトークン(6.1のリンク合成)を使うと崩れます。リンク側のトークンを載せた例外が飛んでくるためです。その構成では、WorkLoop の出口で ct.ThrowIfCancellationRequested() を呼んで外側のトークンに「翻訳」してから抜けるか、フィルターを when (cts.IsCancellationRequested) に緩めて「停止要求中のキャンセルは正常」と割り切るか、どちらかを設計として明示的に選んでください。

Cancel() を1回呼ぶToken を渡すToken を渡すToken を渡すIsCancellationRequested を確認後片付けして自分で終わるThrowIfCancellationRequested待機中でも即座に中断止める側CancellationTokenSourceワーカー処理1ワーカー処理2ライブラリのキャンセル対応API正常終了OperationCanceledException= キャンセル完了として扱われるキャンセル完了

図6: 協調キャンセルの構図。止める側は Cancel() を呼ぶだけで、「いつ・どう終わるか」は各処理が自分で決める。だから一貫した状態を保ったまま止まれる

ライブラリ側の作法も決まっています。キャンセル可能な操作は CancellationToken を受け取る公開メソッドを提供し、計算ループでは定期的に IsCancellationRequested を確認するか ThrowIfCancellationRequested() を呼びます。後者は OperationCanceledException を送出し、Task はこれを「失敗」ではなく「キャンセル完了」として扱います。外部から渡されたトークンと内部の都合(タイムアウトなど)の両方で止めたい場合は、リンクトークンで合成します。7

6.2. Thread.Abort は存在しないものと考える

「言うことを聞かないスレッドを外から殺す」Thread.Abort は、.NET Core / .NET 5 以降では PlatformNotSupportedException を投げるだけで、もう使えません。相手がどこを実行中か分からないままスレッドに例外を投げ込む行為は、リソース解放の中断や状態破壊を招くためです。協調キャンセルに応じない(応じるように書けない)サードパーティコードを強制終了する必要があるなら、別プロセスで実行して Process.Kill で止めるのが公式の指針です。8

6.3. 待つときはポーリングではなく待機ハンドルで

「フラグが立つまで Sleep(100) のループで待つ」書き方は、CPUと応答性の両方を無駄にします。スレッド間の合図には ManualResetEventSlimSemaphoreSlim などの同期プリミティブがあり、シグナル状態になるまでスレッドを正しく休ませられます。13 Windowsでのタイマー精度とイベント待機の使い分けは「WindowsでSleep(1)よりイベント待機を優先すべき理由」で詳しく扱っています。

7. UIスレッドという特殊事情 ── Windowsデスクトップアプリの掟

Windowsのデスクトップアプリには、汎用の原則に加えてもう一つ強い制約があります。UIはそれを作ったスレッド(UIスレッド)だけが触れるという掟です。

WinFormsのコントロールはスレッドセーフではなく、複数のスレッドから操作するとコントロールが不整合な状態に追い込まれ、競合・デッドロック・フリーズの原因になります。Windowsはアプリに対して、システムメッセージを受け取る専用スレッドを1本用意することを要求しており、UIの作成と操作はそのスレッドに集約されている必要があるのです。9 WPFもまったく同じ構造で、UI要素を変更できるのはUIスレッドだけです。10

別スレッドからUIを更新したいときは、直接触るのではなく「UIスレッドへの依頼」に変換します。

Control.Invoke /Dispatcher.InvokeAsync で依頼コントロールを直接触るWindowsマウス・キーボード・再描画UIスレッドのメッセージキューバックグラウンドスレッド(重い処理・通信)UIスレッドコントロールに触れる唯一のスレッド禁止競合・デッドロック・フリーズの原因

図7: UI更新は「依頼」に変換する。バックグラウンドスレッドの仕事はメッセージキューに載せてもらうところまでで、コントロールに触るのは常にUIスレッド自身

フレームワーク 依頼の手段
WinForms Control.Invoke(同期)/ Control.BeginInvoke(非同期)/ .NET 9以降は Control.InvokeAsync9
WPF Dispatcher.Invoke(同期)/ Dispatcher.InvokeAsync / Dispatcher.BeginInvoke(非同期)10

このうち同期形(Control.Invoke / Dispatcher.Invoke)には注意が必要です。UIスレッドがそのワーカーの完了を同期的に待っている状態でワーカーが Invoke を呼ぶと、互いを待ち合うデッドロックになります(2章の循環待ちそのものです)。バックグラウンドからの通知・進捗報告は非同期形(BeginInvoke / InvokeAsync)を既定とし、同期形はUIスレッドが自分を待っていないと言い切れる場面に限定してください。

実務ではもう一段よい答えがあります。UIスレッド上で開始した処理を async/await で書くと、await がUIスレッドの SynchronizationContext を捕捉し、後続処理を自動的にUIスレッドで再開してくれるため、Invoke を手書きする場面自体が大きく減ります。ただしこれは無条件の性質ではありません。バックグラウンドのコールバックから入ったコードや、ConfigureAwait(false) を挟んだ後の継続処理はUIスレッドに戻らないので、その経路でUIに触るなら明示的なディスパッチが引き続き必要です。「重い処理は Task.Run か非同期I/Oへ、結果の画面反映は await の続きで」という形に揃えるのが現代のWindowsアプリの基本形です。UIスレッドとasync/awaitの関係は「WPF/WinFormsのasyncとUIスレッドを一枚で整理」に一枚絵でまとめています。

また、Office連携やレガシーコンポーネントなどCOMが絡む場合には、COM独自のスレッドモデル(STA/MTA)という別のレイヤーが加わります。「UIスレッドでCOMオブジェクトを作ったのに別スレッドから呼んで固まった」という事故はこの層の話で、「COM STA/MTA の基礎知識」で解説しています。

8. ネイティブ(C++/C)で書く場合は

ここまでの原則 ── スレッドを直接作らない、共有可変状態を減らす、ロックの規律、止め方の設計 ── は、ネイティブコードでもそのまま通用します。変わるのは道具立てです。C++ならRAIIと std::jthread / std::mutex / std::atomic、CならWin32 APIの _beginthreadex・SRWロック・条件変数・停止イベントパターンが対応物になります。それぞれ本シリーズの「C++編」「C言語編」で、言語固有の落とし穴(std::thread のデストラクタ、TerminateThread の危険性、DllMainとローダーロックなど)まで含めて扱います。

9. 検証とデバッグ ── 「再現しない」前提で備える

マルチスレッドのバグはテストで見つかることを期待できません。通常のユニットテストは「たまたま競合しなかった」実行を成功と数えてしまうからです。備えは3層で考えます。

第一の防衛線は、ここまでの設計原則そのものです。共有可変状態が5個のアプリと50個のアプリでは、疑うべき場所の数が10倍違います。レビューでは「共有している可変データはどれか」「それぞれどのロックが守るか」「ロックの取得順序は一意か」「停止経路はどこか」を表で確認します。この表が書けない設計は、動いていてもまだ完成していません。

第二に、異常を隠さず観測可能にします。Monitor.TryEnter のタイムアウトでロック待ちの異常を検出してログに残す4、スレッドプールに投げた処理の未観測例外を握りつぶさず記録する、ハング時にはフルダンプを採取して全スレッドのスタックを確認できるようにしておく ── 「たまにしか起きない」バグとの戦いは、起きた1回からどれだけ情報を取れるかで決まります。ダンプとログの整備は「Windowsアプリのクラッシュ時にログとダンプを残す設計」で扱っています。

第三に、負荷をかけて揺さぶります。コア数より多い並列度で長時間回す、処理順をランダム化する、遅延を人工的に挿入するなど、開発機でインターリーブの当たりを引きやすくするストレステストは、出荷前に競合を炙り出す現実的な手段です。デバッグ実行で消えるバグも、リリースビルド+高負荷なら再現することがよくあります。

10. まとめ ── スレッドを増やす前のチェックリスト

マルチスレッドプログラミングのベストプラクティスは、突き詰めれば「同期を正しく書く技術」ではなく「同期を書かずに済む設計」です。着手前に次の8問に答えられれば、大きな事故はほぼ防げます。

  1. その処理はCPUバウンドか、I/Oバウンドか(後者ならスレッドではなくasync/awaitが答え)
  2. new Thread を書こうとしていないか(Task・Parallel・スレッドプールで表現できないか)
  3. スレッド間で共有される可変データはどれか、列挙できるか
  4. その共有は「分割」「不変化」「キューでの受け渡し」で消せないか
  5. 残った共有データそれぞれに、対応するロックが1つ決まっているか
  6. ロックの取得順序は全スレッドで一意か、ロック中に外部呼び出しをしていないか
  7. CancellationToken はすべての長時間処理に渡っているか、停止経路を説明できるか
  8. UIに触るコードはUIスレッドに集約されているか

マルチスレッドのバグは、書いた日には現れず、忘れたころに客先で牙をむきます。逆に言えば、設計の段階でこのチェックリストを通しておけば、「たまに落ちる」「月に一度固まる」という最も高くつく種類の障害を、コードを書く前に摘み取れるということです。

関連記事

関連する相談領域

合同会社小村ソフトでは、マルチスレッド化を含む業務アプリの設計レビュー、「たまに落ちる・固まる」といった再現性の低い不具合の原因調査(ダンプ解析・競合箇所の特定)、既存アプリの並列化・非同期化の技術相談を扱っています。「この設計で競合が起きないか見てほしい」といった段階からで構いません。

参考リンク

  1. Microsoft Learn, Task Parallel Library (TPL). TPLが.NET Framework 4以降のマルチスレッド・並列コードの推奨手段であること、並列度を利用可能なプロセッサに合わせて動的に調整すること、仕事の分割・スレッドプールへのスケジューリング・キャンセル対応・状態管理を引き受けること、1反復の仕事が小さいループでは並列化のオーバーヘッドで遅くなりうること、TPLを使うにもロック・デッドロック・競合状態の基本理解が推奨されることについて。  2

  2. Microsoft Learn, The managed thread pool. ThreadPoolクラスがシステム管理のワーカースレッドのプールを提供し、開発者がスレッド管理でなくアプリのタスクに集中できること、.NETがTPLの操作・非同期I/O完了・タイマーコールバック・登録済み待機・ソケット接続など広範にスレッドプールを使っていることについて。  2

  3. Microsoft Learn, Potential Pitfalls in Data and Task Parallelism. 並列ループが逐次より遅くなる場合があり必ず測定すべきこと、並列ループ内の共有メモリへの書き込みを避けるべきでスレッドローカル状態を使うオーバーロードが推奨されること、For/ForEachの各反復が並列実行される保証はなく反復間で待ち合わせるコードがデッドロックしうることについて。  2 3 4

  4. Microsoft Learn, Managed threading best practices. 競合状態(カウンターのインクリメントが読み込み・加算・書き戻しに分解され上書きで失われる例)とデッドロックの定義、Thread.Abortを使わず協調キャンセルを使うべきこと、型やthisをロック対象にしてはならず.NET 9/C# 13以降は専用のSystem.Threading.Lockインスタンスを使うこと、C#のlockステートメントがfinallyでMonitor.Exitを保証すること、Monitor.TryEnterのタイムアウトによるデッドロック検出、単純な状態変更にはInterlockedクラスが高速であること、静的データは既定でスレッドセーフに・インスタンスデータは既定で非スレッドセーフにという設計指針について。  2 3 4 5 6 7 8 9 10

  5. Microsoft Learn, System.Threading.Channels library. チャネルがプロデューサー/コンシューマーモデルのFIFOで同期を内部管理すること、CreateBoundedで容量上限付きチャネルを作れること、上限到達時の既定動作が書き込み側の待機(Wait)でありDropOldest等のFullModeも選べること、書き込みが読み出しより速い場合に背圧がかかることについて。  2 3

  6. Microsoft Learn, Thread-safe collections. System.Collections.Concurrent配下のコレクションが細粒度ロックやロックフリー機構でスレッドセーフを実現していること、ConcurrentQueueとConcurrentStackはロックを使わずInterlocked操作で実装されており複数スレッドからの高頻度な追加・削除に耐えることについて。  2

  7. Microsoft Learn, Cancellation in Managed Threads. CancellationTokenSourceとCancellationTokenによる協調キャンセルモデルの手順、キャンセルが強制ではなく協調であり停止の仕方はリスナー側が決めること、ポーリング・コールバック登録・待機ハンドルの3つの監視手段、ThrowIfCancellationRequestedによるOperationCanceledExceptionの送出をTaskがキャンセル完了として扱うこと、リンクトークンによる複数トークンの合成、ライブラリはCancellationTokenを受け取る公開メソッドを提供すべきことについて。  2 3

  8. Microsoft Learn, Using threads and threading. スレッドの停止にはCancellationTokenを使うべきこと、.NET Coreおよび.NET 5以降ではThread.AbortがPlatformNotSupportedExceptionを投げ、.NET 5以降はコンパイル時にも非推奨警告(SYSLIB0006)になること、協調キャンセルに応じないサードパーティコードを強制終了するには別プロセスで実行してProcess.Killを使うべきことについて。  2

  9. Microsoft Learn, How to handle cross-thread operations with controls. WinFormsのコントロールへのアクセスがスレッドセーフではなく、複数スレッドからの操作が不整合状態・競合・デッドロック・フリーズを招くこと、すべてのコントロールは同一スレッドで作成・アクセスされる必要があり、Windowsがシステムメッセージを配送する専用のUIスレッドを要求すること、別スレッドからはControl.Invoke、.NET 9以降のControl.InvokeAsync、またはBackgroundWorkerで安全に呼び出すことについて。  2 3

  10. Microsoft Learn, Threading model (WPF). WPFではUIを変更できるのが1つのスレッドに限られ、バックグラウンドスレッドはUIスレッドのDispatcherに作業項目を登録して依頼すること、Dispatcher.Invokeが同期・InvokeAsyncとBeginInvokeが非同期であること、Dispatcherが優先度付きキューで作業を処理することについて。  2 3

  11. Microsoft Learn, Data Parallelism (Task Parallel Library). Parallel.For / Parallel.ForEachがforループとほぼ同じ書き味でデータ並列を提供すること、スレッドの作成やワークアイテムのキューイングが不要で基本的なループではロックも不要なこと、TPLがデータソースを分割して複数スレッドで処理し負荷が偏れば再配分することについて。 

  12. Microsoft Learn, BlockingCollection<T> Class. BlockingCollectionがブロッキングと容量制限を備えたプロデューサー/コンシューマー実装であること、容量制限により生産側が消費側を追い越しすぎるのを防げること、非同期アクセスを想定した設計ではなく非同期のプロデューサー/コンシューマーにはChannel<T>の使用を検討すべきとされることについて。  2

  13. Microsoft Learn, Overview of synchronization primitives. Monitorがロック対象オブジェクトを介した相互排他を提供しスレッド親和性を持つこと、C#ではMonitor直接ではなくlockステートメントを使うべきこと、ReaderWriterLockSlimが書き込みは排他・読み取りは同時アクセス可とすること、SemaphoreSlimがプロセス内専用の軽量セマフォでありSemaphoreは名前付きでプロセス間同期に使えることについて。  2 3

  14. Microsoft Learn, Async semaphores, locks, and reader/writer coordination. C#のlockステートメントとLock型がスレッド親和性を持つためawaitをまたいで使えないこと(awaitの前後で継続を実行するスレッドが替わりうるため)、非同期コードでの相互排他にはカウント1のSemaphoreSlimをWaitAsyncとtry/finallyのReleaseで使うこと、スロットリング用途ではboundedなChannelが代替になることについて。 

同じタグを共有する最新の記事です。さらに近い話題で知識を深められます。

このテーマと近いトピックページです。記事を起点に、関連するサービスや他の記事へ進めます。

この記事は次のサービスページにつながります。近い入口からご覧ください。

よくある質問

この記事のテーマについて、相談時によくある質問をまとめています。

lock(this) や lock(typeof(MyClass)) はなぜ避けるべきなのですか?
ロック対象のオブジェクトが自分のコード以外からも見えるためです。this は自分のインスタンスそのものなので、そのインスタンスを参照できる外部コードが同じオブジェクトをロックできてしまい、意図しない競合やデッドロックの原因になります。typeof(MyClass) はさらに危険で、Type オブジェクトはアプリケーションドメイン内に1つしかないため、まったく無関係のコードとロックを共有してしまいます。ロック対象には外部に公開しない専用のオブジェクトを使ってください。.NET 9 / C# 13 以降では専用の System.Threading.Lock 型のインスタンスをロックオブジェクトにするのが推奨されています。
スレッドは何本まで作ってよいのですか?最適なスレッド数は?
「自分でスレッド数を決めない」のが現代の答えです。Task や Parallel クラスを使えば、スレッドプールが CPU コア数と負荷に応じて並列度を自動調整します。自前で new Thread を繰り返す設計は、コア数の違う客先マシンで過剰・過少になりがちです。目安として意識すべきなのは本数ではなく仕事の種類で、CPU を使い切る計算はコア数以上に並列化しても速くならず、I/O 待ちが主体の処理はそもそもスレッドを増やすのではなく async/await の非同期 I/O にするのが正解です。
volatile を付ければスレッドセーフになりますか?
なりません。volatile が保証するのは、そのフィールドへのアクセスが前後のメモリ操作と並べ替えられないという順序付け(取得/解放のセマンティクス)であって、「読んで、計算して、書き戻す」という複合操作の原子性ではありません。たとえば volatile int のカウンターに複数スレッドから ++ しても加算は失われます。カウンターの増減や比較して入れ替える操作には Interlocked クラスを、複数の変数をまとめて守る必要がある場合には lock を使ってください。volatile が検討対象になるのは、停止フラグのような「1つのスレッドが書き、他のスレッドが読むだけ」の単純なケースにほぼ限られ、そのフラグも現在は CancellationToken で表現するのが標準です。
たまにしか起きない不具合がマルチスレッド起因かどうか、どう見分ければよいですか?
疑うべき兆候は「同じ操作でも再現したりしなかったりする」「デバッガーを付けたり、ログを足したりすると再現しなくなる」「負荷が高いときや起動直後にだけ起きる」の3つです。タイミング依存のバグは実行のたびに結果が変わるのが特徴で、これは競合状態の定義そのものです。切り分けとしては、まず共有している可変データを洗い出し、それぞれ「どのロックで守られているか」を表にします。守られていないアクセスが1か所でもあれば容疑者です。ハングの場合は、全スレッドのスタックを取得して、互いのロック待ちで循環していないかを確認します。Visual Studio のデバッガーで一時停止して「並列スタック」を見るか、本番環境ならダンプを採取して解析します。

著者プロフィール

記事の著者プロフィールページです。

小村 豪

合同会社小村ソフト 代表

Windows ソフト開発、技術相談、不具合調査を中心に、既存資産が残る案件や原因が見えにくい障害調査に強みがあります。

ブログ一覧に戻る