游乐游手机版
首页/AI教程/文章详情

PHP 8.6 Fiber与Polling API异步编程初探

时间:2026-08-15 14:03
Fiber 与 PHP 8 6 Polling API 异步编程初探 几周前的一篇文章曾介绍过 Polling API RFC,并将其视为一个被低估的提案,核心原因在于:不少解读往往忽略了它真正的动机——重点其实是内部的 php_poll h API,而不是用户态暴露出的 IoPoll 类。到现在,

Fiber 与 PHP 8.6 Polling API 异步编程初探

几周前的一篇文章曾介绍过 Polling API RFC,并将其视为一个被低估的提案,核心原因在于:不少解读往往忽略了它真正的动机——重点其实是内部的 php_poll.h API,而不是用户态暴露出的 IoPoll 类。到现在,这个判断依然成立。不过随后也收到了很多类似“好吧,但它到底能拿来做什么?”的反馈。这是个非常合理的问题,也正是本文希望解答的重点。

Fiber 与 PHP 8.6  Polling API 异步初探

现在讨论这个话题,时机也非常合适。该 RFC 已以 33 票赞成、1 票反对、4 票弃权顺利通过,并在 6 月 3 日结束投票,实现代码也已经合入 master 分支。Alpha 1 预计于 7 月 2 日发布,功能冻结和 Beta 1 则安排在 8 月中旬,GA 暂定 11 月 19 日。这意味着,PHP 8.6 Polling API 已经不再只是 RFC 文档里的设想,而是可以直接从 master 分支编译、立即体验和测试的真实能力,甚至在正式 alpha 标签之前就能提前上手。接下来我们就来实际看看它能做什么。

与此同时,PHP 社区目前正围绕一个非常现实的话题激烈讨论:在生产环境中,到底该选择哪种异步方案?是基于用户态事件循环的 Fiber、ReactPHP、Swoole,还是运行在 Revolt 之上的 Amp v3?但截至目前,很少有人真正把这场讨论与 Polling API 带来的变化明确联系起来。本文要做的,正是把这层关系讲清楚,而且依靠的是可运行的真实代码,而不是模糊印象,更不是凭直觉做判断。

Fiber 是并发模型,Polling API 是那块缺失的底层原语

这两者经常被混为一谈,因此在动手构建任何示例之前,最好先把它们彻底区分开来。

Fiber 是 PHP 的协作式并发原语,自 PHP 8.1 起进入核心。它提供的是一种可以主动暂停的函数执行能力:通过 Fiber::suspend() 挂起,之后再由 $fiber->resume() 恢复,暂停期间调用栈和局部变量状态都会被完整保留。这就是 Fiber 的本质。它并不了解 socket、定时器或 I/O 事件,也不会主动管理异步任务,它只是一个控制流原语,仅此而已。

真正决定“何时恢复一个挂起 Fiber”的,是事件循环。在 PHP 过去的实现方式里,这通常要么依赖底层的 stream_select(),要么借助 PECL 扩展(例如 ext-uv 或 ext-event)来获得原生的 epoll 或 kqueue 能力。ReactPHP 一口气提供了四种独立的循环实现——StreamSelectLoop、ExtUvLoop、ExtEventLoop、ExtEvLoop——恰恰就是因为过去一直缺少一个可靠、统一的原生轮询原语。AMPHP 则通过 Revolt 构建了对应的一整套方案。

而 Polling API,正是长期缺失的这一层原语。它不会取代 Fiber,本身也不是事件循环。它处于事件循环之下,是 PHP 过去一直没有原生提供的关键能力:一种高效向操作系统询问“这些文件描述符中哪些已经就绪”的方式,而不必为了兼容不同平台和场景维护四套后端实现。

把两者结合起来看,Fiber 提供可挂起、可恢复的执行单元,Polling API 提供高效判断何时恢复这些单元的机制。事情的本质就是这样。其余能力——例如定时器、取消机制、背压控制——都属于用户态库或框架基于这两层继续构建出来的功能。

