いまさら恥ずかしくてasyncをawaitした – 第9回まどべんよっかいち

本日、まどべんよっかいちで「いまさら恥ずかしくてasyncをawaitした」というお題目で登壇してきました。DoorKeeperはココです
ちょっとまくる進行の予定だったのですが、時間が長くとれたので、詳しく解説しました。

ご清聴ありがとうございました。

非同期処理はここで解説した事を押さえておけば、大体OKと思って良いと思います。
他にもポイントはあるのですが、とても一回のセッションでは解説できないので省きました。次回にでも続きをやろうかと思います。

デモプログラムはGitHubに上げてあります。

スライドはこちら:いまさら恥ずかしくてasyncをawaitした_2.pptx

追記:忘れてました。ストアアプリの場合は、TaskクラスではなくIAsyncInfoというインターフェイスでawaitする事ができます。この話も次回出来たら良いなと思います。

このネタはリクエストがあったので、GeekBarでも再登壇する事になりそうです。決まったらまたtweetします。

Async訪ねて3000里 (6) : I/O完了ポートによるワーカースレッドの運用

前回、TaskCompletionSourceによるタスクの継続に、APCコールバックを使用した場合の欠点を示しました。

APCコールバックはユーザーモードから見た場合の挙動がわかりやすい(コールバックが実行される)のですが、MsgWaitForMultipleObjectsEx APIなどのAPCコールバック対応APIで待機する必要があり、待機したスレッド(通常はメインスレッド)からコールバックが呼び出されるため、そのスレッドだけでI/Oの完了を捌く必要があります。

仮にシステムに多くのCPUコアが存在する場合でも、I/Oの完了による継続処理(翻ってawait後の処理)が、単一のスレッドだけで処理されることになり、パフォーマンスが上がりません。


