从一个真实的超时事故说起
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创建时,通过uclibc的makecontext()分配独立的栈空间。挂起/恢复通过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值得一试。