8.非同期I/Oとプロアクターフレームワーク
複数の I/O エンドポイントを持つアプリケーションは伝統的に二つのモデルがある。
- リアクティブ。各種のイベントハンドラオブジェクトを登録しておき、それぞれが動作可能な状態になった時にその処理をすばやく行って、また待機に戻るモデル。7章の Reactor フレームワークはこちらのモデルである。
- マルチスレッド。I/O 操作ごとに一つのスレッドを作成して割り当てるモデル。このモデルは開いているエンドポイントが増えるとスケールするのが困難になる。
ネットワークアプリケーションでは特にリアクティブ I/O モデルが一般的である。これは BSDソケットAPI のファイルデスクリプタをデマルチプレクスする select() の普及のためだろう。一方、複数のエンドポイントを取り扱うのに非同期I/O(またはプロアクティブI/O)と呼ばれる方法があり、こちらはよりスケールしやすい。
非同期と呼ばれるのは、I/Oリクエストとその完了が分離し、独立して別々の時間にイベントを発生させるためである。非同期I/O を利用すると、たくさんのエンドポイントに対して複数のI/Oリクエストを投入しておき、ブロックせずにその完了を待つことができる。処理が完了すると、OS は完了ハンドラを起動して結果を処理するのである。
この章では非同期I/Oとプロアクティブモデルについて説明を行い、どうして ACE のプロアクタフレームワークが最有力であるのかを示す。
8.1 どうして非同期I/Oを利用するのか?
リアクティブI/O ではリアクタのイベントループによって駆動される。この時、各スレッドは一度に一つの I/O 操作しか行えない。このシーケンシャルな動作により、複数のエンドポイントを持つ大量通信アプリケーションや複数のCPUを持つ並行アプリケーションにおいてボトルネックが発生する。
マルチスレッド化I/O の場合には、スレッドプールモデルやスレッドパーコネクションモデルのように並行性を活用することでシングルスレッドのリアクティブI/Oの主要なボトルネックを解決することができる。マルチスレッドはアプリケーションのI/O操作を並列化し、パフォーマンスを向上させることができるのだ。また、この技法はブロックする関数コールを使うときなどには非常に直観的である。しかしながら、次のような理由により常に最善の策というわけではない。
- スレッディングポリシーは並行性ポリシーと密結合しやすい。並行的な操作や要求ごとに別々のスレッドが要求される。この際にはリソースに基いてスレッディングポリシーを決め、スレッドプールを利用する方が良い。
- 同期による複雑化。共有データにアクセスする場合、全スレッドはアクセスを直列化しなければならない。これには更なる分析とデザインが必要となり、複雑さが増加する。
- 同期によるパフォーマンスへのペナルティ。スレッドのためのコンテキストスイッチやスケジューリングにははっきりしたオーバーヘッドが見られる。
このため、I/O の並列化にマルチスレッドを利用するのが必ずしも最善とは言えない。
プロアクティブI/Oモデルでは次のように分解された2ステップを行う。
- I/O 操作を初期化する。
- 後から、終了した操作に対してハンドラで処理を行う。
これらのステップはリアクティブI/Oモデルのほぼ逆の構造である。
このデザインはシングルスレッドのアプリケーションにおいて、多量のオーバーヘッドやデザイン上の複雑さの増加をもたらすことなく複数のI/O操作を同時並行的に行うことができる。
次のような時にプロアクティブI/Oモデルが役に立つ。
- windows の名前付きパイプのような IPC 機構を利用しており、それが必要である。
- 並行 I/O 操作によってアプリケーションが明らかに恩恵を受ける時。
- ハンドル数やパフォーマンスなど、リアクティブモデルの限界が問題になるとき。
8.2 データの送受信方法
非同期通信は同期通信と利用方法が若干異なる。サンプルを見ながらプロアクタフレームワークとリアクタフレームワークの違いや類似点について示していこう。
プロアクタフレームワークは相互の関連した多数のクラスで構成されているため、それぞれに対する説明無しでそれらについて論じるのは難しい。これについては章の最後で再度取り上げることにする。p.190 図8.1 がプロアクタフレームワークの構成図になっているので、分からなくなったら戻ってきて参照してほしい。
p.189-190 のコードは、前の二つの章で行った基本的な通信に関するクラス宣言の例である。先頭にはプロアクタフレームワークで利用する次のようなクラス群を導入するためのヘッダインクルードが置かれている。
- ACE_Service_Handler:プロアクタフレームワークで新しいサービスハンドラを作る時の基底クラスである。アクセプターコネクターフレームワークにおける ACE_Svc_Handler と同様である。
- ACE_Handler:ACE_Service_Handler の親クラスであり、非同期I/Oの完了をハンドルするためのインターフェースを持つ。リアクタフレームワークの ACE_Event_Handler にあたる。
- ACE_Asynch_Read_Stream:接続済みの TCP/IP ソケットに対する読み取り操作を初期化するファクトリクラス。
- ACE_Asynch_Write_Stream:接続済みの TCP/IP ソケットに対する書き込み操作を初期化するファクトリクラス。
- Result:ファクトリが初期化した操作に対する結果を持つクラス。それぞれの I/O ファクトリにネストされた型であり、ACE_Asynch_Result から継承される。
8.2.1 ハンドラのセットアップとI/Oの初期化
TCP 接続が開かれると、そのハンドルをハンドラオブジェクト(この例では HA_Proactive_Service)に渡すことになる。これは次のような理由によるものだ。
- ソケットの寿命を管理するのに便利である。
- I/O 操作の基点となるクラスである。
新しい接続が開かれると、プロアクタフレームワークの非同期接続確立クラス(後の8.3節で説明)を利用して、ACE_Service_Handler::open() フックメソッドが呼ばれる。この例では open() は p.192 のように実装されている。
最初にあるように、新しいソケットハンドルは ACE_Handler::handle() メソッドで保存される。このメソッドは受け取ったハンドルを適切な場所へ保管しておく。これはソケットハンドルの寿命管理のためである。
I/O を取り扱う前にファクトリを初期化しておく必要がある。ソケットハンドルの保存を行ったら、reader_ と writer_ のファクトリクラスそれぞれに対して open を呼び出す。これらの完全なシグネチャを p.193 に示す。
最初の引数はファクトリオブジェクトが準備した操作の完了ハンドラである。プロアクタフレームワークでは I/O 操作に対する処理が完了すると、ここで設定したオブジェクトをコールバックする。そのため、このハンドラは完了ハンドラと呼ばれる。
例では ACE_Handler の子孫である HA_Proactive_Service クラスが読み書きのハンドラとなるので、*this が渡されている。他の値はデフォルト値である。ハンドルを渡さない場合、I/O ファクトリは完了ハンドラの handle() メソッドを呼び出して自動的に取得する。これが open() の最初にハンドルを設定した理由の一つでもある。
proactor 引数もデフォルト値を利用している。この場合、シングルトンになっている ACE_Proactor オブジェクトが利用される。特定の ACE_Proactor インスタンスを利用したい場合には、この引数に設定すること。
open() では最後に ACE_Asynch_Read_Stream::read() メソッドを呼んでいる。このメソッドのシグネチャは p.193 の通りである。
同期の場合と異なるのは読み込みバッファに ACE_Message_Block を利用していることだ。このことにより、ACE_Message_Block の高度なバッファ管理機能が使えるとともに、ACE の他の機能との親和性も高まる。ACE_Message_Block に関する詳細は p.261 から始まる。
読み取り処理の準備が整うと、データは指定したバッファの書き込みポインタの位置から受信されていく。
8.2.2 I/O操作の完了
プロアクタフレームワークとリアクタフレームワークはどちらもイベントベースであるが、リアクタがI/O操作の準備ができたことを知らされて動くハンドラを登録するのに対し、I/O処理の終了を待って動くハンドラを登録するのがプロアクタである。
それぞれのI/O操作は各自のコールバックメソッドを持っている。この例では ACE_Handler::handle_read_stream() が呼ばれる。その実装は p.194 の通りである。
渡される ACE_Async_Read_Stream::Result には、初期化時のパラメータと読み込み操作の結果を保持したオブジェクトが格納される。操作に使われたメッセージブロックは message_block() メソッドで取得できる。
handle_read_stream() は、まず読み込み操作が正常に完了したかどうかと読み込みサイズが 0バイトでなかったことを確認する(0バイトの読み込みはソケットが相手側からクローズされたことを示す)。どちらかであった場合には、メッセージブロックを解放し、ハンドラ自身を削除する。ハンドラの削除に伴い、ソケットもクローズされる。
読み込み操作が何らかのデータを取得した場合、次の二つの事柄を行う。
- 読み取ったデータをそのままクライアントにエコーバックするための書き込み操作を準備する。
- 新しい ACE_Message_Block オブジェクトを準備し、クライアントからのデータ読み込みのために設定する。
なお、書き込みが完了した時のハンドラは p.195 のようにシンプルである。
書き込み操作が完了すると、その成否に関わらずメッセージブロックが解放される。ソケットの不良については別に設定された読み込み操作のハンドラにより、handle_read_stream() で処理されるため、こちらでは対処しない。
一番重要なことは、同じ ACE_Message_Block オブジェクトが読み込みと、そのエコーバックに利用されているため、使い終わったらしっかり解放する必要があることだ。
この例でのシーケンス図を p.196 図8.2 に示す。今回の例が示しているのは、ACE プロアクタフレームワークで非同期I/Oを使うために従うべきガイドラインと次の原則である。
- 全てのデータ転送に ACE_Message_Block が用いられる。読み書きの全ての操作にはメッセージブロックが使われる。これにより、ACE の他のパーツ(特にACE_Message_QueueやACE タスクフレームワーク)とのやりとりを簡素化している。また、プロアクタフレームワーク内で共通のメッセージブロックを使うことで、フレームワーク側で自動的に読み書きのポインタを操作することができ、プログラマが面倒な管理を行う必要が無くなる。
- クリーンアップにおける制限はゆるいが、慎重に管理されている。プロアクタフレームワークではリアクタのそれと異なり、単純にハンドラを削除するだけでクリーンアップできる。プロアクタと完了ハンドラは、それらから独立した I/O 操作によって関連付けられる。独立した I/O 操作に利用されているメッセージブロックを削除することは問題である。各I/Oファクトリクラスは cancel() メソッドを持っているが、それが確実に有効であるとは限らない。そのため、安全な方法は全ての独立I/Oリクエストが完了するまで待ち、それからハンドラを削除することである。
8.3 接続の確立
接続には ACE_Async_Acceptor と ACE_Async_Connector のどちらかを利用する。これらで接続を確立すると、プロアクタフレームワークは ACE_Service_Handler を継承したクラスを元に、その接続用のハンドラオブジェクトを準備する。
ACE_Asynch_Acceptor は非常に使いやすいクラスだ。通常の場合には直観的な設定で利用でき、また二つのフックメソッドを利用することで簡単に機能を拡張できる。
今回の例では p.198 のように ACE_Async_Acceptor にサービスを提供する(ACE_Service_Handlerから継承した)クラスをテンプレートパラメータに指定してクラスを宣言すればよい。また、ここで宣言している validate_connection メソッドは ACE_Async_Acceptor と ACE_Async_Connector の両方で定義されているフックメソッドである。p.198-199 の例ではクライアントのIPアドレスがサーバのそれと同じネットワークに属していることを確認している。
新たに接続したソケットのハンドルは ACE_Async_Accept::Result::accept_handle() で取得できる。SSL のハンドシェークはこのポイントで行われている。validate_connection() が -1 を返すと、接続は速やかに破棄される。
もう一つのフックメソッドは ACE_Asynch_Acceptor::make_handler() である。この protected virtual メソッドはプロアクタフレームワークが新しい接続に対処するためのハンドラオブジェクトを用意するために呼び出される。デフォルトでの実装は p.199 のようになっている。もしアプリケーションで別のハンドラオブジェクトを使いたい場合などは、このメソッドをオーバーライドすればよい。
実際の ACE_Asynch_Acceptor の使い方は p.200 のようになる。アクセプタを初期化して接続を受け取れるようにするためには、オブジェクトの open() メソッドを呼ぶ。このメソッドの必須引数は最初に与える待機ポート番号である。backlog と reuse_addr はACE_SOCK_Acceptor と同様である。また、デフォルトの proactor 引数(0)はシングルトンのプロアクタインスタンスを利用する意味だ。validate_new_connection に非ゼロを指定すると接続時に validate_connection メソッドを呼ぶようになる。
bytes_to_read 引数には、接続を受け付けた直後に読み込むバイト数を指定できる。もし利用する場合には、p.192 で見た ACE_Service_Handler::open() に渡されるメッセージブロックにデータが格納される。
pass_address 引数は、サービスの動作中にローカルまたはリモートのアドレスを得る必要があるときに使う。ACE_Service_Handler::addresses() フックメソッドを実装し、open() の pass_addresses に非ゼロ値を渡すことで利用できる。
能動的に接続を張る場合も、受動的に張る時とほぼ同様である。p.200 に一例がある。
8.4 ACE_Proactor デマルチプレクサ
ACE_Proactor クラスはプロアクタフレームワークにおいて完了イベントを駆動する。このクラスはI/Oファクトリクラスによって作成された操作の完了を待ち、それらを関連するハンドラへと渡し、適切なフックメソッドを起動する。非同期I/O完了イベントを処理したい部分においてプロアクタイベントループを駆動することで、I/O操作であるか接続操作であるかを問わず処理を開始できる。アプリケーション中では次のように書けばよい。
ACE_Proactor::instance()->proactor_run_event_loop();
8.4.1 ACE_WIN32_Proactor
ACE_WIN32_Proactor クラスは windows用の実装である。これは WindowsNT4.0 以降で動作する。これは完了イベントの検知に I/O completion port(I/O完了ポート) を利用している。ファクトリによって I/O操作が始められると、プロアクタのI/O完了ポートへと関連付けられる。この実装では、Windows の GetQueuedCompletionStatus() をイベントループの軸にしている。ACE_WIN32_Proactor は、複数スレッドで同時にイベントループを実行しても問題無い。
8.4.2 ACE_POSIX_Proactor
POSIX システムにおける非同期I/Oメカニズムは多岐にわたる。これらのうちパフォーマンスの良いものを利用できるように、ACE はそれぞれのメカニズムをカプセル化している。また、プロアクタフレームワークでは気を付けて使えばマルチスレッドでの処理ができる。
8.5 タイマの利用
プロアクタフレームワークでも、リアクタフレームワークと同様のタイマを利用することができる。利用方法もほとんど同様であるが、若干の差異があるため、詳しくはリファレンスを参照しながら利用してほしい。
8.6 その他の I/O ファクトリクラス
リアクタフレームワークと同様に、プロアクタフレームワークでも各種のI/Oエンドポイントを利用することができる。しかしながら同期I/Oとは違い、それぞれのタイプごとに別のクラスになっている。
- ACE_Asynch_Read_File、ACE_Asynch_Write_File:ファイルおよびWindows 名前付きパイプ用
- ACE_Asynch_Transmit_File:接続済みTCP/IPストリームを通してのファイル転送
- ACE_Asynch_Read_DGram、ACE_Asynch_Write_DGram:UDP/IP ダイアグラムソケット用
8.7 リアクターとプロアクターフレームワークの結合
時にはリアクタフレームワークとプロアクタフレームワークのいいところを取り合わせて利用したい場合もある。特に windows (を含むマルチプラットフォーム) のアプリケーションの場合には。これから三つほど両方のフレームワークを組み合わせて使う方法を挙げてみる。
8.7.1 コンパイル時
コンパイル時に ACE_Svc_Handler と ACE_Service_Handler のどちらを使うかを切り替えることができる。その時にはコールバックで実際の処理を行う前に次に挙げたガイドラインに沿ったクラス設計をするべきである。
- データの取り扱いには ACE_Message_Block を用いる。プロアクタフレームワークでは通常なので、主にリアクタフレームワークでの注意点となる。とにかくネイティブ配列などの代わりに ACE_Message_Block を使うこと。
- 処理の中心をコールバック以外の private または protected なメソッドに置く。そしてそのメソッドは ACE_Message_Block を受け取り、処理する。もしそれとは逆の方向への処理があるのなら、その時は新しく ACE_Message_Block のインスタンスを作り、その中にデータを入れて返すこと。
- (プロアクタで)完了ハンドラにおいて、転送ステータスのチェックを行ったら、先程の作業メソッドにメッセージブロックを渡して処理をお願いする。
- (リアクタで) handle_input() においてデータを受信したら、そのデータを ACE_Message_Block に格納して、プロアクタで行っているように作業メソッドに渡す。
8.7.2 混合モデル
Windows では ACE_WFMO_Reactor にシグナル受理ハンドルを登録できることを思い出そう。オーバーラップド windows I/O を使う際にそのイベントハンドルをリアクタに登録することができるのだ。これはソケットでない I/O を、少量であればリアクタに渡すことができる。しかしながら、リアクタとプロアクタのイベントループを両方回すなどと考えてはいけない。
8.7.3 プロアクタとリアクタのイベントループの統合
同じアプリケーション内でリアクタとプロアクタの両方のモデルを利用できると便利な場合がある。一つの方法としては、それぞれのイベントループを別のスレッドで回すことだ。しかしながら、それにはマルチスレッドの同期テクニックについて紹介する必要がある。どうにかして一つのスレッドで両方のイベントハンドリングを行う方法は無いだろうか?ACE は windows プログラミングで ACE_Proactor フレームワークへの実装を ACE_WFMO_Reactor へ用いる方法を提供している。
I/O完了ポートのハンドルは wait可能ではないためリアクタへ登録できないが、それをラップする ACE_WIN32_Proactor には wait可能なハンドルがある。これを利用してプロアクタのハンドルをリアクタへ登録しておき、イベントが起きるとプロアクタの handle_signal() 経由で完了ハンドラを呼ぶことができる。この手法を使うには次のような手順を行う。
- ACE_WIN32_Proactor オブジェクトを、二番目の引数を 1 にして作成する。これにより、プロアクタはイベントハンドルと関連付けられ、そのハンドルを get_handle() で取得できるようになる。
- 上で作成した ACE_WIN32_Proactor オブジェクトを実装として ACE_Proactor オブジェクトをインスタンス化する。
- ACE_WIN32_Proactor のハンドルを ACE_Reactor オブジェクトへ登録する。
p.205 に実際に作成するコードサンプルが載っている。このプログラムが終了して、プロアクタが破壊される前に、そのイベントハンドラをリアクタから除去しておく必要があることに注意。
Index
Top