C#のデッドロックとは?原因・再現コード・回避策を初心者にもわかりやすく解説

はじめに

C#でマルチスレッド処理や非同期処理を書いていると、「プログラムが止まったまま戻ってこない」「画面が固まる」「CPU使用率は高くないのに処理が進まない」といった現象に遭遇することがあります。

その代表的な原因のひとつがデッドロックです。

デッドロックは、C#初心者にとって少し難しく感じやすいテーマです。特に、lockTask.Wait()Task.Resultasync/await、UIスレッドなどが関係すると、コード上は一見正しく見えるのにアプリが停止したように見えることがあります。

この記事では、C#のデッドロックとは何か、なぜ起きるのか、どのようなコードで再現できるのか、そして実務でどう回避すればよいのかを初心者にもわかりやすく解説します。

1. C#のデッドロックとは?まず押さえるべき基本

1-1. デッドロックの意味を初心者向けにわかりやすく解説

デッドロックとは、複数の処理がお互いに相手の終了やリソース解放を待ち続けてしまい、どちらも先に進めなくなる状態のことです。

たとえば、次のような状況を考えてみます。

スレッドAは「鍵1」を持っていて、「鍵2」が空くのを待っています。
一方、スレッドBは「鍵2」を持っていて、「鍵1」が空くのを待っています。

この場合、スレッドAもスレッドBも相手が鍵を手放すまで待ち続けます。しかし、どちらも待機中なので鍵を手放す処理まで進めません。

これがデッドロックです。

C#では、主に次のような仕組みが関係してデッドロックが起こります。

  • lock

  • Monitor

  • Mutex

  • SemaphoreSlim

  • Task.Wait()

  • Task.Result

  • async/await

  • UIスレッド

  • SynchronizationContext

特に初心者がつまずきやすいのは、lockによるデッドロックと、非同期処理を同期的に待ってしまうことによるデッドロックです。

1-2. C#でデッドロックが起きる代表的な場面

C#でデッドロックが起きやすい場面には、いくつかの典型パターンがあります。

まず多いのが、複数のlockを異なる順序で取得してしまうケースです。

C#
lock (lockA)
{
lock (lockB)
{
// 処理
}
}

このようなコード自体が必ず悪いわけではありません。しかし、別の場所で次のように逆順でロックを取得していると危険です。

C#
lock (lockB)
{
lock (lockA)
{
// 処理
}
}

スレッドAがlockAを取得し、スレッドBがlockBを取得したあと、それぞれが相手のロックを待つとデッドロックになります。

次に多いのが、Task.ResultTask.Wait()で非同期処理を無理やり同期的に待つケースです。

C#
var result = GetDataAsync().Result;

このコードはコンソールアプリでは動くことがありますが、WPFやWindows FormsなどのUIアプリではデッドロックの原因になることがあります。

1-3. フリーズ・無限ループ・処理待ちとの違い

デッドロックは「アプリが固まる」という見た目になるため、フリーズや無限ループと混同されやすいです。

しかし、それぞれ原因は異なります。

フリーズは、画面やアプリが応答しなくなる現象全般を指します。デッドロックもフリーズの原因になりますが、フリーズの原因が必ずデッドロックとは限りません。

無限ループは、同じ処理を終わりなく繰り返している状態です。

C#
while (true)
{
// 終了条件がない
}

無限ループではCPU使用率が高くなることがあります。一方、デッドロックではスレッド同士が待機しているだけなので、CPU使用率があまり上がらないこともあります。

処理待ちは、単に時間のかかる処理を待っている状態です。たとえば、ネットワーク通信やファイル読み込みを待っている場合です。処理が完了すれば先に進みます。

デッドロックは、待っている相手もまた別の待機状態に入っているため、基本的に自然には解消されません。

1-4. デッドロックが発生するとアプリで何が起きるのか

C#でデッドロックが発生すると、アプリには次のような症状が出ます。

  • 処理が途中で止まる

  • 画面が固まる

  • ボタンを押しても反応しない

  • ログが特定の箇所で止まる

  • 例外が出ない

  • CPU使用率が低いまま処理が進まない

  • デバッグ実行すると特定のlockWait()で止まっている

厄介なのは、デッドロックは必ず例外として表れるわけではない点です。

NullReferenceExceptionのようにエラーが表示されるわけではなく、単に「待ち続ける」状態になります。そのため、原因箇所を見つけるには、スレッドの状態、コールスタック、ログなどを確認する必要があります。

