はじめに:PHPは「同期型言語」という古い呪縛を捨てよ
コードレビューの場で、こんな議論をしたことはないか?
> 「PHPで外部APIを5つ叩く必要があるんだが、直列で同期的にリクエストを送るとレイテンシが合計で2秒になってしまう。並行処理させたいけれど、そのためだけにGoやNode.jsに書き換えるのはリソースがない……」
もし君がこの問いに対し、「じゃあ `curl_multi_` を使おう」と即答したなら、それは実務を生き抜くためのサバイバル術としては正解だが、Zend Engineの内部メカニズムを理解しているアーキテクトの答えとしては不十分だ。
PHPは長年、「1リクエスト・1プロセス(または1スレッド)」の同期的モデルの代名詞とされてきた。Apache + mod_phpの時代から、Nginx + PHP-FPMに至るまで、PHPのコードは「上から下へ、I/Oの完了を泣く泣く待つ(ブロックされる)」という宿命を背負ってきたように見える。
しかし、現代のPHP(特にPHP 8.1以降のFiberの導入や、Swoole, ReactPHP, Ampといったエコシステム)において、この前提は完全に過去のものとなった。PHPのストリーム(Stream)は、OSのカーネルが提供する `epoll`(Linux)や `kqueue`(macOS/BSD)といったI/O多重化プリミティブと密接に連携し、ノンブロッキングI/Oとイベントループを駆動するための堅牢な基盤を隠し持っている。
今回は、PHPのストリームラッパーが内部でどのようにOSのファイル記述子(FD)と対話し、Zend VMのコンテキストスイッチを支えているのか。その深淵なる内部挙動を解き明かしていこう。
—
1. Zend VMとストリーム:すべてのI/Oは「ファイル」である
PHPの最大の強みであり、同時に抽象化の美学でもあるのが、統一されたストリームAPIだ。HTTPであろうが、TCPソケットであろうが、標準入力であろうが、PHPはすべてを `php_stream` というCレベルの構造体として抽象化している。
内部構造:`php_stream` と `php_stream_ops`
Zend Engineのソースコード(`main/streams/streams.c`)を覗いたことがあるだろうか? すべてのストリーム操作は、`php_stream_ops` という関数ポインタのテーブル(仮想メソッドテーブルのようなもの)によってラップされている。
typedef struct _php_stream_ops {
size_t (write)(php_stream stream, const char buf, size_t count);
size_t (read)(php_stream stream, char buf, size_t count);
int (close)(php_stream stream, int close_handle);
int (flush)(php_stream stream);
const char label;
int (seek)(php_stream stream, zend_off_t offset, int whence, zend_off_t new_offset);
int (cast)(php_stream stream, int castas, void ret);
int (stat)(php_stream stream, php_stream_statbuf ssb);
int (set_option)(php_stream stream, int option, int value, void ptrval);
} php_stream_ops;
PHPのスクリプト層で `$fp = stream_socket_client(…)` を実行した瞬間、Zend VMは内部でソケットを作成し、OSから返されたファイル記述子(File Descriptor: FD)をこの `php_stream` 構造体に紐付ける。
ここで重要なのは、デフォルトではこのFDはブロッキングモードで動作しているという点だ。つまり、`fread()` や `fwrite()` を呼ぶと、データが到着するか、OSのバッファが溢れるまで、PHPプロセス(FPMのワーカー)のそのスレッド/プロセスはCPUを明け渡さずにカーネル空間で停止(ブロック)させられる。これが、高負荷時にFPMのプロセスプールが枯渇する根本原因だ。
—
2. ノンブロッキングI/Oと `epoll` / `kqueue` の裏側
このブロッキングの呪縛を解き放つのが、ノンブロッキングモードへの切り替えと、OSのイベント通知メカニズム(Linuxなら `epoll`、BSD系なら `kqueue`)である。
`stream_set_blocking($fp, false)` の正体
PHPでストリームをノンブロッキングに設定すると、内部では対象のFDに対して `fcntl(fd, F_SETFL, O_NONBLOCK)` が実行される。これにより、データが来ていない状態で `fread()` を呼んでも、処理はブロックされず、即座に `EWOULDBLOCK` または `EAGAIN` というエラーコードが返されるようになる。
しかし、ここで一つの疑問が生じる。
「ブロックされないなら、データが到着したタイミングをどうやって知るのか? 毎フレーム無限ループでポーリング(忙しい待機)するのか?」
そんなことをすれば、CPU使用率が100%に張り付いてサーバーが即座に死ぬ。そこで登場するのが、OSカーネルレベルのイベント通知機構である。
イベントループと `stream_select`
PHPの標準関数である `stream_select()` は、まさにこのOSの `select()` や `poll()` (あるいは拡張モジュールによる `epoll`)のラッパーだ。
[PHPスクリプト (イベントループ)]
│
├─ stream_select($read, $write, $except, $tv) ──┐
│ ▼
│ [OSカーネル (epoll / kqueue)]
│ – 複数FDの状態監視を効率化
│ – イベント発生までCPUを消費せず待機
│ │
[イベント発火:データ到着] ◄──────────────────────────────┘
│
▼
[PHPのコールバック/処理へ制御を戻す]
`stream_select()` を使うことで、PHPは数千ものソケット(ファイル記述子)を同時に監視し、「データが読める状態になったFD」だけをピンポイントで効率よく処理することが可能になる。これが、PHPにおける非同期I/Oおよびイベント駆動アーキテクチャの根幹である。
—
3. 【実務リファレンス】純粋なPHPストリームと非同期HTTPクライアントの構築
外部依存(Swooleなど)を入れず、PHP標準のストリームとノンブロッキング機構だけで、複数ドメインへの並行HTTPリクエストをさばく堅牢なコードを提示しよう。
コードレビューで「なぜこの設計が必要なのか」を問われたとき、メモリ管理とエラーハンドリングの観点から完璧に説明できるレベルに仕上げている。
/
class AsyncHttpClient
{
/ @var array
private array $sockets = [];
/ @var array
private array $buffers = [];
/ @var array
private array $responses = [];
/ @var array
private array $meta = [];
/
- リクエストの登録(まだ接続は行わない、または非同期接続を開始)
/
public function addRequest(string $host, int $port, string $path): int
{
$errno = 0;
$errstr = ”;
// ソケットを非同期/ストリームとしてオープン
// STREAM_CLIENT_ASYNC_CONNECT を指定することで、TCPの3ウェイハンドシェイクの完了を待たずに即座に制御が戻る
$fp = @stream_socket_client(
“tcp://{$host}:{$port}”,
$errno,
$errstr,
ini_get(“default_socket_timeout”),
STREAM_CLIENT_CONNECT | STREAM_CLIENT_ASYNC_CONNECT
);
if ($fp === false) {
throw new \RuntimeException(“ソケットの作成に失敗しました: {$errstr} ({$errno})”);
}
// 必須:ノンブロッキングモードへ強制移行
stream_set_blocking($fp, false);
$id = (int)$fp;
$this->sockets[$id] = $fp;
$this->meta[$id] = [‘host’ => $host, ‘port’ => $port, ‘path’ => $path];
// HTTPリクエストヘッダの構築
$this->buffers[$id] = “GET {$path} HTTP/1.1\r\n” .
“Host: {$host}\r\n” .
“Connection: close\r\n\r\n”;
$this->responses[$id] = ”;
return $id;
}
/
- イベントループの実行:すべてのリクエストが完了するまで epoll/select で効率的に待機・送受信を行う
/
public function execute(float $timeout = 5.0): array
{
$startTime = microtime(true);
while (!empty($this->sockets)) {
// タイムアウト計算
$elapsed = microtime(true) – $startTime;
$timeLeft = $timeout – $elapsed;
if ($timeLeft <= 0) {
// タイムアウト時のクリーンアップ
$this->closeAll();
throw new \RuntimeException(“非同期リクエストがタイムアウトしました。”);
}
$read = [];
$write = [];
$except = [];
// 状態に応じて監視セットを振り分ける
// – 接続確立中・送信待ちのソケットは $write セットへ
// – データ受信待ちのソケットは $read セットへ
foreach ($this->sockets as $id => $fp) {
$read[$id] = $fp;
// まだ送信バッファにデータがある場合は書き込み可能イベントを監視
if (isset($this->buffers[$id]) && strlen($this->buffers[$id]) > 0) {
$write[$id] = $fp;
}
}
$tv_sec = (int)$timeLeft;
$tv_usec = (int)(($timeLeft – $tv_sec) 1000000);
// OSカーネルにイベント通知を依頼(ここでCPUをブロックせず待機)
$numChanged = @stream_select($read, $write, $except, $tv_sec, $tv_usec);
if ($numChanged === false) {
// システムコールがシグナル等で中断された場合は継続
continue;
}
if ($numChanged === 0) {
continue; // タイムアウト期間内での変化なし
}
// 書き込み処理(送信)
foreach ($write as $id => $fp) {
if (!isset($this->sockets[$id])) continue;
$written = @fwrite($fp, $this->buffers[$id]);
if ($written === false) {
// 書き込みエラー時は即座に切断・破棄
$this->removeSocket($id);
continue;
}
// 送信済み分をバッファから削る
$this->buffers[$id] = substr($this->buffers[$id], $written);
// 送信完了したらバッファキーを削除し、読み込みに専念させる
if (strlen($this->buffers[$id]) === 0) {
unset($this->buffers[$id]);
}
}
// 読み込み処理(受信)
foreach ($read as $id => $fp) {
if (!isset($this->sockets[$id])) continue;
$data = @fread($fp, 8192);
// 接続が切断された、またはエラー
if ($data === ” || $data === false) {
$this->removeSocket($id);
continue;
}
$this->responses[$id] .= $data;
}
}
return $this->responses;
}
private function removeSocket(int $id): void
{
if (isset($this->sockets[$id])) {
@fclose($this->sockets[$id]);
unset($this->sockets[$id]);
unset($this->buffers[$id]);
unset($this->meta[$id]);
}
}
private function closeAll(): void
{
foreach ($this->sockets as $id => $fp) {
@fclose($fp);
}
$this->sockets = [];
$this->buffers = [];
$this->meta = [];
}
}
// ==========================================
// 実行例(プロダクションコードのテスト)
// ==========================================
/
try {
$client = new AsyncHttpClient();
// 複数の外部エンドポイントへ同時にリクエストを登録
$client->addRequest(‘api.github.com’, 443, ‘/users/nikic’); // PHPコア開発者
$client->addRequest(‘api.github.com’, 443, ‘/users/rasmusl’); // PHP創始者
// ※注意: 本格的なHTTPS通信にはTLSハンドシェイク(stream_socket_enable_crypto)が必要ですが、
// 概念実証のためHTTPプレーンテキストまたはテストサーバーを想定してください。
$results = $client->execute(3.0);
foreach ($results as $id => $response) {
echo “=== Response ID: {$id} ===\n”;
echo substr($response, 0, 200) . “…\n\n”;
}
} catch (\Throwable $e) {
echo “Error: ” . $e->getMessage() . “\n”;
}
/
—
4. チーフアーキテクトが警鐘を鳴らす:実務設計における3大アンチパターン
上記のコードや、フレームワーク(Swoole, ReactPHPなど)を用いた非同期開発において、現場のエンジニアが犯しがちな致命的ミスを指摘しておく。コードレビューでこれらを見つけたら、即座に差し戻しを命じてほしい。
1. 「ノンブロッキング空間」での同期ブロッキング関数の使用
非同期イベントループの内部(コールバック内やループの最中)で、`file_get_contents()`、PDOを通じた重いDBクエリ、`sleep()`、さらには同期型の外部APIクライアントを呼び出す行為は、イベントループ全体を凍結(ブロック)させるテロ行為である。
1つのリクエストがブロックした瞬間、同じイベントループ上で動いている他のすべての並行処理が完全に停止する。I/Oはすべて非同期版(またはFiber対応ドライバー)に置き換えなければならない。
2. メモリリークとリソースの閉じ忘れ(FD枯渇)
ノンブロッキング処理では、ネットワークの切断検知が遅れたり、例外ハンドリングが漏れたりすると、OSのファイル記述子(FD)がオープンされたまま残り続ける。
Linux環境ではプロセスごとのFD上限(`ulimit -n`)が存在するため、これが枯渇すると `Too many open files` エラーが発生し、Webサーバー全体が新規リクエストを受け付けなくなる。必ず `try-finally` 構文やデストラクタで確実な `fclose()` を保証すること。
3. CPUバウンドな処理の混入
ノンブロッキングI/Oや `epoll` はあくまで「I/O待ち」を効率化する仕組みであり、CPUの演算処理を速くする魔法ではない。
イベントループのなかで巨大なJSONのパース、複雑な暗号化計算、数万件の配列ループなどを同期実行すると、結局CPUコアが占有され、後続のI/Oイベントの処理が遅延する。CPUバウンドな処理は、Workerプロセスを分けるか、pcntlによるプロセスフォーク、あるいは外部キュー(RabbitMQ等)へオフロードする設計が不可欠である。
—
おわりに:Zend VMの進化とこれからのPHP
PHPは単なる「HTMLを生成するお手軽なスクリプト言語」ではない。Zend Engineの内部構造、ストリームの抽象化レイヤ、そしてOSカーネルの `epoll` / `kqueue` との連携を正しく理解すれば、極めてスケーラブルで高効率な非同期Webアプリケーションのバックエンドを構築できる。
フレームワークが隠蔽してくれている抽象化の裏側で、CPUキャッシュ、メモリ空間、そしてファイル記述子がどう動いているのか。その「低レイヤの感触」を手に持ったエンジニアだけが、トラフィックの増大や複雑なアーキテクチャの要求に怯えることのない、真に堅牢なシステムを設計できるのだ。
コードレビューの基準を上げろ。PHPの限界を決めているのは言語自身ではなく、いつだってコードを書く人間の知見の深さなのだから。