非同期の非同期化:.NETでの非同期/待機の処理におけるアンチパターン

私たちのうち誰が刈らないのですか? 私は定期的に非同期コードでエラーに遭遇し、自分でそれを行います。 サムサラのこの車輪を止めるために、私はあなたと時々捕まえるのが非常に難しいそれらの最も典型的な横棒をあなたと共有しています。











このテキストは、競争力、非同期性、マルチスレッド、その他の恐ろしい言葉についてすべてを知っているスティーブン・クラリーブログに触発されています。 彼は、競合を扱うための膨大な数のパターンを集めた「 Concurrency in C#Cookbook」という本の著者です。


古典的な非同期デッドロック



非同期デッドロックを理解するには、awaitキーワードを使用して呼び出されたメソッドをどのスレッドが実行するかを把握する価値があります。







まず、非同期のソースに遭遇するまで、メソッドは非同期メソッドの呼び出しのチェーンを掘り下げます。 非同期のソースがどのように正確に実装されているかは、この記事の範囲外のトピックです。 ここで、簡単にするために、これは、結果(データベース要求やHTTP要求など)を待機している間にワークフローを必要としない操作であると想定しています。 このような操作の同期開始とは、システムで結果を待機しているときに、リソースを消費するが有用な作業を行わないスリープスレッドが少なくとも1つ存在することを意味します。







非同期呼び出しでは、非同期操作の「前」と「後」のコマンドの実行フローを中断します。.NETでは、awaitの後にあるコードがawaitの前のコードと同じスレッドで実行されるという保証はありません。 ほとんどの場合、これは必要ありませんが、プログラムが機能するためにそのような動作が不可欠な場合はどうすればよいですか? SynchronizationContext



を使用する必要があります。 これは、コードが実行されるスレッドに特定の制限を課すことができるメカニズムです。 次に、2つの同期コンテキスト( WindowsFormsSynchronizationContext



およびAspNetSynchronizationContext



)をAspNetSynchronizationContext



ますが、Alex Davisは彼の本の中で.NETに約12個あると書いています。 SynchronizationContext



についてSynchronizationContext



ここここ 、およびここでよく記述されおり著者は独自の実装を行っています。







そのため、コードが非同期のソースに到達するとすぐに、 SynchronizationContext.Current



thread-staticプロパティにあった同期コンテキストを保存し、非同期操作が開始して現在のスレッドを解放します。 つまり、非同期操作の完了を待機している間、単一のスレッドをブロックすることはありません。これは、同期操作と比較した非同期操作の主な利益です。 非同期操作の完了後、非同期ソースの後にある指示に従う必要があります。ここでは、非同期操作後にコードを実行するスレッドを決定するために、以前に保存した同期コンテキストを調べる必要があります。 彼が言うように、私たちはそうします。 彼は、待機する前にコードと同じスレッドで実行するように指示します-私たちは同じスレッドで実行しますが、言うことはありません-プールから最初のスレッドを取得します。







しかし、この特定のケースで、awaitの後のコードがスレッドプールの空きスレッドで実行されることが重要な場合はどうでしょうか。 ConfigureAwait(false)



マントラConfigureAwait(false)



を使用する必要があります。 continueOnCapturedContext



パラメーターに渡されるfalse値は、プールからのスレッドを使用できることをシステムに伝えるだけです。 また、たとえばコンソールアプリケーションのように、awaitを使用してメソッドを実行したときに、同期コンテキストがまったくなかった場合( SynchronizationContext.Current == null



)、どうなりますか。 この場合、 ConfigureAwait(false)



場合のように、待機後にコードを実行するスレッドに制限はなく、システムはプールから最初のスレッドを取得します。







非同期デッドロックとは何ですか?







WPFおよびWinFormsのデッドロック



WPFアプリケーションとWinFormsアプリケーションの違いは、同じ同期コンテキストを持っていることです。 WPFとWinFormsの同期コンテキストには、ユーザーインターフェイススレッドという特別なスレッドがあります。 SynchronizationContext