2. C#でデッドロックが起きる主な原因

2-1. lock文の取得順序が逆になるケース

C#のlock文は、複数スレッドが同時に同じ処理へ入らないようにするための仕組みです。

C#
lock (obj)
{
// 同時に1つのスレッドだけが入れる
}

lockは便利ですが、複数のロックを扱う場合は注意が必要です。

次のように、スレッドごとにロックの取得順序が異なるとデッドロックが起きる可能性があります。

C#
// スレッドA
lock (lockA)
{
lock (lockB)
{
}
}

// スレッドB
lock (lockB)
{
lock (lockA)
{
}
}

スレッドAはlockAを持ったままlockBを待ちます。
スレッドBはlockBを持ったままlockAを待ちます。

この時点で、どちらも先に進めません。

複数のロックを使う場合は、必ず取得順序を統一することが重要です。

2-2. Task.ResultやTask.Waitで非同期処理を同期的に待つケース

Task.ResultTask.Wait()は、非同期処理が終わるまで現在のスレッドをブロックします。

C#
var result = GetDataAsync().Result;
C#
GetDataAsync().Wait();

これらは一見便利に見えますが、UIアプリや古いASP.NET環境ではデッドロックの原因になることがあります。

理由は、非同期処理の続きが元のスレッドに戻ろうとする一方で、その元のスレッドがResultWait()でブロックされているためです。

つまり、次のような状態になります。

  • UIスレッドがTask.Resultで待つ

  • 非同期処理が完了する

  • await後の続きがUIスレッドに戻ろうとする

  • しかしUIスレッドはResultでブロック中

  • 続きが実行できない

  • Resultも完了できない

これが非同期処理でよくあるデッドロックです。

2-3. async/awaitとSynchronizationContextによる待ち合わせ

async/awaitは、非同期処理を読みやすく書くための仕組みです。

C#
var data = await GetDataAsync();

awaitを使うと、非同期処理が完了したあと、通常は元のコンテキストに戻って続きの処理を実行しようとします。

WPFやWindows Formsでは、UI操作はUIスレッドで行う必要があります。そのため、await後の処理はUIスレッドに戻ろうとします。

この「どのスレッドに戻るか」を管理している仕組みのひとつがSynchronizationContextです。

通常は便利な仕組みですが、UIスレッドをWait()Resultでブロックしてしまうと、await後の処理がUIスレッドに戻れなくなります。

その結果、デッドロックが発生します。

2-4. UIスレッドでブロックして画面が固まるケース

WPFやWindows Formsでは、画面の描画やボタン操作などはUIスレッドで処理されます。

そのUIスレッドで次のようなコードを書くと危険です。

C#
private void Button_Click(object sender, EventArgs e)
{
var result = LoadDataAsync().Result;
label1.Text = result;
}

このコードでは、ボタンクリック時にUIスレッドがLoadDataAsync().Resultでブロックされます。

非同期処理の完了後にUIスレッドへ戻ろうとしても、UIスレッドはすでに待機状態です。そのため処理が進まず、画面が固まります。

UIアプリでは、イベントハンドラーもasyncにしてawaitを使うのが基本です。

C#
private async void Button_Click(object sender, EventArgs e)
{
var result = await LoadDataAsync();
label1.Text = result;
}

2-5. 複数スレッドが同じリソースを奪い合うケース

デッドロックは、ロックオブジェクトだけで起きるわけではありません。

次のような共有リソースでも起こりえます。

  • ファイル

  • データベース接続

  • ネットワーク接続

  • キュー

  • キャッシュ

  • コレクション

  • 外部API

  • 排他制御されたオブジェクト

たとえば、ある処理がファイルAを開いたままデータベース接続を待ち、別の処理がデータベース接続を保持したままファイルAを待つような設計になっていると、デッドロックの原因になります。

C#のコード上ではlockが見えなくても、ライブラリ内部や外部リソース側で排他制御が行われている場合があります。

2-6. ConfigureAwait(false)を理解せず使っているケース

ConfigureAwait(false)は、await後に元のコンテキストへ戻らないようにする指定です。

C#
await Task.Delay(1000).ConfigureAwait(false);

ライブラリコードでは、UIスレッドに戻る必要がないことが多いため、ConfigureAwait(false)を使うことでデッドロックを避けやすくなる場合があります。

ただし、理解せずにすべてのawaitへ付ければよいわけではありません。