构建一个尽可能小的异步调度器

想真正理解 Amp v3 与 ReactPHP 内部在做什么,最直接的方法其实是自己实现一个玩具版本。接下来就来完成这个实验:写一个并发运行多个 Fiber 的极简调度器,每个 Fiber 通过原始非阻塞 socket 抓取一个 URL,并由同一个 IoPollContext 统一负责恢复执行。

 */private array $fibers = [];public function __construct(){ $this->poll = new Context();}public function spawn(callable $task): void{ $fiber = new Fiber($task);$fiber->start();if ($fiber->isTerminated()) { return;}$this->fibers[spl_object_id($fiber)] = $fiber;}public function run(): void{ while ($this->fibers !== []) { foreach ($this->poll->wait(timeoutSeconds: 1) as $watcher) { $fiber = $watcher->getData()['fiber'];$fiber->resume($watcher);if ($fiber->isTerminated()) { unset($this->fibers[spl_object_id($fiber)]);}}}}public function awaitReadable($stream): void{ $fiber = Fiber::getCurrent();$handle = new StreamPollHandle($stream);$watcher = $this->poll->add($handle, [Event::Read], ['fiber' => $fiber]);Fiber::suspend();$watcher->remove();}public function awaitWritable($stream): void{ $fiber = Fiber::getCurrent();$handle = new StreamPollHandle($stream);$watcher = $this->poll->add($handle, [Event::Write], ['fiber' => $fiber]);Fiber::suspend();$watcher->remove();}}

实际上,这已经覆盖了大部分关键逻辑。spawn() 会启动一个 Fiber,而它会一直执行,直到遇到 Fiber::suspend()。run() 则对 poll 上下文调用 wait();这个调用会阻塞,直到某个被监听的流真正变为可读,然后恢复恰好等待这个事件的那个 Fiber。这里没有紧密轮询循环,也不需要在每个 tick 中扫描所有流。操作系统会在对象就绪的第一时间发出通知,控制权随即直接回到真正关心该事件的 Fiber 手中。

接下来是真正的任务:基于原始 socket 实现非阻塞 HTTP 抓取。这里还需要修正初稿中的一个细节问题:对非阻塞流写入时,可能发生短写,因此请求数据必须循环写入;每当内核发送缓冲区出现回压,就等待流变为可写,而不能假设一次 fwrite() 就能把整个请求完整发送出去:

awaitWritable($stream);continue;}$data = substr($data, $written);}}function fetch(MiniScheduler $scheduler, string $host, string $path): string{ $stream = stream_socket_client("tcp://{$host}:80", $errno, $errstr, 30);if ($stream === false) { throw new RuntimeException("Connect to {$host} failed: {$errstr}");}stream_set_blocking($stream, false);writeAll($scheduler, $stream, "GET {$path} HTTP/1.1rHost: {$host}rConnection: closerr");$response = '';while (!feof($stream)) { $scheduler->awaitReadable($stream);$response .= fread($stream, 8192);}fclose($stream);return $response;}

关于连接终止:这个循环依赖 feof() 在读到流末尾后切换为 true,这与 RFC 自带的 TCP 客户端示例采用的是同一种模式。这里之所以可行,有一个值得明确说明的原因:awaitReadable() 虽然只请求了 Event::Read,但 Event::Error 和 Event::HangUp 会被每个后端自动监控,无论实际请求的事件是什么,因此当服务器关闭连接时,wait() 仍然会返回对应的 watcher。代码在调用 fread() 之前并没有根据 hasTriggered() 做额外分支,而是被唤醒后直接无条件读取;而对于已关闭的非阻塞流,fread() 会返回空字符串并设置 EOF 标志,既不会阻塞,也不会报错。正是这个行为,让最后一轮循环可以干净地退出,而不会卡在最后一个文件描述符上。如果要写得更严格一些,当然也可以在读取前显式检查 hasTriggered(Event::Read),并对 Event::HangUp 做单独处理;只是那样会让核心示例被很多并不会改变普通 HTTP GET 结果的防御性分支淹没。

