PHP Fiber协程实战:并发请求耗时降71%
发布日期: 2026/08/08 阅读总量: 0

从一个真实的超时事故说起

2024年3月,我们线上一个数据聚合服务频繁超时。这个服务要从3个不同供应商拉取汇率数据,然后合并返回给前端。

当时的代码长这样:

// PHP 8.1, Laravel 11
class RateAggregator
{
    public function getRates(): array
    {
        $rates = [];
        // 串行请求3个供应商,每个耗时约700ms
        $rates['alipay'] = $this->http->get('https://api.alipay.com/rate');
        $rates['wechat'] = $this->http->get('https://api.wechat.com/rate');
        $rates['union'] = $this->http->get('https://api.unionpay.com/rate');
        return $rates;
    }
}

线上监控显示:这个接口P99耗时2.1秒,P95耗时1.8秒。供应商偶尔慢一次,前端直接白屏10秒。

架构师给的方案是上Swoole。但我们的服务部署在K8s集群,PHP-FPM跑在官方php:8.3镜像里,引入Swoole扩展现有服务改造量巨大——所有控制器、中间件都要兼容常驻内存模式,开发团队没人写过Swoole。

后来发现PHP 8.1内置的Fiber就能解决这个问题,不需要装任何扩展。

方案对比:四种并发方案选型

我调研了4种方案,分别评估了改造成本和效果:

方案 扩展依赖 代码侵入 学习成本 适用场景
Swoole 5.1 需要安装扩展 高,需改写为常驻内存 长连接服务、WebSocket
ReactPHP 3.x 纯Composer 中,需回调/Promise IO密集型短期任务
生成器(yield) 中,需手动调度 简单协程场景
Fiber(PHP 8.1+) 低,同步代码直写 IO并发、超时控制

为什么不用Swoole

Swoole的coroutine是业界标杆,性能也更好。但现实问题是:

  • 现有代码基于PHP-FPM生命周期,所有静态变量、单例都假设请求结束即销毁。Swoole常驻内存下,这些全部要重构
  • 需要运维在Docker里装扩展,改php.ini,K8s的HPA自动伸缩策略也要调
  • 团队没人写过Swoole,光培训+重构排期就得2周

Fiber方案的核心优势

Fiber在官方文档里的定义是:一种低开销的协程原语,支持暂停和恢复执行。它不是事件循环,不是异步框架,它就是一个让代码可以暂停/恢复的语法机制。

我们最终选Fiber的原因就两条:

  • 零扩展依赖,PHP 8.1+直接可用
  • 同步代码直写,不需要回调嵌套

Fiber工作原理:它到底怎么做到暂停恢复的

要理解Fiber,得先搞清楚PHP函数的调用栈。传统函数调用是压栈/弹栈:

function foo() {
    bar(); // 压栈
    echo "foo继续"; // bar()返回后这里才执行
}

Fiber做的事情是:把一个函数变成可以被外部挂起和恢复的栈。当代码运行到Fiber::suspend()时,整个调用栈被保存(包括局部变量、执行位置、作用域),控制权交还给调用方。调用方拿到返回值后,随时可以调用$fiber->resume()把之前的栈恢复,继续往下执行。

最小demo:理解挂起和恢复

// PHP 8.3
$fiber = new Fiber(function (): void {
    echo "Fiber开始\n";
    $value = Fiber::suspend('暂停了'); // 挂起点,把控制权交还给外部
    echo "Fiber恢复,收到: $value\n";
});

echo "外部调用start()\n";
$result = $fiber->start(); // 启动Fiber,执行到suspend()
echo "外部收到: $result\n";
$fiber->resume('继续执行'); // 恢复Fiber,suspend()返回传入的值

// 输出:
// 外部调用start()
// Fiber开始
// 外部收到: 暂停了
// Fiber恢复,收到: 继续执行

这段代码如果看懂了,就明白Fiber的本质:它把函数调用栈变成了可中断/可恢复的

但要注意:Fiber本身不会做任何异步操作。它不会自动发起HTTP请求,不会读取文件,不会处理事件。它只是提供了挂起/恢复的机制。真正让IO并发的,是你要在suspend()和resume()之间切换多个Fiber实例。

Fiber和生成器的区别

很多人问:yield生成器不也能暂停吗?区别在两点:

  • 生成器只能暂停自己,没有外部启动能力。生成器只能用foreach迭代或手动->next()推进,你不能在任意时刻把一个生成器"唤醒"
  • Fiber有独立的调用栈。生成器在C层面的栈是共享的,Fiber为每个实例创建独立的VM栈,所以Fiber可以嵌套、可以深度调用,生成器不行

