C++/Boostとコルーチンを使って非同期I/Oを実現(C10K問題もこれで解決?)

みなさん、こんにちは、
kaoruです。

今回は、C10K問題 (参考1, 参考2) を回避するためにコルーチンを使う方法をご紹介したいと思います。

また、ちょっと実用的な例として、言語としてC++、ライブラリーとしてBoost.AsioとBoost.Asio付属のコルーチンを利用して、
ApacheBenchもどきを実際に作ってみました。

ネットワークプログラミングを調査しようと思ったきっかけは、
技術評論社から出ている「WEB+DB PRESS Vol.55」
「モダンネットワークプログラミング入門」という記事です。
こちらには、ネットワークプログラミングの基本的な設計モデルが分かりやすく説明されているため、とても参考になります。

ご存じのように、C10K問題とは、ハードウェアに余裕があるにもかかわらず、同時コネクション数が1万前後になると、パフォーマンスが急速に劣化するという問題です。
これは、ネットワークアプリケーションの伝統的な設計ではコネクション数に比例する数のプロセスやスレッドが生成されることに原因があります。
例として、Apache HTTP Serverのpreforkモードでは、コネクション数に比例する数の子プロセスが起動して、リクエストを処理します。
同時コネクション数が1万前後にするには、子プロセスを1万前後起動しなければならず、これはあまり現実的な数字ではありません。

C10K問題を回避するためには、コネクション数とプロセス数やスレッド数が比例しないような設計をしなければなりません。
これには、非同期I/Oを利用したイベント駆動モデルが有効です。
これはすなわち、接続成功、ヘッダーの読み取り完了、レスポンスの送出完了などといったイベントの発生毎にあらかじめ登録しておいたイベントハンドラを呼び出してもらう方法です。
この方法は非常に効率がよいのですが、本来はひと続きの処理がたくさんのイベントハンドラの中にばらばらになって散らばってしまうため、
処理の流れが分かりにくくソースコードの可読性が悪いという欠点がありました。

コルーチンと呼ばれる手法を使うと、非同期I/Oを利用したイベント駆動モデルでありながら、同期モデルとほぼ同じようにプログラミングを行うことができ、
処理の流れが非常に分かりやすくなります。
コルーチンとは、プログラムの流れの中で中断・再開ができたり、スレッドのように制御を分岐したりできるプログラミングのしくみです。Luaなどの一部の言語では言語レベルでサポートされていますが、C/C++ではライブラリーを使って実装します。

今回作ったソースコードの一部を掲載します。 (この記事の下のリンクから全てのソースコードをダウンロードできます。)


#include “yield.hpp” // Enable the pseudo-keywords reenter, yield and fork.

void
client::operator()(
boost::system::error_code ec,
std::size_t length,
tcp::resolver::iterator endpoint_iterator)
{

static
int n = 0;
static
int c = 0;
if
(ec && ec != boost::asio::error::eof) {
std::cerr << ec.message() << std::endl;
return
;
}

// On reentering a coroutine, control jumps to the location of the last
// yield or fork. The argument to the “reenter” pseudo-keyword can be a
// pointer or reference to an object of type coroutine.
reenter (this)
{

for
(c = 0; c < concurrency_level_ && c < num_request_; ++c) {
fork client(*this)();
if
(is_child())
break
;
}

if
(is_parent())
return
;
while
(n < num_request_) {
socket_.reset(new boost::asio::ip::tcp::socket(io_service_));
yield socket_->async_connect(endpoint_, *this);
n++;
if
(n % 1000 == 0)
cout << “Completed “ << n << ” requests” << endl;
// Send the request.
yield boost::asio::async_write(
*
socket_,
boost::asio::buffer(
request_.c_str(),
request_.size()
),
*
this
);

// Start reading remaining data until EOF.
static char dummy_buffer[1 * 1024 * 1024]; // Dirty buffer.
yield boost::asio::async_read(
*
socket_,
boost::asio::buffer(dummy_buffer, sizeof(dummy_buffer)),
boost::asio::transfer_all(),
*
this);
// Initiate graceful connection closure.
socket_->shutdown(tcp::socket::shutdown_both, ec);
}
}

}
#include “unyield.hpp” // Disable the pseudo-keywords reenter, yield and fork.

#include “yield.hpp” から #include “unyield.hpp” の区間では、
reenter, yield, fork という3種類のキーワードが有効になります。
(実際にはプリプロセッサーマクロですが、キーワードであるかのように使えます。)

コルーチン化したいコードの部分をreenter { } で囲み、
async_connect, async_read, async_write といった非同期I/Oを行う関数の手前に、
yieldキーワードをつけます。(下図の(1),(2))
forkキーワードは、UNIXのforkシステムコールと同じような感じに、コルーチンオブジェクトをコピーして子供のコルーチンを生成します。(下図の親・子)
is_child(), is_parent()という関数で、子として実行しているのか、親として実行しているのかがわかりますので、
処理を分岐することが可能です。forkキーワードを使っても、実際にはプロセスやスレッドは生成されませんので、非常に軽量です。

yieldキーワードを使うと、非同期I/Oが完了したときに次の行から処理が再開されます。すなわち、下図で説明すると、まず最初に(1)から(2)まで処理が進み、ここでyieldキーワードがありますので、async_connect関数(非同期接続を行う関数です。)が実行された後、一旦reenterブロックを抜けます。抜ける際に、再開する場所として(2)の場所が自動的に保存されます。(2)のasync_connectの引数として*thisが渡されていますので、接続完了時にもう一度(1)に制御が来ます。ここで、先ほど(2)まで処理が進んだことを保存していましたから、(1)から(2)に制御が移動し、(2)から処理が再開されるという仕組みです。

co-routine.gif

以前はこのような処理を記述する際には、あらかじめ同時接続数と同じ数のスレッドを生成した後、
同期処理を行うAPIを用いて記述することが多かったのですが、今回はスレッドは明示的に生成することなく実装ができました。

この例のように、まるで同期処理を書いているかのような感覚でプログラミングを行うことができるのがうれしいところです。

前回の記事「WEBサーバーを比較してみる」と同じ環境で、nginxサーバーに対してリクエストを発行したところ、
ApacheBench を若干上回る 9345.84 req/s という結果を得ました。

また、機能が違うため正しい比較はできませんが、同時コネクション数を10000程度まで増やしても、
ApacheBenchよりもメモリー使用量が少なくなりました。

今回、コルーチンを使うことで、ApacheBenchもどきのクライアントを非常に簡単に作ることができました。
みなさんが、非同期I/Oを使ったプログラミングをされる際に、少しでも参考になるところがありましたら幸いでございます。

今回作成したソースコード
ファイルをダウンロード

ビルド方法
※ビルドには、あらかじめBoost C++ Librariesをインストールして、適切にパスの設定をする必要があります。

Windows環境の場合
Visual Studio 2008 で、http_stress_tool.slnを開いてください。

UNIX環境の場合
Makefile等は用意していません。以下のようなコマンドでビルドができます。
g++ -O3 -o http_stress_tool http_client.cpp main.cpp -Wall -Wextra -lboost_system -lboost_date_time



Fatal error: Allowed memory size of 268435456 bytes exhausted (tried to allocate 16271409 bytes) in /home/yumeco/www/prod/wp-includes/wp-db.php on line 1171