C# 5の非同期、パート2: awaitはどこから?
2018-01-09 09:37:18
まず、とある2つのことについて、完全に明確にすることから始めよう。というのも、私たちのユーザビリティ調査によると、これらは混乱の元だそうだからだ。前回の小さいプログラムを覚えているかな?
async void ArchiveDocuments(List<Url> urls)
{
Task archive = null;
for(int i = 0; i < urls.Count; ++i)
{
var document = await FetchAsync(urls[i]);
if (archive != null)
await archive;
archive = ArchiveAsync(document);
}
}
2つのこと、というのは以下の通り:
- メソッドの「
async
」修飾子は、「このメソッドは、ワーカー・スレッド上で非同期的に動作するよう自動的にスケジュールされる」という意味 ではなく 、むしろその 正反対 である; この意味は、「このメソッドは非同期操作を待つ必要がある制御フローを含んでいるので、非同期処理が適切な場所からこのメソッドを再開できるように、コンパイラによって継続渡しスタイルに書き直される。」だ。 非同期メソッドのどの地点も、できる限り現在のスレッド上にいる。 そういう所はコルーチンに似ている: 非同期メソッドは、C#でシングルスレッドの協調マルチタスキングを実現するものだ。(非同期メソッドであることをコンパイラーが推論するのではなく、async
修飾子を明示的に必要とする理由は、後日検討する。) - そのメソッドで2度使われた「
await
」演算子は「非同期処理がリターンするまで、今からこのメソッドは現在のスレッドをブロックする」という意味 ではない 。 それでは非同期操作を同期操作に戻してしまうことになってしまうので、まさに我々が避けようとしていることだ。そうではなく、この演算子はその 正反対 を意味する; この意味は、「終わるのを待っているタスクがまだ完了していないなら、このメソッドの残りをそのタスクの継続として登録し、それから、呼び出し側にすぐに戻れ; タスクが完了したとき、タスクは継続を実行する。」だ。
残念なことに、 async
と await
という文脈キーワードを初めて見た人々が、直感的に想像するそれらの意味は、実際の意味の正反対、ということがしばしばある。 私たちも、もっと良いキーワードを考えようといろいろやってみたものの、これらよりましなものを見つけることができなかった。 もしあなたが、短くてスマートで正しい意味が伝わるような、キーワードまたはその組合せについて、アイデアを持っているなら聞かせてほしい。 私たちが一旦思いついたけれど、いろいろな理由から却下した案は、以下のようなものだった:
wait for FetchAsync(…)
yield with FetchAsync(…)
yield FetchAsync(…)
while away the time FetchAsync(…)
(FetchAsync()
の時間つぶしをしろ)hearken unto FetchAsync(…)
(FetchAsync()
にこそ耳を傾くれ)for sooth Romeo wherefore art thou FetchAsync(…)
(おおロミオ、どうしてあなたはFetchAsync()
なの?)
冗談はさておき、やることが多いので次に進もう。次に話したいのはこれだ; 「私がこの前適当にごまかした「アレ(thingy)」とは、実際は何なのか?」
前回、私はC# 5.0の式
document = await FetchAsync(urls[i])
は、以下のように解釈されることをほのめかした。
state = State.AfterFetch;
fetchThingy = FetchAsync(urls[i]);
if (fetchThingy.SetContinuation(archiveDocuments))
return;
AfterFetch: ;
document = fetchThingy.GetValue();
アレ(thingy)って、何だろう?
私たちの非同期モデルにおいて、非同期メソッドは、一般的に Task<T>
を返す; FetchAsync
が Task<Document>
を返すと今のところ仮定しよう。(繰り返すが、このタスク・ベースのAsynchrony Patternが採用された理由は、後日検討する。) 実際のコードは、以下のように実現される。
fetchAwaiter = FetchAsync(urls[i]).GetAwaiter();
state = State.AfterFetch;
if (fetchAwaiter.BeginAwait(archiveDocuments))
return;
AfterFetch: ;
document = fetchAwaiter.EndAwait();
FetchAsync
への呼び出しは、 Task<Document>
――つまり、「熱い」実行タスクを意味するオブジェクト―― を生み出して、返す。このメソッドを呼びだすとすぐに Task<Document>
が返る。このオブジェクトはその後、望ましい文書をどうにかして非同期で取ってくる。おそらく別スレッドの上で動くとか、スレッドのWindowsメッセージ・キューのようなものに自分自身をポストしておくと、メッセージ・ループ的なものがアイドル時に、実行するべきタスクに関する情報のポーリングをするとか、そんなところだろう。そのような詳細はタスク自身の仕事なので、私たちは、タスクが完了するとき何かが起こることが必要だということを知っておけばいい。(繰り返すが、シングルスレッドの非同期性は、後日検討する。)
タスクが完了するとき何かが起きるようにするために、タスクから Awaiter
をもらう必要がある。 Awaiter
は2つのメソッドを公開する。 BeginAwait
は、このタスクに継続を登録する; タスクが完了すると奇跡が起こり、どういうわけか継続が呼び出される。(繰り返すが、これを正確に統制する方法は、後日の話題だ。) BeginAwait
が true
を返すならば継続が呼び出される; もしそうでなければ、それはタスクがすでに完了しているからなので、継続メカニズムを使う必要がない。
EndAwait
は、完了したタスクの結果を抽出する。
私たちは、Task
(論理的に void
を返すタスクを表す)と Task<T>
(値を返すタスクを表す)の両方に、 BeginAwait
と EndAwait
の実装を提供する。しかし、 Task
または Task<T>
オブジェクトを返さない非同期メソッドはどうなるのだろうか? そこでは、LINQで使ったのと同じ戦略を使うつもりだ。LINQでは、こう書いたならば、
from c in customers where c.City == "London" blah blah blah
それはこのように翻訳される。
customers.Where(c => c.City == "London") ……
そして、オーバーロード解決は、一番良い Where
メソッドを見つけようとする。 Customer
がそのようなメソッドを実装するかどうかをチェックしたり、あるいは、実装していなければ、拡張メソッドを探したりする。 GetAwaiter
/ BeginAwait
/ EndAwait
パターンも同様だ; 全く同じように、変形後の式でオーバーロード解決をして、何が得られるかを確認する。拡張メソッドを見る必要があるなら、そうする。
最後に:なぜ「 Task
」なのか?
ここでの洞察は、非同期は平行を必要としないけれど、平行は非同期を必要とするということと、平行に役立つツールの多くが、平行ではなく非同期でも同じように簡単に使うことができるということだ。 Task
には平行性は必須じゃない; Task Parallel Libraryが、平行化できる保留中の仕事の単位を表すためにタスク・ベースのパターンを使っていることには、マルチスレッディングを必要としない。
私が何度か指摘したように、結果を待っているコードの観点からは、その結果がこのスレッドのアイドル時に計算されているとか、このプロセスのワーカー・スレッドでとか、このマシンの別プロセスでとか、ストレージ装置でとか、世界の裏側のマシンでとか、そういうことはまったく重要じゃない。重要なことは、結果を計算するのに時間がかかりそうだということと、私たちがそのようにしさえすれば、CPUは結果を待つ間に他の何かをしていることができるということだけだ。
TPLの Task
クラスは、すでに多くの投資をうけているので、キャンセル・メカニズムなどの役に立つ機能を備えている。新しい「 IFuture
」型みたいに、何か新しいものを発明するよりはむしろ、単に既存のタスク・ベースのコードを拡張するだけで、私たちの非同期ニーズを満たすことができる。
次回: さらなる非同期タスクを組み立てる方法。