Menu


C# 5の非同期、パート4: 魔法じゃない

原文

今日、私は全くいかなるマルチスレッディングも含まない非同期について話したい。

人々は私に聞き続ける。「しかし、どうすればマルチスレッディングなしで非同期性を持つことができるのか?」;あなたは多分すでに答えを知っているだろうから、尋ねるには変な質問である。問題をひっくり返させてほしい:どうすれば、複数のCPUなしでマルチタスキングを持つことができるのか?仕事を実行しているCPUが1つしかないならば、あなたは「同時に」2つのものを実行することができない!しかし、あなたはすでにそれに対する答えを知っている;シングルコアでのマルチタスキングは、単にオペレーティングシステムが1つのタスクを止めて、その継続をどこかに保存して、もう一つのタスクへ切り替えをして、しばらくそれを走らせて、その継続を保存して、そのうち、最初のタスクを続けるために切り替えをすることを意味する。並列性は、シングルコア・システムでは幻想である;2つのものが同時に本当に起こっていることは、正しくない。どうすれば、1人のウェイターが「同時に」2つのテーブルに給仕することができるか?それは可能ではない:テーブルは交替で給仕される。上手なウェイターは各々の客のニーズがすぐに満たされるように彼らに感じさせる。誰も待つ必要がないようにタスクをスケジューリングすることによって。

マルチスレッディングのない非同期は、同じ着想である。あなたはしばらくタスクを実行する、そして、それが制御を明け渡すとき、あなたはそのスレッドの上で別のタスクをしばらく実行する。あなたは、誰も給仕されるのに容認できないほど長く待つ必要が決してないことを望む。

この間、Windowsの初期バージョンが複数のプロセスを実装した方法を短くスケッチしたのを覚えているだろうか?かつて、1つの制御スレッドだけがあった;各プロセスはしばらく動作して、それからオペレーティングシステムへ制御を明け渡した。オペレーティングシステムは、それからいろいろなプロセスをループして、それぞれに実行する機会を与える。それらのうちの1人がプロセッサーを独り占めすることに決めたならば、他は非応答になった。それは、まったく協調の冒険であった。

それでは、マルチスレッディングについてちょっと話そう。この間、2003年に、私がアパート・スレッディング・モデルについて少し話したことを覚えているだろうか?ここの着想は、スレッド安全なコードを書くことが高価で難しいということである;あなたがその出費を受け入れる必要がないならば、そうするな。「UIスレッド」だけが特定の制御を呼ぶだろうと我々が保証することができるならば、その制御は複数スレッドの上で使用するために安全である必要はない。大部分のUIコンポーネントはアパート・スレッド化されているので、したがってUIスレッドはWindows 3のように動作する:誰もが協力しなければならない。さもなければUIは更新するのを止める。

驚くべき数の人々が、Windowsでアプリケーションがユーザー入力に正確に応じる方法について、魔法の信仰を持っている。私は、それが神秘的でないことをあなたに約束する。インタラクティブなユーザ・インタフェースがWindowsにビルトインされる方法は、全く簡単である。何かが、たとえばボタンのマウス・クリックが起こるとき、オペレーティングシステムはそのことを書きとめる。ある時点で、プロセスはオペレーティングシステムに「最近、何か面白いことが起こったか?」と尋ねて、オペレーティングシステムは「そうだね、誰かがこれをクリックした。」と言う。そしてプロセスは何かしら適切なアクションを実行する。何が起きるかはプロセス次第である;プロセスはクリックを無視するか、それ自身の特別な方法でそれを取り扱うか、オペレーティングシステムに「進んで、こういうイベントのデフォルトを何かしら実行しなさい。」と話すか、選ぶことができる。 これらすべては、あなたがこれまでに見た中で最も単純なコードによって、典型的に駆動される:

while(GetMessage(&msg, NULL, 0, 0) > 0)
{
  TranslateMessage(&msg);
  DispatchMessage(&msg);
}

