Fiber 与 PHP 8.6 Polling API 异步初探

作者:BingoGo日期:2026/8/13

Fiber 与 PHP 8.6 Polling API 异步初探

几周前的一篇文章曾介绍 Polling API RFC,并将其定位为被低估的提案,原因主要在于:报道往往漏掉了真正的动机——那是内部 php_poll.h API,而非用户态的 Io\Poll 类。这一判断至今仍然成立。不过随后收到了不少类似"好吧,可它到底能用来做什么?"的反馈。这是个合理的问题,也正是本文要回答的问题。

时机也恰到好处。该 RFC 以 33 票赞成、1 票反对、4 票弃权通过,并于 6 月 3 日投票截止,实现已经合入 master 分支。Alpha 1 预定 7 月 2 日发布,功能冻结与 Beta 1 在 8 月中旬落地,GA 初步定于 11 月 19 日。这意味着 Polling API 已经从一个 RFC 文本,变成可以从 master 编译并立即上手试用、先于正式 alpha 标签就能动手的东西。接下来就做这件事。

与此同时,PHP 社区此刻正围绕"生产环境到底该用哪种异步方案"展开一场活跃的争论:基于用户态事件循环的 Fiber、ReactPHP、Swoole,还是跑在 Revolt 之上的 Amp v3。尚未有人把这场争论与 Polling API 带来的变化清晰挂钩。本文要做的正是这件事,用真实代码,而不只是感觉。

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

这两者常常被混为一谈,在动手构建任何东西之前,先把它们彻底分开。

Fiber 是 PHP 的协作式并发原语,自 8.1 起进入核心。它提供一个可以自行暂停的函数:通过 Fiber::suspend() 暂停,之后由 $fiber->resume() 恢复,暂停期间自己的栈与局部状态都原封不动。这就是它的全部。它不知道任何关于 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,并由一个 Io\Poll\Context 统一恢复。

1<?php
2
3declare(strict_types=1);
4
5use Io\Poll\{Context, Event};
6
7final class MiniScheduler
8{
9    private Context $poll;
10
11    /** @var array<int, Fiber> */
12    private array $fibers = [];
13
14    public function __construct()
15    {
16        $this->poll = new Context();
17    }
18
19    public function spawn(callable $task): void
20    {
21        $fiber = new Fiber($task);
22        $fiber->start();
23
24        if ($fiber->isTerminated()) {
25            return;
26        }
27
28        $this->fibers[spl_object_id($fiber)] = $fiber;
29    }
30
31    public function run(): void
32    {
33        while ($this->fibers !== []) {
34            foreach ($this->poll->wait(timeoutSeconds: 1) as $watcher) {
35                $fiber = $watcher->getData()['fiber'];
36
37                $fiber->resume($watcher);
38
39                if ($fiber->isTerminated()) {
40                    unset($this->fibers[spl_object_id($fiber)]);
41                }
42            }
43        }
44    }
45
46    public function awaitReadable($stream): void
47    {
48        $fiber = Fiber::getCurrent();
49        $handle = new StreamPollHandle($stream);
50        $watcher = $this->poll->add($handle, [Event::Read], ['fiber' => $fiber]);
51
52        Fiber::suspend();
53
54        $watcher->remove();
55    }
56
57    public function awaitWritable($stream): void
58    {
59        $fiber = Fiber::getCurrent();
60        $handle = new StreamPollHandle($stream);
61        $watcher = $this->poll->add($handle, [Event::Write], ['fiber' => $fiber]);
62
63        Fiber::suspend();
64
65        $watcher->remove();
66    }
67}
68

这实际上就是大部分内容。spawn() 启动一个 Fiber,它会一直运行到遇上 Fiber::suspend()。run() 对 poll 上下文调用 wait(),该调用会阻塞,直到某个被监听的流真正可读,然后恢复恰好等待它的那个 Fiber。没有紧密轮询循环,没有每个 tick 都扫描一遍所有流。操作系统在就绪的第一时间通知,控制权随即直接交还给关心它的那个 Fiber。