现在把五个这样的任务接入调度器,模拟并发运行:

spawn(function () use ($scheduler, $host) { $body = fetch($scheduler, $host, '/');echo "{$host}: " . strlen($body) . " bytes";});}$scheduler->run();

三个 Fiber,各自阻塞在自己的 socket 上,全部由一个 IoPollContext 独立恢复。从整体结构来看,这本质上就是 Revolt 的 driver 在做的事情,也是 ReactPHP 事件循环在做的事情。这里只不过把它写成了一个依然可运行、而且尽可能精简的最小版本。

刻意没有展示的部分

这个调度器没有定时器、没有取消令牌、没有从 Fiber 回传给调用方的错误传播机制、没有防止某个永不挂起的 Fiber 拖住其他所有任务的保护措施,也没有处理 DNS——除了 stream_socket_client() 默认帮你做掉的那部分。这不是遗漏,而恰恰是重点所在。

Amp v3 与 ReactPHP 之所以有存在价值,正是因为要把这套机制做成真正正确、可维护、可用于生产的版本,难度非常高;要让数百个并发 Fiber 的取消、背压、错误传递都处理妥当,绝不是一个周末就能完成的小项目。不要把这个玩具调度器带到生产环境里去。真正应该带走的,是它帮助你建立起来的理解,然后把这种理解应用到你最终选择的那个异步库上。

坦诚地看待基准测试

在给出测试数据之前,先把限制条件说清楚。写下本文时,PHP 8.6 仍然处于 pre-alpha 阶段,需要从 master 分支自行编译,并不是随手 apt install 就能获取的发行版构建。没有稳定版本,也没有正式软件包,IoPoll 的实现细节在 GA 之前仍然可能调整。因此,下面的测试结果更适合作为趋势性参考,而不是生产环境级别的 SLA 依据。

上面的三主机抓取示例,分别以四种方式各跑一遍:顺序阻塞的 file_get_contents() 调用、基于 8.6 alpha 构建的上述迷你调度器、PHP 8.4 上的 ReactPHP StreamSelectLoop,以及运行在 Revolt 之上的 Amp v3。同样的三个主机、同样的简单 GET 请求,每种方式执行十轮,并取中位数。

顺序阻塞方式的耗时,大致等于三次网络往返时间之和,这并不令人意外,因为每个请求都必须等待前一个完成后才能开始。迷你调度器与 ReactPHP 的 StreamSelectLoop 结果彼此接近,二者都大致受限于最慢的单个请求,而不是所有请求耗时的总和,这正是并发 I/O 设计想要达成的效果。运行在 Revolt 上的 Amp v3 则比两者略微领先一些,这同样符合预期——毕竟 Revolt 背后经过了多年的打磨和性能调优,而这里展示的调度器不过只有几十行代码。

真正有意思的地方,不是总耗时本身,而是 CPU 行为。基于 stream_select() 的事件循环,随着被监听流数量不断增长,会在用户态不断重扫文件描述符集合,从而消耗明显更多的开销。而 IoPollContext 版本则表现得更加平稳。无论是三条流还是三十条流,每次 wait() 调用的成本都大致接近,因为 epoll 不会重新扫描整个集合,它只告诉你哪些描述符已经就绪。这才是 Polling API 和原生异步 I/O 真正有价值的回报。只是如果没有人在高并发场景——数百甚至上千个连接,而不是仅仅三条流——下做基准测试,这种优势往往不会特别直观地显现出来。

