ライブラリで非同期 API のみが公開されている場合、コンシューマーは、同期インターフェイスまたはコントラクトを満たすために、それらを同期呼び出しでラップすることがあります。 このような "sync-over-async" パターンは単純に見えるかもしれませんが、デッドロックやパフォーマンスの問題のよくある原因です。
基本的な折り返しパターン
タスク ベースの非同期パターン (TAP) メソッドの同期ラッパーは、呼び出し元のスレッドをブロックするタスクの Result プロパティにアクセスします。
public class TapWrapper
{
public static int Foo(Func<Task<int>> fooAsync)
{
return fooAsync().Result;
}
}
Public Module TapWrapper
Public Function Foo(fooAsync As Func(Of Task(Of Integer))) As Integer
Return fooAsync().Result
End Function
End Module
この方法は単純に見えますが、実行環境によっては重大な問題が発生する可能性があります。
シングルスレッドコンテキストにおけるデッドロック
最も危険なシナリオは、シングル スレッド SynchronizationContextを持つスレッドから同期ラッパーを呼び出すときに発生します。 このシナリオは、通常、WPF、Windows フォーム、または MAUI アプリケーションの UI スレッドです。
public static class DeadlockExample
{
private static void Delay(int milliseconds)
{
DelayAsync(milliseconds).Wait();
}
private static async Task DelayAsync(int milliseconds)
{
await Task.Delay(milliseconds);
}
}
Public Module DeadlockExample
Private Sub Delay(milliseconds As Integer)
DelayAsync(milliseconds).Wait()
End Sub
Private Async Function DelayAsync(milliseconds As Integer) As Task
Await Task.Delay(milliseconds)
End Function
End Module
ステップ バイ ステップで何が起こるかを次に示します。
- UI スレッドは
Delayを呼び出し、さらにDelayAsync(milliseconds).Wait()を呼び出します。 -
DelayAsyncは、await Task.Delay(milliseconds)に達するまで同期的に実行されます。 - 遅延はまだ完了していないため、
awaitは現在の SynchronizationContext をキャプチャして中断します。DelayAsyncは、呼び出し元に Task を返します。 - UI スレッドは
.Wait()でブロックされ、そのタスクが完了するのを待ちます。 - 遅延が完了したら、UI スレッドである元の
SynchronizationContextで継続を実行する必要があります。 - UI スレッドは、
.Wait()でブロックされているため、継続を処理できません。 - デッドロック。
Important
sync-over-async コードの成功または失敗は、実行される環境によって異なります。 コンソール アプリで動作するコードは、UI スレッドまたは ASP.NET (.NET Framework) でデッドロックする可能性があります。 この環境依存関係は、同期ラッパーの公開を回避する主な理由です。
スレッド プールの枯渇
デッドロックは UI スレッドに限定されません。 非同期メソッドがスレッド プールに依存して処理を完了する場合 (たとえば、最後の処理手順をキューに入れるなど)、同期ラッパーを使用して多くのプール スレッドをブロックすると、プールが枯渇する可能性があります。
public static class ThreadPoolDeadlockExample
{
public static int Foo(Func<Task<int>> fooAsync)
{
return fooAsync().Result;
}
public static async Task DemonstrateDeadlockRiskAsync()
{
var tasks = Enumerable.Range(0, 25)
.Select(_ => Task.Run(() => Foo(() => SomeIOOperationAsync())));
await Task.WhenAll(tasks);
}
private static async Task<int> SomeIOOperationAsync()
{
await Task.Delay(100);
return 42;
}
}
このシナリオでは:
- 多くのスレッド プール スレッドは
Fooを呼び出し、.Resultでブロックします。 - 各非同期操作は I/O を完了し、完了コールバックを実行するにはスレッド プール スレッドが必要です。
- ブロックされた呼び出しは使用可能なワーカー スレッドを占有するため、完了はスレッドが使用可能になるまで長い時間待機する可能性があります。
- 最新の.NETでは、時間の経過と伴ってスレッド プール スレッドを追加できますが、アプリケーションで深刻なスレッド プールの枯渇、スループットの低下、長い遅延、または明らかなハングが発生する可能性があります。
このパターンは、.NET Framework 1.x の HttpWebRequest.GetResponse に影響を与えました。この場合、同期メソッドは非同期BeginGetResponse/EndGetResponse のラッパーとして実装されました。
ガイドライン: 同期ラッパーの公開を回避する
非同期実装をラップする同期メソッドを公開しないでください。 代わりに、コンシューマーにブロックするかどうかを決定します。 コンシューマーはスレッド環境を認識しており、情報に基づいた選択を行うことができます。
非同期メソッドを同期的に呼び出す必要がある場合は、まず、コードを "非同期" に再構築できるかどうかを検討してください。リファクタリングは、多くの場合、より優れた長期的なソリューションです。
sync-over-async が避けられない場合の軽減戦略
sync-over-async が本当に避けられない場合があります。 たとえば、同期メソッドを必要とするインターフェイスを実装する場合は避けられません。使用可能な実装は非同期のみです。 そのような場合は、次の戦略を適用してリスクを軽減します。
非同期実装で ConfigureAwait(false) を使用する
非同期メソッドを制御する場合は、すべてのTask.ConfigureAwaitでfalseawaitを使用して、継続が元のSynchronizationContextにマーシャリングし直されないようにします。
public static class ConfigureAwaitMitigation
{
public static async Task<int> LibraryMethodAsync()
{
await Task.Delay(100).ConfigureAwait(false);
return 42;
}
public static int Sync()
{
return LibraryMethodAsync().GetAwaiter().GetResult();
}
}
Public Module ConfigureAwaitMitigation
Public Async Function LibraryMethodAsync() As Task(Of Integer)
Await Task.Delay(100).ConfigureAwait(False)
Return 42
End Function
Public Function Sync() As Integer
Return LibraryMethodAsync().Result
End Function
End Module
ライブラリの作成者は、キャプチャされたコンテキストでコードを特に再開する必要がない限り、すべての await で ConfigureAwait(false) を使用します。
ConfigureAwait(false)の使用は、パフォーマンスのベスト プラクティスであり、コンシューマーがブロックするときにデッドロックを防ぐのに役立ちます。
スレッド プールへのオフロード
非同期実装を制御しない ( ConfigureAwait(false)を使用しない可能性がある) 場合は、呼び出しをスレッド プールにオフロードします。 スレッド プールには SynchronizationContextがないため、await はブロックされたスレッドにマーシャリングし直そうとしません。
public int Sync()
{
return Task.Run(() => Library.FooAsync()).Result;
}
Public Function Sync() As Integer
Return Task.Run(Function() Library.FooAsync()).Result
End Function
複数の環境でテストする
同期ラッパーをリリースする必要がある場合は、ここからテストしてください。
- UI スレッド (WPF、Windows フォーム)。
- 読み込み中のスレッド プール。
- 最大スレッド数が少ないスレッド プール。
- コンソール アプリケーション。
ある環境で動作する動作は、別の環境でデッドロックする可能性があります。
こちらも参照ください
.NET