现在是真正的任务:基于原始 socket 的非阻塞抓取。这里需要修正初稿中的一处问题:向非阻塞流写入可能发生短写,因此请求必须在一个循环中写入,每当内核发送缓冲区回压时就等待可写状态,而不能指望一次 fwrite() 调用就把整个请求发完:

1<?php
2
3declare(strict_types=1);
4
5function writeAll(MiniScheduler $scheduler, $stream, string $data): void
6{
7    while ($data !== '') {
8        $written = fwrite($stream, $data);
9
10        if ($written === false) {
11            throw new RuntimeException('Write failed');
12        }
13
14        if ($written === 0) {
15            $scheduler->awaitWritable($stream);
16            continue;
17        }
18
19        $data = substr($data, $written);
20    }
21}
22
23function fetch(MiniScheduler $scheduler, string $host, string $path): string
24{
25    $stream = stream_socket_client("tcp://{$host}:80", $errno, $errstr, 30);
26
27    if ($stream === false) {
28        throw new RuntimeException("Connect to {$host} failed: {$errstr}");
29    }
30
31    stream_set_blocking($stream, false);
32
33    writeAll($scheduler, $stream, "GET {$path} HTTP/1.1\r\nHost: {$host}\r\nConnection: close\r\n\r\n");
34
35    $response = '';
36
37    while (!feof($stream)) {
38        $scheduler->awaitReadable($stream);
39        $response .= fread($stream, 8192);
40    }
41
42    fclose($stream);
43
44    return $response;
45}
46

关于终止:这个循环依赖 feof() 在一次读到流尾之后翻转为 true,这与 RFC 自己的 TCP 客户端示例采用同一模式。这里之所以成立,有一个值得点明的理由:awaitReadable() 只请求 Event::Read,但 Event::Error 与 Event::HangUp 会被每个后端自动监控,无论请求的是什么,因此当服务器关闭连接时 wait() 仍会返回该 watcher。代码在调用 fread() 之前并不依据 hasTriggered() 做分支,一旦被唤醒就无条件调用它;而在已关闭的非阻塞流上,fread() 会返回空字符串并置位 EOF 标志,既不会阻塞也不会报错。正是这一点让最后一轮迭代干净利落地退出,而不是卡在最后一个描述符上。如果想更严格,读取前检查 hasTriggered(Event::Read)、把 Event::HangUp 单独处理,是更防御性的写法;只是不想让核心循环淹没在一堆不改变普通 HTTP GET 结果的分支之下。

把五个这样的任务接入调度器并发运行:

1<?php
2
3declare(strict_types=1);
4
5$scheduler = new MiniScheduler();
6
7$hosts = [
8    'example.com',
9    'httpbin.org',
10    'jsonplaceholder.typicode.com',
11];
12
13foreach ($hosts as $host) {
14    $scheduler->spawn(function () use ($scheduler, $host) {
15        $body = fetch($scheduler, $host, '/');
16        echo "{$host}: " . strlen($body) . " bytes\n";
17    });
18}
19
20$scheduler->run();
21

三个 Fiber,各自阻塞在自己的 socket 上,全部由一个 Io\Poll\Context 独立恢复。从结构上讲,这就是 Revolt 的 driver 在做的事,也是 ReactPHP 的 loop 在做的事。这里只是写出了仍然可用、且最小的那个版本。

刻意不展示的东西

这个调度器没有定时器,没有取消令牌,没有从 Fiber 传回调用方的错误传播,没有对永不挂起、从而拖住其他所有人的 Fiber 的防护,也没有 DNS 处理——除 stream_socket_client() 免费提供的部分之外。这不是疏漏,这正是重点。

Amp v3 与 ReactPHP 之所以存在,是因为把这件事做成正确、安全的版本确实很难;要让数百个并发 Fiber 的取消与背压都处理妥当,不是周末就能完成的项目。别把这个调度器带到生产环境附近。把它带来的理解带进你真正选用的那个库。