特にUIアプリでは、ConfigureAwait(false)を使ったあとにUI部品を直接操作するとエラーや不具合の原因になります。

C#
await Task.Delay(1000).ConfigureAwait(false);

// UIスレッドではない可能性があるため危険
label1.Text = "完了";

ConfigureAwait(false)は、UIに戻る必要のない処理、特に共通ライブラリやバックグラウンド処理で適切に使うものです。

3. C#のデッドロックを再現するサンプルコード

3-1. lock文で発生する基本的なデッドロックの再現コード

次のコードは、lockの取得順序が逆になることでデッドロックが発生する例です。

C#
using System;
using System.Threading;
using System.Threading.Tasks;

class Program
{
static readonly object lockA = new object();
static readonly object lockB = new object();

static void Main()
{
Task task1 = Task.Run(() =>
{
lock (lockA)
{
Console.WriteLine("Task1: lockAを取得");

Thread.Sleep(100);

lock (lockB)
{
Console.WriteLine("Task1: lockBを取得");
}
}
});

Task task2 = Task.Run(() =>
{
lock (lockB)
{
Console.WriteLine("Task2: lockBを取得");

Thread.Sleep(100);

lock (lockA)
{
Console.WriteLine("Task2: lockAを取得");
}
}
});

Task.WaitAll(task1, task2);

Console.WriteLine("完了");
}
}

このコードでは、Task1lockAを取得し、Task2lockBを取得します。

その後、Task1lockBを待ち、Task2lockAを待ちます。

結果として、どちらも先に進めなくなります。

3-2. Task.Waitで発生するデッドロックの再現コード

Task.Wait()によるデッドロックは、特にUIアプリで起こりやすいです。

C#
private void Button_Click(object sender, EventArgs e)
{
LoadAsync().Wait();

MessageBox.Show("完了");
}

private async Task LoadAsync()
{
await Task.Delay(1000);

// await後、UIスレッドに戻ろうとする
}

このコードでは、Button_ClickがUIスレッド上で実行されます。

LoadAsync().Wait()によってUIスレッドがブロックされます。一方、LoadAsyncawait後の処理はUIスレッドに戻ろうとします。

しかしUIスレッドはWait()で待機中なので、await後の処理が実行できません。

3-3. Task.Resultで発生するデッドロックの再現コード

Task.ResultTask.Wait()と同じように、現在のスレッドをブロックします。

C#
private void Button_Click(object sender, EventArgs e)
{
string result = GetMessageAsync().Result;

label1.Text = result;
}

private async Task<string> GetMessageAsync()
{
await Task.Delay(1000);

return "処理が完了しました";
}

このコードもUIアプリではデッドロックの原因になります。

GetMessageAsync()の中でawaitしたあと、続きの処理がUIスレッドへ戻ろうとします。しかし、UIスレッドはResultでブロックされています。

そのため、return "処理が完了しました";まで進めず、Resultも返ってきません。

3-4. async/awaitで起きやすいデッドロックの再現コード

async/await自体が悪いわけではありません。問題は、非同期処理の途中で同期的な待機を混ぜることです。

C#
public string GetData()
{
return GetDataAsync().Result;
}

private async Task<string> GetDataAsync()
{
await Task.Delay(1000);

return "データ";
}

このように、同期メソッドの中で非同期メソッドをResultで待つ設計は危険です。

呼び出し元がUIスレッドや特定のSynchronizationContextを持つ環境の場合、デッドロックが発生する可能性があります。

良い書き方は、呼び出し元までasyncにすることです。

C#
public async Task<string> GetDataAsync()
{
await Task.Delay(1000);

return "データ";
}

そして呼び出し側でもawaitします。

C#
string data = await GetDataAsync();

3-5. WPF・Windows FormsなどUIアプリでの再現例

WPFの例を見てみます。

C#
private void Button_Click(object sender, RoutedEventArgs e)
{
var text = GetTextAsync().Result;

TextBlockMessage.Text = text;
}

private async Task<string> GetTextAsync()
{
await Task.Delay(1000);

return "完了しました";
}

このコードは、ボタンを押したあと画面が固まる可能性があります。

修正するには、イベントハンドラーをasyncにしてawaitを使います。

C#
private async void Button_Click(object sender, RoutedEventArgs e)
{
var text = await GetTextAsync();

TextBlockMessage.Text = text;
}

private async Task<string> GetTextAsync()
{
await Task.Delay(1000);

return "完了しました";
}

