编程 PHP 8.6 的 Io\Poll:900 连接下吞吐 3.88 倍,不用再手搓三个引用数组

2026-10-22 21:03:21

PHP 8.6 的 Io\Poll:900 连接下吞吐 3.88 倍,不用再手搓三个引用数组

PHP 8.6 加入了新的 Polling API(RFC),它是 PHP Foundation「Stream Improvement」项目的一部分。在这之前,轮询连接基本只有 stream_select() 一条路,而它直接映射到 POSIX select(2)。

参考资料:

  • Tideways 原文:
  • php-src commit Introduce Io\Poll userspace API:
  • php.cvs PHP-8.6: Fix StreamPollHandle changing unread_bytes of a TLS stream when added:

stream_select 的两个硬伤

第一个是状态问题:stream_select() 不保持任何状态,所以每次调用时,操作系统都要把整个连接列表从头检查一遍,才能挑出有数据的少数几个。

第二个是描述符上限:POSIX 默认把 select(2)(以及 stream_select())能检查的最高文件描述符编号限制在 1024。

而 epoll 这类可扩展轮询 API 恰恰依赖调用之间保留状态,stream_select() 与它们在结构上就不兼容。

Context、Event、StreamPollHandle 与元数据

新 API 里,每个注册进去的 Polling Handle 都能关联任意元数据。有活动上报时,返回的 Watcher 可以把这份元数据取回来,应用状态随手可用,不必再手工维护读、写、异常那三个引用数组。

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

$poll = new Context(); // Backend 默认 Backend::Auto,Linux 上选 Backend::Epoll
$poll->add(new StreamPollHandle($listener->stream), [Event::Read], $listener);

$timeout = Duration::fromMilliseconds(200);

for (;;) {
$ready = $poll->wait($timeout);
foreach ($ready as $watcher) {
$subject = $watcher->getData();
// ...
}
}

第三方基准数据

Tideways 用 RFC 862 的 echo server 做了测试,客户端固定为同一个 Node.js 实现,场景是 0–5000 条空闲连接加 8 条活跃连接:

  • 900 条已建立连接时,Polling API 实现的吞吐是旧实现的 3.88 倍;
  • 同场景下 p99 延迟只有旧实现的 34%;
  • stream_select() 在约 1000 条打开连接时失败,Polling API 撑住 5000 条空闲连接且性能稳定;
  • 服务端代码量少约 90 行。

这些数字来自 Tideways 的基准,属第三方实测,不是 PHP 官方公布的性能结论。

最小可运行示例

把上面的主循环补成一个能跑的 echo server:

add(new StreamPollHandle($server), [Event::Read], $server);

for (;;) {
foreach ($poll->wait(Duration::fromMilliseconds(200)) as $watcher) {
$stream = $watcher->getData();

if ($stream === $server) {
$client = stream_socket_accept($server, 0);
if ($client !== false) {
stream_set_blocking($client, false);
$poll->add(new StreamPollHandle($client), [Event::Read], $client);
}
continue;
}

$data = fread($stream, 8192);
if ($data === '' || $data === false) {
fclose($stream); // 真实代码还需把对应 handle 从 Context 移除
continue;
}
fwrite($stream, $data);
}
}

注册 Write 的实际含义

这里有个反直觉的点:订阅 Event::Write 的意思是「能写的时候告诉我」,不是「这个 socket 有变化时告诉我」。socket 的发送缓冲只要有空间,当下就是可写的,所以一对刚建好的 socket pair 以 [Event::Read, Event::Write] 注册后会立刻就绪。刚接触这套 API 时,这一条最容易让人误判成异常触发。

TLS 流上的 unread_bytes

StreamPollHandle 早期是通过 select 那个 cast 去取描述符的。在 TLS 流上,这个 cast 带有读语义:它会把 OpenSSL 手里已解密的数据搬进流缓冲,好让 stream_select() 能报告出来。结果是往 Io\Poll\Context 里加一个 watcher 这个动作本身就从流里读了数据,把用 stream_set_read_buffer() 设成无缓冲的流的缓冲区填满,还改变了 unread_bytes。

8.6 增加了新的 cast PHP_STREAM_AS_FD_FOR_POLL,只返回描述符,不做别的。socket、TLS、plain 和 pgsql 几类 wrapper 都实现了它。加 handle 属于注册动作,必须让流保持原样。

什么时候不需要它,以及 8.6 还缺什么

单条流带超时读取,用原来的方式就够了,没必要引入 Context 和事件循环。这套 API 的价值在连接数量上来之后才体现。

8.6 初始版本里没有定时器、信号和子进程,这些在面向 PHP 8.7 的「Polling API additions」RFC 中补齐:TimerHandle、SignalHandle、ProcessHandle、NotifyHandle、StreamPollWeakHandle、ext/sockets 的 Socket 轮询,以及 Event::Priority。需要这些能力的话,现在还得自己拼超时参数或借助其它扩展。

复制全文 生成海报 PHP 并发 IO多路复用 性能

推荐文章

程序员茄子在线接单