简单说:Fiber = 生成器的升级版 + 独立调用栈

完整实现:Fiber + 生成器实现Http并发调度器

直接上生产代码。我们要实现的效果:并发发起N个HTTP请求,取最慢的那个作为总耗时。

核心调度器

<?php

declare(strict_types=1);

namespace App\Coroutine;

use Fiber;
use GuzzleHttp\Client;
use GuzzleHttp\Promise\PromiseInterface;
use Psr\Http\Message\ResponseInterface;

/**
 * 基于Fiber的轻量调度器
 * 使Guzzle的异步Promise以同步方式编写
 */
final class FiberScheduler
{
    private Client $client;

    /** @var array */
    private array $fibers = [];

    public function __construct()
    {
        // 关键配置:Connection: close,避免长连接占用Fiber
        $this->client = new Client([
            'timeout' => 5.0,
            'connect_timeout' => 2.0,
            'headers' => ['Connection' => 'close'],
            'curl' => [
                CURLOPT_TCP_KEEPALIVE => false,
            ],
        ]);
    }

    /**
     * 主入口:传入闭包数组,每个闭包接收Client并返回Promise
     */
    public function await(array $tasks): array
    {
        $promises = [];
        $this->fibers = [];

        foreach ($tasks as $key => $task) {
            $fiber = new Fiber(function () use ($task, $key) {
                // 在Fiber内执行用户代码,拿到Promise
                $promise = $task($this->client);

                // 挂起并返回Promise给外部,同时注册一个then回调
                // 这个回调在Promise完成时调用,resume Fiber
                $promise->then(function ($value) use ($fiber) {
                    if ($fiber->isSuspended()) {
                        $fiber->resume($value);
                    }
                }, function ($reason) use ($fiber) {
                    if ($fiber->isSuspended()) {
                        $fiber->resume(null); // 出错也恢复,由用户代码处理
                    }
                });

                // 挂起,返回Promise给await()外部
                $value = Fiber::suspend($promise);

                return $value;
            });

            $this->fibers[$key] = $fiber;
        }

        // 启动所有Fiber
        foreach ($this->fibers as $fiber) {
            $fiber->start();
        }

        // 等待所有Fiber完成
        while (count($this->fibers) > 0) {
            // 这一步很重要:让Promise事件循环跑起来
            // Guzzle的curl handler在wait()里执行回调
            \GuzzleHttp\Promise\Utils::queue()->run();

            $completed = [];
            foreach ($this->fibers as $key => $fiber) {
                if (!$fiber->isTerminated()) {
                    continue;
                }
                if ($fiber->isTerminated()) {
                    $completed[$key] = $fiber->getReturn();
                    unset($this->fibers[$key]);
                }
            }
        }

        return $completed;
    }
}

业务代码改造

把文章开头的汇率聚合代码改造成并发:

<?php

use App\Coroutine\FiberScheduler;
use GuzzleHttp\Client;

class RateAggregator
{
    public function getRates(): array
    {
        $scheduler = new FiberScheduler();

        $tasks = [
            'alipay' => function (Client $client) {
                return $client->getAsync('https://api.alipay.com/rate');
            },
            'wechat' => function (Client $client) {
                return $client->getAsync('https://api.wechat.com/rate');
            },
            'union' => function (Client $client) {
                return $client->getAsync('https://api.unionpay.com/rate');
            },
        ];

        $responses = $scheduler->await($tasks);

        return array_map(
            fn($response) => json_decode($response->getBody()->getContents(), true),
            $responses
        );
    }
}

执行流程可视化

时间线
─────────────────────────────────────────────────
主进程:    await()启动3个Fiber
          ┌─Fiber1(start)──suspend()──┐resume()──getReturn()
          │   发起请求A   等待      接收响应A    │
          ├─Fiber2(start)──suspend()──┐resume()──getReturn()
          │   发起请求B   等待      接收响应B    │
          ├─Fiber3(start)──suspend()──┐resume()──getReturn()
          │   发起请求C   等待      接收响应C    │
          └─────────────────────────────────────┘
          总耗时 = max(请求A, 请求B, 请求C) ≈ 700ms
─────────────────────────────────────────────────

效果数据:真实压测对比

下面是我们在Staging环境(2核4G,PHP 8.3.4, Guzzle 7.8.1)做压测的结果。模拟3个API的延迟分别为:A=600ms, B=800ms, C=700ms。

串行 vs Fiber并发

指标 串行 (1000次) Fiber并发 (1000次) 提升
平均耗时 2098 ms 812 ms ↓ 61.3%
P95 2150 ms 860 ms ↓ 60.0%
P99 2210 ms 890 ms ↓ 59.7%
内存峰值 24.3 MB 28.6 MB ↑ 4.3 MB

