次の方法で共有


非同期プログラミング

新しい Visual Studio Async CTP により容易になった非同期プログラミング

Eric Lippert

 

人間が、コンピューター プログラムと同じように動く世界を想像してみてください。

 

void ServeBreakfast(Customer diner)
{
  var order = ObtainOrder(diner);
  var ingredients = ObtainIngredients(order);
  var recipe = ObtainRecipe(order);
  var meal = recipe.Prepare(ingredients);
  diner.Give(meal);
}

各サブルーチンは、食事の準備にフライパンを熱する、オムレツを作る、パンを焼くといった作業だけでなく、当然、さらに細分化されます。このような作業を人間が一般的なコンピューター プログラムのように処理するとしたら、すべての作業を階層化したタスクのシーケンスとしてチェックリストに慎重に書き出し、次の作業に着手する前にそれぞれの仕事が完了しているかどうかしつこく確認することになります。

サブルーチンを基本とするアプローチは一見合理的に思えます。注文を取る前に卵を料理することはできません。しかし、実際にはこのようなしくみは時間を浪費することにつながり、アプリケーションが応答しなくなる原因にもなります。卵を焼き終わって冷え切ってしまうまで放置するのではなく、卵と同時にパンを焼きたいと考えます。つまり、時間を無駄にしています。注文を受けた料理の最中に別の客が到着したときは、先に来た客に朝食を出すまで入り口で待たせるのではなく、新しく来た客の注文を受けたいと考えます。つまり、先に来た客にはいったん応答を停止するように見えます。チェックリストにそのまま従う給仕人は、予期しない出来事に適切なタイミングで対応する能力がありません。

解決策 1: スレッドを多く作成してスタッフを増やす

朝食の準備というのは奇抜なたとえかもしれません。もちろん現実はまったく異なります。UI スレッド上で制御を実行時間の長いサブルーチンに移すと、そのたびに UI はそのサブルーチンが完了するまで一切応答しなくなります。どうすればよいでしょう。アプリケーションが UI イベントに応答するために UI スレッド上でコードを実行すると、そのスレッドは他の処理には応答できなくなります。コマンドがキューに入れられ、ユーザーがいらいらしても、リストの各ジョブは、前のジョブが完了してからようやく取り掛かってもらえます。この問題に対する通常の解決策は、"同時実行" を使用して複数の作業を "同時に" 行うことです (2 つのスレッドが 2 つの独立したプロセッサ上にあれば、その 2 つのスレッドは実際に同時に実行されます。スレッドの数が各スレッド専用のプロセッサの数よりも多くなると、OS が各スレッドのタイム スライスを定期的にスケジューリングしてプロセッサを制御することで、同時実行のシミュレーションを行います)。

同時実行の解決策の 1 つは、スレッド プールを作成して、新しいクライアントのそれぞれに固有のスレッドを割り当てて、要求を処理する方法です。今回のたとえで言うなら、給仕人のグループを雇うということです。新規の来客があったら、手が空いている給仕人をその客に割り当てます。こうすると、各給仕人が、注文を取る、食材を探す、食事を作る、食事を運ぶといった作業を独立して行うことができます。

このアプローチの難点は、UI イベントが、ほぼ毎回同じスレッドに着信し、そのスレッドですべてのサービスを提供するように想定していることです。ほとんどの UI コンポーネントは、UI スレッド上で要求を作成し、そのスレッド上でのみ通信することを想定しています。UI 関連の各タスクに新しいスレッドを専用に割り当てても、うまく機能することはまずありません。

この問題に対処するには、"注文を取る" UI イベントだけをリッスンする 1 つのフォアグラウンド スレッドを作成し、そのイベントを 1 つ以上のバックグラウンド ワーカー スレッドに委託します。今回のたとえで言うなら、客とのやり取りを担当する給仕人が 1 人だけいて、要求された作業を実際に行う料理人がたくさんいるキッチンが 1 つあるということです。これで、UI スレッドとワーカー スレッドは、互いに通信し合うよう調整するようになります。料理人は客と直接会話をすることはありませんが、食事は必ず提供されます。

