编程 PHP 8.6 的 Io\Poll:替掉 stream_select 的 1024 fd 上限与全量扫描

2026-11-03 21:02:16

PHP 8.6 的 Io\Poll:替代 stream_select 的 1024 fd 上限与全量扫描

PHP 8.6 增加了 Io\Poll 命名空间下的轮询 API(RFC 以 33:1 通过)。它的目标是替代 stream_select() 面临的两个长期问题:

  • 文件描述符上限:stream_select() 受 FD_SETSIZE 限制,默认是 1024,超出后直接失效。
  • 每次调用都会全量扫描:stream_select() 每次调用都会以 O(n) 的复杂度扫描所有 fd,连接数一多,代价会随规模线性上涨。

这套 API 的定位是低层 I/O 多路复用,不直接提供事件循环或定时器,而是它们的基础设施。PHP 的 Stream API 历史上一直被认为比较混乱,Io\Poll 是后续 async 支持的第一步。

API 组成

名称作用
Io\Poll\Context轮询上下文,watcher 的注册、变更与等待都通过它进行
Io\Poll\Watcher单个被监视对象的句柄,绑定具体的关注事件
Io\Poll\Backend后端抽象,决定底层用哪套系统调用
Io\Poll\Event枚举,表示 watcher 关注/就绪的事件类型
Io\Poll\Handle句柄接口,StreamPollHandle 是它的一个实现
StreamPollHandle把 PHP stream 包装成可被 Poll 监视的句柄

异常类型划分得比较细,基本按失败阶段区分:

  • 基类:Io\IoException
  • Io\Poll\PollException
  • FailedPollOperationException
  • FailedContextInitializationException
  • FailedHandleAddException
  • FailedWatcherModificationException
  • FailedPollWaitException
  • BackendUnavailableException
  • InactiveWatcherException
  • HandleAlreadyWatchedException
  • InvalidHandleException

后端:Linux 走 epoll,BSD / macOS 走 kqueue

后端由运行平台决定:Linux 上用 epoll,BSD 和 macOS 上用 kqueue。这两者都不受 FD_SETSIZE 约束,也避免了每次调用重新扫描整个 fd 集合。

如果平台没有可用后端,会抛出 BackendUnavailableException——这类失败发生在 Context 初始化阶段,最好在启动时就让它暴露出来,而不是等到第一个请求进来才发现。

一段代码骨架

<?php
declare(strict_types=1);

use Io\Poll\Context;
use Io\Poll\Event;
use Io\Poll\StreamPollHandle;

// 1. 建立 Context,后端在这一步选定:Linux -> epoll,BSD/macOS -> kqueue
$ctx = new Context();

// 2. stream_select() 能直接吃流资源,Poll 必须先包装成 StreamPollHandle
$server = stream_socket_server('tcp://127.0.0.1:8080', $errno, $errstr);
$serverHandle = new StreamPollHandle($server);

$pipe = popen('some-producer', 'r');
$pipeHandle = new StreamPollHandle($pipe);

// 3. 注册 watcher,声明各自关心的事件
$wServer = $ctx->watch($serverHandle, Event::Read);
$wPipe   = $ctx->watch($pipeHandle, Event::Read);

// 4. 等待事件并分发
while (true) {
    $events = $ctx->poll($timeout);   // 超时语义与 stream_select 不同,见下

    foreach ($events as $event) {
        $watcher = $event->watcher();

        if ($watcher === $wServer) {
            $client = stream_socket_accept($serverHandle->getStream());
            // ...
        } elseif ($watcher === $wPipe) {
            // 读管道,读空后按需改关注事件或摘除 watcher
        }
    }
}

// 5. watcher 的增删改都回到 Context 上做
$ctx->unwatch($wPipe);

方法签名以 8.6 最终文档为准,上面的结构用于说明使用顺序:建 Context → 包装句柄 → 注册 watcher → 等待 → 分发 → 在 Context 上变更。

从 stream_select 迁移时的注意点

流资源不能直接传。 stream_select() 接受的是 stream 资源数组;Io\Poll 要求先包成 StreamPollHandle,watcher 绑定的是句柄而不是资源本身。

超时参数需要重新核对。 stream_select() 用的是 $seconds 加 $microseconds 两个参数,poll() 不是这个形态。迁移时不要机械照搬原来的数值,尤其是那些用 0 表示非阻塞轮询、用 null 表示永久阻塞的调用点。

watcher 的变更必须通过 Context。 修改关注事件、摘除 watcher 都得在 Context 上完成,直接操作 watcher 对象不会生效。对已经失效的 watcher 做修改会得到 InactiveWatcherException,重复监听同一个句柄则是 HandleAlreadyWatchedException。

后端不可用要在早期暴露。 BackendUnavailableException 属于环境问题,不是运行时偶发错误,适合放在启动检查里。

顺带修掉的另一处

PHP 8.6 同时让 filtered stream 可以 cast 成文件描述符,交给 stream_select() 使用——这条给仍在旧路径上的代码留了余地。不过 stream_select() 的 FD_SETSIZE 上限和 O(n) 扫描并没有因此改变,该换的还是要换。

复制全文 生成海报 PHP 异步 网络编程

推荐文章

程序员茄子在线接单