非同期ライブラリを構築するには、そのライブラリを利用するライブラリとは全く異なる独特のデザインパターンを使う必要があります。しかし、基本的な原則に従えば、ライブラリの利用者の利便性を大幅に改善することができます。
Definitions
Creating Async Libraries That Are Modular, Reusable and FastというプレゼンでMicrosoftのLucian Wischik氏は非同期ライブラリの作者とライブラリの利用者であるアプリケーション開発者向けに、別々の定義を提示しています。
アプリケーションの開発者はメソッドのシグネチャしか見ません。その開発者にとってメソッドは次のどちらです。
- 同期: すべての処理が終わったらコントロールができるようになる。ファンクションはそれまでブロックされる。
- 非同期: コントロールは即座に返ってくる。
一方、非同期ライブラリの作者は、どのリソースが使われているかという観点でライブラリを見ます。CPUバウンドなら同期処理、CPUはほとんど関係ない(例えば、I/Oバウンド)のなら、非同期です。
以上を踏まえ、Lucianはライブラリのメソッドは次のルールに従うべきだと主張しています。
- スレッドバウンドでない場合だけ、非同期メソッドを定義する。
- デッドロックを起こさない、高速に動作する同期メソッドがある場合だけ、同期メソッドを定義する。
アプリケーション開発者はシグネチャを見て、このルールに従っていると仮定します。例えば、ライブラリに同期メソッドがあれば、利用者側はスレッドプールを使って安全に並列化できると仮定できます。しかし、非同期であれば、スレッドを新しく生成するのは無駄で、シングルスレッドのループの中で非同期メソッドを実行するほうがいいと判断するでしょう。
このように考えると、さらに基本的な原則が生まれます。
“ライブラリ内でTask.Runを使わない”
スレッド、特にスレッドプールのスレッドはグローバルに共有されているリソースで、アプリケーション開発者に属しています。ライブラリの作者はTask.Runを使ったり、スレッドを作るメソッドを作成するべきではありません。どのようなタイミングでスレッドを追加するか決めるのはアプリケーション開発者の権利と責任です。
次のコードは典型的なアンチパターンです。
public static async Task FetchFileAsync(int fileNum)
{
await Task.Run(() =>
{
var contents = IO.DownloadFile();
Console.WriteLine("Fetched file #{0}: {1}", fileNum, contents);
});
}
IO.DownloadFileの同期呼び出しがあるので、スレッドプールのスレッドをブロックしてしまいます。上述したように、ランタイムが最終的にスレッドがブロックされたのを検知し、もうひとつのスレッドをプールに追加します。しかし、時間がかかりますし、スレッドも限られています。最終的にはスレッドプールの最大値に達してしまいます。スレッドプールの最大値は非同期メソッドの呼び出し回数よりも遥かに小さいのです。
また、アプリケーションに必要なスレッドプールのリソースが枯渇するかもしれません。ライブラリの開発者は、アプリケーションの開発者と違い、どのくらいスレッドが必要なのかわからないのです。
“サーバでTask.Runを使わない”
Task.Runはスケーラビリティが求められるサーバでは不適切です。アプリケーションを効率的にスケールするためにはスレッドのような共有のリソースは注意深く調整して無駄を排しなければなりません。コア毎に1つのスレッドを走らせるのが理想的です。それ以上のスレッドを立ててしまうとコンテキストのスイッチでCPUのサイクルを無駄にし、メモリ上のスレッドのスタックを無駄にします。
高いスケーラビリティではなく遅延が減少するようにサーバをチューニングするなら、Task.Runを使うのは意味があります。しかし、この決定はアプリケーション開発者がするものです。ライブラリの作者ではありません。
クライアントでのTask.Run
クライアント側ではTask.Runを使う理由がたくさんあります。しかし、どれもアプリケーションレベルの理由です。ライブラリのコードはバックグラウンドスレッドにどのような処理をさせればいいのか判断するための文脈がありません。ライブラリのファンクションが呼ばれたときに、すでにアプリケーションのコードはバックグラウンドで動作しているかもしれません。また、アプリケーションはUIを操作しているかもしれません。この場合は、UIスレッドにとどまる必要があります(WinRT/XAMLの場合はアプリケーションにひとつ以上のUIスレッドがあります)
これらの理由や他の理由からLucianは次のように言います。
ライブラリでTask.Runを使うと、ライブラリのユーザが最適にスレッドプールを使うのを妨げることになります。
例外: マルチスレッドとWinJS
Windows 8/WinRT (WinJS)向けのJavaScriptから利用されるライブラリを作る場合、上述の懸念を踏まえた上でTask.Runを使う場合があります。WinJSは新しいバックグラウンドスレッドを作ることができないので、かわりにライブラリ側で作る必要があるのです。
Windows 8のデザインガイドラインによれば、50ミリ秒以上かかるCPUバウンドの関数は非同期のラッパーを公開する必要があります。
例外: Stream.ReadAsync
.NET 4.5がWinRTに移植されたとき、Stream.ReadAsyncには問題がありました。Streamから派生したクラスはReadAsyncを持ちますが、ある種のストリームはこれをサポートしません。サポートされない場合は、基底クラスであるSteamクラスでTask.Runを実行するのが最も安全な方法でした。
幸い、FileStreamとNetworkStreamはこの挙動をオーバーライドし、非同期を実現します。また、MemoryStreamのような他の型の場合は、同期で読み取るのが良いでしょう。
Waitを使う同期メソッドで非同期メソッドをラップしない
アプリケーション開発者は非同期バージョンも提供しているメソッドの同期バージョンに対してある種の仮定をします。ひとつは同期バージョンの方が速いということです。そうでなかったら、両方のバージョンを提供する理由がないからです。その場合は非同期バージョンを呼び出すときにTask.Waitを使って同期をとるほうがいいでしょう。
また、もうひとつの仮定はUIスレッドで実行しても安全だということです。アプリケーションの開発者はコンテキスト(ロードされるアイテムの数)を理解しており、速度低下も織り込み済みです。しかし、その“同期”メソッドがTask.Waitを使っていたら、デッドロックが発生する可能性があります。
キーワードasyncは対象のファンクションが同じコンテキストで実行されるべきであることを示しています。UIスレッドの場合は、実行完了を待つためにディスパッチャが使われるということです。しかし、Task.Waitが呼ばれるとスレッドがブロックされ、そのスレッドはasyncキーワードが終わるまで待つので、処理が返ってこなくなってしまいます。
ライブラリでは基本的にはasyncをブロックするべきではありません。
責任あるライブラリ開発者になる必要があります。メソッドが本当に同期処理を行うなら、非同期バージョンを提供せずに、同期バージョンだけを提供します。同様に本当に非同期だったら、非同期バージョンだけを提供します。
デッドロックとSynchronizationContext
“SynchronizationContextはPostメソッド経由で動作する対象を表現します。” SynchronizationContextは.NET 2.0からありましたが、.NET 4.5でasync/awaitキーワードが登場するまで使われませんでした。WinFormの場合、SynchronizationContextはControl.BeginInvokeにマップされています。XAMLの場合Postメソッドはディスパッチャに向かいます。ASP.NETが並列実行されないようにするSynchronizationContextもあります。SynchronizationContextは.NET Framework全体で10の実装があり、開発者は独自の実行をするのが推奨されています。
awaitキーワードが使われる場合、その環境でのSynchronizationContextが捉えられます。そのSynchronizationContextのPostメソッドが呼ばれ、非同期処理が完了すると動作を再開します。SynchronizationContextがない場合、継続がTaskSchedulerに追加されます。
アプリケーションレベルのコードでは、これはほとんど正しい挙動です。しかし、ライブラリではこの動作は間違いを起こします。ライブラリの場合は、次のパターンを利用します。
await FooAsync.ConfigureAwait(false);
こうすることで、SynchronizationContextを捕まえないようにして、OSが与えたスレッド上で、動作が続くようになります。これにはふたつの利点があります。
- 性能: スレッドの不必要なマーシャリングがなくなり、性能が改善します
- ロック: デッドロックが少なくなります
あるデモでLucianはデフォルトのConfigureAwait(true)を使うと、ConfigureAwait(false)を使う場合より14倍遅いことを示しています。1回の呼び出しの時間はわずかですが、ループの中で何千回も呼び出された場合、その時間が積み重なってしまいます。
もっと重要なのは、ライブラリのユーザがUIスレッド上の非同期メソッドでTask.Waitが呼ばれてしまうかもしれないということです。この場合、ライブラリ側でConfigureAwait(false)を使わずに、UIスレッド上の処理も先に進もうとすると、デッドロックが発生する可能性があります。
なぜこのようなことが発生するのでしょう。
asyncがウイルスのように動作するからです。
呼び出しスタックの一番下でasyncを使うと、呼び出し側の名前を変え、asyncを使うようにしなければなりません。さらにその呼び出し側もasyncを使うようして、以下同じように、理論的には一番外側までasyncを使うようにする必要があります。しかし、実際には動作を変えることができないフレームワークやライブラリを使っているので、asyncを使うように変更できません。このような場合、アプリケーションの開発者はTask.Waitやその他のブロック手段を使って同期を取るしかありません。
それゆえ、Lucianは次のような原則を打ち立てています。
ユーザのスレッドはライブラリの作者ではなくユーザに属しています。ライブラリのコードでユーザのものであるスレッドを汚してはなりません。
つまり、概して言えば、ライブラリは常にタスクを待つときはConfigureAwait(false)を使うべきだ、ということです。
性能とExecutionContext
これも環境に属するコンテキストです。偽装をしていた場合のログインユーザやカルチャ情報などを保持します。また、スレッドからスレッドへ移動しても動作するスレッド局所記憶の代替と考えられます。
asyncはExecutionContextが既定の状態を離れ、妨害されていない状態において最適化されます。アプリケーションやライブラリの開発者はCallContext.SetLocalDataを使い、ある種のデータを保持します(例えば、非同期バージョンのアンビエントトランザクションの状態)。この処理は非同期呼び出しに少量の性能コストを追加します。60%から100%のコストで、何十万、何百万の反復処理の中で非同期メソッドが実行されなければ問題にはなりません。
性能モデルについて
Async/Await- パフォーマンス上のオーバーヘッドと他の落とし穴という記事で述べされているように、asyncを使ったメソッドはTaskの生成やTaskの実行管理のためにコストを払わなければなりません。ちょっとした処理でも同期メソッドと比べて10倍のコストを払わなければならない場合もあります。
これは、数百万回のループ処理の中で非同期メソッドを実行した場合にだけ問題が起きます。したがって、ライブラリのユーザにはループ内で呼び出さないように注意喚起するのがいいでしょう。あまり頻繁に呼び出されない、そして呼び出し当たりの処理内容が大きくなった“がっしりした”APIを提供するのがいいでしょう。
注: 手動でコールバックを使って処理を重複化するより、awaitを使った方がわずかに性能が良いです。これは、async/awaitのコンパイラのコードを作った開発者がJITコンパイラを深く理解しており、Task型の公開されていない特別な機能にアクセスできたからです。
メモリはグローバルなリソース
不必要なメモリ確保はアプリケーションの性能に深刻な影響を与えます。メモリ確保のコストはガベッジコレクタが走るまで先延ばしになるので、コストを発生させているコードとコストは簡単に関連付けることはできません。
典型的な非同期メソッドの呼び出しは次の3つのためのメモリ確保処理を生みます。
- ローカルの変数を保存するためのステートマシン
- 継続のためのデリゲート
- 結果をを返すためのタスク
ステートマシンとデリゲートはawaitキーワードがランタイムに現れたときに作成されます。したがって、通常の処理がawaitを避けるように実行されているなら、3つのうち2つのメモリ確保は避けられます。
例えば、ストリームからの読み取りを行うGetIntメソッドの場合、メソッド内部のawaitの呼び出しが一度に1000バイトの読み込みを行い、それをバッファに落とします。
これはメモリ消費を大幅に削減しますが、タスクはなくなりません。しかし、同じようにタスクも最適化できる場合があります。完了したタスクは不変なので、ランタイムは、1、0、true、false、nullなど共通の結果を保持するタスクをキャッシュすることができます。
ランタイムはすべての返却値をキャッシュすることはできません。しかし、ライブラリ側はもっとも使われる返却値をキャッシュすることはできます。返却値が列挙型の場合や有限の範囲に収まる場合はキャッシュすることを検討するのがいいでしょう。
著者について
Jonathan Allen氏 は2006年から現在までInfoQでニュースを執筆しており、.NETキューのリードエディタである。