Zenn
🗂

async/awaiについて(C#)

2025/02/11に公開

はじめに

async/awaitでwaitを使用しない方が良いという記載を見るが、あまり理解してなかったので調べてみました

環境

Windows11 Pro(24H2)
VisualStudio2022(64bit) Ver17.12.4

デッドロックするコード

「Windowsフォームアプリ」で作成したプロジェクトにて、Buttonコントロールを1つ置いて、
以下のコードを動作させるとデッドロックが発生します
<From1.cs>

private void button1_Click(object sender, EventArgs e)
{
    MethodWait();
}
private void MethodWait()
{
    Debug.WriteLine($"MethodA Before ID: {Thread.CurrentThread.ManagedThreadId}");
    var task = MethodAsync();
    task.Wait();
    Debug.WriteLine($"MethodA after ID: {Thread.CurrentThread.ManagedThreadId}");
}

private async Task MethodAsync()
{
    await Task.Run(() => Debug.Write($"ID: {Thread.CurrentThread.ManagedThreadId}"));
}

引数を付けた場合でも同じ

private void button1_Click(object sender, EventArgs e)
{
    MethodA();
}
private void MethodA()
{
    Debug.WriteLine($"MethodA Before ID: {Thread.CurrentThread.ManagedThreadId}");
    var result = MethodB().Result;
    Debug.WriteLine($"MethodA after ID: {Thread.CurrentThread.ManagedThreadId}");
}
private async Task<int> MethodB()
{
    await Task.Run(() => Debug.WriteLine($"ID: {Thread.CurrentThread.ManagedThreadId}"));

    return 1;
}

「MethodA after」の箇所にブレイクを付けて実施しても、この処理に行く事は無く、デッドロックが発生しています

CUIではデッドロックしない

コンソールアプリで同様の処理を行った場合には、デッドロックせずに終了しました
<Program.cs>

static void Main(string[] args)
{
    MethodA();
}
static private void MethodA()
{
    Console.WriteLine($"MethodA Before ID: {Thread.CurrentThread.ManagedThreadId}");
    var task = MethodB();
    task.Wait();
    Console.WriteLine($"MethodA After ID: {Thread.CurrentThread.ManagedThreadId}");
}

static private async Task MethodB()
{
    Console.WriteLine($"MethodB Before ID: {Thread.CurrentThread.ManagedThreadId}");
    await Task.Run(() => Console.WriteLine($"ID: {Thread.CurrentThread.ManagedThreadId}"));
    Console.WriteLine($"MethodB After ID: {Thread.CurrentThread.ManagedThreadId}");
}

Copilotに聞いてみよう

VisualStudio2022にはCopilotがあります、質問してみましょう。
task.Wait() でデッドロックが発生する理由は、UI スレッドが非同期タスクの完了を待機している間にブロックされるためです。MethodB が await を使用して非同期に実行されると、UI スレッドは task.Wait() でブロックされ、MethodB が完了するために必要なコンテキストを取得できなくなります。
この問題を解決するためには、MethodA を非同期メソッドに変更し、await を使用して MethodB を待機する必要があります。以下のように修正できます:

private async void button1_Click(object sender, EventArgs e)
{
    await MethodA();
}

private async Task MethodA()
{
    Debug.WriteLine($"MethodA Before ID: {Thread.CurrentThread.ManagedThreadId}");
    var task = MethodB();
    Debug.WriteLine(task.Id);
    await task;
    Debug.WriteLine($"MethodA after ID: {Thread.CurrentThread.ManagedThreadId}");
}

デバックコンソールの出力は以下の通り
MethodA Before ID: 1
MethodB Before ID: 1
ID: 4
MethodB after ID: 1
MethodA after ID: 1

MethodBの処理が終わった後に、MethodAのafterが行われており、正しくMethodBの処理終了を待っています。さすがCopilotです。解決しました

検証

CUIでの実装の場合には、実行するとコンソール画面が開いて、以下の結果となります

「MethodB After」のメッセージが表示されていて、処理が最後まで進んでいます
MethodB内で実行されているIDの番号と、その後の「MethodB After ID」が同じ数値となっています
GUIの場合は、ここが同値ではなく「MethodB After ID」「MethodB Before ID」が同じ値となります

GUIでの実装の場合には、MethodB内の処理はasync/await処理にてスレッドが元のUIスレッドに戻されるけど、MethodAの「task.Wait」でUIスレッドがすでにロックされているので、デッドロックとなる。CUIでの実装時には、async/awaitでスレッドがUIスレッドに戻される事は無いので、MethodBが終了する事が出来るので、MethodAでのTask.Waitも終了待ちの後に次処理を行える、といった事らしいです

という事で、GUI実装の場合には、
Task.Wait,Task.Resultの利用は推奨されないらしいです

同様にTask.WaitAnyTask.WaitAllも同期待ちなので、デッドロックします ただし、よく似た関数でTask.WhenAnyTaskWhenAll`は非同期待ちなので、デッドロックしません。間違えそうですね。

CUIとGUIとでIDが違う理由

「同期コンテキストが存在する場合に、await処理後に自動でスレッドを戻す」ためです
CUIの場合には、同期コンテキストが自動で設定されずに、GUIの場合には自動で設定されます
ここで言っている同期コンテキストとは、SynchronizationContext.Currentの事で
GUIの場合にはメインスレッドに対して自動的にセットされます
この値がセットされている場合には、awaitの前後でスレッドを保存してくれます

この動きを確認したい場合には、

SynchronizationContext.SetSynchronizationContext(null);

で強制的にnullを入れて、自動セットをキャンセルして、確認出来ます

または
ConfigureAwait(false)を使用する事で、元スレッドに戻す処理を無くす事が出来ます

Discussion

ログインするとコメントできます