Windows Formsの場合も考え方は同じです。

C#
private async void button1_Click(object sender, EventArgs e)
{
var text = await GetTextAsync();

label1.Text = text;
}

UIアプリでは、UIスレッドをブロックしないことが非常に重要です。

3-6. コンソールアプリでは再現しにくいケースがある理由

同じTask.ResultTask.Wait()を使っても、コンソールアプリではデッドロックが発生しにくいことがあります。

理由は、通常のコンソールアプリにはWPFやWindows FormsのようなUI用のSynchronizationContextが存在しないためです。

UIアプリでは、await後の処理がUIスレッドに戻ろうとします。
一方、コンソールアプリでは、await後の処理がスレッドプール上で継続されることが多く、UIスレッドへの復帰待ちが発生しにくいです。

ただし、コンソールアプリならデッドロックが起きないという意味ではありません。

lockの取得順序が逆になるコードや、共有リソースの待ち合わせが循環するコードでは、コンソールアプリでも普通にデッドロックは発生します。

4. デッドロック発生時の処理の流れを図解で理解する

4-1. スレッドAとスレッドBが互いに待ち続ける仕組み

デッドロックの基本構造は、互いに相手を待つことです。

スレッドA: lockAを保持 → lockBを待つ
スレッドB: lockBを保持 → lockAを待つ

この状態になると、スレッドAはスレッドBがlockBを解放するまで進めません。
スレッドBはスレッドAがlockAを解放するまで進めません。

しかし、どちらもロックを解放する処理まで到達できないため、永久に待ち続けます。

4-2. lock取得順序の違いで止まる流れ

ロック取得順序が異なる場合の流れは次のとおりです。

1. スレッドAがlockAを取得
2. スレッドBがlockBを取得
3. スレッドAがlockBを取得しようとして待機
4. スレッドBがlockAを取得しようとして待機
5. どちらも相手のロック解放を待つ
6. 処理が進まなくなる

ポイントは、ロックを複数使うこと自体ではなく、取得順序が統一されていないことです。

常にlockA、次にlockBという順序に統一すれば、このタイプのデッドロックは避けやすくなります。

4-3. await後の継続処理が戻れなくなる流れ

非同期処理で起きるデッドロックは、次のような流れです。

1. UIスレッドでGetDataAsync().Resultを呼ぶ
2. UIスレッドがResultでブロックされる
3. GetDataAsync内でawaitが実行される
4. 非同期処理が完了する
5. await後の続きがUIスレッドに戻ろうとする
6. UIスレッドはResultでブロック中
7. 続きが実行できない
8. Resultも完了できない

このように、待つ側と戻る側が同じUIスレッドを必要としているため、処理が止まります。

4-4. UIスレッドがブロックされる流れ

UIスレッドは、画面描画やユーザー操作を処理する重要なスレッドです。

UIスレッド
├─ ボタンクリック処理
├─ 画面更新
├─ マウス操作
├─ キーボード入力
└─ await後のUI更新

このUIスレッドをThread.Sleep()Wait()Resultなどでブロックすると、画面全体が応答しなくなります。

C#
private void Button_Click(object sender, EventArgs e)
{
Thread.Sleep(5000);
}

これはデッドロックではありませんが、UIが固まる原因になります。

一方、次のようなコードはデッドロックになる可能性があります。

C#
private void Button_Click(object sender, EventArgs e)
{
LoadAsync().Wait();
}

UIスレッドで同期的に待つ処理は、できるだけ避けるべきです。

4-5. 初心者が誤解しやすい「awaitすれば安全」の落とし穴

awaitを使っていれば必ず安全、というわけではありません。

次のように、一部だけawaitを使っていても、どこかでResultWait()を使っていると危険です。

C#
public string Load()
{
return LoadAsync().Result;
}

public async Task<string> LoadAsync()
{
await Task.Delay(1000);
return "完了";
}

async/awaitは、呼び出し元まで非同期でつなげてこそ効果を発揮します。

悪い例は、途中で同期処理に戻してしまうことです。

C#
var result = LoadAsync().Result;

良い例は、呼び出し元もasyncにすることです。

C#
var result = await LoadAsync();

「非同期メソッドを作ったから安全」ではなく、「非同期の流れを最後まで保つ」ことが重要です。

5. C#のデッドロックを回避する方法

5-1. lockの取得順序を必ず統一する

複数のロックを使う場合は、必ず取得順序を統一します。