ごとに1つのUIスレッドのみがあり、このスレッドからのみユーザーインターフェイス要素と対話できます。 デフォルトでは、UIスレッドでの作業を開始したコードは、そのスレッドでの非同期操作の後、操作を再開します。







次に例を見てみましょう。



 private void Button_Click(object sender, System.Windows.RoutedEventArgs e) { StartWork().Wait(); } private async Task StartWork() { await Task.Delay(100); var s = "Just to illustrate the instruction following await"; }
      
      





StartWork().Wait()



を呼び出すとどうなりますか? StartWork().Wait()







  1. 呼び出しスレッド(およびこれはUIスレッド)は、 StartWork



    メソッドにStartWork



    await Task.Delay(100)



    命令にawait Task.Delay(100)



    ます。
  2. UIスレッドは非同期のTask.Delay(100)



    操作を開始し、 Button_Click



    メソッドに制御を返します。そこで、 Task



    クラスのWait()



    メソッドがそれを待機します。 Wait()



    メソッドが呼び出されると、UIスレッドは非同期操作の終わりまでブロックされます。完了するとすぐに、UIスレッドはすぐに実行を開始し、コードに沿って進みますが、すべてがそうなるわけではありません。
  3. Task.Delay(100)



    完了するとすぐに、UIスレッドはまずStartWork()



    メソッドの実行を継続する必要があり、このためには実行が開始されたスレッドが正確に必要です。 しかし、UIスレッドは現在、操作の結果を待っています。
  4. StartWork()



    StartWork()



    は実行を継続して結果を返すことができず、 Button_Click



    は同じ結果を待っています。ユーザーインターフェイススレッドで実行が開始されたという事実により、アプリケーションは動作を継続することなく単にハングします。


この状況は、 Task.Delay(100)



へのTask.Delay(100).ConfigureAwait(false)



Task.Delay(100).ConfigureAwait(false)



変更することで簡単に処理できます。



 private void Button_Click(object sender, System.Windows.RoutedEventArgs e) { StartWork().Wait(); } private async Task StartWork() { await Task.Delay(100).ConfigureAwait(false); var s = "Just to illustrate the instruction following await"; }
      
      





ブロックされたUIスレッドではなく、プールのスレッドを使用してStartWork()



メソッドを完了することができるため、このコードはデッドロックなしで機能します。 Stephen Claryは、ブログのすべての「ライブラリメソッド」でConfigureAwait(false)



を使用することをお勧めしConfigureAwait(false)



が、 ConfigureAwait(false)



を使用してデッドロックを処理することはお勧めできません。 代わりに、 Wait()



Result



GetAwaiter().GetResult()



などのブロッキングメソッドを使用しないことをお勧めしGetAwaiter().GetResult()



および可能な場合は、非同期/ Wait()



を使用するようにすべてのメソッドをGetAwaiter().GetResult()



ます(いわゆるAsync全方向原理)。







ASP.NETのデッドロック



ASP.NETにも同期コンテキストがありますが、わずかに異なる制限があります。 リクエストごとに一度に1つのスレッドのみを使用できます。また、await後のコードは、await前のコードと同じスレッドで実行する必要があります。







例:



 public class HomeController : Controller { public ActionResult Deadlock() { StartWork().Wait(); return View(); } private async Task StartWork() { await Task.Delay(100); var s = "Just to illustrate the code following await"; } }
      
      





また、このコードは、 StartWork().Wait()



呼び出し時にデッドロックを引き起こしますStartWork().Wait()



許可されてStartWork().Wait()



唯一のスレッドがブロックされ、 StartWork()



操作がStartWork()



するまで待機します。待っています。







これはすべて同じConfigureAwait(false)



によって修正されます。







ASP.NET Coreのデッドロック(実際にはそうではありません)