坦诚地做基准测试

在给出数据之前,先把局限说清楚。写下本文时,PHP 8.6 仍是 pre-alpha,从 master 自行编译,并不是可以 apt install 的打包构建。没有稳定版本,没有发行版软件包,Io\Poll 实现到 GA 之前仍可能变化。请把后面的内容视为方向性参考,而非生产环境 SLA。

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

顺序阻塞大致等于三次往返之和,这并不意外,因为每个请求都要等上一个完成后才开始。迷你调度器与 ReactPHP 的 StreamSelectLoop 结果接近,二者大致受限于最慢的单个请求而非总和,这正是并发 I/O 想要的结果。跑在 Revolt 上的 Amp v3 比两者都略微领先,这也符合预期——Revolt 背后有多年调优,而这里只有四十行的调度器。

有趣的结果不是耗时,而是 CPU 行为。基于 stream_select() 的循环,随着被监听流数量的增长,会在用户态重扫描述符集合上消耗明显更多的开销。Io\Poll\Context 版本则保持平稳。三条流对三十条流,每次 wait() 调用代价大致相同,因为 epoll 不会重扫整个集合,它只告诉你哪些就绪了。这才是真正的回报,而且除非有人真的在高并发——数百上千条流,而不是三条——下做基准测试,否则它不会清楚地显现出来。

这个对比中有一个诚实的缺口:ReactPHP 与 Amp v3 跑在 8.4 上,迷你调度器跑在 8.6 pre-alpha 上,因此测到的是循环实现与 PHP 版本一起变化,而非循环本身。同版本对比——ReactPHP 与 Amp v3 都基于同一个 8.6 构建上的 Polling API 后端——才能干净地剥离出循环自身的贡献。目前这还做不到,因为两个项目都还没有交付 Io\Poll driver,而这恰恰是整篇文章的题眼。请把 CPU 行为当作更可靠的信号,因为它是架构层面的,不依赖跑的是哪个 PHP 构建;墙钟耗时则当作粗略的合理性校验,而非定论。

这对 Fiber、ReactPHP、Swoole 与 Amp v3 之争意味着什么

这正是前文说要串联起来的争论,以下是最终结论。

Swoole 其实不属于这个对比的范畴,把它当作四个选项之一,正是大量网络争论跑偏的地方。Swoole 是另一个 PHP 运行时。它取代应用原有的启动方式,透明地修补核心函数,使其变为非阻塞,并提供内置 worker 进程的生产级 HTTP 服务器。不是往现有应用里加 Swoole,而是从零开始为 Swoole 构建应用。对合适的工作负载,它确实非常出色;当每一毫秒都要精打细算、且具备在 Docker 镜像中管理编译扩展的运维纪律时,C 层面的协程切换让它对用户态 Fiber 拥有真实优势。Polling API 不会改变这个权衡的任何一个侧面,因为 Swoole 从未被 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 优先 API 读起来更接近同步代码;ReactPHP 的 promise 链式风格在你想要时给你对循环更显式的控制;而如果确实需要两者共存于同一进程,它们可以通过 revolt/event-loop-adapter-react 互操作。Polling API 所做的是剥夺二者继续维护特制原生后端的借口,同时意味着:一台五美元的 droplet 上跑原版 PHP 8.6,就能获得过去需要编译 PECL 扩展(大多数共享主机都不具备)才能得到的同等 epoll 性能。

用 PestPHP 对调度器做个快速检查

如果不想仅凭上面的耗时数字相信并发论断,而是想自行验证,这是大致的测试方式:用两个服务器,各自在响应前固定睡眠一段时间,以确认总耗时跟随最慢的请求而非总和:

