Async訪ねて3000里 (1) : ユーザーモードのターン

C# 5.0から、本格的に非同期処理を行うための土台が整いました。何回かの連載で、非同期処理の意味を、Windowsのカーネルレベルにまで掘り下げて紹介したいと思います。

もう周知だと思いますが、C# 5.0では新たに「async」と「await」という予約語が導入され、非同期処理が簡単に書けるようになりました。この効果は絶大で、過去に様々な非同期処理に関する補助ライブラリが出現しては消え、と言う事を繰り返し、結局どれもが市民権を得られなかったのとは対照的です(非同期処理の種類については「避けて通れない「非同期処理」を克服しよう (@IT)」が詳しいので参照して下さい)。

async/awaitが無くても、非同期処理を記述する事は可能です。これは丁度、LINQが使えなくてもループ処理でリストを操作できるのと似ています。そして、async/waitを使えば、実装がはるかに容易になるという点でもまた、LINQとよく似ています。同時に、async/awaitの内部処理(これはC#コンパイラが頑張っている所ですが)もLINQと良く似ています。

async/awaitの予約語を使うと、コンパイラがどのように処理を行うのかは、「非同期メソッドの内部実装とAwaitableパターンの独自実装 (@IT)」の記事に詳しいので説明を省きます。

この連載では、実際に非同期APIを使うと、内部ではどのように処理されるのかに焦点を当てます。なお、.NET CLRやWindowsカーネルの内部実装を「透視」したわけではないので、現実の実装とは異なる場合があります。非同期処理の道筋についての解釈と思って下さい。


私たちはC#の世界に住んでいるのでC#で説明しますが、とりあえずVB.NETについても非同期処理を行うと同じ過程を踏むはずです。他のCLR対応言語は…awaitに対応するものがあるのかどうか、良く分かりません。

Async1

まず、ユーザーコードから、非同期処理対応のAPIを呼び出すことから始まります。これは、.NET 4.0で導入された「Task」クラスのインスタンスを返却するメソッドとして定義されています。例として、ファイルからデータを読み取る「ReadAsync」という架空のメソッドを考えます。

ReadAsyncは、バックグラウンドで非同期的にデータを読み取り、結果を返します。これを従来の技術でユーザーレベルで実現する場合、ワーカースレッドを生成し、その別スレッド内でファイルの読み取り処理を行い、結果が得られた時点で元のスレッドにデータを通知する、という手順を踏むと思います。

しかし、ネイティブな非同期処理(非同期I/O、といった方が良いので、以降では変えます)では、そもそもワーカースレッドに処理を頼ることはしません。ワーカースレッドが処理をせず、どうやって非同期I/Oが実現するのか?は、おいおい明らかになります。

まず、ReadAsyncの「出口」を固めます。出口とは、データの読み取り処理が完了した事をどのようにユーザコードに通知するのか、という点です。
ユーザーコードは単純に「await」予約語を使って待つか、あるいは戻り値の「Task」を使って待機する事が考えられます。

ワーカースレッドの実行と待機を行うのに、「Task.Run」メソッドを使い、返されたTaskインスタンスをWaitメソッドで待機した事があるのでは無いでしょうか。あるいは、ContinueWithメソッドを使って、ワーカースレッドの継続処理を登録して処理させるなど。つまり、Taskクラスのインスタンスは、非同期処理の結果を操作する役目を持っています。

しかし、今回はワーカースレッドは使いません。そのため、別の方法で、非同期I/Oの終了を通知するためのTaskクラスのインスタンスを入手する必要があります。「TaskCompletionSource」クラスは、この目的に使用する事が出来ます。

TaskCompletionSourceクラスは、特定のワーカースレッドに紐づきません。単に、非同期処理の結果だけを管理します。このクラスにはTaskプロパティがあり、TaskCompletionSourceに非同期処理の完了が通知されると、このTaskプロパティのインスタンスを通じて、その結果が(Taskで待っている処理に)通知されます。そして、awaitから処理が継続したり、Waitメソッドから抜けたりするわけです。

この完了時の動作は、いずれ旅路の復路で明らかになるので、ここまでとしておきます。
重要なのは、

  • 非同期I/Oはワーカースレッドを使って実現するわけではない。
  • TaskCompletionSourceを使って、Taskインスタンスを通じて、処理の完了が通知される。

と言う事です。


次に、ReadAsyncメソッドは、P/Invokeなどを使用してWin32の「ReadFileEx」APIを呼び出します。このメソッドは、一般的な「ReadFile」APIに付加機能が付いたものです。C++でWin32レベルのコードを書いた人であれば、ReadFile APIの同期的な使用方法は容易に理解出来ると思います。

ReadFile及びReadFileEx APIには、「OVERLAPPED」構造体が指定出来ます。この構造体は、非同期I/O要求の管理を行うためのもので、ファイル上のデータのオフセットや、完了時のイベント通知の為の情報を保持します。この構造体を指定しない場合は、I/O操作が同期的に実行されます。ほとんどの方はこの引数にNULLを指定したと思います。また、OVERLAPPED構造体を使用する場合は、ファイルハンドルがFILE_FLAG_OVERLAPPEDフラグを指定してオープンされている必要があります

上記のリンクを見て分かる通り、このKBはとても古く、「Windows NT 3.51」と書かれてます。実際、非同期I/Oは最初のWindows NTカーネル(Windows NT 3.1)からサポートされています。つまり、非同期I/Oの土台としては、現在のWindowsが生まれた時から既に存在すると言う事です。

ReadFileEx APIは何が拡張されているのかというと、「完了ルーチン」なる、コールバックメソッドへのポインタが指定可能になっています。これは、非同期I/O処理が完了した際に、ここで指定されたコールバックメソッドが呼び出されると言う事です。

図中では「APC Callback」と書いた箱がそのコールバックメソッドの実装に対応するわけですが、このメソッドがどのようにして呼び出されるのかは、またかなり大がかりな話となるので、復路で述べることにします。

とにかく、これで、ユーザーコードがReadAsyncメソッドを通じて非同期の読み取り操作を開始し、それがReadFileEx APIを通じてWindowsに要求されたところまで来ました。次は更にこの下に潜ります。

投稿者:

kekyo

A strawberry red slime mold. Likes metaprogramming. MA. Bicycle rider. http://amzn.to/1SeuUwD