CatchAdmin PHP 后台管理框架 Logo CatchAdmin

Fiber 与 Polling API,PHP 8.6 异步初探

几周前的一篇文章曾介绍 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 花了整整十年被人断言做不好这件事。事实证明,它只是需要操作系统不再陌生。

本作品采用《CC 协议》,转载必须注明作者和本文链接