この方法では、"UI イベントに適切なタイミングで対応する" という問題は確実に解決できますが、効率性の欠如については解決することができません。ワーカー スレッドで実行中のコードは、卵の調理が完了するのを依然として同期をとって待ち続けており、パンをトースターに入れられずにいます。この問題は、同時実行を "さらに" 増やすことで解決できます。つまり、1 つの注文につき 2 人の料理人を割り当て、それぞれ卵とトーストを別々に調理させる方法です。しかし、かなりコストがかかる恐れがあります。必要になる料理人の数と、料理人の作業を調整する必要が生じると何が起こるかを考えておきます。

この種の同時実行では、よく知られた問題がたくさん起こります。第一に、ご存知のとおり、スレッドの負荷が高くなります (既定では、スタックなどの多数のシステム リソース用に、1 つのスレッドで 100 万バイトの仮想メモリが消費されます)。第二に、多くの場合、UI オブジェクトは UI スレッドに "関連付けられて" いて、ワーカー スレッドから呼び出せないことです。UI スレッドが必要な情報を UI 要素からワーカー スレッドに送信し、ワーカー スレッドが更新結果を (UI 要素ではなく) UI スレッドに送り返せるよう、ワーカー スレッドと UI スレッドの間でなんらかの複雑な調整が必要になります。こうした調整をコーディングするのは難しく、競合状態、デッドロックなどのスレッド処理の問題に陥りがちです。第三に、開発者が 1 つのスレッド環境ですべてを処理するという快適な考え方 (予測可能かつ一貫性のあるシーケンスでメモリの読み取りや書き込みが行われるなど) の多くが、もはや信頼できなくなります。この問題は、再現が難しい最悪のバグを生み出す結果につながります。

まだ応答性が高く、効率よく実行しているシンプルなプログラムをビルドするのに、スレッドベースの同時実行という大仕掛けを持ち込むことは間違いのように思えます。開発者は、実際に、イベントに対する応答性を維持したまま、何とか複雑な問題を解決しています。実世界では、たくさんある保留中の要求を同時に処理するために、テーブルごとに 1 人のウェイターを割り当てたり、1 つの注文に 2 人の料理人を割り当てたりする必要はありません。スレッド処理を使って問題を解決すると、料理人を採用しすぎる結果になります。このように多くの同時実行を伴わない、より良い解決策はあるはずです。

解決策 2: DoEvents による注意欠陥障害の発生

実行時間の長い操作によって UI が応答しなくなる問題に同時実行を使わず対処する一般的な "解決策" は、問題が解決するまでプログラムのあちこちに Application.DoEvents という魔法の言葉をたくさん振りかけることです。これは間違いなく実用的な解決策ですが、技術的に非常に優れた策とは言えません。

void ServeBreakfast(Customer diner)
{
  var order = ObtainOrder(diner);
  Application.DoEvents();
  var ingredients = ObtainIngredients(order);
  Application.DoEvents();
  var recipe = ObtainRecipe(order);
  Application.DoEvents();
  var meal = recipe.Prepare(ingredients);
  Application.DoEvents();
  diner.Give(meal);
}

基本的に DoEvents を使用することは、「直前の作業の最中に、興味を引くようなことが起こったどうかを確認し、対応すべき出来事が起こっていれば、現在実行中の作業を記憶して新しい状況に対処してから、中断した作業の続きに戻る」という処理を行うことです。これにより、プログラムは注意欠陥障害があるかのように動作して、新しく発生する出来事すべてに即座に反応します。これは、応答性向上のもっともらしい解決策のように聞こえます (場合によっては成功することさえあります)。しかし、このアプローチには多くの問題があります。

