1. C#でのDeadlockの例
Deadlock — これは、スレッド群のそれぞれがあるリソースを占有し、別のスレッドが占有している別のリソースの解放を待っている状況で、誰も待ちを抜けられない状態のことです。
実例で見てみましょう。ロッカーオブジェクトが2つとスレッドが2つあるとします。各スレッドは1つのオブジェクトをロックしてから、次にもう1つを取ろうとします。
using System;
using System.Threading;
class Program
{
static readonly object lockerA = new object();
static readonly object lockerB = new object();
static void Main()
{
Thread thread1 = new Thread(Thread1Work);
Thread thread2 = new Thread(Thread2Work);
thread1.Start();
thread2.Start();
thread1.Join();
thread2.Join();
Console.WriteLine("両方のスレッドが終了しました(Deadlock が発生していなければ)");
}
static void Thread1Work()
{
lock (lockerA)
{
Console.WriteLine("スレッド 1: lockerA を取得しました");
Thread.Sleep(100); // スレッド2が lockerB を取得するチャンスを与える
lock (lockerB)
{
Console.WriteLine("スレッド 1: lockerB を取得しました");
}
}
}
static void Thread2Work()
{
lock (lockerB)
{
Console.WriteLine("スレッド 2: lockerB を取得しました");
Thread.Sleep(100); // スレッド1が lockerA を取得するチャンスを与える
lock (lockerA)
{
Console.WriteLine("スレッド 2: lockerA を取得しました");
}
}
}
}
このコードを何度か実行してみてください — ほぼ確実にアプリが「ハング」するのに遭遇します。Deadlock が発生したのです! 各スレッドが自分のロックを持ち、相手が解放するのを待っているため、誰も進めません。
Deadlockの可視化
図はこんな感じです:
sequenceDiagram
participant スレッド1
participant スレッド2
participant lockerA
participant lockerB
スレッド1->>lockerA: lockerA を取得
スレッド2->>lockerB: lockerB を取得
スレッド1->>lockerB: lockerB の解放を待機
スレッド2->>lockerA: lockerA の解放を待機
結果として: スレッド1は lockerA を持ち lockerB を待ち、スレッド2は lockerB を持ち lockerA を待ちます。誰も譲らない — プログラムは停止します。
2. Deadlock をどう検出するか
なぜ起きるのか?
原因を理解するためにいくつかの要素を見てみます:
- 複数ロックの取得: スレッドが複数のロックを同時に取得する場合。
- ロック順序の不一致: スレッドが異なる順序でロックを取得すると deadlock が起きる可能性がある。
- 待機の発生可能性: スレッドが別のスレッドが占有するリソースの解放を待つ。
- 強制奪取がない: C#ではスレッドが他のスレッドからロックを強制的に奪えない — 待つしかない。
クラシックな罠: N 個のスレッドと N 個のリソースがあり、スレッドがそれらを異なる順序でロックすると、deadlock の危険性が高まります。
典型的な Deadlock のサイン
Deadlock は外側から見ると普通の「ハング」に見えます。スレッドは生きていますが、リソースアクセスを無限に待っています。
典型的な兆候:
- アプリケーションが突然応答しなくなる(時々、特定の負荷時のみ)。
- デバッガでスレッドが lock, Monitor.Enter, WaitOne, EnterReadLock などの同期操作でスタックに張り付いているのが見える。
- システムツール(Task Manager や Rider/Visual Studio のツール)が CPU 使用率をほぼ 0% と示す — 「何も動いていない」。
- ログにエラーはないが、活動もない。
JetBrains Rider や Visual Studio を使っているなら、デバッガでスレッドがどこにハマっているか(Stack Trace)を見てください。複数のスレッドが互いに lock/Mutex.WaitOne で待ち合っているのが見えたら — おめでとう(いや、同情します):それは deadlock です!
典型的な発生シナリオ
ロック順序の不一致。 上の例のように: スレッド1が lockerA を取り、その後 lockerB を取り、スレッド2が逆の順序で取ると、罠が出来上がります。
ネストされたロック。 ある lock ブロックの中で別のオブジェクトに対する lock を行う場合。
メソッド間で交差するリソース。 ロックが別のメソッドやクラスに分散していると追跡が難しくなります。ロックが2つ以上あるとシナリオは複雑化します。
3. Deadlock を防ぐ方法
良いニュース: deadlock は防げます!
悪いニュース: マルチスレッド設計に注意が必要です。
いくつかの実践的な推奨(覚えておけば面接でも評価されます):
常に同じ順番でロックを取得する
複数のオブジェクトを同時にロックする可能性があるなら、共通の順序(例えば名前やインデックス順)を定め、常にそれに従ってください。
例 — 正しいロック取得
static void SafeLock(object objA, object objB)
{
// 参照の比較 — スレッドが常に同じ順序でオブジェクトをロックするようにする
object first = objA.GetHashCode() < objB.GetHashCode() ? objA : objB;
object second = objA.GetHashCode() < objB.GetHashCode() ? objB : objA;
lock (first)
{
lock (second)
{
// クリティカルセクション
}
}
}
ここでは — 両方のスレッドがまず lock(objA) に入り、その後 lock(objB) に入ります、もし objA が objB より「小さい」なら。
タイムアウトを使う
スレッドが合理的な時間内にロックを取得できない場合は、例外を投げるか安全に抜けるべきです。Monitor.TryEnter が使えます。
Monitor.TryEnter の例
bool lockTakenA = false;
bool lockTakenB = false;
try
{
Monitor.TryEnter(lockerA, 500, ref lockTakenA);
if (!lockTakenA)
{
// 500ms でロックを取得できなかった — 相互デッドロックを回避して諦める
return;
}
Monitor.TryEnter(lockerB, 500, ref lockTakenB);
if (!lockTakenB)
{
return;
}
// クリティカルセクション...
}
finally
{
if (lockTakenB)
Monitor.Exit(lockerB);
if (lockTakenA)
Monitor.Exit(lockerA);
}
ロックを取得できなければ — ハングしないようにしましょう!
ロック範囲を最小化する
クリティカルセクション内には必要最小限のコードだけを置いてください。
異なる同期メカニズムを混ぜない
同時に lock、Mutex、Semaphore、ReaderWriterLockSlim を使うと、混乱して相互デッドロックのリスクが増えます。
4. Mutex, Semaphore, ReaderWriterLockSlim による Deadlock
相互デッドロックは古典的な lock や Monitor だけでなく、他の同期手段でも発生します。
Mutex による Deadlock
static Mutex mutexA = new Mutex();
static Mutex mutexB = new Mutex();
void Work1()
{
mutexA.WaitOne();
Thread.Sleep(100);
mutexB.WaitOne();
// クリティカルセクション
mutexB.ReleaseMutex();
mutexA.ReleaseMutex();
}
もし別のスレッドが逆の順序でこれらを取得しようとすると — deadlock になります。
ReaderWriterLockSlim による Deadlock
ReaderWriterLockSlim は柔軟ですが、あるスレッドが一種類のロックを既に取得していて別の種類を取ろうとすると、相互デッドロックを防げません。
例えば、スレッドが read-lock(EnterReadLock)を保持している状態で write-lock(EnterWriteLock)を取りに行くと — write-lock はすべての read-lock が解放されるまで取得できないため、deadlock が発生します。
5. 実際のアプリで Deadlock を避ける方法
デモアプリでどう見えるか考えてみましょう。オンラインストアのシミュレータを作っていて、同時に動くものがあるとします:
- 在庫を更新するスレッド(writers)
- 商品の在庫を確認するスレッド(readers)
- 管理者(特別なスレッド)、読み書き両方を行う
悪い例:
lock (stockLock)
{
// 在庫の更新
lock (userLock)
{
// ユーザー関連の処理
}
}
// ... そして別の場所で逆に!
lock (userLock)
{
lock (stockLock) { }
}
良い例:
合意すること — 常にまず stockLock を取得し、次に userLock を取得する、というルールを全スレッドで守る。
6. 仕事や面接で Deadlock を避ける方法
実プロジェクトで:
- 設計時にスレッドとロックの図を描く(紙やホワイトボードでOK)。
- ロック順序に関する合意をチーム全体で共有する。
- 静的解析ツール(Rider/Visual Studio、ReSharper)を使う — 潜在的な deadlock を見つけられることがある。
- Microsoft Docs の Deadlock に関する記事を読む。
面接で:
- 問題とその典型的な症状を理解していることを示す。
- 防御手段を挙げる: 単一の順序、タイムアウト(TryEnter)、最小のクリティカルセクション。
- TryEnter の例を示し、なぜ finally で取得したリソースをすべて解放することが重要かを説明する。
7. 典型的な間違いと意外な Deadlock
ミス #1: サードパーティライブラリ内の見えないロック。
典型例 — クリティカルセクション内でのログ出力。スレッドがハマっているのに、原因が外部のコードにあることに気づかない。
ミス #2: 同じリソースの再取得。
これは reentrant deadlock: スレッドが既に取得しているロックに再び入ろうとして、しかしメカニズムが再入をサポートしていない場合に起きる。
ミス #3: クリティカルセクション内での非同期メソッドの使用。
特に await は危険です: スレッドが制御を手放すタイミングが不適切だとシステム全体をハングさせることがあります。
GO TO FULL VERSION