最近は Admiral Sir Cloudesley Shovell というバンドの 1st フルアルバム Don’t Hear It… Feat It をよく聴いています。
Sir Lord Baltimore のような、骨太なリフと、ブルージーでドラマ性もある楽曲が魅力のバンドですが、現在進行形で活動中のバンドです。
ストーナーロックとか好きな人は是非。

ここから本題です。
あるところに、Web API を数十万回単位で叩きまくる、PHP で書かれたバッチプログラムがあったとしましょう。
処理件数が増えるごとに処理時間が増大するので、いつしか 1 日経っても終わらないようになってしまいました。

そのプログラムは HTTP リクエストを直列に行っていたので、それを並列化させれば何とかなるのではないか、と考えて作ったのが以下のライブラリ。

yuya-takeyama/parallel_http

念のため書いておくと、全然プロダクションレベルにはなってないので、その辺りは自己責任でご利用ください。

試してみる

Packagist に登録しているので、Composer を使用してインストールすることができます。
Composer をよく知らないという方はこのあたりの資料を参考にすると良いのではないでしょうか。

composer install してやると EDPS (エディプス) というキラキラネームのライブラリも一緒にインストールされます。
これは Événement という PHP 5.3 または 5.4 以降で動作するイベントディスパッチャライブラリを 5.2 でも動くように移植したものです。

これを使えば、以下のように、Node.js ライクなコールバックスタイルで HTTP リクエストを行い、そのレスポンスを処理することができます。

PHP で並列 HTTP リクエストを実現する方法

PHP で並列に HTTP リクエスト、といえば curl_multi_* 系の関数がおなじみで、ちょっとググるだけでも以下のような記事が見つかります。

今回作った ParallelHttp も同様に curl_multi_* 系の関数を使用しての並列リクエストを行っています。

また、僕は以前にも HTTP_Parallel というライブラリを作ったことがあって、これも curl_multi_* 系の関数を使用しています。
上記の記事は curl_multi_* 系関数の紹介ということもあって、わりとゴリゴリと実装されていて再利用しづらいので、オブジェクト指向なインターフェイスにラップしよう、というモチベーションのもとに作りました。

既存のやり方がダメだった理由

先に紹介した記事の方法や、それを応用した HTTP_Parallel では、今回の問題に対応することができませんでした。

これらに共通するのは、「リクエストは並列に行うが、レスポンスの処理は最後に全部まとめてやる」というところです。
HTTP リクエストが 1 つでも残っている限りは他のタスクを一切行わず、最後のレスポンスが返ってくるまでただ待ち続けることになります。

これではリクエストを並列化させているだけで、非同期処理とは言えないでしょう。

そこで ParallelHttp は以下のようなフローで HTTP リクエストと、そのレスポンスの処理を行っています。

  1. イベントループにリクエスト情報とコールバックを登録 ($client->get($url, $cb))
  2. イベントループを開始 ($loop->run())
  3. 登録されたリクエストを同時に実行
  4. 終わったリクエストが無いかチェックし、終わったものから逐一コールバックで処理、まだ終わってないものは引き続きチェック
  5. 全てのリクエストが終了したらイベントループを抜ける

リクエストを待つ間に、先に処理できるレスポンスを処理することで、待ち時間を有効に活用することができます。
数 10 件程度のリクエストなら大した差は出ないと思いますが、今回のように数 10 万規模のリクエストを行うとなると、かなりの差が出ることが予想されます。

処理件数によるさらなる問題

レスポンスの処理を逐一行うことで、処理効率を上げられそうな感じがしてきました。
が、問題はその手前にありました。

前のセクションに書いた通り、イベントループを開始すると、登録されたリクエストを同時に実行することになるので、素直に実装するとものすごい数のリクエストが全て同時に行われてしまいます。

そうなると以下のような問題が発生します。

  1. ものすごい数の処理を同時に実行するので、CPU 等のマシンリソースを大量に消費する
  2. 対象のサーバが単一または小数だった場合には DoS 攻撃になってしまう
  3. それ以前に全てのリクエスト情報とそのコールバックの分のメモリを確保する必要がある

これらについても解決しないと、今回の問題に ParallelHttp を使うことはできませんでした。

対策 1. 並列数を保てるようにする

同時に行うリクエストの数を設定として持たせ、最大でもその数までしか並列化させなければ、ローカルマシンにもリモートマシンにも優しくなります。

ParallelHttp ではイベントループの内部にキューを持っており、並列数が最大になるまでは curl_multi リソースにリクエスト情報を登録していきます。
curl_multi に登録したリクエスト情報が最大に達すると、その後のリクエストはとりあえずキューの中にためておいて、1 つでもレスポンスが返ってきたらそれを処理しつつ、キューから次のリクエスト情報を curl_multi リソースに登録する、という方法を採りました。

イベントループの生成時に以下のようなオプションを指定することで、並列数を指定することができるようになります。

デフォルトでは 10 になっているので、必要に応じて調節することで、さらなるパフォーマンスを得たり、マシンリソースに気をつけて処理を行ったりすることができます。

これで、前のセクションで示した問題のうち最初の 2 つが解決されました。

対策 2. リクエスト情報の登録を少しずつにする

最後に残るのは、リクエスト情報とコールバックのために、大量のメモリを確保する必要がある、という点です。

結論からいうと、これは ParallelHttp の実装だけで解決することはできませんでした。
そこで、ParallelHttp の使い方を工夫することにしました。