第一に、DoEvents が最適に機能するのは、何回も実行されるがそれぞれの実行時間が短い 1 つのループが遅れの原因になるときです。ループ全体の実行時間が長い場合でも、ループの中で数分おきに保留中のイベントをチェックすれば、応答性を維持できます。しかし、通常このパターンは応答性の問題の原因にはなりません。応答性の問題の原因の多くは、待ち時間の長いネットワーク経由で同期をとってファイルにアクセスするなど、本質的に実行時間の長い操作にあります。今回の例で言うと、実行時間の長い仕事はおそらく食事を準備する工程で、DoEvents を適切に配置する場面はありません。DoEvents が役立つ場面があるとすれば、それはソース コードのないメソッド内です。

第二に、DoEvents を呼び出すことにより、プログラムは、先に発生したイベントに関連する処理を完了する "前に"、新しく発生したイベントを "すべて" 処理しようとします。後から来た客全員分の食事が運ばれるまで、誰も食事にありつけない状況を想像してみてください。客が次から次へと絶え間なく到着すると、最初に来た客は、食事にありつけず、餓死する "恐れ" があります。それどころか、だれも食事にありつけない事態が発生する可能性すらあります。先に発生したイベント関連の処理の完了は、新しいイベントの処理が割り込み続ける限り、自動的にはるか先まで先延ばしになります。

第三に、DoEvents には、予期しない再入という非常に現実的な危険性があります。つまり、ある客に給仕しているときに、興味深い UI イベントが新しく発生していないかどうかをチェックすると、既に給仕を始めているにもかかわらず再び同じ客に誤って給仕をやり直すことがあります。ほとんどの開発者は、このような再入を検出するようにコードを設計することはありません。再帰を考慮していないアルゴリズムが、DoEvents により予期せず自分自身を呼び出した場合は、プログラムが非常に奇妙な状態になる可能性があります。

要するに、DoEvents は、状況がごく単純なときに応答性の問題を解決するためのものであり、複雑なプログラムで UI の応答性を管理するのに適切な解決策であるとは言えません。

解決策 3: コールバックを使用してチェックリストを裏返しにする

DoEvents による手法の同時実行を使わないという性質は魅力的ですが、複雑なプログラムにふさわしくない解決策であることは明らかです。より優れた考え方は、アプリケーションがイベントに十分応答できるところまで、チェックリストの項目を一連の短いタスクに細分化することです。

この考え方は特に目新しいものではなく、そもそも複雑な問題を細分化するためにサブルーチンが生まれています。興味深い点は、チェックリストを堅苦しく上から確認して何が終了して何を次に行うのかを判断し、完了したら呼び出し元に制御を戻すというのではなく、「新しいタスクのそれぞれに、各タスクの後に行うべき処理のリストを与えられる」ことです。特定のタスクが完了した後に行う必要がある処理を、そのタスクの "後続処理" と呼びます。

1 つのタスクが完了すると、後続処理を確認してその場で完了させることが可能で、完了させない場合でも、後で実行するように後続処理のスケジュールを設定できます。後続処理が、前のタスクの処理情報を必要とする場合は、前のタスクからその情報を後続処理の呼び出しに引数として渡すことが可能です。

このアプローチでは、基本的に、処理全体をそれぞれ迅速に実行できる小さな処理に分割します。1 つの処理の実行からもう 1 つの処理の実行に移る "間" に、保留中のイベントが検出および対処されるため、システムの応答性が維持されます。しかしこれらの新しいイベントに関連するアクティビティも、すべて細分化され後で実行するようキューに登録されるため、新しいタスクによって古いタスクの処理を完了できなくなるという先ほどの「餓死」の問題は発生しません。新しく発生した実行時間の長いタスクが直ちに処理されることはなく、最終的に処理されるようキューに登録されます。

これは優れた方法ですが、このようなソリューションを実装する方法はまったくわかっていません。根本的な難点は、細分化されたそれぞれの処理単位に、その処理の後続処理が何なのか (つまり、次に行う必要のある処理は何か) を伝達する方法を決定することにあります。

