追記
この記事は間違いを含んでおり、訂正後の結論は末尾に追記しています。末尾を先にお読み願います。
C# を仕事で使うことになり、ふと思ったのです
株式会社 ACCESS にて働く三原と申します。
弊社はとても珍しい社歴を持ち、僕は以前には「iモード」などに使われた NetFront Browser の開発も担当していました。当時に記した記事もあります。
https://qiita.com/aKatsuhiroMihara/items/ca1cee35642487538024
ですが僕も最近はあちこちの案件を点々としています。
その中の一つとして C# を用いる案件も担当させていただいています。現在進行中です。その中で、ふと思ったのです。
C# 開発者に広まるイディオム 「C#でasync Task<T>型関数をWait()/Resultで待つとデッドロックする」
C# はマルチスレッドについて強力な機能を簡易な構文で記述できます。そんな C# を用いる開発者の間で、とあるイディオムが認識されていることに気づきました。
「C#でasync Task<T>型関数をWait()/Resultで待つとデッドロックする」
ここでの論理展開は
- async キーワードを指定した関数で await により Task を待った場合、Task が終了した後の処理は、呼び出し元のスレッドにより実行される
- 呼び出し元のスレッドは Wait()/Result により async キーワードを指定した関数の実行結果を待ってブロックしている
- 1 は 2 が解放されるのを待っており、2 は 1 が終了するのを待っている、そのためお見合いを起こしてデッドロックする
あれ…… そんなはずないけど……
Micorosoft の技術者が C# の Wait() について記したブログ記事があります。
Task.Wait and “Inlining”
“What does Task.Wait do?”
ある関数が Wait() により Task の終了を待つと、待たれている Task が優先して実行されることが記されています。
そうだよなあ…… デッドロックを容易に起こす仕様が堂々と実用プログラミング言語に残っているはずないよなあ……
物は試しで
以下のコードを動かすとデッドロックしません。
// See https://aka.ms/new-console-template for more information
Console.WriteLine("Hello, World!");
AnAsync().Wait();
Console.WriteLine("Hello, World! 2");
async Task AnAsync()
{
await Task.Run(() => Task.Delay(1000));
Console.WriteLine("AnAsync");
}
Windows 11 と .NET SDK 8.0.402 での実行結果が以下です。思ったとおり最後まで走ります。
PS C:\Users\one_user\deadlock_sample\bin\Debug\net8.0> dotnet --version
8.0.402
PS C:\Users\one_user\deadlock_sample> dotnet build
復元対象のプロジェクトを決定しています...
復元対象のすべてのプロジェクトは最新です。
deadlock_sample -> C:\Users\one_user\deadlock_sample\bin\Debug\net8.0\deadlock_sample.dll
ビルドに成功しました。
0 個の警告
0 エラー
経過時間 00:00:01.82
PS C:\Users\one_user\deadlock_sample> cd .\bin\Debug\net8.0\
PS C:\Users\one_user\deadlock_sample\bin\Debug\net8.0> dir
Directory: C:\Users\one_user\deadlock_sample\bin\Debug\net8.0
Mode LastWriteTime Length Name
---- ------------- ------ ----
-a--- 2024/10/08 20:36 437 deadlock_sample.deps.json
-a--- 2024/10/08 20:36 6144 deadlock_sample.dll
-a--- 2024/10/08 20:36 138752 deadlock_sample.exe
-a--- 2024/10/08 20:36 10820 deadlock_sample.pdb
-a--- 2024/10/08 20:36 268 deadlock_sample.runtimeconfig.json
PS C:\Users\one_user\deadlock_sample\bin\Debug\net8.0> .\deadlock_sample.exe
Hello, World!
AnAsync
Hello, World! 2
PS C:\Users\one_user\deadlock_sample\bin\Debug\net8.0>
macOS Sonoma 14.7 と .NET SDK 8.0.100(都合によりサービスレベルが低め)での実行結果が以下です。こちらも最後まで走ります。マルチプラットフォームで動作する C# における Microsoft の品質保証は確かです。
one_user@host deadlock_sample % dotnet --version
8.0.100
one_user@host deadlock_sample %
one_user@host deadlock_sample % dotnet build
MSBuild のバージョン 17.8.3+195e7f5a3 (.NET)
復元対象のプロジェクトを決定しています...
/Users/one_user/xxx/deadlock_sample/deadlock_sample.csproj を復元しました (2.68 sec)。
deadlock_sample -> /Users/one_user/xxx/deadlock_sample/bin/Debug/net8.0/deadlock_sample.dll
ビルドに成功しました。
0 個の警告
0 エラー
経過時間 00:00:32.62
one_user@host deadlock_sample % cd bin/Debug/net8.0
one_user@host net8.0 % ls -F
deadlock_sample* deadlock_sample.pdb
deadlock_sample.deps.json deadlock_sample.runtimeconfig.json
deadlock_sample.dll
one_user@host net8.0 % ./deadlock_sample
Hello, World!
AnAsync
Hello, World! 2
one_user@host net8.0 %
デッドロックなら発生しないこともある、という指摘は当コードには当てはまりません。イディオムではタイミングではなく複数のスレッドと複数の関数の構造により生じていると主張していますから、タイミング問題にはならず確実にデッドロックを発生させるはずです。
ということは、イディオムが疑わしいです。
経緯を調べていませんが
そのように認識された経緯を想像すると、実際にデッドロックを起こした開発現場があったのはたしかだろうと想像します。ただ、それをどう解釈するかでした。
C# は Task と async/await によりマルチスレッドを容易に扱えるようになりましたが、もしもオブジェクトのインスタンスをスレッド間で共有していればデッドロックは発生し得ます。計算機科学の原則は時には物理法則にも匹敵する拘束力を持ち、言語設計者の工夫では如何ともし難いです。
オブジェクトのインスタンスによるデッドロックを起こした(至って当たり前のことです)開発者が、デッドロックが発生した原因を C# の言語仕様に求めたのではないか。
つまり確認不足だったという厳しい指摘ですが、現在から見ると、そんな構図に見えるのです。
やや引いた目線で言えることは
今回の話をやや引いた目線で言及すると次の二つです。
- 言語仕様を確認する際には、なるべく開発元に近い資料を当たろう
- 数行のコードを書けば判明することなら、書いたら分かることもある
- つまらない言語仕様が残っていると言語設計者をみくびった自分を、逆に疑ってみる
3 は僕も失敗したことがあります。誰もが通る道なんです。
https://qiita.com/aKatsuhiroMihara/items/2400b96c3acc137257bb
これで話を締めさせていただきます。
追記、僕がまた間違えたことについて。Microsoft による説明
これについては Microsoft に説明がありました。
GUI アプリケーションと ASP.NET アプリケーションには、一度に実行するコードを 1 つのチャンクに限定する SynchronizationContext があります。await が完了するときは、キャプチャしたコンテキスト内で async メソッドの残りを実行しようとします。しかし、このコンテキストは既にその内部にスレッドを持っており、これは asyncメソッドが完了するのを (同期して) 待機します。それらは、それぞれもう一方を待機し、デッドロックを引き起こします。
コンソール アプリケーションはこのデッドロックを引き起こしません。コンソール アプリケーションでは、一度に 1 つのチャンクに制限する SynchronizationContext ではなく、スレッド プールを備えた SynchronizationContext を使用するため、await が完了するとき、スレッド プールのスレッドで async メソッドの残り処理のスケジュールが設定されます。このメソッドは完了でき、返されたタスクを完了するため、デッドロックは発生しません。この動作の違いにより、プログラマーがテスト コンソール プログラムを作成し、部分的な非同期コードが想定どおりに動作するのを確認した後、同じコードを GUI アプリケーションまたは ASP.NET アプリケーションに移行するとデッドロックが発生する場合があるため、混乱が生じます。
コンソールアプリケーションで実験したコードは GUI アプリケーションでは正しく動作しないことを Microsoft 自身が認識しています。そして環境による差異を回避するために、すべて非同期で実装すると問題が起きない、と説明しています。
コンソールアプリケーションで実験した結果では判別できないというのは、僕が C# 初心者だから知らなかったところです。年寄りが格好つけるものではありませんね。失敗、失敗。
長らく参照されてきた上記ドキュメントは 2015 年に記されたものです。.NET 1.0 から .NET Framework を経て .NET Core に至るまでの変遷を記したドキュメントが以下です。内部実装が相当に変更されています。
https://devblogs.microsoft.com/dotnet/how-async-await-really-works/
それと SynchronizationContext が制約であるということは、スレッドプールにあるワーカースレッドで動作している場合には話が違ってきます。新規に Task を生成して Result 参照をラップするとデッドロックしないという回避策が見られます。
Wait()/Result によるデッドロックの有無を分けるのは、雑な言葉で言えば、「呼び出し元スレッドに身代わりがいるかいないか」。
例えば、.NET MAUI アプリでは Task.Run()
などでスレッドプールにあるワーカースレッドを使用できます。そこから Wait()/Result を使用するとデッドロックしないように見えます。そこでプログラマが誤まった成功体験を積み「GUIアプリでもデッドロックしないじゃないか」とネット記事を疑い始めると話がおかしくなります。僕が陥ったのは、それでした。
大切なのはメインスレッドとそれ以外を分けて考えること。GUI アプリでもワーカースレッドではデッドロックしないように見えるけれども、メインスレッドで同じ誤りをしてはいけませんでした。当記事に誤りの指摘を受けた後での僕の結論です。
追記その2 一つのアンチパターンについてドキュメントはないので実装を確認しました
一つ疑問を持ちました。
設問 : async/await と Wait()/Result を混在させたコードをスレッドプールにあるスレッドで実行するとデッドロックするのか?
async/await と Wait()/Result を混在させることはアンチパターンですから、その先を確認した解説が意外とネットに見つかりません。
そこで実装を確認しました。dotnet/runtime のタグ v8.0.8 だと次の箇所が要点でしょうか。
namespace System.Threading
{
/// <summary>
/// Class for creating and managing a threadpool.
/// </summary>
internal sealed partial class ThreadPoolWorkQueue
{
// (引用注 : 中略)
internal static bool Dispatch()
{
// (引用注 : 中略)
// Start on clean ExecutionContext and SynchronizationContext
currentThread._executionContext = null;
currentThread._synchronizationContext = null;
スレッドプールにあるスレッドは SynchronizationContext.Current に null を設定しています。これは GUI の GUI コンテキストとも、CUI の Main メソッド配下とも違います。
@Kosei-Yoshida 様による「[C#]await利用時の同期コンテキストと実行スレッドの動きについてコードを動かして見ていく」より
awaitにはその前後で実行スレッドを自動で保存してくれる機能があり、それは"SynchronizationContext.Current != null"の場合のみ働く。
スレッドプールにあるスレッドは SynchronizationContext.Current に null を設定している結果として、await において実行スレッドを保存せず、Wait()/Result を参照した際に障害が生じません。
結果として、設問への回答はこうなります。
設問 : async/await と Wait()/Result を混在させたコードをスレッドプールにあるスレッドで実行するとデッドロックするのか?
回答 : アンチパターンですが今のところデッドロックしません
GUI と CUI の両方ともスレッドの性質が異なり、諸条件によりデッドロックが発生しません。そのようなスレッドが .NET MAUI など GUI アプリの内部で使用できます。すると GUI コンテキストと話が違ってきます。
GUI アプリで Task.Run() により開始したスレッドでデッドロックを起こさず、疑問を解くために CLI でサンプルコードを書いたらデッドロックを起こさず、「GUI で async/await と Wait()/Result を混在させたコードを実行するとデッドロックする」と戒めるネットの解説書に逆恨みした、というのが、僕が嵌っていった道です。失礼しました。
謝辞
@chocolamint 様、ご指導ありがとうございました。
@Kosei-Yoshida 様、適切な文書を残してくださりありがとうございました。