guzzlehttp/promisesで理解するPHPの非同期HTTPリクエスト
概要
PHPは「リクエストごとにプロセスが生まれて死ぬ」同期的なモデルで動くことが多く、非同期処理とは縁遠いと思われがちです。しかし外部APIを複数呼ぶバッチ処理やスクレイピング、マイクロサービス間の集約では、HTTPリクエストを並行に投げられるかどうかが処理時間を大きく左右します。
Guzzle(guzzlehttp/guzzle)には guzzlehttp/promises というPromise実装が同梱されており、これを理解するとPHPでも「複数のHTTPリクエストを同時に走らせ、結果が揃ってからまとめて処理する」書き方ができます。本記事では、このPromiseが「何をしていて」「なぜ非同期になるのか」を考察します。
結論を先に述べると、PHPのGuzzle PromiseはJavaScriptのような言語ランタイムレベルのイベントループではなく、cURLのマルチハンドルを土台にした協調的な実行モデルです。ここを押さえると、ハマりどころも自然に理解できます。
環境
- PHP 8.1 以上
guzzlehttp/guzzle7.x(guzzlehttp/promisesを内包)- cURL拡張が有効であること(マルチリクエストの並行性はここに依存します)
composer require guzzlehttp/guzzle
発生していた問題
よくある素朴な実装は次のようなものです。
use GuzzleHttp\Client;
$client = new Client();
$urls = [
'https://example.com/api/a',
'https://example.com/api/b',
'https://example.com/api/c',
];
$results = [];
foreach ($urls as $url) {
$results[] = $client->get($url); // 同期。1本ずつ待つ
}
get() は同期メソッドなので、3本のリクエストがそれぞれ200msかかるなら合計約600ms、10本なら2秒。リクエスト本数に比例して線形に遅くなります。CPUは遊んでいて「ネットワークの応答待ち」で時間を食っているだけ、という状態です。
原因
Client::get() の実体は Client::request() の同期版で、内部的には非同期版 requestAsync() を呼び出し、その戻り値のPromiseをすぐ wait() して解決しています。つまりGuzzleのAPIは最初から非同期を前提に作られており、同期メソッドは「非同期を即座に待つショートカット」に過ぎません。
// 概念的な内部イメージ
public function request($method, $uri, array $options = [])
{
$options['synchronous'] = true;
return $this->requestAsync($method, $uri, $options)->wait();
}
ここが本記事の核心です。同期的に見えるPHPのHTTP呼び出しは、1本ずつ wait() しているから遅いのであって、Promiseを解決せずに溜めておけば並行に走らせられます。
対応方法
getAsync() / requestAsync() はPromiseを返します。これを配列に溜め、Utils::settle() や Utils::unwrap() でまとめて待てば並行実行になります。
use GuzzleHttp\Client;
use GuzzleHttp\Promise\Utils;
$client = new Client();
$promises = [
'a' => $client->getAsync('https://example.com/api/a'),
'b' => $client->getAsync('https://example.com/api/b'),
'c' => $client->getAsync('https://example.com/api/c'),
];
// 全部成功前提でまとめて待つ(1本でも失敗すると例外)
$responses = Utils::unwrap($promises);
echo $responses['a']->getStatusCode();
失敗を握りつぶさず個別に扱いたい場合は settle() を使います。成功/失敗を区別した連想配列を返します。
$results = Utils::settle($promises)->wait();
foreach ($results as $key => $result) {
if ($result['state'] === 'fulfilled') {
$response = $result['value'];
echo "$key: " . $response->getStatusCode() . "\n";
} else {
$reason = $result['reason']; // 例外(RequestException等)
echo "$key: failed - " . $reason->getMessage() . "\n";
}
}
なぜこれで「並行」になるのか
ここでPromiseの仕組みを考察します。getAsync() を呼んだ時点では、まだ通信は本格的に進んでいません(開始キューに積まれただけ)。wait() が呼ばれて初めて、Guzzleのハンドラ(デフォルトは CurlMultiHandler)がcURLのマルチインターフェース(curl_multi_*)に全ハンドルを登録し、ソケットの準備が整ったものから順に処理を進めます。
use GuzzleHttp\Promise\Promise;
$promise = new Promise(
// wait 関数:Promiseが未解決のときに呼ばれ、解決を「前に進める」
function () use (&$promise) {
$promise->resolve('解決された値');
}
);
$promise->then(function ($value) {
echo "受け取った: $value\n";
});
echo $promise->wait(); // ここで wait 関数が走り、then も発火する
JavaScriptのPromiseが「イベントループが勝手に回す」のに対し、Guzzle(PHP)のPromiseは誰かが wait() を呼ぶまで前に進みません。これが決定的な違いです。wait 関数という「保留中の仕事を一歩進めるためのコールバック」を各Promiseが抱えていて、wait() がそれを再帰的に駆動します。複数のPromiseをまとめて wait() すると、マルチハンドルが全接続を同時に面倒見てくれて、結果として並行実行になります。
同時実行数を制御する:Pool
数百本のリクエストを一度に settle() すると、相手サーバーにもこちらにも負荷がかかります。同時実行数(concurrency)を絞りたいときは Pool を使います。
use GuzzleHttp\Client;
use GuzzleHttp\Pool;
use GuzzleHttp\Psr7\Request;
$client = new Client();
$requests = function ($total) {
for ($i = 0; $i < $total; $i++) {
yield new Request('GET', "https://example.com/api/item/$i");
}
};
$pool = new Pool($client, $requests(100), [
'concurrency' => 5, // 同時に走るのは常に5本まで
'fulfilled' => function ($response, $index) {
// 1本成功するたびに呼ばれる
},
'rejected' => function ($reason, $index) {
// 1本失敗するたびに呼ばれる
},
]);
$pool->promise()->wait();
requests をジェネレータにしているのがポイントで、100本分のRequestオブジェクトを一度にメモリへ展開せず、Poolが必要になったぶんだけ遅延生成します。大量リクエストでもメモリが膨らみません。
実装例:複数APIの結果を集約する
実務で一番ありがたみを感じるのが、複数のサービスやAPIから情報を集めて1つのレスポンスにまとめるケースです。
use GuzzleHttp\Client;
use GuzzleHttp\Promise\Utils;
function aggregateUserDashboard(Client $client, int $userId): array
{
$promises = [
'profile' => $client->getAsync("https://example.com/users/$userId"),
'orders' => $client->getAsync("https://example.com/users/$userId/orders"),
'notices' => $client->getAsync("https://example.com/users/$userId/notices"),
];
$results = Utils::settle($promises)->wait();
$dashboard = [];
foreach (['profile', 'orders', 'notices'] as $key) {
$dashboard[$key] = $results[$key]['state'] === 'fulfilled'
? json_decode((string) $results[$key]['value']->getBody(), true)
: null; // 一部が落ちても全体は返す(部分的縮退)
}
return $dashboard;
}
3本直列なら合計レイテンシの単純合計ですが、並行なら最も遅い1本の時間 + わずかなオーバーヘッドで済みます。しかも settle なので通知APIだけ落ちてもプロフィールと注文は返せます。
then でチェーンする
Promiseは then で変換を繋げられます。レスポンスを受け取ってそのままデコードして返す、といった加工をPromiseのまま書けます。
$promise = $client->getAsync('https://example.com/api/a')
->then(function ($response) {
return json_decode((string) $response->getBody(), true);
})
->then(function ($data) {
return $data['items'] ?? [];
});
$items = $promise->wait(); // ここで初めて実行され、then群が順に適用される
確認方法
並行化の効果はログの時刻差で確認するのが手早い方法です。
$start = microtime(true);
$responses = Utils::unwrap($promises);
printf("elapsed: %.3fs\n", microtime(true) - $start);
同じ本数を同期ループ(get())で回したときと比較し、本数を増やしても時間がほぼ増えないことを確認できれば並行実行が効いています。逆に本数に比例して伸びるなら、どこかで1本ずつ wait() してしまっています。
注意点
- CPUバウンドは速くなりません。 速くなるのは「ネットワークの応答待ち」が重なっている部分だけです。重い計算を並行化したいわけではありません。
wait()を忘れるとそもそも走りません。 Promiseを作っただけで解決しなければ通信は始まりません(または完了しません)。「非同期にしたのに結果が空」の典型原因です。- ループ内で個別に
wait()するのは無意味です。foreach ($promises as $p) { $p->wait(); }は結局1本ずつ待っているので同期と変わりません。必ずsettle/unwrap/Poolでまとめて待ちます。 - 並行数は相手への配慮です。 concurrency 無制限で数百本投げると、相手サーバーのレート制限に引っかかるか、自分のソケット/メモリを食いつぶします。
Poolで必ず上限を設けます。 - 真の「ノンブロッキング」ではありません。 PHPのこのモデルは、イベントループを持つ言語のように「待っている間に別の仕事をする」わけではなく、あくまで複数のHTTP待ちをまとめて面倒見ているだけです。DBクエリや他のI/Oと並行に走るわけではない点は誤解しやすいところです。
まとめ
- Guzzleの同期メソッドは「非同期Promiseを即
wait()するショートカット」。遅いのは1本ずつ待つからです。 getAsync()でPromiseを溜め、settle/unwrap/Poolでまとめて待つと、cURLマルチハンドルが複数接続を同時に進めてくれます。- Promiseは
wait()されるまで前に進まない「協調的」モデル。JavaScriptのイベントループとは別物だと理解すると挙動が腑に落ちます。 - 大量リクエストはジェネレータ+Poolで同時実行数とメモリを制御します。
PHPは非同期が苦手という先入観はありますが、「ネットワーク待ちを重ねる」という一点に絞れば、Guzzle Promiseだけで十分実用的な並行処理が書けます。処理時間がリクエスト本数に比例して困っているなら、まず getAsync + settle を試す価値があります。
