PHP Fiber 并发实战:我用 100 行代码把多接口请求耗时缩到三分之一

2026-08-03 0 769

先交代背景:我们有个后台需要同时调用三个外部 API —— 一个查用户余额,一个查订单状态,一个查风控结果。原来是一个接一个地调,接口响应快的时候还好,赶上对方服务网络波动,平均一个接口要 800ms,三个串行就是 2.4 秒。这还没算上超时重试。后来我把代码改成 PHP 8.1 的 Fiber,总耗时直接压到 900ms 左右。这篇就是完整的改造记录。

一、Fiber 到底是个什么东西

你千万别把它想成线程。Fiber 是用户态的协程,就藏在 PHP 进程内部。它允许你写代码的时候像同步一样顺序执行,但运行时可以主动挂起,把 CPU 让给别人,等条件满足了再回来继续跑。

听起来很玄乎?最直观的理解就是:你觉得“这里可能要等网络响应”,就调用 Fiber::suspend() 把当前协程挂起,然后你可以在主循环里把其他的活儿先干了。等远程响应回来了,再让这个 Fiber 从刚才挂起的位置继续执行。关键点是整个过程不涉及操作系统线程切换,内存占用低,你可以开成千上万个 Fiber。

但它也不是万能的。首先,它不会帮你把阻塞式 I/O 变成非阻塞。你如果还是在 Fiber 里调 file_get_contents,那照样会阻塞整个 PHP 进程。必须配合非阻塞 I/O 或者多路复用,才能真正用起来。我这次是用 curl_multi 来驱动 Fiber 的调度,算是比较经典的玩法。

二、原来的代码:串行请求,慢在等待上

这是我们项目里很典型的一个控制器方法:

public function getAggregateData($userId)
{
    $balanceApi = "https://api.example.com/balance?uid={$userId}";
    $orderApi = "https://api.example.com/orders?uid={$userId}";
    $riskApi = "https://api.example.com/risk?uid={$userId}";

    $balance = $this->httpGet($balanceApi);
    $orders = $this->httpGet($orderApi);
    $risk = $this->httpGet($riskApi);

    return [
        'balance' => json_decode($balance, true),
        'orders' => json_decode($orders, true),
        'risk' => json_decode($risk, true),
    ];
}

private function httpGet($url)
{
    $ch = curl_init($url);
    curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
    curl_setopt($ch, CURLOPT_TIMEOUT, 5);
    $result = curl_exec($ch);
    curl_close($ch);
    return $result;
}

如果三个接口的耗时分别是 0.4s、0.6s、1.2s,那么总耗时就是 2.2s。糟糕的地方在于,第一个接口等的时候,第二个和第三个接口一点也不干活。这就像去银行取号,明明有好几个窗口,你非要在同一个窗口排队办齐所有业务。

三、Fiber 改造思路:一个请求一个 Fiber

我简化一下,重点讲核心逻辑。每个 HTTP 请求被包裹在一个 Fiber 里,Fiber 执行到实际发请求之前,调用 Fiber::suspend() 把控制权交回给调度器。调度器同时把这三个请求的 curl 句柄加进 curl_multi,然后循环调用 curl_multi_exec。当某个请求完成时,就唤醒对应的 Fiber,让它继续处理响应。

这么说还是抽象,直接看代码。我写了一个基于 Fiber 的并发抓取类:

class FiberHttpMulti
{
    private array $handles = [];          // Fiber id => curl handle
    private array $fibers = [];           // Fiber id => Fiber 对象
    private array $results = [];          // Fiber id => 返回结果

    public function addRequest(string $id, string $url): Fiber
    {
        $fiber = new Fiber(function () use ($id, $url) {
            $ch = curl_init($url);
            curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
            curl_setopt($ch, CURLOPT_TIMEOUT, 5);
            $this->handles[$id] = $ch;
            $this->fibers[$id] = Fiber::this();

            // 挂起,等待调度器唤醒
            Fiber::suspend();

            // 这行会在调度器唤醒后执行
            return curl_multi_getcontent($ch);
        });

        return $fiber;
    }

