C# 5の非同期、パート4: 魔法じゃない
2020-12-15 09:37:20
今日は、マルチスレッドを一切使わない非同期について話そうと思う。
「マルチスレッドなしで非同期を実現するにはどうしたらいいのか」とよく聞かれる。 たぶんもう答えをわかっているはずなのに聞いてくるんだから、おかしな質問だ。 質問を逆にしてみようか。「複数のCPUを使わずにマルチタスクを実現するにはどうすればいいのか」と。 もし作業をしているのが1つだけなら、2つのことを「同時に」行うことは無理に決まっている! でも、その疑問の答えはもうわかっているはずだ。 シングルコアでのマルチタスクとは、OSがあるタスクを停止して、その続きをどこかに保存し、別のタスクに切り替えてしばらく実行し、その続きを保存し、最後にはまた最初のタスクに切り替えて続きを実行する、ということを意味する。 シングルコアシステムでは同時実行性は幻想であり、2つのことが本当に同時に起きているかはどうでもよい。 一人のウェイターが2つのテーブルに「同時に」サービスを提供することは可能なのか?いいや。各テーブルは順番にサーブされていく。 熟練したウェイターなら、誰も待つ必要がないようにタスクをスケジューリングするので、どの客の要望もすぐに満たされているように感じさせることができる。
マルチスレッドを使わない非同期も同じ発想だ。 あるタスクをしばらく実行して、制御を渡せるようになったら、しばらくの間、別のタスクを同じスレッド上で実行する。 許容できないほど長く待たされるタスクがないことを願うのみである。
少し前に、初期バージョンのWindowsがマルチプロセスをどう実装していたかを簡単にスケッチしたのを覚えているだろうか? 当時は、制御のスレッドは1つしかなかった。各プロセスはしばらくの間実行され、その後OSに制御を返していた。 それからOSは様々なプロセスをループさせて、それぞれのプロセスに実行のチャンスを与えていた。 もしそのうちの1つがプロセッサを独占してしまうと、他のプロセスは無反応になってしまう。この仕組みはすべてが協力的な事業だった。
では、少しだけマルチスレッドの話をしよう。 ちょっと前だが2003年に、私がアパート式スレッドモデルについて少し話したことを覚えているだろうか。 そこで重要だった考え方は、スレッドセーフなコードを書くのは高価で難しいということだ。 なので、その費用を負担する必要がないのであれば、負担しなければいい。 もし「UIスレッド」だけが特定のコントロールを呼び出すことを保証できるのであれば、そのコントロールが複数のスレッドで使用されても安全にする必要はない。 ほとんどの UI コンポーネントはアパートモデルでスレッド化されていて、したがって UI スレッドは Windows 3 のように動作する: つまり、誰もが協力しなければならず、そうでなければ UI は更新を停止するということだ。
Windows でアプリケーションがユーザーの入力に正確にどう応答しているかについて、驚くほど多くの人が魔法を信じているが、もちろんそれは魔法ではない。 Windows でインタラクティブなユーザーインターフェイスが構築される仕組みは非常に単純なものだ。 何かが起こると、例えばボタンをマウスでクリックすると、OSはそれを記録しておく。 ある時点で、プロセスがOSに「最近何か面白いことがあった?」と尋ね、OSは「そうだ、誰かがこれをクリックした」と答える。 そしてプロセスは、そのために適切なアクションを行う。 何が起こるかはプロセス次第で、プロセスは、クリックを無視するか、独自の方法で処理するか、OSに「その種のイベントのデフォルト動作を適当に実行してくれ」と指示するかを選択できる。 これらはすべて、見たこともないほどシンプルなコードによって駆動されているのが普通だ。
while(GetMessage(&msg, NULL, 0, 0) < 0)
{
TranslateMessage(&msg);
DispatchMessage(&msg);
}
以上。
UIスレッドを持つプロセスはすべて、内部のどこかにこのようなループがある。
1回の呼び出しごとに次のメッセージを取得する。
そのメッセージは低レベルすぎるかもしれない。
例えば、「キーボードの、特定のコード番号のキーが押された」みたいに。
これを「numlockキーが押された」と翻訳したい場合は、TranslateMessage
がやってくれる。
このメッセージを処理するための、特別な手続きがあるかもしれない。
そこで、DispatchMessage
はメッセージを適切なプロシージャに渡す。
これは魔法ではないことを強調したい。たたの while
ループだ。
これまでに見たことがあるだろう、C言語における他のwhileループと同じように動作する。
このループは3つのメソッドを繰り返し呼び出し、それぞれがバッファを読み書きし、制御を戻す前に何らかのアクションを行う。
これらのメソッドのうちのどれか1つが、制御を戻すまでに長い時間がかかる場合(メッセージに関連した作業を実際に行っているのは DispatchMessage
なので、一般的には DispatchMessage
が一番長く実行される)、どうなるだろうか?
UI は、そのメソッドから戻ってくるまで、OSからの通知をフェッチしたり、翻訳したり、ディスパッチしたりすることはない。
(あるいは、リンク先の記事でRaymondが指摘しているように、コールチェーン上の他のメソッドがメッセージキューをポンピングしていなければ、だ。これについては後述する。)
前回のドキュメントアーカイブのコードをさらにシンプルにしてみよう。
void FrobAll()
{
for(int i = 0; i < 100; ++i)
Frob(i);
}
ボタンクリックの結果としてこのコードを実行していて、最初の Frob
の呼び出し中に「誰かがウィンドウのサイズを変更しようとしている」というメッセージがOSに届いたとしたらどうなるだろうか?
何も起こらない、それだけだ。
メッセージループに制御が戻るまで、メッセージはキューに入ったままで、ループは動いてない。なぜか?
それは、メッセージループはただの while
ループでしかなく、そのコードを含むスレッドは Frob
するのに忙しいからだ。
100 個の Frob
がすべて終わるまで、ウィンドウのサイズは変更されない。
さて、このようになっていたら
async void FrobAll()
{
for(int i = 0; i < 100; ++i)
{
await FrobAsync(i); // このスレッドで Frob(i) 操作を行う、開始されたタスクをどうにかして取得する
}
}
どうなるだろう?
誰かがボタンをクリックする。クリックに対応するメッセージがキューに入る。
メッセージループはメッセージをディスパッチし、最終的に FrobAll
を呼び出す。
FrobAll
はアクションを伴う新しいタスクを作成する。
タスクのコードは自分のスレッドに「ねえ、時間があったら呼び出して」というメッセージを送る。
そして FrobAll
に制御を返す。
FrobAll
はタスクを待ち受けるオブジェクトを作成し、タスクの継続を登録する。
その後、制御はメッセージループに戻る。メッセージループは、「呼び出して」というメッセージが待っていることを確認する。
そこで、メッセージループはメッセージをディスパッチし、タスクはアクションを開始する。
タスクは最初に Frob
を呼び出す。
さて、この時点で別のメッセージ、例えばサイズ変更イベントが発生したとする。
何が起こるだろうか?何も起こらない。
メッセージループは実行されていない。Frob
するのに忙しいからだ。
メッセージは処理されずにキューに入る。
最初の Frob
が完了して 制御が元のタスクに戻る。
タスクは自身に完了の印をつけて、別のメッセージをキューに送る: 「お時間がありましたら、私の継続を呼び出してくれませんか」 1
タスクの呼び出しが終了する。制御はメッセージループに戻る。 メッセージループは保留中のウィンドウサイズ変更メッセージがあることを確認し、それがディスパッチされる。
非同期化することで、追加のスレッドを使わずにUIの反応が良くなるのがわかるだろうか?
これで、UIが反応するまでに、すべての Frob
が終了するのではなく、1つの Frob
が終了するのを待つだけで済むようになった。
もちろん、これでもまだ十分ではないかもしれない。すべての Frob
に時間がかかりすぎる場合だってあるかもしれない。
この問題を解決するには、Frob
を呼び出すたびに短い非同期タスクを生成するようにすれば、メッセージループを実行する機会を増やすことができる。
あるいは、実際に新しいスレッドでタスクを起動したってかまわない。
(ただしその場合、タスクの継続を実行するメッセージを、適切なスレッド上の適切なメッセージループにポストすることが厄介になる。
これは高度な話題なので、今日は取り上げない。)
とにかく、メッセージループはリサイズイベントをディスパッチしてから再びキューをチェックし、最初のタスクの継続を呼び出すように要求されていることを確認する。
そしてそれが実行される: 制御は FrobAll
の途中で分岐し、再びループを一周することになる。
2回目はまた新しいタスクを作成して…というサイクルが続いていく。
ここで強調しておきたいのは、これらはずっと一つのスレッド上に留まっているということだ。 ここでやっているのは、作業を小さな断片に分割して、キューにくっつけることだ。 そしてそれぞれの作業の断片が、次の作業を待ち行列に追加する。 我々は、どこかにメッセージループがあって、そのキューから仕事を取り出して実行してくれているという事実に頼っているのだ。
追記: 「ということは、タスク非同期パターンはメッセージループを持つUIスレッドでのみ動作するということですか?」と多くの人に聞かれた。 そうではない。タスク並列ライブラリは、同時実行に関わる問題を解決するために明示的に設計されているが、タスク非同期はその作業を拡張するものだ。 ASP.NETのように、UIを駆動するメッセージループのないマルチスレッド環境で非同期を動作させるメカニズムもある。 この記事は、非同期がマルチスレッドではない 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();
}
非同期作業が保留されている間は、ボタンを再度クリックできないようにした。
次回: 非同期は素晴らしいが、万能薬ではない: どのようにして物事がひどく間違った方向に進むかについての実話。
-
あるいは、その場で継続を起動する。継続を積極的に呼び出すのか、それとも単にポストバックしてスレッドの作業を増やすのかは、ユーザーが設定できるが、これは高度なトピックなので、説明までたどり着かないかもしれない。 ↩︎