1. 堅牢なマルチスレッドコードの秘訣
マルチスレッドを複数の作業員が同時に車を修理しているチームに例えると、誰かが他人の道具を掴んだ瞬間に作業が止まるのが分かります。コードでも同様に、共有データの不用意な扱いは、現場でしか出ない隠れたバグを引き起こします。
この講義では、カードの家のように崩れないマルチスレッドコードの書き方と、もし何かがおかしくなったときに役立つツールについて説明します。
1. クリティカルセクション(lock)を最小化する
lockの中にあるコードが少ないほど良いです。あるスレッドがロックを保持している間、他のスレッドは待機します。
例:
// ダメ: ビジネスロジック全体を lock の中に入れている — 全スレッドが待つ
lock(_locker)
{
// 長時間の操作(共有リソースと無関係)
Thread.Sleep(500);
counter++;
}
// 良い: 必要最小限の操作だけを lock の中に入れる
// 重い処理は lock の外で行う
Thread.Sleep(500);
lock(_locker)
{
counter++;
}
現実: ロック内でネットワーク呼び出しや重い計算があると、パフォーマンスが劇的に落ちます。
2. ロックのキーに汎用オブジェクトを使わない
lock(this) や lock(typeof(MyClass)) を使うのは良くありません。
なぜ? 他の誰かが同じオブジェクトをロックキーに使っていると、相互ブロックや隠れたバグを招きます。必ず専用のプライベートオブジェクトを使いましょう:
private readonly object _locker = new object();
lock(_locker)
{
// あなたの操作
}
禁止: 文字列(string)、public フィールド、値型のオブジェクト。
3. 取得したリソースの解放には必ず try...finally を使う
Mutex、セマフォ、ReaderWriterLockSlim のような取得は、finally で解放する必要があります。
_mutex.WaitOne();
try
{
// クリティカルセクション
}
finally
{
_mutex.ReleaseMutex();
}
4. 同期を乱用しない
本当に共有リソース(例えばコレクション)へのアクセスだけを同期し、「過剰な同期」は避けましょう。過剰なロックはコードを待ち行列に変えます。
5. スレッドセーフなコレクションと型を使う
.NET はマルチスレッド向けの専用コレクションを提供しています:ConcurrentDictionary、ConcurrentQueue、ConcurrentBag、BlockingCollection など。これらは内部で自分自身を保護します。
using System.Collections.Concurrent;
ConcurrentDictionary<int, string> users = new ConcurrentDictionary<int, string>();
users.TryAdd(1, "ヴァーシャ");
users[2] = "ペーチャ";
6. deadlock(相互ブロック)に注意する
典型的な罠は、複数のロックを異なる順序で取得することです。
// スレッド1
lock(obj1)
{
lock(obj2)
{
// 何かをする
}
}
// スレッド2
lock(obj2)
{
lock(obj1)
{
// 何かをする
}
}
アドバイス: 全てのスレッドで同じ順序でロックを取得すること。
7. 可能なら不変状態(immutable state)を使う
オブジェクトが生成後に状態を変えないなら、どのスレッドから読んでも安全です。例:string、Tuple、DateTime、読み取り専用の独自DTOなど。
2. マルチスレッド問題の診断ツール
同期バグは狡猾で、稀にしか出なかったり予測不能です。発見と解析に役立つツールとアプローチを使いましょう。
1. イベントとスレッドのログ出力
現在の Thread.ManagedThreadId と重要操作をログすることで、「誰がいつ」クリティカルセクションに入った/出たかを把握できます。
Console.WriteLine($"[{Thread.CurrentThread.ManagedThreadId}] クリティカルセクションに入った");
// ...
Console.WriteLine($"[{Thread.CurrentThread.ManagedThreadId}] クリティカルセクションから出た");
実運用アプリでは Microsoft.Extensions.Logging、NLog、Serilog を使いましょう。
2. Thread Sanitizer & Race Detector
.NET に完全な組み込みの ThreadSanitizer はないですが、有用なツールがあります:
- JetBrains ReSharper — インスペクションは危険なパターンを一部検出します。
- Roslyn Analyzers — 静的コード解析。
- Concurrency Visualizer — 待ち/ロックとスレッド負荷の解析。
3. Visual Studio Diagnostics Tools
Visual Studio のプロファイラで確認できること:
- アプリ内にどのスレッドが存在するか;
- スレッドがどこで待っているか(waiting);
- どこでロック争奪や競合が起きているか;
- deadlock や contention が発生しているタイミング。
トレースを取得してロック使用の詳細なグラフを入手しましょう。
4. ダンプ解析と WinDbg
サーバが「ハング」した場合、プロセスのダンプを取り、WinDbg や dotnet-dump で開きます。コールスタックからスレッドの停滞場所や誰がどのロックを保持しているかが分かります。
スタック解析の例:
0:000> !syncblk
Index SyncBlock MonitorHeld Recursion Owning Thread Info SyncBlock Owner
1 000001d4b6f90e08 1 1 000001d4b5c941c0 000001d4b6f03458
(ダンプ解析はデプロイの「玄人」向けの武器ですが、強力なので怖がらないでください。)
5. ストレステストによるユニットテスト
マルチスレッドコードを並列で何百/何千回も実行して、稀なレースを見つけやすくします。
[Test]
public void Counter_IsThreadSafe()
{
var counter = 0;
var locker = new object();
var tasks = new List<Task>();
for (int i = 0; i < 100; i++)
{
tasks.Add(Task.Run(() =>
{
for (int j = 0; j < 10000; j++)
{
lock (locker)
{
counter++;
}
}
}));
}
Task.WaitAll(tasks.ToArray());
Assert.AreEqual(100 * 10000, counter);
}
6. アサートや専用チェックの活用
デバッグ時に状態の整合性を保証するチェックを追加しましょう。例えば、同じスレッドによるリソースの再取得を検出するために Debug.Assert を使うなど。
3. 結論と推奨
ビジュアル図: 危険領域と安全
graph TD
A[共有リソース] -- 同期なし --> B(レース状態)
A -- ロック (lock/Mutex) --> C[安全なアクセス: クリティカルセクション]
C -- "過剰なロック" --> D(パフォーマンス低下)
A -- ReaderWriterLockSlim --> E{多数の読み取り / 単一の書き込み}
E -- "読み取り" --> F[多数のスレッドが同時に読む]
E -- "書き込み" --> G[1つだけが書き、他は待つ]
同期プリミティブとその用途
| プリミティブ | 用途 | 何スレッド通すか | プロセス間 | パフォーマンス | 使う場所 |
|---|---|---|---|---|---|
|
シンプルなクリティカルセクション | 1 | いいえ | 非常に高い | 99% のケース |
|
同様のセクションだがプロセス間で使える | 1 | はい | 中程度 | ファイル、IPC |
|
N 以下のスレッドを許可 | N | はい | 中程度 | リソースプール |
|
同様だがプロセス内で高速 | N | いいえ | 高い | コード内のプール |
|
多数の読み取り、単一の書き込み | 多数/1 | いいえ | 高い | キャッシュ、設定 |
GO TO FULL VERSION