C# 5の非同期、パート3: 複合
2018-01-19 09:37:18
私は、この前の朝、午前6時45分頃にいつものバスに乗ろうと歩いていた。私が45番通りへ曲がろうとしていたちょうどその時、シャツを着ていない血まみれの青年がかなりの速度で私の目の前に走ってきた。彼の後に、野球バットを振り回すもう一人の人がいた。私の最初の考えは「なんてこった、すぐ警察を呼ばなければ!」だった。
それから、私は野球バットの人自身が追われているのを見た。ドラキュラ伯爵、ゾンビの群れ、海賊団、1人の中世の騎士、そしてしんがりをつとめるのは巨大なマルハナバチ。明らかに、ウォリンフォードあたりのジョギングクラブが今週末にハロウィン気分になったのだ。
閑話休題:我々は、ここまで非同期機能についてのたくさんのすばらしいコメントとフィードバックを受け取った; それを続けてほしい。 それは読まれている; それを消化することは何週もかかるし、通信の量は個人への答えを排除してしまうかもしれない。それについては謝罪するが、それはまあそういうものだ。
今日、私は非同期コードの複合化と、それがCPSで難しい理由と、そしてそれがC# 5の await
で非常に簡単になる方法について少し話したい。
このもののために名前が必要だ。LINQという名前はLanguage Integrated Queryだからそのようにつけた。今のところ、この新機能を仮にTAP(Task Asynchrony Pattern)と呼ぼう。私は、我々が後でより良い名前を思い付くと確信する; 忘れるな。これはまだただのプロトタイプである1。
私がここまで使っていた例は、文書を取ってきてアーカイブすることで、void
を返す1つのメソッドの中で2つの非同期タスクを単純にオーケストレーションするように、故意に目論まれたのは明らかだ。
我々が見えたように、標準的なContinuation Passing Styleを用いるときは、2つの非同期メソッドをオーケストレーションすることさえ慎重を要することがありえる。今日、私は非同期メソッドの複合化について少し話したい。我々のArchiveDocuments
メソッドはvoid
を返すもので、それはものごとを大いに単純化する。ArchiveDocuments
が値を、例えばアーカイブされる総バイト数を返すことになっていたとしよう。同期的であれば、それは直接である:
long ArchiveDocuments(List<Url> urls)
{
long count = 0;
for(int i = 0; i < urls.Count; ++i)
{
var document = Fetch(urls[i]);
count += document.Length;
Archive(document);
}
return count;
}
さて、我々がどのようにそれを非同期で書き直すかについて考えよう。最初のFetchAsync
が動き出したときArchiveDocuments
がすぐにリターンして、最初の取得が完了するときだけ再開されそうならば、その場合 return count;
はいつ実行されそうか? ArchiveDocuments
の単純な非同期バージョンは、カウントを返すことができない; それは、CPSで書かれなければならない:
void ArchiveDocumentsAsync(List<Url> urls, Action<long> continuation)
{
// somehow do the archiving asynchronously,
// then call the continuation
}
そして今や汚染は広がった。さて、ArchiveDocumentsAsync
の呼び出し側は、継続を渡されることができるように、CPSで書かれる必要がある。それが今度は結果を返すならばどうか?これは、混乱になりそうである; すぐに、全プログラムがさかさまに裏返しに記述される。
TAPモデルにおいては、その代わりに、我々は後で結果を生む非同期仕事を表す型がTask<T>
であると言う。C# 5では、あなたは単に以下のように言うことができる:
async Task<long> ArchiveDocumentsAsync(List<Url> urls)
{
long count = 0;
Task archive = null;
for(int i = 0; i < urls.Count; ++i)
{
var document = await FetchAsync(urls[i]);
count += document.Length;
if (archive != null)
await archive;
archive = ArchiveAsync(document);
}
return count;
}
そして、コンパイラはあなたのために全ての書き直しの面倒を見る。何がここで起こるか、正確に理解することが有益である。これは、以下のような何かに展開される:
Task<long> ArchiveDocuments(List<Url> urls)
{
var taskBuilder = AsyncMethodBuilder<long>.Create();
State state = State.Start;
TaskAwaiter<Document> fetchAwaiter = null;
TaskAwaiter archiveAwaiter = null;
int i;
long count = 0;
Task archive = null;
Document document;
Action archiveDocuments = () =>
{
switch(state)
{
case State.Start: goto Start;
case State.AfterFetch: goto AfterFetch;
case State.AfterArchive: goto AfterArchive;
}
Start:
for(i = 0; i < urls.Count; ++i)
{
fetchAwaiter = FetchAsync(urls[i]).GetAwaiter();
state = State.AfterFetch;
if (fetchAwaiter.BeginAwait(archiveDocuments))
return;
AfterFetch:
document = fetchAwaiter.EndAwait();
count += document.Length;
if (archive != null)
{
archiveAwaiter = archive.GetAwaiter();
state = State.AfterArchive;
if (archiveAwaiter.BeginAwait(archiveDocuments))
return;
AfterArchive:
archiveAwaiter.EndAwait();
}
archive = ArchiveAsync(document);
}
taskBuilder.SetResult(count);
return;
};
archiveDocuments();
return taskBuilder.Task;
}
(我々がまだ、スコープの外にあるラベルに関する問題を持っていることに注意すべきである。思い出そう。コンパイラが代わりにコードを生み出しているとき、コンパイラはC#ソースコードの原則に従う必要はない; ラベルがgoto
のところのスコープにあると偽る。そして、我々がここではまだ例外処理を持っていない点に注意しよう。先週、CPSで例外処理を構築することに関する私の投稿で私が検討したように、2つの継続があるので、例外は少し怪しくなる:通常の継続とエラー継続。我々はどうやってその状況に対処するか?私は、TAPで例外処理が動作する方法を後日検討する。)
制御フローがここで明白であることをはっきりさせよう。最初に取るに足らないケースを考慮しよう:リストは空である。何が起こるか?我々は、タスク・ビルダーをつくる。我々は、void
を返すデリゲートをつくる。我々は、デリゲートを同期的に呼び出す。それは、外側の count
変数をゼロに初期化して、スタート・ラベルに分岐して、ループをスキップして、「あなたが結果を持っている」とヘルパーに話して、復帰する。デリゲートはもう完了している。taskbuilder
は、タスクを要求される; それはタスクの仕事が完了しているということを知っているので、それは単に数値0
を意味する完了されたタスクを返す。
タスクが完了しているので、呼び出し側がそのタスクを待とうとするならば、非同期操作を開始するよう頼まれるとき、そのawaiterはfalse
を返す。呼び出し側がそのタスクを待たないならば……さて、その場合、彼らは彼らがTask
で実行するものは何でも実行する。いずれ、彼らはそれにその結果を要求するか、彼らが気にかけないならばそれを無視することができる。
さて、些細でないケースを考慮しよう; アーカイブする複数の文書がある。再度、我々はタスク・ビルダーと同期的に呼び出されるデリゲートをつくる。最初にループに入った時、我々は非同期収録を開始し、デリゲートの上にその継続を登録し、デリゲートから戻る。その時、タスク・ビルダーは「私は、ArchiveDocumentsAsync
の本体に、非同期で取り組んでいる」ことを表現するタスクを構築して、そのタスクを返す。取得タスクが非同期で完了して、その継続を呼び出すとき、デリゲートはステート・マシンの魔法のおかげで「それが離れて去った点から」再び動き出す。すべては正確に前の通り、void
を返すバージョンと同じように進行する; 唯一の違いは、ArchiveDocumentsAsync
から返されたTask<long>
が、デリゲートが結果をセットするようにタスク・ビルダーに伝えるとき、(その継続を呼び出すことによって)それが完了していると合図するということである。
お分かり?
私がタスクの複合化に関するさらにいくつかの考えを再開する前に、TAPの拡張性について手短かなメモを。我々は、非常に拡張可能であるようにLINQを設計した; Select
やWhere
などを実装するか、彼らのために実装される拡張メソッドを持っているどんな型でも、クエリ内包で使われることができる。TAPでも同様だ:BeginAwait
やEndAwait
などを持っている型を返すGetAwaiter
を持っているどんな型でも、 await
式で使われることができる。しかし、非同期であるとマークされるメソッドは、void
、Task
またはいくつかのT
のためのTask<T>
だけを返すことができる。我々は既存の非同期的なものを利用することに関して、だいたい効果的な拡張を可能にするが、エキゾチックな型で非同期メソッドの生産を可能にすることを目的とする願望は持っていない。(油断のない読者は、私がタスク・ビルダーのための拡張点を検討しなかった点に注意しただろう。後日、私はタスク・ビルダーがどこから来るかについて検討する。)
続けよう:(わはは)
LINQには、where
節のような「言語」機能の使用がより自然である状況もあるし、「流れるような」構文 (Where(c=>...)
) を使うことがより自然な状況もある。TAPでも同様だ:我々のゴールは非同期タスクを組み立てて統制するのに通常のC#構文を使えるようにすることだが、時々は、より「コンビネーター」ベースのアプローチを取りたいこともある。そのためには、我々は、WhenAll
または WhenAny
のような名前のメソッドを利用可能にして、このようにタスクを組み立てられるようにしている:
List<List<Url>> groupsOfUrls = whatever;
Task<long[]> allResults = Task.WhenAll(from urls in groupsOfUrls select ArchiveDocumentsAsync(urls));
long[] results = await allResults;
これは、何をするか?さて、ArchiveDocumentsAsync
はTask<long>
を返すので、クエリはIEnumerable<Task<long>>
を返す。WhenAll
は一連のタスクを受けとって、それらをそれぞれ非同期で待って、結果を配列に満たして、それから、利用できるときにその結果をもって継続を呼び出すような新しいタスクを作り出す。
同様に、我々は、一連のタスクを受けとって、それらのタスクのどれかが完了したとき、最初の結果でその継続を呼び出す新しいタスクを作り出すWhenAny
を持っているだろう。(面白い問題は、最初のものは正常に完了するが、残りがすべて例外を投げるならば、何が起こるかということである。しかし、我々はそれについて後で話すだろう。)
他にも、タスク・コンビネーターと、関連するヘルパー・メソッドがある; いくつかの例についてはCTPサンプルを見ること。CTPリリースにおいて、我々は既存のTask
クラスを修正することができなかったことに注意すべきである; その代わりに、我々は新しいコンビネーターを**TaskEx
**に仮に加えた。最終版において、きっと彼らはほとんどをTask
の上に動かされる。
次回: いいえ、まじめな話、非同期がマルチスレッディングを必ずしも含むというわけではない。
-
私はこれが暫定的であり、私自身の修辞的な目的のためであり、何の公式な名前でもないと強調する。「エッセンシャルTAP」とか、「21の時間単位でTAPを学ぶ」とかいった本を出版しないように。私は、本棚に「手軽なDHTML Scriptlets」という本を持っている; Dino Espositoはあまりに速く本を書いたので、彼が本全体を出版したのは、我々が製品のコード名を彼に言った時と、我々が本当の名前を発表した時の間だった。(「Windows Script Components」が、最終的な名前であった。) ↩︎