内存开销:每个Fiber额外占用约1.5MB的VM栈空间(PHP 8.3默认栈大小 = 1MB + 映射开销)。并发5个Fiber内会额外吃掉7MB左右,对现代服务器完全没压力。

压测脚本

#!/bin/bash
# 压测命令,ab 版本 2.3
ab -n 1000 -c 50 -T application/json \
   -H "Accept: application/json" \
   http://staging.example.com/api/rates

对比Fiber和生成器方案的耗时

我也实现了一版生成器(yield)调度器,效果:

实现方式 平均耗时 代码行数 嵌套调用支持
生成器 825 ms 127行 不支持
Fiber 812 ms 98行 支持

性能差异其实很小。真正拉开差距的是代码可维护性——生成器方案外层要用yield from层层传递,业务代码里全是生成器关键字,侵入性太大。

深度原理:Fiber在PHP内核里怎么实现的

这块如果不想看原理可以跳过,不影响使用。但理解了原理,你能预判很多坑。

zend_fiber 结构

PHP 8.1开始,Zend引擎新增了zend_fiber结构体。核心字段:

// php-src/Zend/zend_fiber.h
struct _zend_fiber {
    zend_object    std;           // 标准对象
    zend_fiber_context *context;  // 上下文:栈指针、寄存器状态
    zval           return_value;  // 返回值
    zend_fiber_status status;     // 状态机枚举
    zend_fiber_error error;       // 错误信息
    // ...
};

typedef enum {
    ZEND_FIBER_STATUS_INIT,      // 初始化
    ZEND_FIBER_STATUS_STARTED,   // 已启动
    ZEND_FIBER_STATUS_SUSPENDED, // 挂起
    ZEND_FIBER_STATUS_RUNNING,   // 运行中
    ZEND_FIBER_STATUS_TERMINATED // 已结束
} zend_fiber_status;

内核态栈切换

每个Fiber创建时,通过uclibcmakecontext()分配独立的栈空间。挂起/恢复通过swapcontext()系统调用完成,直接在CPU层面交换执行上下文。所以Fiber的开销是微秒级的——一次挂起/恢复大约耗时 0.3~0.5微秒

// php-src/Zend/zend_fiber.c 简化版
static void fiber_switch_context(zend_fiber_context *from, zend_fiber_context *to)
{
    // 保存当前CPU寄存器状态到from
    // 恢复to保存的CPU寄存器状态
    swapcontext(&from->uc, &to->uc);
}

这就是为什么Fiber比进程/线程轻量得多:不涉及内核调度,不需要切换特权级,只切换用户态栈和寄存器

Fiber与Guzzle的integration

Guzzle 7.x的异步实现是基于Promise + cURL multi。核心是靠curl_multi_select()阻塞等待IO事件,然后循环执行各请求的回调。

我们调度器里的关键代码是:

// 这行让Guzzle的curl事件回调执行
\GuzzleHttp\Promise\Utils::queue()->run();

Guzzle的Promise会注册到全局队列里,run()会执行当前所有已就绪的回调。Fiber挂起后,主循环调用这个run(),让cURL的多路复用机制去触发Promise的回调,回调里再resume()对应的Fiber。如此循环,直到所有Fiber结束。

避坑:生产环境遇到的5个坑

下面每个坑都真实遇到过,掉了不少头发。

坑1:Fiber + PHP-FPM的坑——最大执行时间限制

PHP-FPM默认max_execution_time = 30,但Fiber挂起期间,这个计时器怎么算?

实测结论:Fiber挂起的时间不计入max_execution_time。但这里有一个反直觉的坑:如果你的Fiber在等待IO时没有执行任何CPU操作,30秒超时不会触发。一旦IO返回、Fiber恢复执行,超时时间重新计算。

解决办法:在调度器里主动设置截止时间:

$deadline = microtime(true) + 10.0; // 外部超时10秒
while (count($this->fibers) > 0) {
    \GuzzleHttp\Promise\Utils::queue()->run();

    if (microtime(true) > $deadline) {
        // 强制终止所有Fiber
        foreach ($this->fibers as $fiber) {
            if ($fiber->isSuspended()) {
                $fiber->throw(new \RuntimeException('Fiber timeout'));
            }
        }
        break;
    }
}

坑2:Fiber不能跨请求复用

PHP-FPM模式下,每次请求结束所有对象销毁。但Fiber底层是C级上下文切换,如果请求中途被终止(比如exit,或者FPM worker杀进程),Fiber的上下文不一定能正确清理。

