curl_multiで複数リクエストを並行実行する
curl_multi系の関数は、複数のcURLハンドルをまとめて登録し、1つずつ順番に待つのではなく並行してHTTP通信を進めるための仕組みです。外部APIを何件も呼ぶ処理を速くしたいときに使います。
foreachでcurl_execを繰り返すコードは書けてしまうんですが、それだと通信待ちの時間がリクエスト数だけ積み上がります。10件のAPIをそれぞれ200ms待つなら単純に足すと2秒ですが、並行して投げれば理屈上は一番遅い1件分の時間で済みます。今日はそのためのcurl_multi系関数と、実務でハマりやすいところを整理します。
curl_multiはどう書く?
まず個々のリクエストは普段通りcurl_initで作り、それをまとめ役のマルチハンドルに登録していきます。
$urls = [
'https://example.com/api/users/1',
'https://example.com/api/users/2',
'https://example.com/api/users/3',
];
$multiHandle = curl_multi_init();
$handles = [];
foreach ($urls as $key => $url) {
$ch = curl_init($url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_TIMEOUT, 5);
curl_multi_add_handle($multiHandle, $ch);
$handles[$key] = $ch;
}
個別のcurl_execを呼ばずに、curl_multi_add_handleで「これから並行して実行する集合」に加えるだけ、というのがポイントです。実際に通信を進めるのは次のループになります。
実行ループの定型パターン
curl_multi_execは1回呼んだだけでは全部終わりません。まだ通信中のハンドル数を参照渡しで受け取りながら、0になるまで回し続ける必要があります。
$stillRunning = null;
do {
curl_multi_exec($multiHandle, $stillRunning);
curl_multi_select($multiHandle);
} while ($stillRunning > 0);
$results = [];
foreach ($handles as $key => $ch) {
$results[$key] = curl_multi_getcontent($ch);
curl_multi_remove_handle($multiHandle, $ch);
curl_close($ch);
}
curl_multi_close($multiHandle);
curl_multi_selectを挟んでいるのは、ソケットに動きがあるまでCPUを使わずに待つためです。これを省いてただのwhileループにすると、CPUを無駄に食うビジーループになってしまいます。curl_multi_getcontentは、CURLOPT_RETURNTRANSFERをtrueにしたハンドルからレスポンス本文を取り出す関数で、これを呼ばないと結果が受け取れません。
1件だけ失敗したときにどう気づくか
並行実行で地味にハマるのが、一部のリクエストだけ失敗したケースです。curl_multi_execやcurl_multi_selectの戻り値は「複数のリクエスト全体」の状態を表すもので、個々のハンドルの成否はここには出てきません。個別の結果はcurl_getinfoやcurl_errorをハンドルごとに確認する必要があります。
foreach ($handles as $key => $ch) {
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$error = curl_error($ch);
if ($error !== '' || $httpCode >= 400) {
// 個別に失敗を記録する
continue;
}
$results[$key] = curl_multi_getcontent($ch);
}
全部まとめて投げて、まとめて成功したかのように扱うと、1件だけタイムアウトしたのを見逃してしまいます。ハンドルごとにチェックする一手間を惜しまない方がいいところです。
URLのキーと結果の対応を保つ
curl_multi_add_handleに渡した順番と、curl_multi_execが処理を終える順番は一致するとは限りません。速く返ってきたレスポンスから先に処理が終わるので、「何番目に追加したか」ではなく、ハンドルのオブジェクト自体をキーにして結果を紐づける必要があります。上の例で$handlesを連想配列にして、元のURLのキーをそのまま使っているのはそのためです。配列のインデックス番号だけで管理していると、後から見たときにどのレスポンスがどのURLのものか分からなくなります。
PHP 8以降でcURLはリソースからオブジェクトに変わった
PHP 8.0でcURL関連の値はresource型からCurlHandle・CurlMultiHandleというオブジェクトに変わりました。is_resource()でチェックしていた古いコードが動かなくなる原因になるので、cURLまわりの古いライブラリを引き継いだときは念のため確認しておくといいと思います。関数の使い方自体はそのままなので、curl_multi_add_handleなどの引数の渡し方を変える必要はありません。
まとめ
curl_multi系の関数は、複数の外部リクエストをまとめて投げて待ち時間を圧縮するための仕組みです。curl_multi_selectを挟んだ実行ループの定型パターンと、個々のハンドルの成否は自分でチェックしないといけない点、この2つを押さえておけば実務で困ることは少ないと思います。件数が多くなってきたらGuzzleのPromiseベースの並行実行に乗り換える選択肢もありますが、素のcURLで十分なケースも意外と多い印象です。
よくある質問
Q. curl_multi_execを1回呼ぶだけでは足りないのですか。
A. はい。1回の呼び出しでは通信の一部しか進まないことがあるため、$still_runningが0になるまでcurl_multi_selectと組み合わせてループする必要があります。
Q. リクエストの同時実行数を制限したい場合はどうしますか。
A. curl_multi自体には同時実行数の上限を指定するオプションはないので、URLの配列を任意の件数ずつに分割して、バッチごとにマルチハンドルの登録から実行までを繰り返すのが一般的なやり方です。
Q. 1つのリクエストだけタイムアウトした場合、他のリクエストも巻き込まれますか。
A. 巻き込まれません。各ハンドルは独立して処理されるので、CURLOPT_TIMEOUTを超えたハンドルだけがエラー状態になり、他のハンドルの結果には影響しません。ただし失敗の検知はハンドルごとにcurl_errorなどで確認する必要があります。