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キューのために並列実行が制限されます。

Async訪ねて3000里 (4) : I/Oの完了とAPC

前回からの続きです。

DPCのキューからキューエントリが取り出されて、DPCコールバックエントリが実行されます。ここで、ディスクデバイスからの結果を処理し(場合によっては、DMAで転送されたデータを移動や加工する)、「I/O要求の完了」とします。

I/O操作を完了するには、「IoCompleteRequest」カーネルモードAPIを呼び出します。これで、STATUS_PENDINGしていた要求が完了した事になります。

Async6但し、(2)で解説した通り、WDMドライバはスタッキングした階層構造になっています。ディスクデバイスの処理が完了しても、そこに至るまでの各階層のドライバが、個別に完了処理を行いたい場合があります。ここではその方法は述べませんが、あらかじめ準備しておけば、これらのドライバも完了と追加処理のタイミングを得る事が出来ます。

重要なのは、この一連の処理は、単一のCPUスタックコンテキスト(スレッドは不明なのでスレッドコンテキストではない)で実行される事です。階層の途中のドライバが再びSTATUS_PENDINGで処理を保留にしない限り、DPCのコールバックからの連続したコールスタックで実行されます。つまり、ここでもワーカースレッドは使われていない、と言う事です。

話を簡単にするため、全ての階層ドライバがI/Oをそのまま完了させたとします。すると、この階層化されたI/Oの要求の出発点である、「ZwReadFile」カーネルモードAPIまで辿ります。ここで本来の(カーネルモード)I/O要求が完了した事になります。

ZwReadFile APIはユーザーモードに遷移して、この事を元のプログラムに通知しなければなりません。ユーザーモードのReadFileEx APIは、処理完了時にAPCコールバックを呼び出す事になっています。しかし、ここに大きな壁があります。

  1. I/O要求が完了した時、コンテキストはDPCのCPUスタックコンテキストです。そのため、Windowsカーネルから見ると、スレッドさえ良く分からないという微妙な状態にあります。このままでは、ユーザーモードに遷移する事は出来ません。
  2. ユーザーモードに遷移するためには、呼び出し元のプロセスが特定出来なければなりません。上記のようにスレッドが不明では当然プロセスは特定出来ません。これは2つのパターンがあります。
    • DPCがCPUコアのアイドル状態から呼び出された場合は、スレッドは完全に不定です。スレッドが不定では、プロセスも特定出来ません。CPUがアイドリングでストールしている状態を、「アイドルスレッド」のように表現する事がありますが、このようなスレッドが実際に存在するわけではありません。実際、スレッドを管理するためのTEB(Thread environment block)構造体は存在しません。
    • DPCが不特定なスレッド(Arbitrary thread context)から呼び出された場合、理論上は何らかのスレッドのスレッドコンテキスト上で実行していることになりますが、このスレッドは、呼び出し元のプロセスとは全く関係ない可能性があります。というよりも、殆どの場合、知らないプロセスの知らないスレッドのスレッドコンテキストを間借りして実行しています。

スレッドについて補足:Windowsカーネルは、全てのプロセスの全てのスレッドを等価に扱います。ユーザーモードから見ると、まずプロセスが存在し、その中にスレッドが存在するように扱われていますが、実際にはすべてがスレッドで、スレッドの一部にプロセスが割り当てられているというイメージです。また、カーネルモードで生成されるスレッドは、特定のプロセスに紐づいていません。この連載で「スレッド」と呼ぶ場合は、これらをひっくるめたすべてのスレッドを指します。

Async7I/O要求を完了するときのDPCを実行するコンテキストが、「偶然に」呼び出し元のスレッドと同じという可能性は殆どあり得ません(タスクマネージャやProcess Explorerで確かめて下さい。Windows稼働時は、常にシステム全体で数千のスレッドが待機しています)。従って、このコンテキストでそのままユーザーモードに遷移し、APCコールバックを呼び出す事は出来ない事になります。


さて、APCコールバックを「APC」と呼んでいる理由を説明する時が来ました。APCの響きはDPCに似ていますね。実は、概念的にAPCとDPCは良く似ています。APCは「Asynchronous Procedure Call」の略で、カーネルモードからユーザーモードに遷移する時に、APCキューに要求を入れ、目的のプロセス空間の目的のスレッドでキューエントリーを取り出して実行するために使います。

DPCキューが割り込み処理からDPCコールバックコンテキストで実行させることを目的としたのに対し、APCキューが異なるスレッドから目的のプロセスのスレッドコンテキストで実行させることに対比出来ます。

(1)の解説で、ユーザーモードのReadFileEx APIからI/O要求を投げていたので、この時のスレッドでAPCキューの内容を取り出して、このスレッドコンテキストでコールバックを実行させる訳です。しかし、このスレッドは、わざわざ非同期I/O処理を要求したので、ReadFileExがERROR_IO_PENDINGを返した直後から、何か別の処理を行っている筈です。そのため、このスレッドが明示的にAPCキューを確認する機会を与える必要があります。

ReadFileEx APIで非同期I/Oを実行した後、何らかの並列実行を行って「手持ちぶたさ」が生じたときに、「SleepEx」ユーザーモードAPI「WaitForSingleObjectEx」ユーザーモードAPI「WaitForMultipleObjecstEx」ユーザーモードAPI「MsgWaitForMultipleObjecstEx」ユーザーモードAPIのいずれかを呼び出すと、APCキューがチェックされ、キューエントリーが存在すると、それに対応したAPCコールバックが実行されます。キューエントリーが無い場合は待機し、新たにキューエントリーが追加されるとAPCコールバックを実行します。

これらのAPIを呼び出していないと、元のスレッドでAPCコールバックを実行する事が出来ないため、キューの内容は保留されたままとなります。