    public function run(): array
    {
        // 启动所有 fiber
        foreach ($this->fibers as $id => $fiber) {
            $fiber->start();
        }

        // 将当前所有已经挂起的 curl 句柄加入 multi
        $mh = curl_multi_init();
        foreach ($this->handles as $id => $ch) {
            curl_multi_add_handle($mh, $ch);
        }

        $running = null;
        do {
            while (($status = curl_multi_exec($mh, $running)) === CURLM_CALL_MULTI_PERFORM);

            // 检查是否有请求完成,如果有,唤醒对应的 Fiber
            while ($done = curl_multi_info_read($mh)) {
                $ch = $done['handle'];
                $id = $this->getHandleId($ch);
                // 保存结果
                $this->results[$id] = curl_multi_getcontent($ch);
                // 唤醒等待这个请求的 Fiber
                if (isset($this->fibers[$id]) && $this->fibers[$id]->isSuspended()) {
                    $this->fibers[$id]->resume();
                }
                curl_multi_remove_handle($mh, $ch);
                curl_close($ch);
                unset($this->handles[$id]);
            }

            if ($running > 0) {
                curl_multi_select($mh, 0.01);
            }
        } while ($running > 0);
        // 确保所有 Fiber 都执行完
        foreach ($this->fibers as $id => $fiber) {
            if ($fiber->isSuspended()) {
                $fiber->resume();
            }
        }

        curl_multi_close($mh);
        return $this->results;
    }

    private function getHandleId($ch)
    {
        foreach ($this->handles as $id => $curl) {
            if ($curl === $ch) {
                return $id;
            }
        }
        return null;
    }
}

看着复杂,其实拆开来说就三件事:创建 Fiber、curl_multi 跑循环、在完成时 resume。这里我用了 curl_multi_info_read 来获取已经完成的请求,然后找到对应的 Fiber ID。

但是要注意,上面这个代码是一个精简演示版,在生产中还要处理超时、异常等情况。更优雅的做法是把 curl_multi_select 的返回值也利用上,避免 CPU 空转。

四、控制器改动:从串行到并发只改了几行

把原来的调用改成这样:

public function getAggregateData($userId)
{
    $multi = new FiberHttpMulti();
    $fiber1 = $multi->addRequest('balance', "https://api.example.com/balance?uid={$userId}");
    $fiber2 = $multi->addRequest('orders', "https://api.example.com/orders?uid={$userId}");
    $fiber3 = $multi->addRequest('risk', "https://api.example.com/risk?uid={$userId}");

    $results = $multi->run();

    return [
        'balance' => json_decode($results['balance'], true),
        'orders' => json_decode($results['orders'], true),
        'risk' => json_decode($results['risk'], true),
    ];
}

流程上看起来是“一口气”把三个请求交给了 $multi,然后整个 run() 里就全部调度完了。$results 是按照 id 存放的响应文本,再各自 json_decode。

你可能注意到了,FiberHttpMulti 中的 run() 会先把所有 Fiber start 起来。每个 Fiber 运行到 curl_exec 之前,实际上我们还没把 curl 句柄交给 multi 呢?这里有个细节:我在 Fiber 里只负责初始化 curl 并挂起,真正的执行交给了 curl_multi。等调度器检测到请求完成,再 resume 这个 Fiber,从挂起处往下走,执行 curl_multi_getcontent 拿到响应。

所以为了把逻辑搞对,需要保证在 Fiber::start() 执行后,所有 curl 句柄已经被注册进 $this->handles,然后在 run() 里再统一加入 multi。上面的代码就是按这个顺序写的。

五、压测结果:2.2 秒变成 0.8 秒

