【実務・中級編】PHPの『非同期I/O』におけるストリームラッパーの内部挙動:epoll/kqueueとPHPのイベントループ連携 – PHPコア・内部エンジンと高速化・並行処理の極意解析バイブル

はじめに: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リクエストをさばく堅牢なコードを提示しよう。

コードレビューで「なぜこの設計が必要なのか」を問われたとき、メモリ管理とエラーハンドリングの観点から完璧に説明できるレベルに仕上げている。

  • 簡易非同期HTTPクライアント(ストリーム + ノンブロッキング + stream_select)
  • 【アーキテクトの解説】
  • 本番環境のコードレビューにおいて、curl_multi_ の代わりに独自ストリーム制御を
  • 採用する場合、ファイル記述子の制限(ulimit -n)とメモリリークの対策が必須となる。
  • /
    class AsyncHttpClient
    {
    / @var array 監視対象のストリームリソース(キーはストリームのID) /
    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の限界を決めているのは言語自身ではなく、いつだってコードを書く人間の知見の深さなのだから。

    タイトルとURLをコピーしました