悪い例です。

C#
// 場所A
lock (lockA)
{
lock (lockB)
{
}
}

// 場所B
lock (lockB)
{
lock (lockA)
{
}
}

良い例です。

C#
// 場所A
lock (lockA)
{
lock (lockB)
{
}
}

// 場所B
lock (lockA)
{
lock (lockB)
{
}
}

どの処理でも同じ順番でロックを取得すれば、循環待ちが発生しにくくなります。

5-2. lockする範囲をできるだけ短くする

lock内の処理は、できるだけ短くします。

悪い例です。

C#
lock (_lock)
{
var data = DownloadData();
SaveToFile(data);
_list.Add(data);
}

このコードでは、ダウンロードやファイル保存の間もロックを保持しています。時間のかかる処理をlock内で実行すると、他のスレッドが長時間待たされます。

良い例です。

C#
var data = DownloadData();
SaveToFile(data);

lock (_lock)
{
_list.Add(data);
}

共有データを操作する部分だけをlockで守ると、デッドロックや性能低下のリスクを減らせます。

5-3. Task.ResultやTask.Waitを避けてawaitを使う

非同期処理を待つときは、基本的にTask.ResultTask.Wait()ではなくawaitを使います。

悪い例です。

C#
var result = GetDataAsync().Result;

良い例です。

C#
var result = await GetDataAsync();

awaitを使うと、現在のスレッドをブロックせずに非同期処理の完了を待てます。

特にUIアプリでは、画面を固めないためにもawaitを使うことが重要です。

5-4. asyncメソッドは呼び出し元までasyncでつなげる

非同期処理は、途中で同期的に待たず、呼び出し元までasyncでつなげます。

悪い例です。

C#
public string GetUserName()
{
return GetUserNameAsync().Result;
}

良い例です。

C#
public async Task<string> GetUserNameAsync()
{
return await LoadUserNameAsync();
}

呼び出し側も次のようにします。

C#
string name = await GetUserNameAsync();

このように、非同期処理は上位の呼び出し元までasyncにしていくのが基本です。

5-5. ConfigureAwait(false)を適切に使う

UIスレッドに戻る必要がない処理では、ConfigureAwait(false)を使うことで、不要なコンテキスト復帰を避けられます。

C#
public async Task<string> LoadFromApiAsync()
{
using var client = new HttpClient();

var result = await client
.GetStringAsync("https://example.com")
.ConfigureAwait(false);

return result;
}

ただし、UIを更新する処理では注意が必要です。

C#
private async void Button_Click(object sender, EventArgs e)
{
var result = await LoadFromApiAsync();

// UIスレッド上で実行される想定
label1.Text = result;
}

共通ライブラリではConfigureAwait(false)を使い、UI層では必要に応じてUIスレッドに戻る、という使い分けが大切です。

5-6. SemaphoreSlimで非同期対応の排他制御を行う

lock文の中ではawaitを使えません。

C#
lock (_lock)
{
await Task.Delay(1000); // コンパイルエラー
}

非同期処理で排他制御をしたい場合は、SemaphoreSlimWaitAsync()を使います。

C#
private readonly SemaphoreSlim _semaphore = new SemaphoreSlim(1, 1);

public async Task SaveAsync()
{
await _semaphore.WaitAsync();

try
{
await Task.Delay(1000);
Console.WriteLine("保存しました");
}
finally
{
_semaphore.Release();
}
}

SemaphoreSlimを使うと、非同期処理の中でも安全に待機できます。

ポイントは、必ずfinallyRelease()することです。途中で例外が発生しても解放されるようにします。

5-7. タイムアウトを設定して永久待ちを防ぐ

デッドロックの怖いところは、永久に待ち続けることです。

そのため、待機処理にはタイムアウトを設定するのが有効です。

C#
if (Monitor.TryEnter(_lock, TimeSpan.FromSeconds(3)))
{
try
{
Console.WriteLine("ロックを取得しました");
}
finally
{
Monitor.Exit(_lock);
}
}
else
{
Console.WriteLine("ロック取得がタイムアウトしました");
}

SemaphoreSlimでもタイムアウトを設定できます。

C#
if (await _semaphore.WaitAsync(TimeSpan.FromSeconds(3)))
{
try
{
await DoWorkAsync();
}
finally
{
_semaphore.Release();
}
}
else
{
Console.WriteLine("待機がタイムアウトしました");
}

