【入門編】FiberとPHPの組み込み関数:`stream_select`や`socket_select`との連携における非同期I/Oの限界 – PHPコア・内部エンジンと高速化・並行処理の極意解析バイブル

こんにちは。Node.jsやGo、あるいはRustあたりの非同期並行処理のモデルに慣れ親しんだ優秀なエンジニアほど、PHPでコードを書くときに「えっ、ここでスレッドやイベントループがブロックされるの?」という壁にぶつかりがちですよね。

「PHPにもFiberが入ったんだから、Node.jsの`async/await`みたいにサクサク非同期I/Oが書けるはずだ!」と思って実装を始めてみたものの、結局データベースへの問い合わせやソケット通信でプロセスがフリーズしてしまう……。そんなもどかしい思いをしたことはありませんか?

今回は、PHP 8.1で導入されたFiber(ファイバー)と、古くからPHPのI/Oを支えてきた`stream_select`や`socket_select`等の組み込み関数が、Zendエンジン内部でどうぶつかり合っているのか、その限界と、それを乗り越えて真の非同期イベントループを構築するための極意を、低レイヤの視点から紐解いていきます。

ここを綺麗に理解できると、「PHPは同期的な言語だ」という古い固定観念が覆り、裏側のエンジンがどう動いているのかが手に取るように見えてきますよ。

—

1. Fiberの本質:Zend VMのスタック管理と「協調的」マルチタスク

まず、PHPのFiberが一体何者なのかを、Zendエンジンのメモリ空間の観点から正確に把握しておきましょう。

よくある誤解として、「Fiberを使うとマルチスレッドのようにコードが並行実行される」というものがあります。しかし、PHPは基本的に単一スレッド(Shared-Nothing Architecture)で動いており、OSのスレッドを勝手に増やすわけではありません。

Fiberの本質は、「Zend VMの実行コンテキスト(コールスタックとローカル変数等のシンボルテーブル)を切り替えるためのプリミティブ」です。

[通常の関数呼び出し]
main() -> fetch_data() -> block (ここで止まる)

[Fiberによる協調的実行]
Fiber 1 (メインロジック) –suspends–> Fiber 2 (イベント待機)
<--resumes-- 従来のPHPの関数は、呼び出されるとスタックフレームを積み、returnするまでその場を離れませんでした。しかし、Fiberを使えば、コードの任意の場所で `Fiber::suspend()` を呼び出すことで、「今の実行状態をヒープ上に退避させ、処理の主導権を親(あるいはイベントループ)に返す」ことができます。そして、準備が整ったら `Fiber::resume()` で、あたかも何事もなかったかのようにその行から再開できるのです。

これが「協調的(Cooperative)マルチタスク」と呼ばれる所以です。OSが強制的にコンテキストスイッチするプリエンプティブな仕組みとは異なり、「プログラマ(またはライブラリ)が意図したタイミングでしか処理が切り替わらない」という点が極めて重要になります。

—

2. 伝統の障壁:`stream_select` とブロッキングI/Oの罠

ここで問題になってきます。Fiberでコードの実行をサスペンド・レジュームできるようになったなら、ネットワークからデータを読み込む際も、データが来るまではFiberを眠らせて、別のFiberを動かしたいですよね。

そこでPHPエンジニアが真っ先に思いつくのが、ファイル記述子(ストリームやソケット)の多重化を行う組み込み関数、`stream_select()` や `socket_select()` です。

これらは、C言語の `select(2)` システムコールをラップしたもので、複数のストリームを監視し、「読み込み可能」「書き込み可能」になるまでプロセス(またはスレッド)をブロックするためのものです。

何が「限界」なのか?

ここで、Zendエンジンの挙動とOSのシステムコールの関係を考えてみましょう。

`stream_select()` にストリームの配列を渡すと、PHPのインタプリタ自体がOSカーネルからの応答を待ち構えて完全にブロック(睡眠状態)に入ります。つまり、そのプロセス上で動いているすべてのFiberが強制的に足止めを食らうのです。

// ⚠️ 典型的なアンチパターン
// stream_selectはプロセス全体をブロックするため、他のFiberもここで止まります
$read = [$socket1, $socket2];
$write = null;
$except = null;

// カーネルが準備完了を返すまで、Zend VMの実行が完全に停止する
stream_select($read, $write, $except, null);

「あれ? Fiberを使っているのに、なぜ `stream_select` でプロセス全体が止まってしまうの?」と思われるかもしれませんが、理由は単純です。`stream_select` は Fiber の存在を全く知らないからです。

FiberはあくまでPHPのランタイム(Zend VM)レベルの制御フローであり、C言語レベルで実装された古いC拡張や組み込みのブロッキング関数は、Fiberのサスペンド機構と連携してくれません。この「協調性の断絶」こそが、PHPで非同期I/Oを実装する際の最大の壁なのです。

—

3. 解決へのアプローチ:イベントループとFiberの統合ラッパー

では、この限界をどう突破すればよいのでしょうか?
答えは、「ノンブロッキングモードに設定したソケット」と「`stream_select`(あるいは `uv` などの拡張)」を組み合わせ、Fiberのサスペンド機構と巧みにブリッジするイベントループ層を自作(あるいは構築)することです。

ここでは、概念を理解するために、`stream_select` を利用しながらも、読み込み待ちの間だけ特定の Fiber をサスペンドさせる簡易的な非同期HTTP/ソケットクライアントのラッパー構造を見てみましょう。

