在开发官方 PHP MCP SDK 的客户端通信功能时,开发团队遇到了一个看似无法优雅解决的架构挑战。传统的异步方案、回调模式和状态机都无法在不牺牲代码简洁性的前提下实现需求。最终,PHP 纤程(Fibers)成为了这个问题的完美解决方案。
该功能在 PR #109 中引入,其实现展示了 PHP 纤程最优雅的使用案例之一。但这不仅仅是关于一个问题的故事,更是关于一个自 PHP 8.1 以来一直隐藏在众目睽睽之下、却被大量误解和未充分利用的强大 PHP 特性。
本文将深入探讨:
PHP 纤程到底是什么(以及它们不是什么)
何时以及为何应该使用它们
如何理解协作式多任务
使用纤程的真实世界实现
可以在自己代码中使用的模式
文章较长,建议准备好咖啡慢慢看。
关于纤程的误解
首先要解决房间里的大象:PHP 纤程不是异步 PHP。它们不是并行机制,不是线程,也不是让 PHP 同时运行多个任务。
当 PHP 8.1 在 2021 年 11 月引入纤程支持时,许多开发者都感到困惑。"太好了,又一个异步东西?"大家这样想。这种困惑是可以理解的,因为纤程最显眼的使用场景一直是在 ReactPHP 和 AmPHP 等异步库中。
ReactPHP 甚至有一个名为 async 的包,使用纤程让异步代码看起来像同步代码:
// 纤程之前:回调地狱
$promise->then(function($result) {
return anotherAsyncCall($result);
})->then(function($finalResult) {
echo $finalResult;
});
// 使用纤程:看起来是同步的!
$result = await($promise);
$finalResult = await(anotherAsyncCall($result));
echo $finalResult;
看到这个,很容易认为"纤程 = 异步魔法"。但这忽略了更大的图景。
纤程的本质是协作式多任务。它们赋予代码暂停执行、执行其他操作、然后在完全保留所有变量、调用栈和执行上下文的情况下,精确地回到离开的位置继续执行的能力。
是的,这对异步库非常有用。但在需要受控中断和恢复的纯同步代码中,它同样有用。而这正是大多数 PHP 开发者错过的机会。
纤程采用缓慢的原因不是因为它们不够有用,而是因为大多数开发者不知道何时使用它们。而这正是本文要解决的问题。
理解纤程:基础知识
在深入复杂示例之前,让我们先建立坚实的基础。纤程到底是什么,它如何工作?
什么是协作式多任务?
理解纤程的一个好类比是,将标准 PHP 脚本想象成单轨道上的火车。它从 A 站开到 B 站,通常在到达 B 之前不能停止。纤程允许火车在轨道中间停下来,让乘客下车(或让乘客上洗手间休息),在此期间甚至让另一列火车使用这条轨道,然后在所有行李(变量和内存状态)完好无损的情况下,精确地从停下的地方恢复。
另一个类比是想象你在做饭的同时读书。你读几页书,然后定时器响了,你标记页面,搅拌锅里的东西,再回到刚才读到的地方继续阅读。这就是协作式多任务。
关键词是协作。你(读者/厨师)决定何时切换任务。没有人强行打断你,而是在合适的时候自愿交出控制权。
在编程术语中:
抢占式多任务:操作系统强制中断你的代码(线程、进程)
协作式多任务:你的代码决定何时交出控制权(协程、纤程)
纤程是 PHP 对协作式多任务的实现。它们让你能够:
开始执行一段代码
在任何点暂停它(挂起)
做其他事情
精确地从离开的地方恢复
根据需要重复任意多次
纤程的结构
让我们看一个简单的例子:
<?php
$fiber = new Fiber(function(): string {
echo "1. 纤程启动\n";
$value = Fiber::suspend('pause-1');
echo "3. 纤程恢复,收到: $value\n";
$value2 = Fiber::suspend('pause-2');
echo "5. 纤程再次恢复,收到: $value2\n";
return 'final-result';
});
echo "0. 启动纤程之前\n";
$suspended1 = $fiber->start();
echo "2. 纤程挂起,返回: $suspended1\n";
$suspended2 = $fiber->resume('data-1');
echo "4. 纤程再次挂起,返回: $suspended2\n";
$result = $fiber->resume('data-2');
echo "6. 纤程返回: $result\n";
输出:
0. 启动纤程之前
1. 纤程启动
2. 纤程挂起,返回: pause-1
3. 纤程恢复,收到: data-1
4. 纤程再次挂起,返回: pause-2
5. 纤程再次恢复,收到: data-2
6. 纤程返回: final-result
这里特意包含了数字,以便读者看清执行如何在纤程内外跳转。suspend 让它跳出纤程,resume 让它跳回纤程!为了更清晰,让我们分解一下发生了什么:
创建:new Fiber(function() {...}) 创建纤程但尚未执行
启动:$fiber->start() 开始执行,直到第一个 Fiber::suspend()
挂起:Fiber::suspend('pause-1') 暂停执行并将控制权返回给调用者
恢复:$fiber->resume('data-1') 从挂起处继续执行
返回:当纤程完成时,resume() 返回最终值
魔法在于执行上下文切换。当纤程挂起时:
所有局部变量都被保留
调用栈被保存
执行跳回到调用 start() 或 resume() 的地方
传递给 suspend() 的值返回给调用者
当你恢复时:
执行跳回纤程内部
传递给 resume() 的值成为 suspend() 的返回值
一切继续,就像什么都没发生过
一个让纤程变得强大的关键洞察:在纤程内部运行的代码不需要知道它在纤程中。
看看这个:
function processData(int $id): string {
$data = fetchData($id); // 这可能会挂起!
$result = transform($data); // 这也可能会挂起!
return $result;
}
// 在纤程内调用
$fiber = new Fiber(fn() => processData(42));
$fiber->start();
从 processData 的角度来看,它只是在调用函数并返回结果。它不知道 fetchData() 和 transform() 可能在幕后挂起纤程。复杂性是隐藏的。
这正是纤程非常适合构建隐藏复杂行为的干净 API 的原因。
异步库中的纤程
现在我们理解了基础知识,让我们看看为什么有些人会将纤程与异步代码联系起来。这也会在我们处理主要问题之前展示一个具体的使用案例。
异步问题
PHP 中的传统异步编程看起来像这样:
// 使用 promises(纤程之前)
function fetchUserData(int $userId): PromiseInterface {
return $this->httpClient->getAsync("/users/$userId")
->then(function($response) {
return json_decode($response->getBody());
})
->then(function($userData) use ($userId) {
return $this->cache->setAsync("user:$userId", $userData);
})
->then(function() use ($userId) {
return "User $userId cached";
});
}
这能工作,但很难阅读和理解。使用 catch() 的错误处理会变得混乱。调试很痛苦。而且感觉不像 PHP。
纤程解决方案
有了纤程,像 ReactPHP 这样的库可以提供这样的方式:
// 使用纤程(PHP 8.1 之后)
function fetchUserData(int $userId): string {
$response = await($this->httpClient->getAsync("/users/$userId"));
$userData = json_decode($response->getBody());
await($this->cache->setAsync("user:$userId", $userData));
return "User $userId cached";
}
好多了!但 await() 是如何工作的呢?让我们看一个简化版本:
namespace React\Async;
function await(PromiseInterface $promise): mixed {
// 挂起纤程并注册 promise 回调
$result = Fiber::suspend([
'type' => 'await',
'promise' => $promise
]);
// 恢复时,我们将得到结果或异常
if ($result instanceof \Throwable) {
throw $result;
}
return $result;
}
如果你感兴趣,像 PHPStan 这样的工具可以让你添加一些泛型魔法,这样 await() 就能准确知道从你的 Promise 返回什么。这种强大的静态分析感觉就像魔法。多酷啊?
以下是发生的过程:
用户代码调用 await($promise)(在纤程内部)
await() 调用 Fiber::suspend() 传递 promise
事件循环看到挂起的纤程和 promise
事件循环在纤程挂起时照常继续处理其他事情
当 promise 解决时,循环调用 $fiber->resume($value)
执行在 await() 中继续,返回值
用户代码得到值,就像它是同步的!
纤程在等待异步操作时挂起,但用户的代码看起来完全是同步的。
更进一步:真正透明的异步
但我们可以走得更远!像 AmPHP 这样的库通过创建围绕异步操作的纤程感知包装器,将其提升到新的水平。你不需要单独的 getAsync() 和 await() 调用,只需要看起来完全同步的方法:
// AmPHP 方法:不需要 await()!
function fetchUserData(int $userId): string {
$response = $this->httpClient->get("/users/$userId"); // 看起来同步,实际异步!
$userData = json_decode($response->getBody());
$this->cache->set("user:$userId", $userData); // 看起来同步,实际异步!
return "User $userId cached";
}
等等,什么?没有 await() 调用?这是如何工作的?
魔法在于 get() 和 set() 内部使用纤程。这是一个简化的例子:
class HttpClient {
public function get(string $url): Response {
// 创建异步操作
$promise = $this->performAsyncRequest('GET', $url);
// 挂起当前纤程并将 promise 传递给事件循环
$response = \Fiber::suspend([
'type' => 'await',
'promise' => $promise
]);
if ($response instanceof \Throwable) {
throw $response;
}
return $response;
}
}
从用户的角度来看,他们只是调用了 get() 并得到了响应。他们完全不知道这是异步的。
这就是纤程的精髓:让异步操作完全透明。用户编写看起来像阻塞的同步 PHP 代码。库使用纤程在幕后处理所有异步复杂性。
比较这些方法
让我们看看演变过程:
// 1. 传统异步与 promises(无纤程)
$promise = $this->httpClient->getAsync("/users/$userId")
->then(fn($response) => json_decode($response->getBody()))
->then(fn($userData) => $this->cache->setAsync("user:$userId", $userData))
->then(fn() => "User $userId cached");
// 2. 使用 await() 辅助函数的异步(使用纤程)
$response = await($this->httpClient->getAsync("/users/$userId"));
$userData = json_decode($response->getBody());
await($this->cache->setAsync("user:$userId", $userData));
return "User $userId cached";
// 3. 完全透明的异步(纤程隐藏在库中)
$response = $this->httpClient->get("/users/$userId");
$userData = json_decode($response->getBody());
$this->cache->set("user:$userId", $userData);
return "User $userId cached";
注意方法 #3 看起来与同步代码完全一样?这就是正确使用纤程的力量。库开发者处理一次复杂性。每个用户都受益于一个干净的、看起来同步的 API,实际上在底层是异步的。
为什么这导致了误解
因为纤程最显眼的用途是让异步代码看起来同步,开发者假设纤程本身就是异步机制。但纤程本身不做任何异步操作。它们只是提供挂起/恢复机制,使得看起来同步的异步代码成为可能。
事件循环仍在做实际的异步工作。纤程只是让 API 更好用。
这个区别至关重要:纤程是管理执行流的工具,而不是实现并行或异步的工具。
真正的问题:MCP SDK 中的客户端通信
现在让我们进入本文的核心问题。在开发 Model Context Protocol (MCP) 的 PHP 实现时,开发团队遇到了一个似乎无法优雅解决的设计挑战。
什么是 MCP?
Model Context Protocol 是连接 AI 助手(如 Claude)与外部工具和数据源的标准。
一个 MCP 服务器暴露:
工具:AI 可以调用的函数(例如:"搜索数据库"、"发送邮件")
资源:AI 可以读取的数据(例如:"项目文件"、"API 文档")
提示:AI 可以使用的模板
该协议是双向的 JSON-RPC,支持不同的传输方式(STDIO、HTTP + SSE、自定义)。
挑战
MCP 规范包含服务器在请求处理期间与客户端通信的功能:
日志记录:向客户端发送日志消息
进度更新:更新客户端关于长时间运行操作的进度
采样:请求客户端使用其 LLM 生成文本
这些不仅仅是响应类型。不,问题是它们需要在工具执行期间发生。例如:
客户端: "嘿服务器,运行 'analyze_dataset' 工具"
服务器: "开始..." [发送日志]
服务器: "25% 完成" [发送进度]
服务器: "50% 完成" [发送进度]
服务器: "生成摘要,需要你的 LLM" [发送采样请求]
客户端: "这是生成的摘要" [响应采样]
服务器: "完成!这是完整结果" [发送最终响应]
服务器需要:
在执行过程中发送消息
等待来自客户端的响应
在收到响应后继续执行
让所有这些感觉起来很自然
API 需求
在 MCP SDK 方面,优先事项之一是使其极其易用。开发团队希望开发者这样编写工具:
$server->addTool(
function (string $dataset, ClientGateway $client): array {
$client->log(LoggingLevel::Info, "开始分析");
foreach ($steps as $step) {
$client->progress($progress, 1, $step);
doWork($step);
}
$summary = $client->sample("总结这些数据:...");
return ['status' => 'complete', 'summary' => $summary];
},
name: 'analyze_dataset'
);
看看这段代码。它很漂亮。它很简单。它看起来完全是同步的。没有回调,没有 promises,没有 async/await 语法,没有 yield 生成器。只是普通的 PHP。
但在底层,这需要:
向客户端发送 JSON-RPC 通知(日志、进度)
发送 JSON-RPC 请求并等待响应(采样)
与任何传输方式工作,无论是否阻塞!
无论你使用原生 PHP、ReactPHP、Swoole 还是 RoadRunner 都能工作
如何实现?
为什么传统方法行不通
开发团队花了几个小时考虑不同的解决方案:
选项 1:让一切都异步