タイムアウトを入れておくと、問題が起きたときにログを出したり、リトライしたり、ユーザーにエラーを表示したりできます。

5-8. 共有リソースを減らして設計で回避する

最も効果的な対策は、そもそも共有リソースを減らすことです。

複数スレッドが同じデータを読み書きするから排他制御が必要になります。共有を減らせば、ロックも減り、デッドロックの可能性も下がります。

たとえば、次のような設計が有効です。

  • 変更可能な共有データを減らす

  • 不変オブジェクトを使う

  • スレッドごとにデータを分ける

  • キューで処理を一方向に流す

  • データ更新を1つの専用スレッドに集約する

  • ConcurrentDictionaryなどのスレッドセーフコレクションを使う

ロックで無理に解決するより、ロックが少なくて済む設計にすることが重要です。

6. デッドロックを防ぐためのC#実装パターン

6-1. 悪い例:lockを入れ子にするコード

次のコードは、デッドロックを起こしやすい悪い例です。

C#
private readonly object _lockA = new object();
private readonly object _lockB = new object();

public void MethodA()
{
lock (_lockA)
{
lock (_lockB)
{
Console.WriteLine("MethodA");
}
}
}

public void MethodB()
{
lock (_lockB)
{
lock (_lockA)
{
Console.WriteLine("MethodB");
}
}
}

MethodAMethodBが別スレッドから同時に呼ばれると、ロックの取得順序が逆なのでデッドロックになる可能性があります。

6-2. 良い例:lock順序を統一したコード

次のように、ロック取得順序を統一すると安全性が上がります。

C#
private readonly object _lockA = new object();
private readonly object _lockB = new object();

public void MethodA()
{
lock (_lockA)
{
lock (_lockB)
{
Console.WriteLine("MethodA");
}
}
}

public void MethodB()
{
lock (_lockA)
{
lock (_lockB)
{
Console.WriteLine("MethodB");
}
}
}

常に_lockA、次に_lockBという順序で取得しています。

このようにルールを決めておけば、複数人で開発している場合でもデッドロックを防ぎやすくなります。

6-3. 悪い例:非同期処理をResultで待つコード

次のコードは、UIアプリでデッドロックの原因になりやすい悪い例です。

C#
private void Button_Click(object sender, EventArgs e)
{
var user = GetUserAsync().Result;

label1.Text = user;
}

private async Task<string> GetUserAsync()
{
await Task.Delay(1000);

return "山田太郎";
}

ResultによってUIスレッドがブロックされるため、await後の処理がUIスレッドに戻れなくなる可能性があります。

6-4. 良い例:awaitで最後まで非同期にするコード

修正するには、イベントハンドラーをasyncにしてawaitします。

C#
private async void Button_Click(object sender, EventArgs e)
{
var user = await GetUserAsync();

label1.Text = user;
}

private async Task<string> GetUserAsync()
{
await Task.Delay(1000);

return "山田太郎";
}

これならUIスレッドをブロックせずに非同期処理の完了を待てます。

async voidは通常避けるべきですが、WPFやWindows Formsのイベントハンドラーでは一般的に使われます。通常のメソッドではasync Taskを使うのが基本です。

6-5. 良い例:SemaphoreSlim.WaitAsyncを使うコード

非同期処理で排他制御をしたい場合は、SemaphoreSlim.WaitAsync()を使います。

C#
private readonly SemaphoreSlim _semaphore = new SemaphoreSlim(1, 1);

public async Task UpdateAsync()
{
await _semaphore.WaitAsync();

try
{
await Task.Delay(1000);
Console.WriteLine("更新しました");
}
finally
{
_semaphore.Release();
}
}

SemaphoreSlim(1, 1)は、同時に1つの処理だけを通す排他制御として使えます。

lockと違い、awaitと組み合わせられるのが大きなメリットです。

6-6. 良い例:CancellationTokenで待機をキャンセルするコード

長時間待機する可能性がある処理では、CancellationTokenを使うと安全です。

C#
private readonly SemaphoreSlim _semaphore = new SemaphoreSlim(1, 1);

public async Task UpdateAsync(CancellationToken cancellationToken)
{
await _semaphore.WaitAsync(cancellationToken);

try
{
await Task.Delay(5000, cancellationToken);
Console.WriteLine("更新しました");
}
finally
{
_semaphore.Release();
}
}

呼び出し側では、一定時間でキャンセルできます。

C#
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(3));