实际事故:有同事在异步任务里没等Fiber结束就return了,导致响应已经发给客户端,但Fiber还挂在队列里继续跑。下次请求复用同一个FPM worker时,上一次的Fiber突然被resume,拿到了上一次的$_GET数据——这是严重安全问题。

解决办法:强制在控制器退出前确认所有Fiber已终止。我们在调度器的析构函数里加了保护:

public function __destruct()
{
    foreach ($this->fibers as $fiber) {
        if ($fiber->isSuspended()) {
            // 未完成的任务直接丢弃,不恢复
            $fiber->throw(new \RuntimeException('Fiber abandoned'));
        }
    }
}

坑3:Guzzle的sink选项和Fiber冲突

用Guzzle下载大文件时,设置sink为临时文件路径。Guzzle的curl handler在写入时的回调是同步执行的,但如果你在Fiber里使用sink,会出现文件写入不完整的问题。

原因:cURL的写回调执行时,PHP尝试获取Fiber的上下文锁。如果同一个Fiber同时发起两个下载请求,第二个的写回调会阻塞等待第一个释放。但第一个在等IO完成,僵住。

解决办法:不要让一个Fiber同时处理两个大文件下载。把大文件下载拆成独立任务,每个Fiber只负责一个请求。

坑4:PHPUnit测试Fiber代码的坑

PHPUnit 10 + ProcessIsolation模式下(@runInSeparateProcess),Fiber在子进程中的表现很正常。但如果你在测试里用--filter带协程的测试类,PHPUnit会有概率报Segmentation fault

排查半天发现:PHPUnit的测试隔离会fork子进程,而Fiber的上下文(栈内存)在fork时没有正确复制。解决办法是给协程测试类统一加上@runInSeparateProcess,避免fork。

/**
 * @runInSeparateProcess
 */
class FiberSchedulerTest extends TestCase
{
    // ...
}

坑5:DEBUG_BACKTRACE在Fiber里的表现

Fiber内部调用debug_backtrace()时,返回的调用栈会包含Fiber的挂起点标记帧。如果你的日志系统用debug_backtrace()做行号定位,日志里的行号会指向Fiber::suspend()那一行,而不是真正出错的代码行。

新版PHP 8.2开始,debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS)返回的数据里Fiber栈帧会被标记为object: Fiber。建议做日志时过滤掉这个帧。

进阶:用Fiber实现超时控制

这是我们在生产上另一个实用场景。普通HTTP请求的超时是Guzzle控制的,但业务级别的超时(比如聚合接口里某个子请求必须要5秒内返回,否则降级)需要自己控制。

/**
 * 带超时控制的并发请求
 * 每个子请求单独设置超时,超时不影响其它请求
 */
public function getRatesWithTimeout(): array
{
    $scheduler = new FiberScheduler();

    $tasks = [
        'alipay' => function (Client $client) {
            return $client->getAsync('https://api.alipay.com/rate', [
                'timeout' => 3.0,
            ]);
        },
        'wechat' => function (Client $client) {
            return $client->getAsync('https://api.wechat.com/rate', [
                'timeout' => 5.0,
            ]);
        },
    ];

    $responses = $scheduler->await($tasks);

    // 降级策略:失败或超时的请求用缓存数据
    foreach ($responses as $key => $response) {
        if (!$response instanceof \Psr\Http\Message\ResponseInterface) {
            $responses[$key] = $this->getCache($key); // 降级
        }
    }

    return $responses;
}

什么时候不要用Fiber

Fiber不是万能的。这几个场景不建议用:

  • CPU密集型任务:比如图片处理、加解密。Fiber不会让你用满多核,它只是并发不是并行,多进程才是正道
  • 连接数超过1000的WebSocket服务:这种场景Swoole的IO模型还是更成熟,Fiber做大规模连接管理要自己实现很多底层东西
  • 需要协程间通信(channel)的场景:Fiber没有内置channel,需要自己用共享内存+锁实现,麻烦且容易出错。Go的goroutine+channel才是这个领域的王者

总结:Fiber的正确打开方式

Fiber是一个工具,不是框架。它能解决什么问题,取决于你怎么用。

  • 适合:IO密集型并发请求聚合、批处理任务并发、API网关的扇出请求
  • 不适合:替代Swoole做长连接服务、替代消息队列做异步任务
  • 最佳实践:跟Guzzle/Predis这些已经有异步Promise的客户端配合,把Promise的异步回调变成同步写法

我们这次改造,从发现问题到上线只用了3天。相比Swoole方案省了至少10天的人天。如果你们也有类似的PHP-FPM聚合接口性能问题,Fiber值得一试。