ここで、ASP.NET CoreのプロジェクトでASP.NETの例のコードを実行してみましょう。 これを行うと、デッドロックがないことがわかります。 これは、ASP.NET Coreに同期コンテキストがないためです。 いいね! それで、デッドロックを恐れることなく、ブロック呼び出しでコードをカバーできますか? 厳密に言えば、はい。ただし、これによりスレッドは待機中にスリープ状態になります。つまり、スレッドはリソースを消費しますが、有用な作業は行いません。



















ブロッキング呼び出しを使用すると、非同期プログラミングがすべての利点を排除して同期化することに注意してください 。 はい、 Wait()



を使用しないとプログラムを作成できない場合がありますが、その理由は重大なものでなければなりません。


Task.Run()の誤った使用



Task.Run()



メソッドは、新しいスレッドで操作を開始するために作成されました。 TAPパターンで記述されたメソッドに適しているため、 Task



またはTask<T>



を返します。非同期/ Task.Run()



に最初に遭遇した人は、 Task.Run()



で同期コードをラップし、このメソッドの結果を詳しく調べたいと強く望んでいます。 コードは非同期になったように見えますが、実際には何も変わっていません。 このTask.Run()



使用で何が起こるか見てみましょう。







例:



 private static async Task ExecuteOperation() { Console.WriteLine($"Before: {Thread.CurrentThread.ManagedThreadId}"); await Task.Run(() => { Console.WriteLine($"Inside before sleep: {Thread.CurrentThread.ManagedThreadId}"); Thread.Sleep(1000); Console.WriteLine($"Inside after sleep: {Thread.CurrentThread.ManagedThreadId}"); }); Console.WriteLine($"After: {Thread.CurrentThread.ManagedThreadId}"); }
      
      





このコードの結果は次のようになります。



 Before: 1 Inside before sleep: 3 Inside after sleep: 3 After: 3
      
      





ここで、 Thread.Sleep(1000)



は、完了するためにスレッドを必要とする同期操作の一種です。 ソリューションを非同期化し、この操作を安楽死させるために、 Task.Run()



でラップしたいとします。







コードがTask.Run()



メソッドに到達するとすぐに、別のスレッドがスレッドプールから取得され、 Task.Run()



渡したコードが実行されます。 適切なスレッドにふさわしい古いスレッドは、プールに戻り、作業を実行するために再度呼び出されるのを待ちます。 新しいスレッドは送信されたコードを実行し、同期操作に到達し、同期的に実行し(操作が完了するまで待機し)、コードに沿ってさらに進みます。 つまり、操作は同期を維持しました。以前と同様に、同期操作の実行中にストリームを使用します。 唯一の違いは、 Task.Run()



を呼び出してExecuteOperation()



戻るときにコンテキストの切り替えに時間を費やしたことExecuteOperation()



。 すべてが少し悪くなりました。







Inside after sleep: 3



およびAfter: 3



では同じIdのストリームが表示されるという事実にもかかわらず、実行コンテキストはこれらの場所で完全に異なることを理解する必要があります。 ASP.NETは、私たちよりも賢く、コンテキストをTask.Run()



内のコードから外部コードに切り替えるときにリソースを節約しようとします。 ここで、彼は少なくとも実行の流れを変えないことに決めました。







そのような場合、 Task.Run()



を使用しても意味がありません。 代わりに、Clary すべての操作を非同期にすることをThread.Sleep(1000)



します。つまり、この場合、 Thread.Sleep(1000)



Task.Delay(1000)



に置き換えることをおThread.Sleep(1000)



しますが、もちろんこれが常に可能であるとは限りません。 書き直せない、または最後まで非同期にしたくないサードパーティのライブラリを使用する場合、どうすればよいのでしょうか?何らかの理由で非同期メソッドが必要ですか? Task.FromResult()



を使用して、ベンダーメソッドの結果をTaskにラップすることをおTask.FromResult()



します。 もちろん、これによりコードは非同期になりませんが、少なくともコンテキストの切り替えは節約できます。











