先交代背景:我们有个后台需要同时调用三个外部 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 和返回格式就能看到效果。如果哪里有问题,欢迎留言交流。