実装イメージ:ノンブロッキングI/OとFiberの調停

class AsyncSocketManager
{
private array $readQueue = []; // 読み込み待ちのFiberとストリームのマップ
private array $sockets = [];

public function addStream($stream, Fiber $fiber): void
{
// 1. ストリームを明示的にノンブロッキングモードに設定する
stream_set_blocking($stream, false);

$resourceId = (int)$stream;
$this->sockets[$resourceId] = $stream;
$this->readQueue[$resourceId] = $fiber;
}

public function runEventLoop(): void
{
// 登録されたストリームがなくなるまでイベントループを回す
while (!empty($this->sockets)) {
$read = $this->sockets;
$write = null;
$except = null;

// タイムアウトを極小(例: 0.1秒 / 100000マイクロ秒)に設定し、
// イベントループが完全にフリーズするのを防ぐ
$numChanged = stream_select($read, $write, $except, 0, 100000);

if ($numChanged === false) {
break;
}

if ($numChanged > 0) {
foreach ($read as $stream) {
$resourceId = (int)$stream;

if (isset($this->readQueue[$resourceId])) {
$fiber = $this->readQueue[$resourceId];
unset($this->readQueue[$resourceId], $this->sockets[$resourceId]);

// 2. データ準備完了のシグナルを受け取り、Fiberを再開させる!
if ($fiber->isSuspended()) {
$fiber->resume($stream);
}
}
}
}

// ここで他のタイマー処理やタスクを実行する余白が生まれる
}
}
}

このラッパーが何をしているのか?

1. ノンブロッキング化: `stream_set_blocking($stream, false)` により、データが来ていない状態で `fread()` を呼んでも、プロセスがブロックされずに即座に空文字を返すようになります。
2. タイムアウトの制御: `stream_select` のタイムアウトを短く(例: 100ms)設定することで、常にイベントループがCPU権を握り続け、他の処理やFiberのスケジューリングを阻害しないようにします。
3. Fiberのサスペンドとレジューム: ユーザーコード側では、「データを待つときは Fiber をサスペンドし、イベントループ側で `stream_select` が検知したら Fiber を `resume` する」というサイクルを作ります。

実際のアプリケーションコード(ユーザーランド)からは、次のように美しく非同期っぽく扱えるようになります。

// 使用例のイメージ
$manager = new AsyncSocketManager();

$fiber = new Fiber(function () use ($socket, $manager) {
echo “データを要求しました。レスポンスを待ちます…\n”;

// ここで自作の非同期待機関数を呼び出す
// (内部で Fiber::suspend() を実行し、$manager に自分を登録する)
$data = awaitRead($socket, $manager);

echo “受信データ: ” . $data . “\n”;
});

$fiber->start();

// イベントループを駆動
// $manager->runEventLoop();

—

4. プロフェッショナルへの道:`stream_select` の限界と `ext-uv` / ReactPHP の世界

ここまで `stream_select` をベースにした解説をしてきましたが、シニアアーキテクトとして正直な限界もお伝えしておかなければなりません。

実は、PHP標準の `stream_select()` には、C言語の `select(2)` が持つ根本的なスケーラビリティの限界がそのまま引き継がれています。

  • ファイル記述子の数制限: Windowsを除き、通常 `FD_SETSIZE`(通常1024)までのファイル記述子しか監視できません。数千、数万の同時コネクションを捌くモダンなWebアーキテクチャでは破綻します。
  • O(N) の計算量: 監視対象のソケットが増えるほど、カーネル内およびPHP側の配列走査コスト(O(N))が線形に増加します。

そのため、真の意味で高パフォーマンスな非同期I/OとFiberを統合したい場合は、`stream_select` のような枯れた関数を直接叩くのではなく、libuv(Node.jsの裏側で動いているCの非同期I/Oライブラリ)をPHPから叩けるようにする拡張モジュール `ext-uv` や、それをベースにした ReactPHP / Amp (Amphp v3) などのモダンな非同期フレームワークを選択するのが実務上の正解となります。

これらモダンなフレームワークの内部では、`epoll` (Linux) や `kqueue` (macOS) といったモダンなOSのイベント通知機構とFiberが見事に統合されており、開発者が `stream_select` の細かい挙動や限界を意識せずとも、美しく効率的な非同期コードが書けるようになっています。

—

まとめ

今回は、PHPのFiberと組み込みのI/O関数 (`stream_select`) の関係、そしてその裏側にある低レイヤの制約について解説しました。

  • Fiberの本質は、Zend VMのスタックフレームを切り替える協調的マルチタスク機構である。
  • `stream_select` などの古いブロッキング/マルチプレクシング関数は、プロセス全体を止めてしまうため、Fiberと連携させるには「ノンブロッキング化 + イベントループによる調停」が必要不可欠。
  • 大規模なシステムや高スループットを求める現場では、`stream_select` の限界を理解した上で、`ext-uv` や Amp v3 などのモダンな非同期エコシステムに委譲するのがアーキテクトとしての賢明な判断。

「PHPは遅い」「非同期が苦手」と言われていた時代は終わりました。Zendエンジンのメモリモデルと、OSのI/Oの仕組みを正しく把握すれば、PHPは驚くほどエレガントでパワフルなバックエンド言語に化けます。

ぜひ、日々の設計やコードリーディングの際に、この「裏側のエンジンがどう動いているか」という視点を思い出してみてください。あなたの書くコードの質が、確実に一段階上のステージへと引き上げられるはずです。

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