まずはじめに、こちらの記事にて PHP でエコーサーバーを作りました。
http://qiita.com/d_nishiyama85/items/1130f11fba76e1afef81
このサーバーはひとつのクライアントが接続している間、他のクライアントの接続を受け付けられないので、全く実用的ではありません。今回はこの点を改良し、同時に複数のクライアントからの接続をさばける(多重化)ようにしてみようと思います。これを実現するために libevent というライブラリを使います。
多重化
この手のサーバーの多重化として、大まかには
- マルチプロセス:子プロセスをいくつも fork して各プロセスで各クライアントを処理する。
- マルチスレッド:スレッドをいくつも起動して各スレッドで各クライアントを処理する。
- イベントループ:シングルプロセス・シングルスレッドで各クライアントを状態変化を監視し、変化があったものを処理していく。
というものが挙げられると思います。このうち、一つ目のマルチプロセスを使った多重化については、以前書いた記事
ひとりアドベントカレンダー2015 第23日目 〜 PHPer でもソケットプログラミングしてみたい その2(多重化するぞ)
をご覧ください。
二つ目のマルチスレッドは PHP で実現することは困難です。いちおう pthreads というスレッドライブラリがありますが、スレッドの中でソケットを扱うことがどうも難しいようです。また、pthreads 自体も PHP をビルドし直さないと使えません。
**今回は三つ目の「イベントループ」を使って多重化を行います。**この方法では接続中のクライアントに対応するソケット(というかファイルディスクリプタ)を管理し、読み込みや書き込みができるようになったものから通知(イベント)が来るようにしておきます。通知があったらそのソケットの読み込みや書き込みを処理し、終わったらまたソケットの監視をつづける、というふうにして複数の接続をさばいていきます。
select, poll, epoll
Linux にはこれを行うために select, poll, epoll という似ているけども微妙に異なるシステムコールが用意されています。このうち、select は監視できるファイルディスクリプタの数に上限(1024個?)があり、パフォーマンスもあまり良くないようです。ただし、PHP で生ソケットを扱うソケットモジュールの関数群には select システムコールに対応する socket_select()
しか用意されていません。ソケットモジュールのみを使うなら必然的に select を使うことになるかと思います。これについての実装例は以前に書いたこちらの記事をご覧ください:
ひとりアドベントカレンダー2015 第24日目 〜 PHPer でもソケットプログラミングしてみたい その3(select()で多重化するぞ)
他方、poll や epoll は監視可能なファイルディスクリプタの数に制限がなく、とくに epoll は監視効率も高いようですが、PHP で実装するには問題があります。FreeBSD など Linux 以外の Unix 系 OS などではこのシステムコールが存在しておらず、移植性に欠けることです(FreeBSD には代替として kqueue, kevent というシステムコールがあるようです)。 PHP はもちろん FreeBSD でも動作しなければならないので epoll に直接依存することはできません。これが socket_epoll()
のような関数が存在しない理由なのかなと思います。
libevent
この問題を解決するライブラリが、今回扱う libevent です。これは上記の環境依存のシステムコールをラップして、ファイルディスクリプタ監視の統一されたインターフェースを提供するライブラリです。内部では epoll や kqueue を呼んでいるのでそれらと同等の効率が得られると思われます。libevent は有名ドコロでは memcached などハイパフォーマンスなサーバーでも使われていて実績はお墨付きとも言えます。C 言語で書かれたライブラリですが PHP からこれを利用するエクステンションが存在します。以下では PHP から libevent を使う方法を見ていきます。(環境は CentOS7 を想定しています。)
libevent (C 言語ライブラリ)のインストール
まずは PHP エクステンションが参照する C 言語ライブラリの libevent 本体をインストールしましょう。yum
コマンドでやるのが簡単だと思います。
$ sudo yum install libevent
また、PHP エクステンションをコンパイルするために必要なヘッダファイルを含む開発用パッケージもインストールします:
$ sudo yum install libevent-devel
PHP エクステンションのインストール
PECL にて提供されています:http://pecl.php.net/package/libevent
インストールも pecl
コマンドで行うのがお手軽です。 pecl
コマンドが入っていない場合は
$ sudo yum install php-pear
などでインストールしておきましょう。pecl
を使って、
$ sudo pecl install "channel://pecl.php.net/libevent-0.1.0"
とします。(sudo pecl install libevent
だけだと、まだベータ版だからバージョンを明示してね、てきな感じで怒られました。)エラーなく終了すれば PHP から libevent のインストールは完了です。最後に php.ini に
extension=libevent.so
を追記してエクステンションを有効化しておきましょう。
簡単な例
公式マニュアルに、標準入力を監視して処理を行う例があります。以下コピペ:
<?php
function print_line($fd, $events, $arg)
{
static $max_requests = 0;
$max_requests++;
if ($max_requests == 10) {
// 10 回書き込んだらループを抜けます
event_base_loopexit($arg[1]);
}
// 行を表示します
echo fgets($fd);
}
// ベースとイベントを作成します
$base = event_base_new();
$event = event_new();
$fd = STDIN;
// イベントフラグを設定します
event_set($event, $fd, EV_READ | EV_PERSIST, "print_line", array($event, $base));
// イベントベースを設定します
event_base_set($event, $base);
// イベントを有効にします
event_add($event);
// イベントループを開始します
event_base_loop($base);
?>
基本的な使い方は以下のようです:
-
event_base_new()
で event_base オブジェクト(イベントループの情報を記述する構造体?)を作成する。 -
event_new()
で監視対象であるイベントオブジェクトを作成する -
event_set()
でイベントオブジェクトに監視するファイルディスクリプタや、イベントを処理するコールバック関数(イベントハンドラ)を登録する -
event_base_set()
でイベントオブジェクトとイベントループを関連付ける -
event_add()
でイベントループの監視リストにイベントオブジェクトを追加する -
event_base_loop()
でイベントループをスタートさせる
socket_select()
はイベント監視ループの while 文などを自前で書く必要がありますが、libevent はライブラリ内でループが回っているようで、自前で書く必要はありません。イベントが起こると登録しておいたコールバック関数が呼ばれる仕組みになっています。
バッファつきイベントの扱い
さて、C 言語の libevent には上で触れた API の他に、ストリームをバッファリングした上で扱う API 群も備えています。こちらに詳しいドキュメントがあります:http://www.wangafu.net/~nickm/libevent-book/Ref6_bufferevent.html
PHP エクステンションにもこれに対応する関数群が用意されており、event_buffer_xxx という名前がついているようです。
基本的な使い方は公式マニュアルの例2にあります:
一部を抜粋すると、
$base = event_base_new();
$eb = event_buffer_new(STDIN, "print_line", NULL, "error_func", $base);
event_buffer_base_set($eb, $base);
event_buffer_enable($eb, EV_READ);
event_base_loop($base);
となっており、
-
event_buffer_new()
でストリームをバッファするバッファリングオブジェクトを生成する -
event_buffer_base_set()
でイベントループにバッファリングオブジェクトを関連付ける -
event_buffer_enable()
でバッファリングオブジェクトに発生するイベントの監視を有効化する -
event_base_loop()
でイベントループをスタートさせる
というような使い方をするようです。
エコーサーバーの多重化
さて、長々と書いてきましたが、ようやく目的であったエコーサーバーの多重化を実装していきます。実装の流れは以下のようにします:
- サーバーソケットを開いて libevent の監視対象にする
- イベントループをスタートさせる
- サーバーソケットからの通知が来たら(= クライアントがアクセスしてきたら) accept してクライアントとのソケットを開く
- そのクライアントソケットも libevent の監視対象に加える
- 以下、サーバーソケットと接続中のクライアントソケットたちの監視をつづける
ソースコードを貼ります(前述の公式マニュアルの例3とほとんど同じですが・・):
<?php
// サーバーソケットの作成
$socket = stream_socket_server('tcp://0.0.0.0:8000', $errno, $errstr);
if (!$socket) {
die("$errstr ($errno)\n");
}
stream_set_blocking($socket, 0);
$base = event_base_new();
$event = event_new();
event_set($event, $socket, EV_READ | EV_PERSIST, 'ev_accept', $base);
event_base_set($event, $base);
event_add($event);
event_base_loop($base);
// 接続中のクライアントを管理する変数
$GLOBALS['connections'] = [];
// クライアントの接続をバッファリングするオブジェクトを管理する変数
$GLOBALS['buffers'] = [];
// サーバーソケットに接続が来たときに呼ばれるコールバック
function ev_accept($socket, $flag, $base) {
static $id = 0;
$conn = stream_socket_accept($socket);
stream_set_blocking($conn, 0);
$id += 1;
echo "client connected.\n";
// welcome メッセージを送信
$welcome = "welcome to simple php echo server!\n";
fwrite($conn, $welcome, strlen($welcome));
// クライアントの監視をイベントループに追加(バッファつきイベントの作成)
$buffer = event_buffer_new($conn, 'ev_read', NULL, 'ev_error', $id);
event_buffer_base_set($buffer, $base);
event_buffer_timeout_set($buffer, 30, 30);
event_buffer_watermark_set($buffer, EV_READ, 0, 0xffffff);
event_buffer_priority_set($buffer, 10);
event_buffer_enable($buffer, EV_READ | EV_PERSIST);
$GLOBALS['connections'][$id] = $conn;
$GLOBALS['buffers'][$id] = $buffer;
}
// エラー処理コールバック
function ev_error($buffer, $error, $id) {
event_buffer_disable($GLOBALS['buffers'][$id], EV_READ | EV_WRITE);
event_buffer_free($GLOBALS['buffers'][$id]);
fclose($GLOBALS['connections'][$id]);
unset($GLOBALS['buffers'][$id], $GLOBALS['connections'][$id]);
echo "connection $id closed.\n";
}
// クライアントソケットが読み取り可能になった(メッセージが届いた)ときに呼ばれるコールバック
function ev_read($buffer, $id) {
while ($read = event_buffer_read($buffer, 4096)) {
echo "message receive: $read";
// エコーバックする
event_buffer_write($buffer, $read);
}
}
注意点
実装時に以下の点でハマったのでメモとして上げておきます。
-
stream_socket_accept()
したクライアントソケットの監視は、なぜかバッファつきイベントの API 群を使う必要があるようです。stream_socket_server()
したサーバーソケットと同じように(バッファつきでない)プリミティブな API を使ってみたのですが、エラーは出ないものの読み取り可能になった時のイベントが発火しませんでした。 - accept したソケットとそれに紐づくバッファリングオブジェクトはグローバル変数に入れてプログラム内で管理しておく必要があります。そうしないと ev_accept() 関数を抜けた際にガベージコレクション(?)されて接続が閉じられてしまうようです。
以上で PHP で libevent エクステンションを使って多重化したエコーサーバーが実装できました。