- 书: 解耦PHP——适用于超越框架的应用程序的清晰和六边形架构
- 我也写了: 用Go思考 (2册系列) — Go编程完全指南 + Go中的六边形架构
- 我的项目: 赫尔墨斯IDE |GitHub — 一个为使用 Claude Code 和其他 AI 编程工具的开发者设计的 IDE
- 我: xgabriel.com | GitHub
在 2021 年 11 月,PHP 8.1 中发布了 Fibers。RFC 的内容很小,API 只有四个方法,而且大多数 Laravel 代码库仍然没有使用它们。公开vendor/在一个新的Laravel项目中,你会发现Guzzle、Symfony HttpClient和ReactPHP都在底层使用它们。而你的代码?可能一个也没有。
对于一个CRUD应用来说,这很好。但当你扩展到五个HTTP服务、流式传输多个GB的文件或设置一个webhook调度器时,队列或阻塞的二元选择就会变得昂贵。Fibers解决的是队列过度解决的一类特定问题,curl_multi 未能充分解决。以下是四个它们发挥作用的情况。
纤维实际上是什么(60秒思维模型)
纤维是一个协程。不是线程。不是进程。一个可以在同一PHP进程中暂停自身并稍后恢复的执行栈。
整个API:
$fiber = new Fiber(function (): void {
echo "step 1\n";
$resumed = Fiber::suspend("paused"); // hands control back
echo "step 2 with: $resumed\n";
});
$out = $fiber->start(); // "step 1", returns "paused"
$fiber->resume("hello"); // "step 2 with: hello"
$fiber->isTerminated(); // true
Fiber::suspend()这是魔法。它只能从正在运行的 Fiber 内部调用。从主流程中调用它,你就会得到Fiber\FiberError: Cannot call Fiber::suspend() outside of a Fiber来电者start()或者resume()恢复你暂停的任何内容。
在继续之前需要牢记两件事:
- 纤维是协作的。没有什么能抢占它们的执行。如果一个纤维没有调用
suspend(),它运行到完成并阻塞主流程。 - ,它们共享同一个进程。没有GIL,没有内存拷贝,没有IPC。全局变量是共享的。PDO连接也是共享的,这是大多数人遇到的第一个坑(更多内容见下文)。
案例一:并行HTTP发散
您的结账端点调用支付网关、欺诈服务、忠诚度API、运费计算器和仓库可用性服务。五个调用,每个约400毫秒,顺序执行。在顺利情况下,这是2秒的时间。
curl_multi_exec() 解决了这个问题,但API是一个20年前的轮询循环,带有do {} while和select风格的文件描述符监视。它有效。读起来并不令人愉快。
这是光纤版本。模式:每个HTTP调用都存在于自己的Fiber中。一个小型调度器泵送curl_multi并继续执行Fibers,直到它们的句柄完成。
final class ParallelHttp
{
/** @param array<string, string> $urls keyed by label */
public static function get(array $urls): array
{
$mh = curl_multi_init();
$handles = [];
$fibers = [];
foreach ($urls as $key => $url) {
$fibers[$key] = new Fiber(function () use ($url, $mh, &$handles, $key) {
$ch = curl_init($url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_TIMEOUT, 5);
curl_multi_add_handle($mh, $ch);
$handles[$key] = $ch;
// hand control back to the scheduler until curl says done
Fiber::suspend();
$body = curl_multi_getcontent($ch);
curl_multi_remove_handle($mh, $ch);
curl_close($ch);
return $body;
});
$fibers[$key]->start();
}
// pump until every handle is finished
do {
curl_multi_exec($mh, $active);
curl_multi_select($mh, 0.05);
} while ($active > 0);
curl_multi_close($mh);
$results = [];
foreach ($fibers as $key => $fiber) {
$results[$key] = $fiber->resume();
}
return $results;
}
}
$out = ParallelHttp::get([
'payment' => 'https://api.example.com/payment/check',
'fraud' => 'https://api.example.com/fraud/score',
'loyalty' => 'https://api.example.com/loyalty/balance',
'shipping' => 'https://api.example.com/shipping/quote',
'stock' => 'https://api.example.com/warehouse/stock',
]);
来自针对五个端点的合成基准测试,服务器端人为延迟400ms的数字:顺序cURL耗时约2,050毫秒;Fiber +curl_multi 版本在 ~440 毫秒内完成。这是可以预料的。实际运行时间大约是最慢的调用加上一点调度开销。Guzzle 异步文档 报告了类似的趋势,而 ReactPHP 的 HTTP 客户端基准测试 则处于同一范围内。
Fiber 版本并不比原始版本更快。curl_multi。它底层的网络原语是相同的。变化的是每个Fiber得到一个看起来正常的函数体。没有回调,没有then(),没有Promise链。当错误处理变得复杂时,这一点很重要。try/catch的工作方式完全符合你的预期.
案例二:带背压的流处理
读取一个10GB的CSV文件,使用fopen +fgetcsv 在向无法及时响应的数据库批量写入数据时会正常工作。该脚本会以 PHP 解析的速度读取,而下游消费者要么导致队列内存溢出,要么丢弃数据行。
纤程允许生产者在消费者落后时暂停。这里是一个带有限制性缓冲区的模式:
final class BoundedChannel
{
/** @var list<array<string, string>> */
private array $buffer = [];
private ?Fiber $waitingProducer = null;
private ?Fiber $waitingConsumer = null;
private bool $closed = false;
public function __construct(private readonly int $capacity = 100) {}
public function send(array $row): void
{
while (count($this->buffer) >= $this->capacity) {
$this->waitingProducer = Fiber::getCurrent();
Fiber::suspend(); // wait for the consumer to drain
}
$this->buffer[] = $row;
if ($this->waitingConsumer !== null) {
$c = $this->waitingConsumer;
$this->waitingConsumer = null;
$c->resume();
}
}
public function receive(): ?array
{
while ($this->buffer === [] && !$this->closed) {
$this->waitingConsumer = Fiber::getCurrent();
Fiber::suspend();
}
if ($this->buffer === []) return null;
$row = array_shift($this->buffer);
if ($this->waitingProducer !== null) {
$p = $this->waitingProducer;
$this->waitingProducer = null;
$p->resume();
}
return $row;
}
public function close(): void { $this->closed = true; }
}
一个真实的摄入管道将一个生产 Fiber 和一个消费 Fiber 连接到通道:
$channel = new BoundedChannel(capacity: 500);
$producer = new Fiber(function () use ($channel): void {
$fp = fopen('orders-2026.csv', 'r');
$header = fgetcsv($fp);
while (($row = fgetcsv($fp)) !== false) {
$channel->send(array_combine($header, $row));
}
fclose($fp);
$channel->close();
});
$consumer = new Fiber(function () use ($channel): void {
$batch = [];
while (($row = $channel->receive()) !== null) {
$batch[] = $row;
if (count($batch) === 50) {
OrderImporter::insertBatch($batch); // blocking DB write
$batch = [];
}
}
if ($batch !== []) OrderImporter::insertBatch($batch);
});
$producer->start();
$consumer->start();
缓冲区达到500行时,生产者会自动暂停。消费者清空缓冲区后,生产者继续运行。内存使用保持稳定。使用这种模式处理9GB的CSV文件,整个运行过程中驻留内存始终低于100MB,因为缓冲区限制在500行。有趣的对比不是速度(数据库是瓶颈),而是无论fgetcsv读取多快,脚本都不会因内存耗尽而崩溃。
生成器会让人懒惰,但是生成器不能在辅助方法内部产生,否则会污染整个调用链。纤维可以。这才是真正的易用性优势.
案例三:替换生成器协程
在纤维出现之前,人们在PHP中这样处理异步工作:
function fetchUser(int $id): Generator {
$raw = yield $http->get("/users/$id"); // a Promise
$orders = yield $http->get("/orders?u=$id");
return ['user' => $raw, 'orders' => $orders];
}
你需要一个"协程运行器",它会遍历生成器,等待每个yield的承诺,并将结果推回。这种模式是有效的(Recoil、Amp v2大量使用它,甚至Laravel Octane也有它的变种),但签名: Generator总是不对。函数返回array。类型系统从未与现实相符。
Fibers解决了这个问题。同样的逻辑,真实的返回类型:
public function fetchUser(int $id): UserBundle
{
$user = $this->client->get("/users/$id"); // suspends internally
$orders = $this->client->get("/orders?u=$id");
return new UserBundle($user, $orders);
}
客户端在等待套接字时暂停当前的纤程。该函数看起来像直线同步代码。PHPStan、Psalm、IDE自动补全:它们都获得了正确的类型。Amp团队在v3版本中围绕纤程重写了所有内容(发布说明),正好就是这个原因。ReactPHP发布了react/async 4.x 做同样的:await() 是一个基于光纤的原始对象,它允许一个返回 Promise 的库看起来是同步的。
如果你有一个基于 Generator 的 2019 年的 "async" 辅助函数的代码库,通常移除它们以使用光纤会减少 30-40% 的文件内容,纯粹是仪式性的。行为没有变化。
案例 4:在 ReactPHP / AMPHP 里面,而不转换整个代码库
你不必完全投入事件循环才能使用 Fibers。这是大多数“我应该使用 Amp 吗?”的帖子所遗漏的点。
一个需要分发给三个 Webhooks 的 Symfony Messenger 处理器可以本地拉入一个 Fiber 感知的 HTTP 客户端,在处理器内部运行一个小await,并保持其他方面不变:
use Amp\Http\Client\HttpClientBuilder;
use Amp\Http\Client\Request;
use function Amp\async;
use function Amp\Future\awaitAll;
final class DispatchWebhooksHandler
{
public function __invoke(OrderPlaced $event): void
{
$client = HttpClientBuilder::buildDefault();
$urls = WebhookSubscriptions::for($event->orderId);
$futures = array_map(
fn(string $url) => async(fn() =>
$client->request(new Request($url, 'POST', json_encode($event)))
),
$urls,
);
[$errors, $responses] = awaitAll($futures);
foreach ($errors as $url => $err) {
FailedWebhook::record($url, $err->getMessage());
}
}
}
async()会生成一个 Fiber。awaitAll() 会阻塞处理器,直到所有 Fiber 完成执行,但仅限于这个处理器。你的 Symfony 应用的其余部分会继续执行它正在做的事情。没有全局运行的事件循环。没有“将整个框架转换为异步的”。
关键在于 Amp 的 EventLoop 在第一次使用时才懒加载初始化,并且在没有待处理任务时能够干净地关闭。同样的情况也适用于react/async。你可以在一个方法中获得协程的易用性,而不会感染整个代码库。
Fibers无法提供帮助的地方
PDO正在阻塞。将PDO::query()放在Fiber内部并不能使其异步。Fiber会阻塞整个进程,直到libpq返回。默认模式下的mysqli也是一样。本地文件上的file_get_contents()也是一样。sleep()也是一样。
CPU密集型工作(图像缩放、对200MB的JSON结构进行编码、bcrypt)从纤程中得不到任何好处。它们是为I/O等待设计的。如果瓶颈是一个紧密的for循环进行哈希操作,你需要使用pthread、并行处理或pcntl_fork,而不是协程.
心理检查:在Node.js中,这个操作会返回一个Promise吗libuv的调用?如果是,一个基于Fiber的包装器会很有帮助。如果不是(你的瓶颈是PHP本身),Fibers将无效。
主要注意事项:存在异步PDO替代品(amphp/postgres,amphp/mysql)。)。值得知道它们的存在。值得知道替换它们很少是无成本的。Eloquent 和 Doctrine 都假定阻塞驱动器。
错误处理的陷阱
Fibers 中的异常表现几乎和你预期的差不多。差不多。
$fiber = new Fiber(function (): void {
Fiber::suspend();
throw new RuntimeException("boom");
});
$fiber->start();
try {
$fiber->resume(); // exception propagates HERE, not where it was thrown
} catch (RuntimeException $e) {
// caught
}
异常会传递到调用者resume() 那通常没问题。陷阱在于 Fiber::throw():
$fiber = new Fiber(function (): void {
try {
Fiber::suspend();
} catch (DomainException $e) {
// the throw() call lands inside the suspend
}
});
$fiber->start();
$fiber->throw(new DomainException("cancelled"));
throw() 在 suspend() 点注入异常。如果 Fiber 没有捕获它,异常会冒泡到调用者。throw()。人们用它来取消操作,但很容易设置一个跨越三个 Fiber 的例外,从而失去跟踪实际捕获它的人。
经验法则:保持 Fiber 身体简短。如果一个 Fiber 在其入口点周围的错误处理比 try/catch 更复杂,你可能想要跨 suspend() 返回一个 Result 风格的对象,而不是通过它抛出。
这对你的代码库意味着什么
Fibers是一种基础组件,而不是你可以随意附加的功能。应该使用它们的地方:并行I/O分支、具有背压感知的流、替换基于生成器的异步辅助工具,以及在通常阻塞的框架内部嵌入使用。不应该使用它们的地方:CPU工作、PDO,以及你没有明确的挂起点描述的任何地方。
如果你已经部署了一个Laravel或Symfony应用,最低风险的第一步是案例4:一个单独的处理程序,通过Amp或react/async生成几个Fibers,在本地运行,然后退出。没有全局事件循环。没有基础设施变更。衡量收益,然后扩展。
你当前应用中最慢的分支是什么,案例4能否在不重写框架的情况下适应它?
如果这有帮助
纤维是运行时原语。更大的问题是架构上的:这个发散应该存在于你的领域层还是适配器中?这就是大多数代码库变得纠缠的地方。解耦PHP 是当框架默认功能不再足够时,你的代码库会去调用的架构层;它涵盖了如何在边缘保持异步 I/O,同时让你的领域代码保持愉悦的同步状态.
可在 Kindle、Paperback 和 Hardcover 版本上购买。英语、德语和日语版本现已上市——葡萄牙语和西班牙语版本即将推出。



