これである。UI threadを持っているすべてのプロセスの中心に、著しくこのように見えるループがある。1つ目の呼び出しは、次のメッセージを得る。そのメッセージが、あなたにとってあまりに低いレベルであるかもしれない;たとえば、特定のキーボード・コード番号を持つキーが押されたと示しているかもしれない。あなたはそれが「numlockキーが押された。」と翻訳されることを望むかもしれない。TranslateMessageはそれを実行する。このメッセージに対処するもういくつかの特定の手続きがあるかもしれない。DispatchMessageは適切な手続きにメッセージを渡す。

私は、これが魔法でないと強調したい。これはwhileループである。それは、あなたがこれまでに見た他のどのCのwhileループとも同じように動作する。ループは繰り返し3つのメソッドを呼ぶ。そして、それぞれはバッファを読んだり書いたりして、リターンする前にいくつかのアクションをとる。それらのメソッドのうちの1つがリターンするのに長い間がかかるならば(一般的に、DispatchMessageはメッセージと実際に関連した仕事を実行するメソッドであるので、もちろん長時間にわたるものである)、それでどうなると思う?それがreturnを実行する時間まで、UIはオペレーティングシステムからの通知を受け取ったり、解釈したり、送ったりしない。(または、リンクされた記事でRaymondが指摘したように、呼び出しチェーンの他のメソッドのいくつかがメッセージ・キューをポンプで汲み上げない限り。我々は、下でこの点に戻るだろう。)

文書をアーカイブしている前回の我々のコードのさらにより単純なバージョンを考えよう:

void FrobAll()
{
    for(int i = 0; i < 100; ++i)
        Frob(i);
}

あなたがボタン・クリックの結果としてこのコードを走らせているとしよう。そして、最初にFrobを呼び出す間に、「誰かがウインドウの大きさを変更しようとしている」ことを意味するメッセージがオペレーティングシステムに到達する。何が起こるか?何も起きない。それが答えだ。すべてが制御をそのメッセージ・ループに返すまで、メッセージはキューにとどまる。メッセージ・ループは現在動作していない;どうしてこうなった?それは単なるwhileループであり、そのコードを含むスレッドはFrobするのに忙しい。100のFrobが全て実行されるまで、ウインドウはリサイズを実行しない。

さて、次のものを持っているとしよう。

async void FrobAll()
{
    for(int i = 0; i < 100; ++i)
    {
        await FrobAsync(i); // somehow get a started task for doing a Frob(i) operation on this thread
    }
}

今度は何が起こるか?

誰かがボタンをクリックする。クリックのためのメッセージは待ち行列に入れられる。メッセージ・ループはメッセージを送って、最終的にFrobAllを呼ぶ。

FrobAllは、アクションを伴なう新しいタスクを生み出す。

タスク・コードは、「おい、時間があるときに私を呼び出せ」と言うメッセージをそれ自身のスレッドに送る。それから、それはFrobAllに制御を返す。

FrobAllはタスクのawaiterを生み出して、タスクに継続を登録する。

それから、制御はメッセージ・ループへ戻る。メッセージ・ループは、待っているメッセージがあるのを見る:私をコールバックしろ。それで、メッセージ・ループはメッセージを送り、タスクはアクションを始める。それは、Frobの最初の呼び出しを実行する。

さて、また別のメッセージ、例えばリサイズ・イベントがこの時点で起こるとしよう。何が起こるか?何も起きない。メッセージ・ループは動作していない。我々は、Frobするのに忙しい。メッセージはキューに入り、未処理である。

最初のFrobが完了して、制御はタスクに復帰する。それはそれ自身を完了したとマークして、メッセージ・キューに別のメッセージを送る:「時間があるときに私の継続を呼び出せ。」(*)

タスク呼び出しは実行される。制御は、メッセージ・ループに復帰する。メッセージループは保留中のウインドウ・リサイズ・メッセージがあることを知る。そしてそれは送られる。

非同期がスレッドなしでもUIをより敏感にする方法がわかっただろうか?今度は、あなたはUIが反応するまで全部のFrobが終わるのを待つのではなく、1つのFrobが終わるのだけを待てばいい。

それは、もちろん、まだ十分によくはないかもしれない。あらゆるFrobが長くかかりすぎることはありえる。その問題を解決するために、あなたはFrobの各々の呼び出し自体に短い非同期タスクを生ませることができる。そうすることで、メッセージ・ループが動作するより多くの機会がある。または、あなたは実際に新しいスレッドの上でタスクを始めることができた。(その場合、手際のいるビットは、正しいスレッド上の正しいメッセージ・ループでタスクの継続を走らせるようにメッセージをポストすることになる;それは、私が今日取り扱わない高等な話題である。)