try
{
await UpdateAsync(cts.Token);
}
catch (OperationCanceledException)
{
Console.WriteLine("処理がキャンセルされました");
}

キャンセル可能にしておくと、永久待ちを避けやすくなります。

7. C#のデッドロックを調査・デバッグする方法

7-1. Visual Studioでスレッドの状態を確認する

デッドロックが疑われる場合は、Visual Studioのデバッグ機能でスレッドの状態を確認します。

デバッグ実行中に処理が止まったら、「すべて中断」を実行します。すると、現在の各スレッドがどこで止まっているかを確認できます。

見るべきポイントは次のとおりです。

  • どのスレッドが待機中か

  • lockで止まっているか

  • Wait()で止まっているか

  • Resultで止まっているか

  • UIスレッドがブロックされているか

スレッドウィンドウを使うと、複数スレッドの状態を一覧できます。

7-2. コールスタックから待機中の処理を特定する

コールスタックを見ると、処理がどのメソッドで止まっているかがわかります。

たとえば、次のような箇所で止まっている場合は注意が必要です。

C#
Task.Wait()
Task<TResult>.Result
Monitor.Enter()
SemaphoreSlim.Wait()
Thread.Sleep()

Monitor.Enter()で止まっている場合は、lockの取得待ちである可能性があります。

Task.Wait()Resultで止まっている場合は、非同期処理を同期的に待っている可能性があります。

コールスタックを複数スレッド分確認すると、「スレッドAがスレッドBを待ち、スレッドBがスレッドAを待っている」といった関係が見えてくることがあります。

7-3. タスクウィンドウで停止しているTaskを確認する

Visual Studioには、非同期処理を確認するためのタスク関連ウィンドウがあります。

Taskを多用しているアプリでは、どのタスクが実行中で、どのタスクが待機中なのかを確認すると原因を絞り込みやすくなります。

確認したいポイントは次のとおりです。

  • 完了していないTaskがあるか

  • どのTaskが待機中か

  • どのメソッドでawaitしているか

  • UIスレッドへ戻ろうとして止まっていないか

  • ResultWait()で待たれていないか

非同期処理のデッドロックでは、タスク自体は完了しかけているのに、継続処理が実行できず止まっていることがあります。

7-4. ログ出力でlock取得前後の流れを追う

デッドロックの調査では、ログが非常に役立ちます。

特に、lockの取得前後にログを出すと、どこで止まったかがわかりやすくなります。

C#
Console.WriteLine("lockA取得前");

lock (_lockA)
{
Console.WriteLine("lockA取得後");

Console.WriteLine("lockB取得前");

lock (_lockB)
{
Console.WriteLine("lockB取得後");
}
}

ログが次のように止まっていたら、lockBの取得待ちで止まっている可能性があります。

lockA取得前
lockA取得後
lockB取得前

ログには、スレッドIDも出すと便利です。

C#
Console.WriteLine($"Thread {Environment.CurrentManagedThreadId}: lockA取得前");

複数スレッドが関係する問題では、「どのスレッドが」「どのロックを」「いつ取得したか」を追うことが重要です。

7-5. 再現性が低いデッドロックを調査するコツ

デッドロックは、毎回必ず再現するとは限りません。

スレッドの実行タイミングによって発生したりしなかったりします。そのため、再現性が低い場合は調査が難しくなります。

調査のコツは次のとおりです。

  • ログを増やす

  • スレッドIDを出力する

  • ロック取得前後を記録する

  • タイムアウトを設定する

  • 高負荷状態でテストする

  • 同じ処理を何度も繰り返すテストを作る

  • Thread.Sleep()でタイミングを調整して再現性を上げる

  • デバッグ時に全スレッドのコールスタックを見る

特に、本番環境でしか発生しないデッドロックは、ログ設計が重要です。

単に「処理開始」「処理終了」だけでなく、「待機開始」「ロック取得」「ロック解放」「タイムアウト」などを記録しておくと原因を追いやすくなります。

8. C#のデッドロックでよくある質問

8-1. lock文を使うと必ずデッドロックするのか

lock文を使ったからといって、必ずデッドロックするわけではありません。

lockは、共有リソースを安全に扱うための基本的な仕組みです。

ただし、次のような使い方は危険です。

  • 複数のlockを逆順で取得する

  • lock内で時間のかかる処理を行う

  • lock内で外部APIやファイルI/Oを行う

  • lock内で別スレッドの完了を待つ

  • ロック対象オブジェクトを外部に公開する

