curl_multi_exec 架构带来了高昂的代价。为了让伪并发工作,我们不得不在每个异步边界显式地序列化和反序列化请求、响应和异常。这意味着大量的重构、新的内部工具,以及开发者必须遵循的约定,只为了正确编写新代码。随着 AI 功能的范围和数量增长,认知开销变得无法忽视。
错误处理复杂性。 处理异常、超时和边缘情况变得越来越痛苦。每个新场景——重试、网络故障、边缘情况——都需要显式处理,而且由于上下文必须在序列化边界生存,每个都添加了另一层样板代码。
分散的上下文。 最难的部分不是编写代码——而是之后阅读它。业务逻辑分散在序列化点之间:一些状态在异步边界之前,一些在之后。追踪单个负载通过系统意味着在同步工作者、异步队列之间以及返回进行心理跳跃。代码审查变得真正困难。
测试开销。 测试也变得更复杂。测试必须考虑完整的序列化/反序列化链。即使是一个简单的模拟也意味着验证多个中间步骤,而不是单个函数调用。
闲置工作者。
在引入 Fibers 之前,Meta API 调用保持同步状态——在调用点序列化和反序列化状态将需要更多重构,因此我们根本没有动它。平均响应时间约为 250ms。不至于慢到让人惊慌——但在 Manychat 的规模下,也快不到可以忽略的地步。在这段时间里,工作者进程只是闲置在那里。
底线:代码越来越难读、难测试,也难扩展。开发速度在放缓——每个人都能感觉到。
在我们审视这些结果的同时,三件事并行发生了:
1. 我们通过 PCNTL fork 解决了内存问题。 通过使用 pcntl_fork() 来生成工作者进程,我们启用了 OPcache 共享和 Linux 写时复制 ——显著降低了每个工作者进程的内存占用。从理论上讲,我们几乎可以不再担心闲置工作者;它们不再消耗那么多内存。但它们仍然消耗网络连接。所以问题并没有完全消失。
2. Ubuntu 升级暴露了一个新瓶颈。 我们从 Ubuntu 20.04 迁移到新的 LTS 版本,CPU 负载跃升了 10%。我们的代码中没有任何变化。
使用纤维的新负载处理流程
我们深入调查了。问题出在 OpenSSL 3.0 —— 与新版 Ubuntu 一起发布的 —— 它使 SSL 握手成本显著增加。OpenSSL 在 Linux 上的根证书存储是一个大型的连接文件 —— 新版本在遍历它时引入了互斥锁风格的锁定。即使 Facebook 自己使用单个根证书文件的优化也没有完全吸收这个打击。
原因是我们仍然是同步调用 Meta API。每个负载打开一个新的 TCP 连接。在 Manychat 的规模下,这很快累积起来 —— 那个 10% 的 CPU 开销成为了下一步的触发器。
所以 Manychat 的首席架构师 Anton Gorin 和我决定将我们现有的异步工作者 —— 基于 curl_multi_exec 构建的 —— 与 PHP 8.1 中引入的 Fibers 结合。
Fibers 是一种用于协作多任务处理的低级机制:在任何点暂停执行,后来从 exact 同一个位置恢复,没有 —— 没有线程,没有进程。
<?php$fiber = new Fiber(function(){ echo"Suspending…\n"; $last = Fiber::suspend(16); echo"Resuming with last value {$last}\n"; }); $last = $fiber->start(); echo"Suspended with last value {$last}\n"; $fiber->resume(42);以最后值 16 暂停;以最后值 42 恢复。
不像真正的多线程,Fibers 在单个 OS 线程中运行,不并行执行。相反,它们通过 Fiber::suspend() 和 resume 显式切换上下文。这使它们非常适合 I/O 绑定工作:在等待响应时让出控制,做其他事情,当准备好时回来。
以前,每次 HTTP 请求都意味着序列化、手动传递、等待、反序列化、恢复。以下是实际操作的样子:
这是这个非常复杂的流程图:
使用 Fibers,逻辑更简单了:
在单个 worker 中,多个 fiber 可能同时被挂起,等待响应到来,同时有一个 fiber 在同一时刻被积极执行,这取决于配置。
更多异步化案例 — 例如通过 Meta SDK 的 API 调用在引入 Fibers 之前,将 Meta API 调用异步化意味着在每次调用前后序列化和反序列化业务状态。我们干脆就不费这个劲了。有了 Fibers,我们只需添加一个挂起点,就搞定了。单个 Meta API 调用需要 ~250ms — 单个很小,但 Manychat 处理数十亿次。复合效果巨大。
资源节省:CPU 和连接我们重写了 Facebook SDK 的部分代码来重用连接。每个 worker 一个 HTTP/2 连接,多路复用多个请求。没有重复的 TCP 握手。没有每个请求的 OpenSSL 开销。
CPU 使用率回到了之前的水平。
异步睡眠有时我们需要等待 — 例如,在 HTTP 500 后重试之前,或在发送下一个消息前确保正确的消息顺序。普通的 sleep() 会阻塞整个进程。如果 API 错误激增且重试逻辑行为异常,你就把整个服务器给睡着了。
有了 fibers,我们可以实现异步睡眠。特定 fiber 在定义的间隔内睡眠,而 worker 继续处理其他 fibers。
更简单的代码不再需要序列化。不再需要反序列化。业务上下文停留在它该在的地方——纤维内部。开发者甚至不需要知道他们是在纤维中运行。代码看起来像普通的 PHP——因为在实际用途中,它就是。实际操作中:重试时,不再需要将请求推回队列,只需进行异步睡眠即可。
更简单的测试使用 Guzzle 测试异步代码需要巨大的努力——必须考虑完整的序列化/反序列化链,即使是一个简单的模拟也意味着要验证多个中间步骤。使用 Fibers,代码读取起来是线性的,测试也随之自然进行。话虽如此,有些事情在生产环境外很难重现——但在实践中,如果在开发环境中有效,它在生产环境中也有效。
一个权衡:爆炸半径Fibers 带来了一个妥协。以前,我们的指导原则是“宁可猛烈崩溃也不要悄无声息地忍受错误”——非致命警告,记录日志,然后终止 worker。一个负载丢失,干净的 slate。
现在,同时有多个纤维挂起,这种方式不再适用。终止 worker 会同时中断所有在飞的负载。我们重新设计了异常处理,以便可捕获的错误只终止受影响的纤维,而 worker 继续处理其他负载。致命错误——如内存不足——仍然会崩溃整个进程。如果有五个负载在飞,所有五个都会丢失。
我们能做更少的工作吗?
这意味着要处理现有的技术债务,并承诺将关键错误视为关键——真正对它们做出反应,而不是让它们滑过去。迁移到 PHP 8.5 有帮助:它为致命错误引入了堆栈跟踪,这使得诊断和修复它们变得显著更容易。
可能。Revolt、ReactPHP、AMPHP 和 OpenSwoole 都解决了类似的问题,并能让我们免于构建自定义事件循环。特别是 AMPHP 走得更远——不仅仅是 HTTP 的异步 SQL 查询,而且开箱即用的经过实战考验的错误处理。
但我们并没有从零开始。我们已经有基于 Guzzle 的事件循环来自早期的概念验证,在此基础上添加 Fibers 是自然的下一步。今天从头开始,我们会首先看看 Revolt,并完全跳过自定义事件循环。
无论如何,我们会保留什么:开发者不需要知道他们是在 fiber 内部。包装发生在幕后。这是一个深思熟虑的选择——在大型代码库中与许多贡献者合作时,这是最重要的一部分。