Fiber 与 PHP 8.6 Polling API 异步初探

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 统一恢复。

php 复制代码
<?php

declare(strict_types=1);

use Io\Poll\{Context, Event};

final class MiniScheduler
{
    private Context $poll;

    /** @var array<int, Fiber> */
    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 的非阻塞抓取。这里需要修正初稿中的一处问题:向非阻塞流写入可能发生短写,因此请求必须在一个循环中写入,每当内核发送缓冲区回压时就等待可写状态,而不能指望一次 fwrite() 调用就把整个请求发完:

php 复制代码
<?php

declare(strict_types=1);

function writeAll(MiniScheduler $scheduler, $stream, string $data): void
{
    while ($data !== '') {
        $written = fwrite($stream, $data);

        if ($written === false) {
            throw new RuntimeException('Write failed');
        }

        if ($written === 0) {
            $scheduler->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.1\r\nHost: {$host}\r\nConnection: close\r\n\r\n");

    $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 结果的分支之下。

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

php 复制代码
<?php

declare(strict_types=1);

$scheduler = new MiniScheduler();

$hosts = [
    'example.com',
    'httpbin.org',
    'jsonplaceholder.typicode.com',
];

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

$scheduler->run();

三个 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 对调度器做个快速检查

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

php 复制代码
<?php

declare(strict_types=1);

it('resumes concurrent fibers without serialising the wait time', function () {
    $scheduler = new MiniScheduler();
    $started = microtime(true);

    foreach (['sleepy-one.test', 'sleepy-two.test'] as $host) {
        $scheduler->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 异步初探

相关推荐
niucloud-admin27 分钟前
NIUCLOUD PHP SAAS上门家政使用文档--服务订单:订单回访
php
大黄说说2 小时前
Composer 常见报错汇总:镜像、依赖冲突、环境坑一站式排查
php·composer
安易算力2 小时前
智算中心网络架构:RDMA/IB/以太网选型深度解析与优化实践
网络·架构·php
程序员大雄学编程3 小时前
Linux 磁盘满了怎么排查?df / du / find 三步定位大文件
linux·运维·php
fengci.5 小时前
CTF2困难部分
开发语言·网络·python·安全·php
大黄说说5 小时前
PHP 异步怎么实现?Swoole、Workerman 使用场景对比
开发语言·php·swoole
乐橙开放平台6 小时前
跨地域监控难管理?通过 GB28181 把各地海康大华乐橙摄像头注册到云端平台
开发语言·笔记·物联网·自动化·php·音视频·智能家居
广东融科数据服务有限公司6 小时前
网络监控经常卡顿、掉线、画面模糊?一线排查问答实录
开发语言·网络·php
外收内放6 小时前
ENSP学习记录(6)
开发语言·学习·php
盛世宏博北京6 小时前
档案库房恒温恒湿消毒净化一体机与3D可视化平台联动调试笔记
开发语言·php·温湿度·档案室