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\PollExceptionFailedPollOperationExceptionFailedContextInitializationExceptionFailedHandleAddExceptionFailedWatcherModificationExceptionFailedPollWaitExceptionBackendUnavailableExceptionInactiveWatcherExceptionHandleAlreadyWatchedExceptionInvalidHandleException
后端: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) 扫描并没有因此改变,该换的还是要换。