当然,这个对比里也存在一个必须坦诚面对的缺口:ReactPHP 与 Amp v3 运行在 PHP 8.4 上,而迷你调度器运行在 PHP 8.6 pre-alpha 上,因此测试中变化的不只是事件循环实现,还有 PHP 版本本身。只有在同一个 PHP 8.6 构建中,让 ReactPHP 与 Amp v3 都切到同样基于 Polling API 的后端,才能更干净地剥离出循环自身带来的差异。目前还无法做到这一点,因为这两个项目都尚未正式交付 IoPoll driver,而这恰恰也是整篇文章讨论的核心。也正因如此,CPU 行为更值得视作可靠信号,因为它体现的是架构层面的变化,不依赖具体跑的是哪个 PHP 构建;至于墙钟时间,更适合作为大致合理性的校验,而不应被当成最终定论。

这对 Fiber、ReactPHP、Swoole 与 Amp v3 的选择意味着什么

这正是前文一直要串起来讨论的争论点,下面给出更明确的结论。

Swoole 其实不太应该被直接放进这组横向对比里。很多讨论之所以越聊越偏,恰恰就始于把它硬塞成四个同级选项之一。原因很简单:Swoole 本质上是一套不同的 PHP 运行时。它会接管应用原本的启动方式,以相对透明的方式改写核心函数,让这些函数变成非阻塞调用,同时还提供面向生产环境的 HTTP 服务器与内置 worker 进程。换句话说,这并不是在现有应用里“加一个 Swoole”,而是围绕 Swoole 去构建整个应用。对于合适的工作负载,它的表现确实非常强,尤其是在每一毫秒都要极致优化、并且团队有能力通过 Docker 镜像或 CI/CD 流程稳定管理编译扩展的前提下,C 层协程切换相较于用户态 Fiber,确实存在真实而明确的优势。至于 PHP 8.6 Polling API,它不会改变这套权衡中的任何核心要素,因为 Swoole 从一开始就不受 Polling API 试图解决的那个限制所困扰。

真正会被 Polling API 深刻影响的,是 ReactPHP 与 Amp v3,而且两者受影响的方向是一致的。它们存在的目标,都是在 Fiber 之上提供事件循环能力,同时不要求你为了异步编程去重写整个应用运行时。多年来,这两个生态都不得不维护多套后端实现——ReactPHP 有 StreamSelectLoop、ExtUvLoop、ExtEventLoop、ExtEvLoop,Amp 也有同等复杂度的底层铺设——本质上只是为了掩盖一个现实:PHP 核心长期没有为它们提供可靠的原生轮询原语。现在,Polling API 终于补上了这一层,它直接削弱了这些平行实现继续存在的理由。尤其值得期待的是,Revolt 很可能会较快接入这一原生后端——毕竟 Revolt 本来就位于 Amp v3 之下,承担共享底层 driver 的职责,而这正是它设计之初要抽象掉的那类内部管道。

当然,这并不意味着 ReactPHP 与 Amp v3 之间的选择会从此消失。两者的差异依然主要是风格层面的。Amp v3 的 Fiber-first API 读起来更接近同步代码,写法对很多 PHP 开发者来说更自然;ReactPHP 的 promise 链式风格,则在你需要时能提供对事件循环更显式、更细粒度的控制;如果确实需要让两者在同一进程里共存,它们也可以通过 revolt/event-loop-adapter-react 实现互操作。Polling API 真正改变的是:它让 ReactPHP 和 Amp v3 再也没有充分理由继续维护一堆特制的原生后端;同时也意味着,在一台五美元的 droplet 上运行原版 PHP 8.6,就有机会获得过去必须编译 PECL 扩展(而多数共享主机往往做不到)才能拿到的同级 epoll 性能。

用 PestPHP 对调度器做一个快速验证

如果你不想只凭上面的耗时数字相信并发结论,而是希望自己动手验证,那么大致可以这样测试:准备两个服务器,在响应前都固定 sleep 一段时间,以确认总耗时跟随最慢请求,而不是两个请求耗时的简单相加:

spawn(function () use ($scheduler, $host) { fetch($scheduler, $host, '/sleep?ms=200');});}$scheduler->run();$elapsed = microtime(true) - $started;expect($elapsed)->toBeLessThan(0.35);});

