Async訪ねて3000里 (5) : TaskCompletionSourceでTaskの継続へ

とうとうAPCコールバックが呼び出されました。ここで、TaskCompletionSourceクラスSetResultメソッドを呼び出して、結果をユーザーモードのコードに返却します。これは、疑似的に以下のようなコードとなります。

// ReadFileEx APIのAPCコールバックエントリーメソッド(疑似コード例)
private static void OnReadCompleted(uint errorCode, uint bytesTransferred, ref OVERLAPPED overlapped)
{
    // OVERLAPPED構造体のメンバーに保存しておいたTaskCompletionSourceを得る
    var taskCompletionSource = overlapped.TaskCompletionSource;

    // TaskCompletionSourceに結果をセットする
    taskCompletionSource.SetResult(overlapped.Buffer);

    // (アンマネージ構造体の解除など)
}

前回の説明で示したように、このコールバックはAPCキューを拾い上げたスレッドが実行します。そのため、何らかのスレッドが、事前にSleepExやWaitForMultipleObjectsExなどのAPIを呼び出し、APCの待機状態でなければなりません。それはさておき、これでTaskCompletionSourceクラスのTaskが非同期処理を継続できることになります。

ここまでの長い道のりで重要なのは、

  • 非同期I/Oを要求してから、非同期処理が完了するまでの間、いわゆるワーカースレッドで「疑似的」に並列動作を行っているのではなく、デバイスのハードウェア並列実行性をそのまま使用して、CPUが完全にフリーな状態を作り出している。

ことです。

async8

ワーカースレッドによる疑似的な並列実行では、並列性を担保するために「CPUコアのコンテキストスイッチング」を使用します。この機能は強力で、全く無関係なコードグラフ(実行単位)を、あたかも同時に動作させているかのようにふるまわせる事が出来ますが、切り替えには大きなオーバーヘッド、そしてメモリを消費します。

しかし、今まで述べた非同期I/Oに必要なオーバーヘッドは、「仕方なく必要となるリソース」以外にありません。仕方なく、とは、

  • 割り込み処理ではどうにもならないためにDPCキューを使うこと
  • スレッドとプロセスを特定するためにAPCキューを使うこと
  • 各ドライバ階層の個別に必要となる処理で要求されるリソース
  • WDMとカーネルがI/O要求を処理するために必要とするリソース

の事で、これ以外の処理は、何をしても「追加リソース」としてオーバーヘッドを伴うことが分かります。よって、非同期I/O要求は最小のコストで実現出来る、同時並列実行を高めるよい方法となります。


ところで、あまり突っ込んで説明していないが重要なポイントがあるので、さらに掘り下げておきましょう。

本当にワーカースレッドは使われないのか?

実はこれは巧妙な嘘です。というのも、これまでに紹介した非同期I/Oの流れは、いわゆる「最適な場合」に相当します。

async9

WDMではスタック化されたドライバ群が存在しますが、その中間処理で「ワーカースレッド」を使っている可能性があります。そこでは、独自の何らかのキューを使用して要求をバッファリングし、ワーカースレッドで処理を行っていると思われます。これは、サードパーティ製ドライバは勿論、Windows標準のカーネルモードドライバにも当てはまります。
(そして、若干ですが、処理の都合上避けられない理由のためにワーカースレッドを使うこともあります)

デバイス寄りのドライバコードであれば、ここにデバイス製品の優劣が生まれます。如何に最小のコスト(ワーカースレッドを使わない)で、I/O処理を捌く事が出来るかによって、製品の性能が左右されることになります。ユーザーモードのコードと同様に、完全に非同期I/O要求だけで駆動するドライバの開発は難易度が高くなり、処理をオフロードするため、ハードウェアにリッチなロジックが必要になる可能性があります。または、ワーカースレッドを使うことで、CPUリソースを消費してしまうものの、安価にデバイスを開発できる可能性もあります。

あるいは、デバイスによってはハードウェア割り込みが限定的にしか機能せず、「ポーリング」という古典的なループ処理で実現しなければならない場合もあります。これは勿論、ワーカースレッドで半永久ループを行うことで実現しているため、CPUリソースを消費します。

APCコールバックを実現するためのスレッドをどうやって待機させるのか

APCコールバックを実行するためには、あらかじめスレッドを待機状態にしておく必要があることは説明しました。SleepExやWaitForMultipleObjectsEx APIをコールすることで、APC待機状態を作る事が出来ます。

これらのAPIのバリエーションに、「MsgWaitForMultipleObjectsEx」もあり、これを使えばUIスレッドのメッセージループ中で、メッセージを待機しながらAPCも待機する事が出来ます。メインスレッドのメッセージループでこのAPIを使うことで、非同期I/Oの完了をメインスレッドで受け取り、APCコールバックをメインスレッドで実行する事が出来ます。

これはつまり、APCコールバックメソッド中で、自由にウインドウ操作が可能になることを意味します。

しかし、.NETのTaskクラスは、async/awaitで待機・継続するときにUIスレッドを自動的にキャプチャ出来るので、余計なお世話に見える

その通りです。APCコールバックを使用することで、本来ならTaskAwaiter.OnCompleteメソッドのUIスレッドキャプチャ処理がやってくれた作業が不要となります。しかし、.NETの世界でTaskクラスを使うのが前提であれば、APCキューを使った結果の返却は、却ってオーバーヘッドのように見えます。

async10

また、APCコールバックが実行されるためには、メッセージループでMsgWaitForMultipleObjectsExを使用する必要がありますが、これは大きな制限です。.NETの世界ではこのようなAPIを直接使用することはまれであり(通常、Applicationクラスに隠ぺいされている)、また既存の実装との親和性が気になります。

更には、APCコールバックでは、処理に対応するスレッドが限定されてしまうため、I/Oの完了が立て込む時にパフォーマンスが悪くなります。APCキューはまさしく「直列化されたキュー」に相当し、スレッドが一つずつ処理を行うため、完了した非同期I/Oの後続処理を並列実行したい(例えばシステムにCPUコアが沢山存在するなど)場合でも、APCキューのために並列実行が制限されます。