とにかく、メッセージ・ループはリサイズ・イベントをディスパッチして、それから再びそのキューをチェックして、最初のタスクの継続をコールバックすることが要求されたのを見る。メッセージループはそれをする;制御はFrobAllの中に分岐して、我々は再びループをまわってよくなる。2回目の時間の中で、我々はまた新しいタスクをつくり……サイクルは続く。

私がここで強調したいことは、我々が全ての間で1つのスレッドの上にいたということである。我々がここで実行しているすべては、作業を小さな部分に分解して、作業をキューの上に刺すことだ;それぞれの作業が、次の作業をキューの上に刺す。我々は、そのキューから作業を取り出して、それを実行するメッセージ・ループがどこかにあるという事実に頼る。

追記:何人かの人々は私に尋ねた。「つまりそれは、Task Asynchrony Patternがメッセージ・ループを持っているUIスレッドでだけ機能するという意味なのか?」いいえ。Task Parallel Libraryは並列性に関係する問題を解決するために明示的に設計された;タスク非同期はその仕事を拡張する。ASP.NET.のように、ユーザ・インタフェースを駆動するメッセージ・ループのないマルチスレッド環境で、非同期が動くのを許すメカニズムがある; 本稿の意図は非同期がUIスレッドでどのようにマルチスレッディングなしで機能するかについて述べることで、非同期がマルチスレッディングなしのUIスレッドだけで機能すると言うことではない。私は、後日、他の種類の「オーケストレーション」コードが、どのタスクをいつ走らせるかを解決するという、サーバー・シナリオについて話すだろう。

さらに追加の話題:VBのベテランは、UIの反応性を得るためにこのトリックを使用することができるということを知っている:

Sub FrobAll()
  For i = 0 to 99
    Call Frob(i)
    DoEvents
  Next
End Sub

それは、上記のC# 5非同期プログラムと同じことを実行するか?VB6は、実際に継続渡しスタイルをサポートしていたのか?

いいえ;これはさらに単純なトリックである。DoEventsは、待たれているタスクがするように、ある種の「ここに復帰する」メッセージで最初のメッセージ・ループへ制御を戻すようなことをしない。むしろ、それは第2のメッセージ・ループを始め(そしてそれは、思い出そう、完全に普通のwhileループである)、保留中のメッセージの残りを片づけて、それから制御をFrobAllに返す。これがなぜ潜在的に危険なのかわかるだろうか?

我々がボタン・クリックの結果としてFrobAllにいるとしたらどうだろうか?そして、frobしている間にユーザーが再びボタンを押したとしたら?DoEventsは別のメッセージ・ループを走らせて、それはメッセージ・キューを空にする。そして今や我々はFrobAllの中でFrobAllを走らせている;それは再入になっている。そしてもちろん、それは再び起こるかもしれない。そして今や我々はFrobAllの3つめのインスタンスを走らせている……

もちろん、同じことはタスク・ベースの非同期にとっても真実である!あなたがボタンクリックのために非同期でfrobし始めるならば、そして、frobしている仕事が保留中の間に2回目のボタン・クリックがあるならば、その場合、2つめのタスク・セットが生み出される。これを防ぐために、FrobAllにTaskをリターンさせて、それから以下のような何かを実行させることは、多分良い考えであるだろう:

async void Button_OnClick(whatever)
{
    button.Disable();
    await FrobAll();
    button.Enable();
}

そうすれば、非同期作業がまだ保留中の間はボタンをもう一度クリックできないようになる。

次回:非同期はすごいが万能薬でない:ものごとがどのぐらいひどいことになってしまうかについての実体験談。

(*)あるいは、それはそこでただちに継続を呼び出す。継続が積極的に呼び出されるか、そのスレッドがするべき追加の仕事として呼び戻されるだけなのかは、利用者が構成可能である。しかし、それは高度な話題なので扱わない。


インデックスへ戻る


Last Update: 2012-09-26 09:36:03