なぜTask.Run()を使用するのですか? 答えは簡単です。CPUにバインドされた操作では、UIの応答性を維持したり、計算を並列化したりする必要があります。 ここで、CPUバウンド操作は本質的に同期であると言わなければなりません。 Task.Run()



が発明されたのは、非同期スタイルで同期操作を開始することTask.Run()





非同期ボイドの誤用



非同期イベントハンドラを記述するために、 void



返す非同期メソッドを記述する機能が追加されました。 他の目的で使用された場合に混乱を引き起こす理由を見てみましょう。



  1. 結果を待つことはできません。
  2. try-catchによる例外処理はサポートされていません。
  3. Task.WhenAll()



    Task.WhenAny()



    および他の同様のメソッドを使用して呼び出しを結合することは不可能です。


これらすべての理由の中で、最も興味深い点は例外の処理です。 実際、 Task



またはTask<T>



を返す非同期メソッドでは、例外がキャッチされてTask



オブジェクトにラップされ、呼び出し元のメソッドに渡されます。 MSDNの記事で Claryは、async-voidメソッドには戻り値がないため、例外をスローするものはなく、同期のコンテキストで直接スローされると書いています。 その結果、処理されない例外が発生し、プロセスがクラッシュし、おそらくコンソールにエラーを書き込む時間があります。 AppDomain.UnhandledException



イベントにサブスクライブすることにより、このような例外を取得および予約できますが、このイベントのハンドラーでもプロセスのクラッシュを停止することはできません。 この振る舞いは、イベントハンドラーだけの典型的なものですが、try-catchを介した標準の例外処理の可能性が予想される通常のメソッドではありません。







たとえば、ASP.NET Coreアプリケーションで次のように記述した場合、プロセスは確実に失敗します。



 public IActionResult ThrowInAsyncVoid() { ThrowAsynchronously(); return View(); } private async void ThrowAsynchronously() { throw new Exception("Obviously, something happened"); }
      
      





ただし、 ThrowAsynchronously



メソッドの戻り値の型を(awaitキーワードを追加することなく) Task



変更することは価値があり、例外は標準のASP.NET Coreエラーハンドラーによってキャッチされ、プロセスは実行されても動作し続けます。











async-voidメソッドに注意してください -それらはプロセスにあなたを置くことができます。


単線方式で待つ



最後のアンチパターンは、以前のアンチパターンほど怖くはありません。 一番下の行は、たとえば、 awaitを使用する場合の例外を除いて、別の非同期メソッドの結果をさらに単純に転送するメソッドでasync / awaitを使用する意味がないことです。







このコードの代わりに:



 public async Task MyMethodAsync() { await Task.Delay(1000); }
      
      





以下を書くことは完全に可能です(できれば)。

 public Task MyMethodAsync() { return Task.Delay(1000); }
      
      





なぜ機能するのですか? awaitキーワードはTaskのようなオブジェクトに適用でき、asyncキーワードでマークされたメソッドには適用できないためです。 次に、asyncキーワードは、このメソッドをステートマシンに展開する必要があることをコンパイラに伝え、 Task



(または別のタスクのようなオブジェクト)のすべての戻り値をラップします。







言い換えると、メソッドの最初のバージョンの結果はTask



であり、 Task.Delay(1000)



待機がCompleted



とすぐにCompleted



し、メソッドの2番目のバージョンの結果はTask



Task.Delay(1000)



によって返されます。 。







ご覧のとおり、両方のバージョンは同等ですが、同時に、最初のバージョンは非同期の「ボディキット」を作成するためにより多くのリソースを必要とします。











Alex Davisは、非同期メソッドを直接呼び出すコストは、同期メソッドを呼び出すコストの10倍になる可能性があると書いているので、試すべきことがあります。




UPD:

コメントが正しく指摘しているように、単一行のメソッドから非同期/待機を見た場合、悪影響が生じます。 たとえば、例外をスローする場合、タスクをスローするメソッドはスタックに表示されません。 したがって、 デフォルトではデフォルトを削除することはお勧めしません 。 解析を伴うClaryの投稿



All Articles