1<?php
2
3declare(strict_types=1);
4
5it('resumes concurrent fibers without serialising the wait time', function () {
6    $scheduler = new MiniScheduler();
7    $started = microtime(true);
8
9    foreach (['sleepy-one.test', 'sleepy-two.test'] as $host) {
10        $scheduler->spawn(function () use ($scheduler, $host) {
11            fetch($scheduler, $host, '/sleep?ms=200');
12        });
13    }
14
15    $scheduler->run();
16
17    $elapsed = microtime(true) - $started;
18
19    expect($elapsed)->toBeLessThan(0.35);
20});
21

两个各约两百毫秒的请求,总耗时远低于二者相加的四百毫秒,这就是整个测试。它不是什么精妙的断言,但它正是这里真正要紧的那一个,而且一旦在某个 Fiber 里不小心写了阻塞代码又忘了,它会立刻失败。

实际结论

如果今天在写应用代码,请选择 Amp v3 或 ReactPHP,而不是手写的调度器,并依据想要原生 Fiber 的使用体验,还是显式的 promise 控制来决定。如果运行的负载中每一毫秒、每个连接每一字节内存都举足轻重,并且具备使用编译扩展的运维成熟度,那么 Swoole 配得上它的复杂度。如果你本人就是维护这些库中某一个的人,Polling API 就是当下应该投入开发的东西——趁它还走在 alpha 之前,你的反馈还能真正影响 11 月发布的东西。

PHP 花了整整十年被人断言做不好这件事。事实证明,它只是需要操作系统不再陌生。Fiber 与 PHP 8.6 Polling API 异步初探


Fiber 与 PHP 8.6 Polling API 异步初探》 是转载文章,点击查看原文


相关推荐


鸿蒙掌上驾考宝典应用开发15:答题交互组件——Exam 与 SelectComponent 全解析
麦田ya2026/8/3

第15篇:答题交互组件——Exam 与 SelectComponent 全解析 一、引言 答题交互是驾考应用最核心的用户交互场景。DriverLicenseExam 项目的 Exam 组件和 SelectComponent 组件共同实现了完整的答题交互体验,包括试题展示、选项选择、答案判断、答题卡、倒计时等。本文将深入解析这两个组件的实现。 二、Exam 组件架构 2.1 组件职责 Exam 组件是考试页面的核心容器,负责: 管理试题列表(Swiper 滑动切换)展示倒计时(模拟考试)管理答题


[V2X]音频数据流图
墨染天玑2026/7/26

Playback / Recording / Voice 三种场景的音频数据流框图: #mermaid-svg-nAP2CEPsCFL66meU{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}


Day 008:Agent 记得越多越好吗?短期与长期记忆的底层逻辑
kisbad2026/7/18

系列: 100 天系统学习 AI Agent 开发 当前阶段: Agent 基础与环境搭建 今日目标: 搞懂短期记忆与长期记忆的核心差异,建立记忆治理机制。 AI Agent 大模型 Agent开发 LangGraph 很多刚接触 Agent 开发的同学,在加上数据库之后都会有一种错觉:“我的 Agent 终于拥有长期记忆了,它可以记住一切!” 但实操下来往往会遇到这种尴尬场景:学习助手如果每次都问“你学到第几天了”,会显得很智障;可如果它把你随口抱怨的一句“我最近不想学 Python”永久保


离线优先:无网络权限的鸿蒙 Flutter 应用设计
程序员小Pyy2026/7/10

在万物互联的时代,做一个"断网"应用反而是最激进、也最负责任的设计选择。本文以 E-Brufen 为例,从架构哲学、存储设计、代码实现到用户体验,全面探讨离线优先应用的构建方法。 一、为什么选择离线? 1.1 一句话:零权限 打开 E-Brufen 的 ohos/entry/src/main/module.json5,你会看到这样一行配置: "requestPermissions": [] 一个空数组。没有 ohos.permission.INTERNET、没有 ohos.p


定时任务(root)与 Web(www)权限冲突问题——使用 ACL 彻底解决
半桶水专家2026/7/2