従来の非同期コードでは、通常、"コールバック" 関数を登録することでこの問題に対処しています。次の処理 (すなわち、給仕するという処理) を示すコールバック関数を受け取る、非同期バージョンの "Prepare" があるとします。

void ServeBreakfast(Diner diner)
{
  var order = ObtainOrder(diner);
  var ingredients = ObtainIngredients(order);
  var recipe = ObtainRecipe(order);
  recipe.PrepareAsync(ingredients, meal =>
    {
      diner.Give(meal);
    });
}

ここで、PrepareAsync が結果を返した "直後に" ServeBreakfast から戻ります。つまり、ServeBreakfast を呼び出すコードが何であれ、発生する他のイベントに自由にサービスを提供することになります。PrepareAsync は、"実際の" 処理はまったく行わず、今後確実に食事を準備するのに必要なことをすばやく行います。さらに、食事を準備するタスクが完了した後の任意の時点で、準備された食事を引数として受け取るコールバック メソッドも PrepareAsync から呼び出されます。食事の準備が完了してから給仕するまでの間に対応する必要のあるイベントが発生した場合は、少し待たされる可能性はありますが、最終的には給仕が行われます。

"2 つ目のスレッドは、これらのいずれにおいて必要ではない" ことに注意してください。PrepareAsync では、食事を準備する処理を独立したスレッド上で行うことも、食事の準備に関連する短い一連のタスクを用意して、後で実行するよう UI スレッドのキューに登録することもできます。それらはまったく問題ではありません。明白なのは、PrepareAsync がなんらかの方法で 2 つの事項 (待ち時間の長い操作により UI スレッドが妨げられることのない方法で食事を準備することと、要求された食事を準備する処理の完了後にコールバックを呼び出すこと) を保証することだけです。

しかし、注文、食材の調達、レシピの入手、食事の準備の "すべての" メソッドに、UI の速度が落ちる原因となる可能性があるとしましょう。この大きな問題は、これらの各メソッドの非同期バージョンがあれば解決できます。その結果、プログラムはどのようになるでしょう。各メソッドは、処理単位の完了時に行う処理を指示するコールバックを、必ず受け取る必要があることを忘れないでください。

void ServeBreakfast(Diner diner)
{
  ObtainOrderAsync(diner, order =>
  {
    ObtainIngredientsAsync(order, ingredients =>
    {
      ObtainRecipeAsync(order, recipe =>
      {
        recipe.PrepareAsync(ingredients, meal =>
        {
          diner.Give(meal);
        })})})});
}

ひどくごちゃごちゃしているように見えるかもしれませんが、実際のプログラムをコールバック ベースの非同期処理に書き直すことに比べれば、たいしたことはありません。ループを非同期にする、すなわち例外、try-finally ブロック、または制御フローのその他の複雑な形態に対処することを想像してください。プログラムは事実上裏表が逆になり、現在コードでは、プログラムの論理的なワークフローがどうなるかではなく、すべてのコールバックがどのように関連付けられているかに重点が置かれます。

解決策 4: タスク ベースの非同期処理によりコンパイラに問題を解決させる

コールバック ベースの非同期処理により、UI スレッドの応答性を維持して、実行時間の長い処理が完了するのを同期をとって待機することで、浪費される時間を最小限に抑えることができます。しかし、その対策方法は、その問題よりもひどいように思えます。応答性とパフォーマンスに対する代償は、コードの意味や目的をあいまいにして同期の "メカニズム" がどのように機能するかということに重点を置いたコードを記述する必要があることです。

次期バージョンの C# および Visual Basic では、コンパイラ内部で必要なメカニズムをビルドするのに十分なヒントを提供して、意味や目的に重点を置いたコードを記述することが可能になります。この解決策には、"型システム" と "言語" という 2 つの部分があります。

CLR 4 のリリースでは、タスク並列ライブラリ (TPL) の主力の型として Task<T> 型が定義され、「今後 T 型の結果を生成するなんらかの処理」という概念が表現されます。非ジェネリックの Task 型では、「今後完了予定だが、結果を返さない処理」という概念が表現されます。