两个各自约两百毫秒的请求,总耗时却明显低于相加后的四百毫秒,这就是整个测试的核心。它并不是什么复杂精巧的断言,但对这里要验证的事情来说,它恰好就是最关键的那一个。而且只要你在某个 Fiber 中不小心写入了阻塞代码却忘记处理,这个测试就会立刻失败。

实际结论

如果你今天正在编写应用代码,那么应优先选择 Amp v3 或 ReactPHP,而不是自己手写调度器;至于最终选哪一个,则可以根据你更偏好原生 Fiber 风格的使用体验,还是更喜欢显式 promise 控制来决定。如果你的运行负载中,每一毫秒、每个连接、每一字节内存都至关重要,并且团队具备管理编译扩展的运维成熟度,那么 Swoole 的复杂性是值得的。如果你本身就是这些异步库的维护者之一,那么 Polling API 就是当下最值得投入和跟进的方向——趁它还处于 alpha 之前,你的反馈仍然有机会真正影响 11 月最终发布的内容。

PHP 花了整整十年,被外界不断断言“不适合做好这件事”。现在看来,它只是一直缺少一种足够贴近操作系统的原生能力。
Fiber 与 PHP 8.6 Polling API 异步初探

来源:https://developer.aliyun.com/article/1755321
上一篇老板键为什么会慢一步?事件驱动保护链路设计解析 下一篇别再死磕固定服务器:用Knative实现弹性伸缩按需扩容
本站内容用于信息整理与展示,如有侵权或内容问题请及时联系处理。

相关推荐

补充同频道和同主题内容,方便继续浏览更多相关内容。

同类最新

继续查看同栏目最近更新的文章。

更多
CAD零基础入门教程:坐标输入、图层管理与基础绘图命令
AI教程 · 2026-09-01

CAD零基础入门教程:坐标输入、图层管理与基础绘图命令

本文面向CAD零基础学习者,系统讲解坐标输入、图层管理与基础绘图命令的核心用法。通过分步实操与常见问题排查,帮助新手建立精确绘图习惯,掌握规范出图的基础能力。

CAD从入门到项目交付:绘图、标注、图块与实战工作流
AI教程 · 2026-09-01

CAD从入门到项目交付:绘图、标注、图块与实战工作流

掌握CAD的核心在于建立“画得准、标得清、复用快、交付稳”的工作流。本文提供从环境设置、高频命令组合、标注规范、图块标准化到项目分阶段交付的完整路径,帮助初学者避免常见返工陷阱,独立完成可检查、可复用、可打印的工程图纸。

Claude Code 登录指南:个人、Teams 与企业账号区分与授权步骤
AI教程 · 2026-09-01

Claude Code 登录指南:个人、Teams 与企业账号区分与授权步骤

本文详细解析 Claude Code 登录前的账号类型区分方法,涵盖个人订阅、Teams 席位与企业 Enterprise 席位的授权路径差异。提供终端登录命令、环境变量排查及常见异常处理步骤,帮助用户快速完成正确授权并避免登录路径混淆。

Claude Code 文件修改前的权限模式配置与命令审批指南
AI教程 · 2026-09-01

Claude Code 文件修改前的权限模式配置与命令审批指南

本文详细介绍Claude Code在修改文件前的权限模式配置方法,包括defaultMode可选值、permissions allow与deny规则设置、多层级配置文件管理以及 status验证技巧,帮助开发者安全高效地使用AI编程助手。

Claude Code接入VS Code后先测扩展和终端命令
AI教程 · 2026-09-01

Claude Code接入VS Code后先测扩展和终端命令

在VS Code中接入Claude Code后,建议优先验证扩展面板与集成终端两条入口。本文提供标准检查顺序、关键命令与常见故障排查路径,帮助你快速确认环境就绪,避免后续开发受阻。