手が遅いので指摘されてしまいました (;´Д`) 最後のピースを埋めましょう。

カーネルモードからユーザーモードに、I/Oの完了を通知する方法は、APCコールバック以外にも方法があります。

ファイルハンドルやイベントオブジェクトなどのWin32カーネルオブジェクトは、「シグナル状態」と呼ばれる一種のフラグを持っています。このフラグは、それぞれ決められたタイミングで「非シグナル状態」から「シグナル状態」と変化します。たとえば、ファイルハンドルであれば、直前のI/O操作が完了した(IoCompleteRequest)タイミングで、ファイルハンドルがシグナル状態となります。

APCコールバックを使用する場合は、I/Oが完了した事をプロセス毎のAPCキューに挿入することで通知しましたが、シグナル状態による通知であれば、キューを操作するコストを削減出来、さらにAPCコールバック対応のAPIで待機することなく、完了した事を通知することができます。但し、シグナル状態による通知は、前述のとおり一種のフラグであるため、コールバックとは異なり、この契機で能動的に処理を行うことができません。

このシグナル状態は、WaitForSingleObjectWaitForMultipleObject APIで監視することができます。つまり、これらのAPIを使用すると、I/O処理が完了するタイミングまで、スレッドを効率よく待機(ハードブロック)することができます。

WaitForSingleObjectでは単一のスレッドがシグナル状態となるまで待機しますが、これをワーカースレッドによる複数のスレッドにまで拡張したものが「I/O完了ポート」です。I/O完了ポートを使用すると、複数のワーカースレッドをあらかじめ待機させておき、シグナル状態となる通知によって、次々とワーカースレッドを使って完了後の処理を実行することができます。

実はasync/awaitの非同期インフラストラクチャーは、APCコールバックではなく、I/O完了ポートによって実装されています。I/O完了ポートに紐づけられるワーカースレッド群は、ThreadPoolクラスのI/Oスレッドとして内部的に割り当てられています。ThreadPoolクラスのワーカースレッドは、古くはIAsyncResultインターフェイスによる初期の非同期インフラの駆動用として使用されてきました。.NET 3.5以降でThreadPoolの実装が見直され、より効率よく動作するようになり、TaskAwaiterのコールバック駆動用として使用しています。

つまり、APCコールバックではAPC待機APIの呼び出しで待機したスレッドがコールバックを実行していましたが、現在の非同期インフラはThreadPoolクラスが保持するI/Oスレッド群が、TaskAwaiterのコールバックを実行しています。そして管理はI/O完了ポートで行っているため、大量のI/O要求の完了処理も、複数のスレッドで同時に処理できるのです。

MSDNブログに、.NETにおけるI/O完了ポートの使用方法が示されていたので、参考にして下さい。


以上をまとめると、async/awaitによる非同期処理は、ハードウェアからの完了通知としてもっともロスが少ないと考えられ、理想的な環境であれば、余計なワーカースレッドは使われません。但し、I/Oの完了がユーザーモードに通知された際に、TaskAwaiterのコールバック処理(await文以降の処理)を実行するためにスレッドが必要なため、この時に限り、効率的にプールされたワーカースレッドを使います。

唯一このタイミングでのみ、わずかな期間(IoCompleteRequestからI/O完了ポートでスレッドが処理に割り当てられるまで)ですが、スレッドのハードブロックと見なせる空白の時間が存在しますが、その他の手法と比べて無駄がありません。モニタAPIや待機APIによる、余計なハードブロックを起こさないのですから、多数のCPUコアで並列実行する場合でも、コンテキストスイッチによるロスは皆無です。

ケースバイケースですが、従来は自前ワーカースレッドによる複雑な実装しか選択肢が無かったものが、async/awaitの構文だけで効率の高い処理を簡便に実現できるようになったのです。ぜひ、非同期処理を実践して行きましょう。

せがゆうさん、ありがとうございました!

Visual Studio Onlineを始める

以前、Team Foundation Serverのクラウド版とも言うべきTeam Foundation Serviceがベータ版で運用されていましたが、正式にVisual Studio Onlineとなったので、オンプレで使っていたTFSからリポジトリを乗り換える前提で、とりあえず使ってみました。

vso4

Visual Studio Onlineは基本契約は無料で、プライベートなリポジトリを持つことが出来ます。巷では無料のリポジトリサービスがいろいろありますが、ほとんどの場合はリポジトリに格納するプロジェクトをオープンソースとして公開することが前提です。

もし、ちょっとしたプライベートなプロジェクトを格納しておく場所に困っているなら(ローカルで自前でTFS・Git・Subversionなどを立ち上げるなど、いくら簡単にインストール出来ても保守は面倒です)、Visual Studio Onlineを検討する価値はあるでしょう。

また、他のサービスと異なるユニークな点は、Professional契約以上(月額45ドル/人)で、開発環境としてのVisual Studioが使えることです(インストールが必要)。さらにAdvanced契約以上では、アジャイルソフトウェア開発で有用な、プロジェクト管理システムもフルで使えます。これは、従来のTFSで使用している機能が、SaaSとして利用可能になったものです。

vso5

CI(継続インテグレーション)も可能です。但し、ビルド時間に制限があります。時間を超えると課金が必要です。未確認ですが、オンプレミスサーバーにVisual Studio Agentをインストールして、CIだけオンプレミスで実行させる事が可能なようです。これならば、大幅な課金を回避しつつ、サーバー管理の負担を軽減することが出来ます。

オンプレミスのTFSを使えばもちろん課金は不要ですが、TFSの管理にかかるコストの事を考える必要があります。TFS(に限らず、類似システムも含め)プロジェクト管理システムは大掛かりなため、全ての機能を使用して維持するのは、それ相応の準備と計画が必要になります。この点を肩代わりしてくれるサービスだと考えれば、課金をペイ出来る開発チームもあるでしょう。

公平の為、このようなクラウドサービスとしてAtlassianも推しておきます

また、Visual Studio Onlineは、従来のTeam Foundation Server方式のソースコード管理に加え、分散バージョン管理システムのGitを使用することもできます。これは、オンプレミスのTFS2013では実現出来ないため、Visual Studio Onlineのみのアドバンテージです(将来は改善されるか?)。


vso1

物は試しということで、Visual Studio OnlineでGitを使用してリポジトリを作り、Visual Studioを「使わずに」接続してみました。この図のように、あらかじめアカウントのProfile画面からCredentialタブを選択し、Basic認証を有効化しておきます。Gitクライアントからはhttpsで接続しますが、この時に使用するパスワードをここで入力して下さい。私はLive IDのパスワードを入力し続けて、暫く嵌っていました。

この操作は面倒ですが、恐らくはセキュリティ上の措置(Basic認証では平文パスワードを保持する必要がある)の為と思われます。念のため、Live IDのパスワードとは変えるようにします。

Windows環境のGitクライアントはいくつか選択肢があります。Git開発者の公式なパッケージはbashを含むMSYSベースのCLI環境です。どのクライアントを使うにしても、このパッケージはインストールしておいたほうが良いでしょう。何故なら、他のクライアントがこのパッケージ内のバイナリを使う事があり、かつすべてのクライアントがGitのすべてのオペレーションをサポートしているとは限らないためです。

vso2

Visual Studio内で使用できるプラグインGitクライアントも数種類あります。問題は、どれもまだ品質が一歩劣るという所でしょう。マイクロソフトも純正のGitクライアントプラグインをリリースしています。これはVS2012用で、VS2013には初めから搭載されています。しかし、Gitで使用する頻度の高いコマンドがサポートされていない・複数のリポートリポジトリに接続出来ないなどの問題があります。石坂さんのブログに詳しいので参考にして下さい。ボチボチ使えるのがGit Source Control Providerです。窓の杜の記事も参考にどうぞ

プラグインではないクライアントとして有名処ではTortoiseGitがあります。が、Tortoiseシリーズは思う所があり、私的にはSourceTreeをお勧めします。このクライアントは独立型(いわゆるVSSクライアントのような)で、Mercurialもサポートします。

操作感についてはそこそここなれている印象です。バックエンドを普通のCLIベースのGitクライアントで賄っているため、スムーズさは今一つですが、どのクライアントもこの方式なので仕方が無いでしょう。それ以外の点では、自分が何をやろうとしているのかが把握しやすいのが良い点です。そして、SourceTreeは前段で紹介したAtlassianが開発しているというのも、説得力があります。

vso3

SourceTreeを使用して、Visual Studio Online上のGitリモートリポジトリに接続し、ローカルリポジトリにコミットしたコードをpushしてみました。当然のことながら、全く問題ありません。一点、Visual Studio Online上のリポジトリURLがどこから取得出来るのか分かりにくいという問題があります。以下の図を参考にして下さい。

vso6

ソースコードエクスプローラーで、ツリーのリポジトリをクリックしたときに表示されているURLが、Gitクライアントで入力すべきURLです。URL末尾のリポジトリ名の手前に「_git」という識別子が入るので、確認してください。


これからGitを始めるという方は、VSS・TFS・Subversionと同じと思って掛かると挫折します(笑) Gitは同じバージョン管理システムでも全く異なる仕組みを持ちます。必ず知識をつけておきましょう。

Gitポケットリファレンスを、お勧めします。

BUILD 2014のWindows Phone 8.1 Word Flow世界最速タイピング

もう界隈では周知ですが、BUILD 2014のDay1 KeyNoteで、スタローン似の御仁(Joe Belfiore)から、Windows Phone 8.1の機能について発表がありました。

最も注目されたのは「cortana」と呼ばれる、iPhoneで言うなら「Siri」のライバルとなる、音声認識エージェント機能です。
ただ、cortanaはまだ日本語を理解できないし、実機のWindows Phoneでもcortanaを使う場合は、表示言語を英語に設定する必要があります。日本語対応cortanaたんに期待しましょう。

で、個人的にもっと強烈に印象を受けたのが「Word Flow」という新しいタイピング機能です。これは、ソフトウェアキーボード上を「なぞる」だけで、単語の入力が出来てしまうというものです。

Channel 9: BUILD 2014 Day1 KeyNote

build2014d1kn1

Word Flowのデモは、49分辺りからです。実際、実機+WP8.1DPでも、こんな感じで入力できます。一度慣れてしまうと、もう指を離したくなくなるぐらいのインパクトです。

また、その後のタイピングレコードの映像では、脅威的とも思える速さで入力しています。ガラケー+JK並の破壊的速さを、スマートフォンで実現出来たと言えるのではないでしょうかww

build2014d1kn2

しかし、これまた残念なことに、Word Flowは日本語キーボードでは使えません。ただし、英語キーボードに切り替えるだけで使う事は出来ます(表示言語を英語に設定する必要はありません)。

動画と実機のWord Flowの働き具合を見ている限り、トレースするキーの位置が不正確でも問題なく入力できているようです。恐らくは、トレースラインの屈曲する位置を検出して、その付近のキーをリストアップし、それらの組み合わせで最もふさわしい単語を候補としているように思います(+文脈も見ているかも)。そうであれば、日本語キーボードでは使えない理由も自明ですね。

早く日本語対応Word Flowが使いたいものです。

「de:code報告」 – Microsoft Azure 勉強会

JAZUG名古屋で、Microsoft Azure勉強会が開催されました。ATNDはココです。
私は、先週の「de:code報告」というお題目で、セッションを行いました。

今回は、de:code遠征の成果、のような内容で発表を行いました。
そのため、コードレベルの内容は無しですが、楽しんで貰えたでしょうか?

早速次回の予定が組まれたりと、活発なJAZUG名古屋をよろしくお願いします。

プレゼンはココです。

それでは、また。

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コールバックを実行する事が出来ないため、キューの内容は保留されたままとなります。

Async訪ねて3000里 (3) : ハードウェア割り込みとDPC

前回、カーネルモードでのデータ読み取り要求が、最終的にディスクコントローラーにコマンドとして発行されるところまでを解説しました。ほとんどの場合、コマンドの発行は瞬時に完了するため、ディスクデバイスドライバーも「STATUS_PENDING」をすぐに返却します。

全体を通して、ReadFileEx APIの呼び出しから、ERROR_IO_PENDINGの返却までにかかる時間は一瞬、と見て良いでしょう。そして、ユーザーモードのコードは、直後から自由に別の処理を開始する事が出来ます。裏ではディスクコントローラーがハードディスクからデータを読み取るために、ヘッドを目的のトラックに移動しようとしている所です。

Async3

ここから、CPUは自由に別の処理を実行する事が出来ます。


さて、しばらくして、ディスクコントローラーがハードディスクからデータを読み取った、としましょう。読み取ったデータはSATA等のインターフェイスでコントローラーに送られてきますが、最初のコマンドに記述されていた通り、所定の物理メモリにこのデータを格納します。これはCPUではなく、ディスクコントローラーが自力でメモリに格納します。この動作を「DMA」(ダイレクトメモリアクセス)と呼びます。

そして、すべからく処理が完了した時点で、CPUに対して「俺、用事あるんだけど?」と通知を行います。この事を「ハードウェア割り込み」と呼びます。
殆どの場合、この通知を受け取ったCPUは、すぐに現在処理中の作業を中断し(後で何事もなく作業続行出来るようにするため、状態を全部記憶し)、「割り込み処理」と呼ばれる特別なメソッドの実行を開始します。

割り込み処理のコードは、ディスクデバイスドライバ内にあります。ここで、ディスクデバイスドライバは、改めてディスクコントローラーに状態を確認し、「あ、さっき要求したコマンドが完了したんだ」と言う事が分かります。なので、すぐにユーザーモードのコードに完了を通知したい…のですが、事は単純ではありません。

CPUは直前まで実行していた「何らかの処理」を放り出して、現在の割り込み処理コードを実行しています。つまり、直前までどのような状態であったのかを知ることは不可能に近いのです。例えば、

  • ユーザーモードの.NET ILコードを実行中
  • ユーザーモードの.NET CLRのガベージコレクタを実行中
  • ユーザーモードのエクスプローラーのアドインコードを実行中
  • ユーザーモードのサービスプロセスを実行中
  • ユーザーモードのDirectXライブラリを実行中
  • カーネルモードのWindowsメモリマネージャを実行中
  • カーネルモードのUSBデバイスドライバを実行中
  • カーネルモードのプロセススケジューラーを実行中
  • カーネルモードのディスプレイドライバを実行中
  • カーネルモードで完全にCPUがアイドル状態

など、他にも数限りない状態が考えられます。このような状況で、例えば、処理結果を返却する為に多少のメモリが必要で、動的に確保したいとします。

メモリを確保するには、「ExAllocatePool」カーネルモードAPIを使う事が出来ますが、このAPIの内部処理は、恐らくはWindowsメモリマネージャと、ページング・スワッピングカーネルコードが連携する必要があります。

しかし、上記リストにも示したように、Windowsメモリマネージャの何らかの処理を実行中に、その処理を放り出して割り込み処理を行っているかもしれません。このような状況下でメモリ割り当て処理をそのまま実行しようとすると、ステート管理が破たんする可能性があります。このような問題を「再入処理問題」と呼びます。

Async5従って、割り込み処理の実行中にはメモリ確保は出来ない事になります。他の処理なら可能なのでしょうか? 少し考えると分かりますが、結局、割り込み処理が実行される直前に何が行われていたのかが分からない限り、あらゆる処理は安全に処理できない可能性が非常に高い事となります。また、分かったとしても、あらゆる状況下を判断して個別に対応する事も、殆ど不可能と言って良いでしょう。

WDM(Windows Driver Model)では、ハードウェア割り込みの割り込み処理中でも安全に処理が可能なAPIを公開しています。しかし、そのようなAPIはごくわずかであり、やはり実作業的な操作は出来ません。一般的に推奨される割り込み処理は、DPC(Deffered Procedure Call・遅延プロシージャコール)キューに、キューエントリーを追加する事です。この処理のために、「IoRequestDpc」カーネルモードAPIを使う事が出来ます。もちろん、このAPIはハードウェア割り込み処理中でも使用可能です。


DPCとは一体何でしょうか? 何故このような事をする必要があるのでしょうか?

先ほど述べたように、割り込み処理中はあまりに制限が多く、事実上何も出来ないため、「都合のいいタイミングまで処理を遅延」するためにキューを使用します。DPCは割り込み処理コードと同じように、一種のコールバックです。割り込み処理コードでDPCキューにキューエントリーを追加しましたが、このエントリーが「いつかのタイミング」で拾い上げられ、指定されたコールバックが実行されます。このコールバック内なら(若干の制限はありますが)、割り込み処理では実現出来なかった様々な処理を安全に実行する事が出来ます。

Async4

一般のユーザーモードデベロッパーはDPCになじみが薄いと思うので、少しだけ垣間見えるものをお見せします。この図はSysinternals Process Explorerの表示です。赤枠の行が、ハードウェア割り込みとDPCの統計を表しています。1秒間当たりの割り込みとDPCコールバックの発生回数を示していますが、安静にしていても結構な回数発生している事が分かります。

DPCが「いつかのタイミング」で実行される、と書きましたが、具体的にはいつでしょうか? あまり深入りすると複雑なので省きますが、「CPUがアイドル状態」又は「カーネルモードAPI実行の区切りの良いタイミングを通過するスレッド」が、DPCキューからキューエントリを取り出して、コールバックを呼び出します。従って、ここでもワーカースレッドを生成する事はありませんが、後者の場合は、何らかの不特定なスレッド(Arbitrary thread context)を間借りして実行している、と言えます。このタイミングは、Windowsカーネルが完全に管理しているため、タイミングを任意にコントロールする事は出来ず、CPUアイドル実行か不特定スレッドによる実行かを選択する事も出来ません。また、DPCコールバックを実行中は、特定のスレッドに紐づかないため、スレッドを特定する事は無意味となります。

なお、これまでの説明は、CPUが1個のみ(1コア)である事を前提としています。マルチコア・マルチCPUの場合、これらの動作は更に複雑になりますが、基本的には同じ構造を踏襲しています。

さて、これでユーザーモードのコードに処理完了の通知が出来るようになりました。次回はどうやってユーザーモードに通知されるのかを追います。

第8回まどべんよっかいち「LINQ基本のキ」

第8回まどべんよっかいちで、「LINQ基本のキ」というタイトルで登壇してきました。

時間が短かったので、スライドはコンパクトにまとめ、ディスカッションで補足しました。内容としては、LINQというキーワードは知っているが、全然使った事が無い・SQLと何が違うの?それおいしいの?・クエリが全然読める気がしないんだけど… という方向けの、取り掛かり的なものです。

クエリの中から参照するローカル変数の扱い(評価タイミング)や、そもそも集合演算に慣れるにはどうすれば良いか、といった、慣れてしまった側から見ても有意義な質問が頂けて、楽しかったです。

懇親会で、次回にエントリーしてしまったので、次回はasync/awaitについて、同様な話をしたいと思っています。

スライドはこちら(とても重要な所にアニメーションを使っているので、是非オリジナルで見て下さい):LINQ基本のキ_2

#ちなみに、人、来ました(ほっ)

ご清聴ありがとうございました。
それではまた。

Async訪ねて3000里 (2) : ファイルシステム・パーティションマネージャ・物理ディスクデバイスドライバ

前回は、TaskCompletionSourceクラスの触りと、Win32 APIのReadFileExを使った非同期処理について書きました。ここまではいわゆる「ユーザーモード」プロセスでの処理ですが、この先はWindows内部へと入っていきます。

ReadFileExの呼び出しでは、ユーザーモードからカーネルモードへの遷移が行われた後、ntoskrnl.dll(プラットフォームによって異なる場合がある)内の「ZwReadFile」カーネルモードAPIが呼び出されます。このAPIは最初から非同期処理が前提です。

Async2

殆どのカーネルモードAPIは非同期で処理を行う事を前提としていて、俗にいう「NTSTATUS」という型の戻り値として「STATUS_PENDING」を返すことで、非同期処理を開始した事を示します。勿論、NTSTATUSを扱うすべてのAPIが非同期処理を可能としているわけではありませんが、とりわけ外部リソース(ディスクやネットワークなど)にアクセスする類の呼び出しは、非同期処理を行う事が出来るように設計されています。

ZwReadFileが非同期処理前提であると言う事は、ユーザーモードのReadFileExやReadFile APIを同期モードで使用した場合はどうなるのでしょうか? そのような場合は、ZwReadFileの呼び出しは非同期処理として実行しつつ、その非同期処理が完了するのを、「手元」のスレッドをハードブロックする事で同期的に処理されるようにしています。

ハードブロックとは、「WaitForSingleObject」APIを使用して、完了が通知されるまでスレッドを停止させる事です。但し、今回の例では最終的にAPCコールバックを使用するため、実際にはなじみの薄い「WaitForMultipleObjectsEx」APIを使用する事になります(APCコールバックについては、復路で説明します)。


カーネルモードでの入り口は、ファイルシステムドライバです。ほとんどの場合、NTFSドライバが処理します。ZwReadFile APIが受け取った、対象のファイル・対象のファイル中の位置を元に、ディスク(実際にはパーティション)上のどこにそのデータがあるのかを特定します。この時、カーネルのメモリマネージャと裏取引(公開されていない)を行い、空きメモリをキャッシュ用に割り当てて、結果を保持しておくか、以前に保持したデータがキャッシュ上に存在しないかを確認し、存在すればI/Oを発生させず、非同期処理をその場で完了させる、などの処理を行います。

そのため、実際にディスクからデータを非同期で読み取るには、これらの条件に引っかからずに通過する必要があります。CreateFile APIのFILE_FLAG_NO_BUFFERINGフラグは、この処理を確実にバイパスさせます。

ファイルシステムドライバを通過した要求は、パーティション上の相対位置でデータの位置が示されます。つまり、次の窓口は「パーティションマネージャ」です。目的とするパーティションと相対位置を示すアドレス値から、物理ディスク上の絶対アドレス値への変換を行います。また、より複雑な、Windows標準のソフトウェアRAID処理(0・1・5)もここで行われます。

どのような場合であれ、これらの処理もまた非同期操作が前提となっているので、要求は更に下層にそのまま非同期処理として送られる可能性があります。なお、ファイルシステムドライバから先の要求には、明確なAPIシンボル名が存在しません。WDM(Windows Driver Model)は、各ドライバが階層化されたスタック構造となっていて、要求がバケツリレーのように次のドライバへと送られます。エントリポイントはただ一つですが、データの読み取り要求として、「IRP_MJ_READ」という識別子が割り当てられています。


そして、いよいよ物理ディスクのデバイスドライバまで要求が到達します。この先にも更にスタックされたドライバが存在する可能性があるのですが、話が複雑になるので割愛します。少なくともここに到達すると、物理ディスクの読み取り対象アドレス値が明確になっている筈です。従って、この値を物理ディスクに指示する事で、データを読み取る事が出来ます。

その場で応答が返って来ればの話ですが。

そうです。例えばハードディスクドライブの場合、「セクタ123456から4セクタ読み取ってほしい」と指示しても、データの読み出しが完了するのは、途方もなく未来の話です、CPUにとっては。

そこで、次のようにします。「セクタ123456から4セクタ読み取ってほしい。読み取ったデータは物理メモリ空間の0x12345000に格納して欲しい。終わったら呼び出してね」とディスクコントローラーに指示しておきます。そして、例の「STATUS_PENDING」を戻り値として返します。

STATUS_PENDINGを戻り値として受け取ったパーティションマネージャは、そのまますぐにファイルシステムドライバにもSTATUS_PENDINGを返します。ファイルシステムドライバもまた、STATUS_PENDINGを返します。この値はZwReadFile APIの戻り値として、やはりそのまま返され、カーネルモードからユーザーモードに遷移し、ReadFileEx APIが「ERROR_IO_PENDING」という、STATUS_PENDINGのユーザーモード表現の値として返します。

ReadFileEx APIがERROR_IO_PENDINGを返したと言う事は、非同期処理が開始されたことを意味します。つまり、バックグラウンドでのデータ読み取りが始まったことになります。今までの経路を見た通り、この状態に至るまでにワーカースレッドは生成も使用もされていません。従って、CPUはその間に自由に動く事が出来るようになります。

実際に非同期の処理を行っているのは、正にハードウェアそのもの、と言う事が出来ます。
これが、非同期処理がもっとも効率が高いと考えられる理由です。また、async/awaitによる記述だけで、ここまでデバイスに密着した動きをさせながら、かつ、低レベルAPIの直接使用を不要とする事が出来る事が画期的です。

次回は、復路の複雑な動きを解説します。