例えば、処理対象の URL がデータベースに 30 万件入っていて、それらの全てにリクエストを行う場合を考えてみましょう。

memory_limit の設定値等にもよりますが、普通は $loop->run() の手前で、メモリ使用量が上限を超えて強制終了になるでしょう。
URL 30 万件程度ならメモリを多めに確保しておけば何とかなりますが、ParallelHttp の内部で生成しているリクエスト情報を保持したオブジェクトであったり、コールバックに使う無名関数であったりでメモリを大量に消費します。

コールバックに使う関数が単一でよければループの手前で変数にいれておき、それをコールバックとして登録するようにすれば、コールバック分のメモリは節約できます。
ですが、クロージャを使用する場合等はリクエストごとにユニークなコールバックを生成することになるので、そういうわけにもいきません。

URL を一度にまとめて取得し、その全てをイベントループに登録するのではなく、少しずつの URL をイベントループに登録し、そのリクエストが全て終わった段階で新たに URL を登録していく… というのを延々繰り返すようにしてみます。

これは擬似的な例ですが、実際にこういうのを作ってみてとりあえず 1 週間はまともに動かすことができました。

ここで UrlIteratorIterator としているものについて、具体的に説明すると、外側のイテレータはデータベースから URL の一覧を LIMIT 1001 で取得し、1000 件分の URL を内側のイテレータとして返します。
LIMIT 1001 としたのはまだデータベース内に次の値があるかどうかをチェックするためで、1001 番目の要素に値があれば、オフセットを適当に指定しつつ次の 1001 件を取得する、といったことを繰り返しています。

まとめ

PHP 5.2 で動作する非同期 HTTP 処理ライブラリ ParallelHttp について紹介しつつ、その中で直面した問題と、その解決について説明しました。

高いパフォーマンスを得る上で非同期処理は大変魅力的ではありますが、それはそれで別の問題が生まれるので、一筋縄では行きません。
とはいえ、筋のいいライブラリであったり、実装パターンを使用することである程度は緩和することができるでしょう。

今回作った ParallelHttp については ReactNode.js の標準ライブラリのコードを大いに参考にさせていただきました。
また、前にブログで紹介した AsyncMysql を実装した経験も大いに活かすことができました。

いろんな実装パターンを学んで、フレームワークや言語にとらわれないプログラミングスキルを身につけて行きたいと感じました。

, ,

最近は非同期処理に興味があります。

Reactor パターンというのは、非同期処理の実装パターンのひとつで、例えば HTTP 問い合わせなんかで「読み込みが完了するのを監視しつつその他のタスクを進め、読み込みが完了したタイミングでその結果を処理する」みたいなヤツです。
例えば Node.js では Reactor パターンを使ったプログラミングが比較的簡単に行えます。

PHP でも同じことをやりたい、というときには React というフレームワークがあって、少し前に話題になりました。

PHPでもリアルタイムWeb。node.php「React」

現状 React ではファイル I/O やソケット通信を非同期に行えるようになっております。
が、MySQL で非同期処理を行う方法は今の所提供されていません。

PHP でも mysqli_poll を使えば非同期に MySQL にクエリを投げられるということを知りました。
以下の記事が詳しいです。

PHPの非同期クエリで並行処理をやってみる

ただマニュアルのままの使い方だとさすがに辛いだろうと思ったので、コールバックを使って書きやすくするためのライブラリを書いてみました。

yuya-takeyama/async_mysql

これを使うと、例えばこんな感じに書けるようになります。

このように、Node.js っぽいインターフェイスで非同期問い合わせができます。

ローカルで試したい場合は以下のようにしてみてください。

localhost に root ユーザがパスワード無しでいることを前提にしているので、そうでない場合は以下のようにしてみてください。

$conn = $loop->connect(‘HOST’, ‘USER’, ‘PASSWORD’);

この例では数秒 SLEEP() するだけのクエリをいくつか投げており、直列に実行した場合は SLEEP() の合計時間が全体の実行時間となってしまうところを、並列に実行することで、ほぼ同時に全てのクエリが発行され、一番長いクエリの分だけの実行時間で済みます。

ただし、実行するクエリそれぞれ別々にコネクションが確率されてしまうため、実際にちゃんと使うにはキューの仕組みが必要だと思われます。
例えば最大で 16 並列までしかクエリを投げない、みたいなことができないと、KEN_ALL.csv の全件 INSERT を試みるだけで too many connections になってしまうことでしょう。 (まだ試してないですが)

ただ、これの開発を継続して行うつもりはなくて、あくまでも実装の例として作っています。
最終的には React に組み込めたらいいなー、なんて思っているんですが、React がイベントループに使用している stream_select() は MySQL では使えないため、どうしたもんかというところです。
(libevent を使った実装もあるけど、そもそも libevent に対する理解が足りていないのでどうすべきかイメージが湧いてない)

とりあえずは Ruby の EventMachine や Perl の AnyEvent あたりのコードを読んでみようと思います。

ところで話は変わるのですが、React 上で子プロセスの実行を非同期に行うものを書いていて、React の中の人たちにレビューをいただきつつ開発を進めています。

Pull Request #61: WIP: ChildProcess by yuya-takeyama · react-php/react

今は API が固まってきたので、ユニットテストを書いているところです。
(こういう自分の中でチャレンジングなものを作るときは API がコロコロ変わって、テストファーストが全然うまくいかないので、とりあえずガーッと作ってその後にテストで固めて行く、というやり方で作ることが多いです)

これについてはマージされたら改めて紹介しようと思います。

, , ,