適切に使えば、lockは有効な排他制御の手段です。

8-2. async/awaitを使えばデッドロックは起きないのか

async/awaitを使っていても、デッドロックは起こります。

特に、次のように途中で同期的に待つと危険です。

C#
var result = SomeAsync().Result;
C#
SomeAsync().Wait();

async/awaitを使う場合は、呼び出し元までasyncでつなげることが大切です。

C#
var result = await SomeAsync();

await自体は安全性を高める仕組みですが、同期ブロックと混ぜるとデッドロックの原因になります。

8-3. Task.Runで囲めばデッドロックは解決できるのか

Task.Runで囲むと、一時的にデッドロックを回避できたように見えることがあります。

C#
var result = Task.Run(() => GetDataAsync()).Result;

しかし、これは根本的な解決ではありません。

Task.Runは処理をスレッドプールへ逃がすだけであり、設計上の問題を隠してしまう場合があります。また、不要なスレッド消費や例外処理の複雑化につながることもあります。

基本は、ResultWait()を使わず、awaitで非同期のまま処理をつなぐことです。

C#
var result = await GetDataAsync();

8-4. ConfigureAwait(false)は常に付けるべきか

ConfigureAwait(false)は常に付ければよいものではありません。

共通ライブラリや、UIスレッドに戻る必要がない処理では有効です。

C#
await SomeLibraryProcessAsync().ConfigureAwait(false);

一方、UI更新が必要な処理では注意が必要です。

C#
await LoadAsync().ConfigureAwait(false);

// UIスレッドではない可能性がある
label1.Text = "完了";

UIアプリの画面側コードでは、await後にUIを操作することが多いため、無理にConfigureAwait(false)を付けると別の問題が起きることがあります。

使いどころを理解して、必要な場所で使うことが大切です。

8-5. コンソールアプリとUIアプリで挙動が違うのはなぜか

コンソールアプリとUIアプリでは、SynchronizationContextの有無や動作が異なります。

WPFやWindows Formsでは、UIスレッドに処理を戻す仕組みがあります。await後の処理も、通常はUIスレッドに戻ろうとします。

そのため、UIスレッドをResultWait()でブロックすると、await後の処理が戻れずデッドロックになることがあります。

一方、通常のコンソールアプリでは、UIスレッドのような特別なコンテキストがないため、同じコードでもデッドロックにならない場合があります。

ただし、コンソールアプリでもlockの循環待ちなどによるデッドロックは発生します。

8-6. デッドロックと競合状態の違いは何か

デッドロックと競合状態は、どちらもマルチスレッド処理で起こる問題ですが、内容は異なります。

デッドロックは、複数の処理が互いに待ち続けて止まる問題です。

スレッドAがスレッドBを待つ
スレッドBがスレッドAを待つ
結果として両方止まる

競合状態は、複数の処理が同じデータを同時に操作し、実行タイミングによって結果が変わる問題です。

C#
_count++;

このような単純な加算でも、複数スレッドから同時に実行すると期待どおりの値にならないことがあります。

つまり、デッドロックは「止まる問題」、競合状態は「結果が不正になる問題」と考えるとわかりやすいです。

まとめ

C#のデッドロックとは、複数のスレッドやタスクが互いに相手の処理完了やリソース解放を待ち続け、処理が進まなくなる状態です。

代表的な原因には、lockの取得順序が逆になるケース、Task.ResultTask.Wait()で非同期処理を同期的に待つケース、UIスレッドをブロックしてしまうケースなどがあります。

特に初心者が注意すべきポイントは次のとおりです。

  • 複数のlockを使う場合は取得順序を統一する

  • lock内の処理は短くする

  • Task.ResultTask.Wait()を安易に使わない

  • 非同期処理はawaitで最後までつなげる

  • UIスレッドをブロックしない

  • 非同期の排他制御にはSemaphoreSlim.WaitAsync()を使う

  • 必要に応じてタイムアウトやキャンセルを設定する

  • 共有リソースを減らす設計を意識する

デッドロックは、発生すると例外が出ずに処理が止まるため、原因を見つけにくい問題です。

しかし、発生パターンを理解しておけば、多くのデッドロックは設計と実装の段階で防げます。

C#で安全なマルチスレッド処理や非同期処理を書くためには、「誰が何を待っているのか」「どのスレッドがブロックされているのか」「共有リソースをどの順序で取得しているのか」を常に意識することが重要です。