正確に今後どのように T 型の結果を生成するかは、特定のタスクの実装の詳細になります。処理をまったく別のコンピューターに委託する、作業中のコンピューターの別のプロセスに委託する、別のスレッドに委託するといった実装が可能です。あるいは、現在のスレッドから簡単にアクセスできる、既にキャッシュ済みの結果を読み取るだけといった実装も考えられます。通常、TPL のタスクは、現在プロセスのスレッド プールに含まれるワーカー スレッドに委託しますが、この実装の詳細は Task<T> 型に必須ではありません。むしろ、Task<T> 型を使用すると、T 型を生成する待ち時間の長いあらゆる操作を表現できます。

この解決策の 2 つ目の言語の部分では、新しい await というキーワードが定義されています。通常のメソッド呼び出しは、「実行中の作業を記憶しておき、呼び出したメソッドを完了するまで実行して、その結果を確認したうえで、作業を中断した箇所を把握する」という処理を行います。これに対して、await 式は、「この式を評価して、今後結果を生成する処理を表すオブジェクトを取得し、現在のメソッドの残りの処理を、そのタスクの後続処理に関連付けるコールバックとして登録する。タスクが生成されてコールバックが登録されたら、"すぐに" 制御を呼び出し元に戻す」という処理を行います。

次のように、新しいスタイルで書き直した簡単な例の方が、はるかにわかりやすいでしょう。

async void ServeBreakfast(Diner diner)
{
  var order = await ObtainOrderAsync(diner);
  var ingredients = await ObtainIngredientsAsync(order);
  var recipe = await ObtainRecipeAsync(order);
  var meal = await recipe.PrepareAsync(ingredients);
  diner.Give(meal);
}

この例では、各非同期バージョンが Task<Order> 型、Task<List<Ingredient>> 型などの結果を返します。現在実行中のメソッドは、await が発生するたびにメソッドの残りの処理を現在のタスクの完了時に行う処理として登録し、すぐに戻ります。各タスクがなんらかの形で完了すると (現在のスレッドのイベントとして実行するスケジュールを設定するか、I/O 完了スレッドまたはワーカー スレッドを使用することによって)、その後メソッドの残りの処理を実行する中で、そのタスクの後続処理として「作業を中断した箇所を取得する」処理が行われます。

メソッドを新しい async というキーワードでマークしていることに注意してください。async キーワードは、このメソッドのコンテキストで、ワークフローが制御を呼び出し元に戻して関連タスクを完了したときに再び制御を取得する場所として扱われることをコンパイラに伝えているにすぎません。今回紹介した例に、C# コードを使用していますが、Visual Basic でも同様の構文を使用して同様の機能が提供されます。C# および Visual Basic におけるこうした機能の設計は、F# の非同期ワークフロー、すなわち F# で以前から使用されていた機能に大きく影響されています。

詳細を知りたい方のために

今回の簡単な概要は、C# と Visual Basic における非同期機能について学習するきっかけを作り、ほんの一部を説明したにすぎません。コンパイラ内部のしくみや非同期コードのパフォーマンス特性について論理的に判断する方法の詳細については、筆者の同僚である Mads Torgersen および Stephen Toub による今月号の関連記事を参照してください。

サンプル、ホワイト ペーパー、および質問、ディスカッション、建設的なフィードバックのためのコミュニティ フォーラムと共にこの機能のプレビュー リリースを入手するには、msdn.com/async (英語) を参照してください。これらの言語機能およびその言語機能をサポートするライブラリは現在も開発中のため、設計チームは、できる限り多くのフィードバックを望んでいます。

Eric Lippert は、マイクロソフトの C# コンパイラ チームにおける開発の第一人者です。

この記事のレビューに協力してくれた技術スタッフの Mads TorgersenStephen Toub、および Lucian Wischik に心より感謝いたします。