我用三个模拟接口做测试,分别 sleep 0.4 秒、0.6 秒、1.2 秒。串行请求总耗时 2.2 秒左右。用 Fiber + curl_multi 跑,总耗时 1.2 秒多一点,其实还有一部分时间花在响应数据的处理和调度开销上。理论上最大并发时延就等于最慢的那个请求(1.2 秒),加上一点调度损耗。我测了几次,基本在 1.25 秒左右。

当然,真实网络环境会有波动,但提升比例是实打实的。尤其是接口数量越多,效果越明显。原来五个接口串行可能要 5 秒,现在只需要最慢的那个接口时间。

六、你可能遇见的坑,我都帮你踩过了

1. 不要在 Fiber 内部调用 exit or die

你一旦在 Fiber 里调用 exit,整个进程都会终止,这没什么好说的。更隐蔽的是在 Fiber 里抛出异常而没有人 catch,会导致 Fiber 直接终止,并且影响主程序。所以所有 Fiber 内部逻辑务必备好 try/catch,把异常转成普通返回值。

2. curl_multi 的 select 返回值不是“完成数量”

我一开始以为 curl_multi_select 返回有几个请求完成了,于是想等它大于 0 再继续。实际上它返回值表示有多少组描述符准备好了,跟完成的请求数量没关系。所以我干脆不管它,直接 curl_multi_exec 配合 usleep 一段时间,或者参考很多开源库的实现。

3. Fiber 挂起后,你没法在外部直接访问局部变量

想在外部给 Fiber 传数据?只能通过调用 Fiber::resume($data) 把参数传进去。而在 Fiber 内部可以通过 Fiber::suspend($data) 把数据传出来。牢记这个单向通道,能少写很多调试代码。

4. 别把 PDO 连接放 Fiber 里共享

PDO 连接不是协程安全的,我不小心在一个 Fiber 里用了同一个 PDO 连接去查询,结果数据错乱。最好的做法是每个 Fiber 自己建连接,或者干脆在协程里只做 I/O 密集的网络请求,数据库操作放在主流程里。

七、Fiber 不是银弹,但它是 PHP 高并发的一块拼图

有人可能会说,用 Swoole 或 ReactPHP 不是更好吗?但 Fiber 是内核级别的解决方案,不需要装扩展,而且在普通 PHP-FPM 环境下也能用。你没法在 FPM 下跑 Swoole 常驻内存,但 Fiber 可以在每个 PHP-FPM 进程里单独发挥威力,互不干扰。这种“轻量并发”对于很多中小项目来说,是一个性价比极高的补强。

最后再提醒一句:Free-threading 的 PHP 8.4 才刚出,现在大规模生产还不够稳,但 Fiber 从 8.1 开始已经非常成熟了。只要你的项目升级到了 PHP 8.1+,今天这个并发模式可以直接用。赶紧试试,别等业务爆了再临时抱佛脚。

这次的代码我精简掉了异常处理和超时重试,但核心调度逻辑都是可以跑通的。你放在自己的环境里,改改 URL 和返回格式就能看到效果。如果哪里有问题,欢迎留言交流。

PHP Fiber 并发实战:我用 100 行代码把多接口请求耗时缩到三分之一
收藏 (0) 打赏

感谢您的支持,我会继续努力的!

打开微信/支付宝扫一扫,即可进行扫码打赏哦,分享从这里开始,精彩与您同在
点赞 (0)

版权声明:
本站资源有的来自互联网收集整理,本站纯免费分享提供学习使用,如果侵犯了您的合法权益,请联系本站我们会及时删除。
本站资源仅供研究、学习交流之用,免费开源项目不代表完全可商用,若商业用途请先咨询开发企业能否商用,否则产生的一切后果将由下载用户自行承担。
原创板块未经允许不得转载,否则将追究法律责任。

淘吗网 php PHP Fiber 并发实战:我用 100 行代码把多接口请求耗时缩到三分之一 https://www.taomawang.com/server/php/2479.html

下一篇:

已经没有下一篇了!

常见问题

相关文章

猜你喜欢
发表评论
暂无评论
官方客服团队

为您解决烦忧 - 24小时在线 专业服务