Born Too Late

Yuya's old tech blog.

PHP 5.2 で並列数を保ちつつ非同期 HTTP 処理

2012-11-03 17:24:55

最近は 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 を実装した経験も大いに活かすことができました。

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