在 Linux 服务器中,权限冲突问是一个非常常见的问题。 例如: Cron 定时任务:root 用户执行 PHP(Nginx + PHP-FPM):www-data 或 www 用户执行 Apache:apache 用户执行 Tomcat:tomcat 用户执行 两个不同用户需要共同读写同一目录。 很多人第一反应就是: chmod -R 777 data/ 虽然能解决问题,但非常不安全。 Linux 提供了更好的方案——ACL(Access Contr


MySQL 8.0 实现 JSON 字段全文检索 | ngram 分词支持单字/字母/中英文混合搜索
勿忘初心12212026/6/23

MySQL 8.0 实现 JSON 字段全文检索 | ngram 分词支持单字/字母/中英文混合搜索 前言一、业务场景二、技术痛点三、概念解释3.1 ngram 中日韩分词器3.2 生成列(Generated Column)3.3 全文索引(FULLTEXT INDEX)3.4 停用词表3.5 BOOLEAN MODE(布尔检索模式) 四、环境五、MySQL 全局配置(my.cnf / my.ini)5.1 完整配置文件5.2 重启 MySQL 服务5.3 验证配置是否生效 六、创


Claude Codde 入门教程—— 从零到独立完成项目
fa_lsyk2026/6/15

Claude Code 入门教程 适合人群:技术小白、编程初学者、对 AI 编程感兴趣的所有人 学习目标:读完本文后,你能够独立使用 Claude Code 完成一个完整的 OCP 项目 阅读时间:约 45-60 分钟 难度等级:★☆☆☆☆(零基础友好) 目录 前言:你即将拥有的"超能力"什么是 Claude Code?—— 你的 AI 编程伙伴安装 Claude Code —— 3 步搞定第一次对话 —— 跟 AI 说"你好"核心概念:理解 Claude Code 的"


HDFS 频繁进入安全模式的原因及解决方案
数据小羊2026/6/8

你是否遇到过 HDFS 集群时不时进入安全模式(Safe Mode)的问题?这不仅会影响数据的读写,还可能导致整个 Hadoop 生态系统的应用出现异常。本文将深入分析 HDFS 安全模式的触发机制,以及如何有效解决这个棘手问题。 什么是 HDFS 安全模式? HDFS 安全模式是一种保护机制,在这种状态下,文件系统只允许读操作,不允许任何修改文件系统的操作。通常在 NameNode 启动时会进入安全模式,以确保文件系统的元数据和数据块信息的一致性。 为什么 HDFS 会频繁进入安全模


【Redis】网络高并发模型
步十人2026/6/1

目录 一、 核心场景:百万并发下的秒杀大考1. 传统多线程服务器会怎么样?2. Redis 凭什么能抗住?第一步:建立连接(非阻塞 + epoll)第二步:读取请求(非阻塞 I/O)第三步:执行命令(单线程串行,纯内存操作)第四步:返回结果(非阻塞写) 二、 深度对比:多线程阻塞 vs 单线程非阻塞三、 演进:Redis 6.0+ 的多线程 I/O 革命四、 微观视角:一个秒杀请求的时间线拆解五、 致命死穴:如果某个命令很慢怎么办?本篇总结 一、 核心场景:百万并发


你写的代码没有测试,就像出门不锁门——Jest + Testing Library 从入门到不慌
kyriewen2026/5/11

你改了一行代码,手动点了一遍页面,觉得没问题就上线了。结果用户反馈“登录按钮点不动了”。你心里咯噔:我根本没改登录相关代码啊。今天我们来给你的代码装一把“智能门锁”——单元测试。用 Jest + Testing Library,把常见 Bug 锁在门外,让你改代码时不再心惊胆战。 前言 很多前端对测试的态度是:项目那么赶,哪有时间写测试?结果修 Bug 的时间比写代码还多。你花 20 分钟写的测试,可能帮你省掉 2 小时的通宵排查。 测试不是“额外工作”,而是安全网。当你需要重构、升级依赖、添

首页编辑器站点地图

本站内容在 CC BY-SA 4.0 协议下发布

Copyright © 2026 聚合阅读