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。需要这些能力的话,现在还得自己拼超时参数或借助其它扩展。