はじめに
ASP.NETの非同期処理についての個人的なメモです。
間違いがあったらコメント欄でご指摘いただけると嬉しいです。
なぜ非同期のアクションメソッドを使用するのか
- 同時に大量のリクエストを受けるとIISのワーカースレッドが枯渇し、リクエストを受けることができなくなる。
- IISのワーカースレッドはマネージドスレッドと呼ばれる。リクエストを受けるのはマネージドスレッド。
- 待機中のマネージドスレッドを効率的に使用することにより、マネージドスレッドの最大数より多くのリクエストを処理することができる。(ASP.NET4.5では1論理コア当たり最大で100スレッドなので、それより多くのリクエストを処理できるようになる)
- スレッドはスレッドプールで管理されている。
- 処理を非同期にすることでマネージドスレッドとは別のワーカースレッドに処理を行わせる。マネージドスレッドはそのときスレッドプールに返却される。
C#の非同期
- .NETの非同期は歴史上いろんな書き方があるが、(特別な理由がない限り *1)async/await を使用する。
- Task.Resultはそのメインスレッドをロックして非同期処理が完了するのを待つので、スレッドプールにマネージドスレッドは返却はされない。
*1 ライブラリやフレームワークの仕様により async/await が使用できず、.Resultを使用しないといけないこともある。
c.f.
ValueTask
同期処理をラップするだけのTaskを書く場面が多くなり、そのときのTaskのオーバーヘッドが問題視されていた。それを解決するための手段。
以下 xin9le.net さんの記事からの引用。
ValueTask は内部で Task を抱える実装をしているのですが、内包する Task を使うケースと使わないケースがあります。ValueTask にすることで効果が発揮されるのは、このうちの Task を使わないケースです。たとえば、以下のように await を通るか通らないかで変わります。
async ValueTask<int> DoSomethingAsync()
{
var useInternalTask = true;
if (useInternalTask)
{
//--- このコードパスは内包する Task を利用する
await Task.Delay(1000);
return 123;
}
//--- こっちのコードパスは内包する Task を利用しません
return 456;
}
また、neue.cc さんの記事によれば
Taskのことは忘れて全部ValueTaskで良いのですー、
とのこと。
同期コンテキスト(SynchronizationContext
)
- 同期コンテキストとは、呼び出し元スレッドから非同期処理を実行し、非同期処理終了後に再び呼び出し元のスレッドで処理を継続するための情報。
- awaitを使用することで同期コンテキストをキャプチャする。
- 同期コンテキストを破棄する場合は
ConfigureAwait(false)
を使用する。 - スレッドの切り替えによりオーバーヘッドを抑制するには
ContinueWith
で数珠つなぎ(*1)にすると良い。
// スレッドA → スレッドB → スレッドA → スレッドB → スレッドA
var hoge = await AsyncMethodA();
var fuga = await HeavyWork();
// (*1) スレッドA → スレッドB → スレッドA
await AsyncMethodA().ContinueWith(_ => HeavyWork());
CUIアプリケーション
- 同期コンテキストを持たない
GUIアプリケーション (Windows Form など)
- GUIアプリケーションではUIスレッドのみ画面の描画が可能。
- 非同期にワーカースレッドで処理を行っても、画面を更新するためにはUIスレッドに戻って描画処理を行う必要がある。
Webアプリケーション(ASP.NET ASP.NETCore)
非常に解りやすかったのでこちらから引用。
ASP.NET の SynchronizationContext は何をするのかと言うと、HttpContext.Current を適切に設定するものらしいです。
HttpContext.Current は、リクエスト スレッドが現在処理中のリクエストに関する情報を持っています。
リクエスト スレッドから非同期処理が呼ばれてワーカー スレッドが作られても、ワーカー スレッドも同じリクエストを処理していると言えるわけですから、リクエスト情報をスレッド間で共有しているわけです。
- ASP.NET において同期コンテキストとは HttpContext.Current を適切に設定するもの。
- await でリクエスト情報をコピーするとワーカースレッドからもリクエストを返すことができる。その場合はリクエストを受けたスレッド(マネージドスレッド)に処理を戻す必要はない。
- リクエスト情報の受け渡しはコスト(オーバーヘッド)がかかる。
同期コンテキストによるデッドロックの例
An async/await example that causes a deadlock (デッドロックを引き起こす非同期/待機の例)のコードをお借りします。
public ActionResult ActionAsync()
{
// DEADLOCK: this blocks on the async task
var data = GetDataAsync().Result;
return View(data);
}
private async Task<string> GetDataAsync()
{
// a very simple async method
var result = await MyWebService.GetDataAsync();
// var result = await MyWebService.GetDataAsync().ConfigureAwait(false); ← こっちだとデッドロックにならない
return result.ToString();
}
デッドロックの原因
Result でスレッドをロックする場合は、それ以降の処理で同期コンテキストを保持しているとデッドロックが発生する。こういった状態の回避方法はアクションメソッドなど、リクエストの受け口から async/await を使用すること。
だがライブラリやフレームワークの仕様によりどうしても Result で待機する必要がある場合もある。
その場合は適切に ConfigureAwait を設定することによりデッドロックを回避することができる。
適切に ConfigureAwait を設定した場合のフロー
クラスライブラリ
- クラスライブラリ内で同期コンテキストを保持すると意図せぬタイミングでデットロックが発生することがある。ゆえに await とセットで
ConfigureAwait(false)
を使用するべき。(こちらの原因にもなる。)
c.f.
ライブラリで非同期メソッドを公開する必要があるか
public T Foo(){}
public Task<T> FooAsync() => Task.Run(() => Foo); // 必要?
- そのメソッドが非同期処理の恩恵を受けているかをライブラリ使用者に解らせるため、非同期にして意味がある場合(*1)は非同期メソッドで公開する。そうでない場合は非同期メソッドにしない。
- 同期メソッドを非同期的に使用するかはライブラリを使用する側が判断するべき。
- 冗長で保守性が悪くなる。
*1 DBアクセスやファイルI/O、APIリクエストなど、メインスレッドをスレッドプールに返却する処理のこと。
c.f.