PHP Fiber 优雅协作式多任务

PHP Fiber 优雅协作式多任务

在 PHP Model Context Protocol (MCP) SDK 开发过程中遇到的实际问题,深入探讨了 PHP 纤程(Fibers)这一被低估的强大特性。文章详细展示了如何使用纤程解决复杂的双向通信问题,以及如何构建既优雅又实用的 API。

原文链接 PHP Fiber 优雅协作式多任务

背景

在开发官方 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 的包,使用纤程让异步代码看起来像同步代码:

php 复制代码
// 纤程之前:回调地狱
$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 对协作式多任务的实现。它们让你能够:

  1. 开始执行一段代码
  2. 在任何点暂停它(挂起)
  3. 做其他事情
  4. 精确地从离开的地方恢复
  5. 根据需要重复任意多次

纤程的结构

让我们看一个简单的例子:

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 让它跳回纤程!为了更清晰,让我们分解一下发生了什么:

  1. 创建new Fiber(function() {...}) 创建纤程但尚未执行
  2. 启动$fiber->start() 开始执行,直到第一个 Fiber::suspend()
  3. 挂起Fiber::suspend('pause-1') 暂停执行并将控制权返回给调用者
  4. 恢复$fiber->resume('data-1') 从挂起处继续执行
  5. 返回 :当纤程完成时,resume() 返回最终值

魔法在于执行上下文切换。当纤程挂起时:

  • 所有局部变量都被保留
  • 调用栈被保存
  • 执行跳回到调用 start()resume() 的地方
  • 传递给 suspend() 的值返回给调用者

当你恢复时:

  • 执行跳回纤程内部
  • 传递给 resume() 的值成为 suspend() 的返回值
  • 一切继续,就像什么都没发生过

一个让纤程变得强大的关键洞察:在纤程内部运行的代码不需要知道它在纤程中

看看这个:

php 复制代码
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 中的传统异步编程看起来像这样:

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 复制代码
// 使用纤程(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() 是如何工作的呢?让我们看一个简化版本:

php 复制代码
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 返回什么。这种强大的静态分析感觉就像魔法。多酷啊?

以下是发生的过程:

  1. 用户代码调用 await($promise)(在纤程内部)
  2. await() 调用 Fiber::suspend() 传递 promise
  3. 事件循环看到挂起的纤程和 promise
  4. 事件循环在纤程挂起时照常继续处理其他事情
  5. 当 promise 解决时,循环调用 $fiber->resume($value)
  6. 执行在 await() 中继续,返回值
  7. 用户代码得到值,就像它是同步的!

纤程在等待异步操作时挂起,但用户的代码看起来完全是同步的。

更进一步:真正透明的异步

但我们可以走得更远!像 AmPHP 这样的库通过创建围绕异步操作的纤程感知包装器,将其提升到新的水平。你不需要单独的 getAsync()await() 调用,只需要看起来完全同步的方法:

php 复制代码
// 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() 内部使用纤程。这是一个简化的例子:

php 复制代码
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 代码。库使用纤程在幕后处理所有异步复杂性。

比较这些方法

让我们看看演变过程:

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" [发送采样请求]
客户端: "这是生成的摘要" [响应采样]
服务器: "完成!这是完整结果" [发送最终响应]

服务器需要:

  1. 在执行过程中发送消息
  2. 等待来自客户端的响应
  3. 在收到响应后继续执行
  4. 让所有这些感觉起来很自然

API 需求

在 MCP SDK 方面,优先事项之一是使其极其易用。开发团队希望开发者这样编写工具:

php 复制代码
$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。

但在底层,这需要:

  1. 向客户端发送 JSON-RPC 通知(日志、进度)
  2. 发送 JSON-RPC 请求并等待响应(采样)
  3. 与任何传输方式工作,无论是否阻塞!
  4. 无论你使用原生 PHP、ReactPHP、Swoole 还是 RoadRunner 都能工作

如何实现?

为什么传统方法行不通

开发团队花了几个小时考虑不同的解决方案:

选项 1:让一切都异步

php 复制代码
// 基于 Promise 的方法 - 嵌套且混乱
$server->addTool(function (string $dataset, $client) {
    return $client->logAsync(LoggingLevel::Info, "开始分析")
        ->then(function() use ($client) {
            return $client->progressAsync(0.33, 1, "步骤 1");
        })
        ->then(function() use ($client) {
            return $client->progressAsync(0.66, 1, "步骤 2");
        })
        ->then(function() use ($client) {
            return $client->sampleAsync("总结...");
        })
        ->then(function($summary) {
            return ['status' => 'complete', 'summary' => $summary];
        });
});

回调嵌套很快就会变得笨拙。即使使用 await() 辅助函数来简化:

php 复制代码
$server->addTool(function (string $dataset, $client) {
    await($client->logAsync(LoggingLevel::Info, "开始"));
    await($client->progressAsync(0.33, 1, "步骤 1"));
    await($client->progressAsync(0.66, 1, "步骤 2"));
    $summary = await($client->sampleAsync("总结..."));
    return ['status' => 'complete', 'summary' => $summary];
});

这强制每个人学习异步 PHP。它使异步库成为核心依赖,并将 SDK 限制在选择的异步运行时。与服务器请求和响应的 PSR-7 不同,PHP 中没有事件循环或异步运行时的标准,因此供应商锁定不是一个选项。对于简单的工具来说,这也是过度杀伤。被拒绝。

选项 2:回调

php 复制代码
// 回调地狱警告!
$server->addTool(function (string $dataset, $client) {
    $client->log(..., function() use ($client) {
        $client->progress(..., function() use ($client) {
            $client->sample(..., function($summary) {
                return ['summary' => $summary];
            });
        });
    });
});

没人想要这个。无需进一步解释。被拒绝。

选项 3:状态机和序列化

如果我们跟踪执行状态并从检查点重新执行处理程序会怎么样?

php 复制代码
// 伪代码
if ($state->step === 0) {
    $client->log(...);
    $state->step = 1;
    return $state->serialize();
}
if ($state->step === 1) {
    $client->progress(...);
    $state->step = 2;
    return $state->serialize();
}
// ...以此类推

这非常复杂。即使抽象部分内容并允许用户编写同步代码,跟踪状态也有很多工作要做。如何序列化闭包?如何恢复局部变量?如何处理循环?这将需要完全改变用户编写工具的方式。被拒绝。

选项 4:带 yield 的生成器

php 复制代码
$server->addTool(function (string $dataset, $client) {
    yield $client->log(...);
    yield $client->progress(...);
    $summary = yield $client->sample(...);
    return ['summary' => $summary];
});

这更接近了,但生成器有限制。你不能轻松地从嵌套函数调用中 yield。语法很笨拙。用户需要理解生成器。不理想,但可行。

选项 5:PHP 纤程

如果挂起/恢复是不可见的会怎么样?如果 $client->log() 看起来像一个普通的方法调用,但在幕后它挂起纤程,发送消息,然后恢复呢?

php 复制代码
// 用户编写的内容(看起来是同步的!)
$server->addTool(function (string $dataset, $client) {
    $client->log(...);  // 内部挂起
    $client->progress(...);  // 内部挂起
    $summary = $client->sample(...);  // 挂起并等待
    return ['summary' => $summary];
});

就是这个。这就是解决方案。用户编写普通的 PHP。SDK 处理所有复杂性。

"啊哈!"时刻

当开发团队意识到纤程是答案时,一切都豁然开朗了。以下是它们完美的原因:

  • 透明:用户代码不需要知道纤程
  • 灵活:适用于任何传输(阻塞或非阻塞)
  • 简单:API 只是常规方法调用
  • 强大:完全控制执行流
  • 通用:适用于同步 PHP、异步 PHP、任何运行时

纤程让开发团队能够在干净的、看起来同步的 API 背后隐藏双向通信的复杂性。用户编写简单的函数。SDK 管理纤程生命周期。传输处理实际的 I/O。

这是完美的关注点分离。

思考过程:为什么纤程在这里有效

让我们深入了解为什么纤程特别适合解决这个问题。

核心挑战

当工具处理程序调用 $client->log() 时,需要:

  1. 暂停处理程序的执行
  2. 向客户端发送 JSON-RPC 通知(机制取决于传输)
  3. 立即恢复处理程序(日志记录不需要等待)

当工具处理程序调用 $client->sample() 时,需要:

  1. 暂停处理程序的执行
  2. 向客户端发送 JSON-RPC 请求(机制取决于传输)
  3. 等待客户端的响应(如何接收响应也取决于传输)
  4. 使用响应恢复处理程序

关键洞察:需要离开处理程序的执行上下文,做其他事情,然后在特定点返回。而且需要能够多次这样做。这正是纤程提供的功能。

架构

解决方案有三层:

  1. ClientGateway(面向用户的 API)

    • 提供 log()progress()sample() 等方法
    • 内部调用 Fiber::suspend() 传递消息数据
    • 恢复时返回响应
  2. Protocol(编排层)

    • 将处理程序执行包装在纤程中
    • 检测纤程何时挂起
    • 提取挂起的值(通知或请求)
    • 将纤程移交给传输层
  3. Transport(I/O 层)

    • 获取挂起纤程的所有权
    • 向客户端发送消息(响应、请求和通知)
    • 等待响应(如果需要)
    • 准备就绪时恢复纤程

每一层都有明确的职责。魔法在于它们如何协调。

为什么它同时适用于同步和异步

这种方法的美妙之处在于纤程与传输无关。无论你使用的是:

  • Stdio(阻塞,单进程)- 官方 SDK 的一部分
  • HTTP 与 PHP-FPM(无状态,多进程)- 官方 SDK 的一部分
  • ReactPHP(非阻塞,事件驱动)- 展示异步兼容性的外部示例
  • Swoole(基于协程)- 使用相同架构可行

挂起/恢复机制都是相同的。传输根据其执行模型决定何时恢复纤程。纤程本身不关心。它只是挂起和等待。

对于阻塞传输,恢复发生在同一进程的循环中。对于非阻塞传输,恢复通过事件循环回调发生。对于多进程传输,当从共享会话中提取响应时恢复发生。纤程不关心这些细节。

实现:架构概述

现在让我们深入实际实现。下面将展示一些来自 PHP MCP SDK 的真实代码,并解释一切如何组合在一起。

三个关键组件

系统有三个主要部分:

复制代码
用户代码(处理程序)
       ↓
ClientGateway(API)
       ↓
   Protocol(编排器)
       ↓
   Transport(I/O)

移交:从 Protocol 到 Transport

这是关键时刻。以下是 Protocol 中的简化流程:

php 复制代码
// src/Server/Protocol.php
// Protocol::handleRequest()
public function handleRequest(Request $request, SessionInterface $session): void {
    $handler = $this->findHandler($request);
 
    // 在纤程内执行处理程序!
    $fiber = new \Fiber(fn() => $handler->handle($request, $session));
 
    $result = $fiber->start();
 
    if ($fiber->isSuspended()) {
        // 纤程产生了某些东西!提取它。
        if ($result['type'] === 'notification') {
            $this->sendNotification($result['notification'], $session);
        } elseif ($result['type'] === 'request') {
            $this->sendRequest($result['request'], $result['timeout'], $session);
        }
 
        // 将纤程交给传输层
        $this->transport->attachFiberToSession($fiber, $session->getId());
    } else {
        // 纤程完成而未挂起
        $finalResult = $fiber->getReturn();
        $this->sendResponse($finalResult, $session);
    }
}

协议启动纤程并检查它是否挂起。如果挂起了,协议提取被挂起的内容(通知或请求),将其排队发送,并将纤程交给传输层。稍后会详细讨论这一点。现在让我们继续。

从这一点开始,传输层拥有纤程的生命周期。进一步的挂起和恢复由传输层处理。

Transport 的职责

每个传输必须:

  1. 从协议接受纤程
  2. 向客户端发送排队的消息
  3. 接收客户端响应
  4. 在适当的时间恢复纤程
  5. 处理纤程终止

不同的传输基于其执行模型以不同方式实现这一点。让我们看看每一个。

用户体验:它的外观

在深入传输实现之前,让我们看看从用户角度来看最终结果是什么样的。这很重要,因为它展示了为什么复杂性是值得的。

示例 1:简单的进度更新

这是来自 MCP SDK 文档的真实示例:

php 复制代码
// server.php
$server->addTool(
    function (string $dataset, ClientGateway $client): array {
        $client->log(LoggingLevel::Info, "对数据集运行质量检查: $dataset");
 
        $tasks = [
            '验证 schema',
            '扫描异常',
            '审查统计摘要',
        ];
 
        foreach ($tasks as $index => $task) {
            $progress = ($index + 1) / count($tasks);
            $client->progress($progress, 1, $task);
 
            usleep(140_000); // 模拟工作
        }
 
        $client->log(LoggingLevel::Info, "数据集 $dataset 通过自动检查");
 
        return [
            'dataset' => $dataset,
            'status' => 'passed',
            'notes' => '未检测到重大问题',
        ];
    },
    name: 'run_dataset_quality_checks',
    description: '执行带进度更新的数据集质量检查'
);

看看这个工具代码。它只是一个普通函数。它遍历任务。它调用 $client->progress(),就像调用普通方法一样。没有迹象表明这在进行复杂的双向通信。

但实际上发生的是:

  1. 处理程序启动(在纤程内)
  2. $client->log() 挂起纤程
  3. 传输发送日志通知
  4. 纤程恢复
  5. 循环开始
  6. 第一个 $client->progress() 挂起纤程
  7. 传输发送进度通知
  8. 纤程恢复
  9. usleep() 运行(仍在纤程中)
  10. 第二个 $client->progress() 再次挂起
  11. ...以此类推

每次挂起和恢复对用户都是不可见的。代码看起来和表现得像同步 PHP。执行不断在传输的循环(现在是所有者)和工具的处理程序之间来回跳转,每次回到处理程序时,都回到完美的位置并恢复(甚至在 foreach 循环内)。请定义美!!

示例 2:请求 LLM 采样

这是一个更复杂的示例,实际等待响应:

php 复制代码
// app/Tools/IncidentCoordinator.php
class IncidentCoordinator implements ClientAwareInterface {
    use ClientAwareTrait;  // 提供 $this->log 和 $this->progress
 
    #[McpTool('coordinate_incident_response', '协调事件响应')]
    public function coordinateIncident(string $incidentTitle): array {
        $this->log(LoggingLevel::Warning, "事件分类开始: $incidentTitle");
 
        $steps = [
            '收集遥测数据',
            '评估范围',
            '协调响应者',
        ];
 
        foreach ($steps as $index => $step) {
            $progress = ($index + 1) / count($steps);
            $this->progress($progress, 1, $step);
            usleep(180_000);
        }
 
        // 请求客户端的 LLM 生成响应策略
        $prompt = "为事件 \"$incidentTitle\" 提供简洁的响应策略
                   基于: " . implode(', ', $steps);
 
        $result = $this->sample($prompt, 350, 90, ['temperature' => 0.5]);
 
        $recommendation = $result->content instanceof TextContent
            ? trim($result->content->text)
            : '';
 
        $this->log(LoggingLevel::Info, "事件分类完成");
 
        return [
            'incident' => $incidentTitle,
            'recommended_actions' => $recommendation,
            'model' => $result->model,
        ];
    }
}

这更加神奇。sample() 调用:

  1. 使用采样请求挂起纤程
  2. 传输向客户端发送请求
  3. 传输等待客户端响应(响应如何到来取决于传输,这可能需要几秒钟!)
  4. 当响应到达时,传输使用它恢复纤程
  5. $result 包含响应,执行继续

从方法的角度来看,它进行了同步调用并得到了结果。在幕后:

  1. 纤程被挂起
  2. 控制权返回到传输的事件循环
  3. 传输处理其他事情(可能是其他请求)
  4. 当响应到来时(可能来自另一个 HTTP 请求/进程),纤程恢复
  5. 方法继续,就像什么都没发生过

这就是协作式多任务的实际应用。编写这个工具的开发者完全不知道这正在发生。

ClientAwareTrait 模式

注意上面示例中的 ClientAwareTrait。这是访问 ClientGateway 的两种方式之一:

方法 1:在处理程序中进行类型提示

php 复制代码
#[McpTool('my_tool')]
public function myTool(string $input, ClientGateway $client): string {
    $client->log(...);
    return $result;
}

SDK 检测 ClientGateway 参数并自动注入它。

方法 2:实现 ClientAwareInterface

php 复制代码
class MyService implements ClientAwareInterface {
    use ClientAwareTrait;  // 提供 setClient() 和辅助方法
 
    #[McpTool('my_tool')]
    public function myTool(string $input): string {
        $this->log(...);  // ClientAwareTrait 提供此方法
        $this->progress(...);
        $result = $this->sample(...);
        return $result;
    }
}

SDK 在调用处理程序之前调用 setClient(),trait 提供像 log()progress()sample() 这样的便捷方法,这些方法内部使用客户端。

两种方法都提供相同的能力。用户根据偏好选择。

底层:ClientGateway

现在让我们剥开第一层,看看 ClientGateway 如何工作。这是用户交互的 API。它出奇地(并不出奇地)简单,但超级强大。

这是实际实现(为清晰起见进行了简化):

php 复制代码
// src/Server/ClientGateway.php
final class ClientGateway {
    public function __construct(
        private readonly SessionInterface $session,
    ) {}
 
    /**
     * 向客户端发送通知(即发即忘)。
     */
    public function notify(Notification $notification): void {
        \Fiber::suspend([
            'type' => 'notification',
            'notification' => $notification,
            'session_id' => $this->session->getId()->toRfc4122(),
        ]);
    }
 
    /**
     * 向客户端发送日志消息。
     */
    public function log(LoggingLevel $level, mixed $data, ?string $logger = null): void {
        $this->notify(new LoggingMessageNotification($level, $data, $logger));
    }
 
    /**
     * 向客户端发送进度更新。
     */
    public function progress(float $progress, ?float $total = null, ?string $message = null): void {
        $meta = $this->session->get(Protocol::SESSION_ACTIVE_REQUEST_META, []);
        $progressToken = $meta['progressToken'] ?? null;
 
        if (null === $progressToken) {
            // 客户端未请求进度,跳过
            return;
        }
 
        $this->notify(new ProgressNotification($progressToken, $progress, $total, $message));
    }
 
    /**
     * 从客户端请求 LLM 采样。
     */
    public function sample(
        array|Content|string $message,
        int $maxTokens = 1000,
        int $timeout = 120,
        array $options = []
    ): CreateSamplingMessageResult {
        // 准备消息
        if (is_string($message)) {
            $message = new TextContent($message);
        }
        if ($message instanceof Content) {
            $message = [new SamplingMessage(Role::User, $message)];
        }
 
        $request = new CreateSamplingMessageRequest(
            messages: $message,
            maxTokens: $maxTokens,
            preferences: $options['preferences'] ?? null,
            systemPrompt: $options['systemPrompt'] ?? null,
            temperature: $options['temperature'] ?? null,
            // ...其他选项
        );
 
        // 发送请求并等待响应
        $response = $this->request($request, $timeout);
 
        if ($response instanceof Error) {
            throw new ClientException($response);
        }
 
        return CreateSamplingMessageResult::fromArray($response->result);
    }
 
    /**
     * 向客户端发送请求并等待响应。
     */
    private function request(Request $request, int $timeout = 120): Response|Error {
        $response = \Fiber::suspend([
            'type' => 'request',
            'request' => $request,
            'session_id' => $this->session->getId()->toRfc4122(),
            'timeout' => $timeout,
        ]);
 
        if (!$response instanceof Response && !$response instanceof Error) {
            throw new RuntimeException('传输返回了意外的载荷');
        }
 
        return $response;
    }
}

关键方法

notify() - 最简单的情况:

php 复制代码
public function notify(Notification $notification): void {
    \Fiber::suspend([
        'type' => 'notification',
        'notification' => $notification,
        'session_id' => $this->session->getId()->toRfc4122(),
    ]);
}

这会使用一个数据结构挂起当前纤程,该结构指示:

  • 这是一个通知(不需要响应)
  • 要发送什么通知
  • 它属于哪个会话

纤程将在通知排队后立即恢复。

request() - 复杂的情况:

php 复制代码
private function request(Request $request, int $timeout = 120): Response|Error {
    $response = \Fiber::suspend([
        'type' => 'request',
        'request' => $request,
        'session_id' => $this->session->getId()->toRfc4122(),
        'timeout' => $timeout,
    ]);
 
    return $response;
}

这会使用一个数据结构挂起纤程,该结构指示:

  • 这是一个请求(期望响应)
  • 要发送什么请求
  • 等待响应多长时间

纤程在以下情况之前不会恢复:

  1. 客户端发送响应,或
  2. 超时到期

当恢复时,传递给 $fiber->resume($value) 的值成为 Fiber::suspend() 的返回值。因此 $response 将是 Response 对象(成功)或 Error 对象(失败/超时)。

何时应该使用纤程?

现在你已经看到了纤程的实际应用,什么时候应该在自己的代码中真正使用它们?

纤程使用案例检查清单

在以下情况考虑使用纤程:

需要暂停和恢复执行 - 核心使用案例。如果你需要离开一个函数,做其他事情,然后回来。

想要对用户隐藏复杂性 - 如果你正在构建一个库,并希望提供一个干净的 API 来隐藏异步或有状态的行为。

需要协作式多任务 - 当你希望多个"任务"在没有线程或进程的情况下取得进展。

正在桥接同步和异步代码 - 当你想让异步操作看起来同步时(如 ReactPHP 的 await)。

需要维护执行上下文 - 当使用生成器暂停和恢复会受到太多限制时(不能轻松地从嵌套调用中 yield)。

正在构建基础设施代码 - 库、框架和 SDK 最能从纤程中受益。

何时不使用纤程

在以下情况不要使用纤程:

简单的回调就足够了 - 不要把事情复杂化。如果回调有效,就使用回调。

需要真正的并行性 - 纤程是协作的,不是并行的。使用进程、线程或异步 I/O 实现并行性。

代码简单且线性 - 如果不需要中断或恢复,纤程会增加不必要的复杂性。

你不控制执行流 - 纤程在库和框架中表现出色,在应用代码中则较少。

生成器工作正常 - 如果生成器(yield)干净地解决了你的问题,坚持使用它们。纤程更强大但也更复杂。

常见陷阱和注意事项

1. 理解谁控制纤程

关于纤程最基本的理解:当纤程挂起时,它将控制权让回给某人。那个"某人"就是编排器,你需要知道它是谁。

纤程代表一个工作单元。当它调用 Fiber::suspend() 时,执行跳出纤程并返回到调用 $fiber->start()$fiber->resume() 的实体。该实体负责决定何时(以及是否)恢复纤程。

在 MCP 传输中:

  • StdioTransport :主循环(while (!feof($input)))是编排器。它持续处理输入、管理纤程并刷新输出。
  • StreamableHttpTransport:SSE 流的阻塞循环在该请求的生命周期内成为编排器。它阻塞整个进程并管理纤程直到完成。
  • ReactPHP:事件循环是编排器。我们不阻塞它;相反,我们注册循环管理的定时器。

关键原则:编排器不能被永久阻塞,否则你的纤程永远不会恢复。如果你正在编写挂起纤程的代码,确保接收控制权的实体有机制来恢复它们。

还要注意:纤程可以嵌套。你可以在纤程内部创建纤程。编排器可以是一个中央管理器(如我们的 TaskManager 示例所示),或者父纤程本身可以充当其子纤程的编排器。只需清楚谁在管理谁,并确保编排器不会无限期地被阻塞。

2. 忘记你在纤程中

php 复制代码
function myHandler() {
    $client->sample("生成文本");  // 这会挂起!
    // 这里的任何代码都在挂起和恢复之后运行
}

记住挂起可能发生在调用栈的深处。始终考虑在挂起期间可能改变的状态。

3. 资源生命周期

php 复制代码
$lock = $mutex->acquire();
$client->sample("...");  // 纤程在这里挂起
$lock->release();  // 这会晚得多才运行!

小心跨越挂起点的资源(锁、数据库事务、文件句柄)。纤程可能会挂起几秒钟或几分钟。

4. 跨挂起的异常处理

php 复制代码
try {
    $result = $client->sample("...");  // 挂起
} catch (\Throwable $e) {
    // 这捕获纤程内部的异常
    // 不是挂起期间的异常
    // 除非纤程用 throw() 恢复
}

异常在纤程内正常工作,但挂起/恢复机制本身有单独的错误处理。因此理解异常不会自动跨越纤程和编排器之间的边界至关重要。

如果纤程抛出异常,它会冒泡到编排器(通过 $fiber->start()$fiber->resume())。如果编排器抛出异常,它不会自动进入纤程(因为异常可能与挂起的纤程相关,也可能无关)。

你必须明确决定如何桥接这个差距。你是希望编排器独立崩溃吗?你想捕获错误并使用失败对象 resume() 吗?还是你想将其 throw() 到纤程中?这些是架构决策,不是默认行为。

5. 全局状态

php 复制代码
global $counter;
$counter++;
$client->log("计数:$counter");  // 挂起
$counter++;  // 如果另一个纤程在挂起期间修改了 $counter 会怎样?

小心全局状态。其他代码(或其他纤程)可能在你挂起时修改它。

6. 纤程创建开销

创建纤程有少量开销。不要创建数百万个。与线程相比它们是轻量级的,但不是免费的。

结论:理解你的工具的力量

当你学习数据结构和算法时,你不仅仅是记忆定义和语法,你还学习何时使用它们。例如,如果你不认识何时需要在两端快速插入/删除,双向链表就没有用。

这同样适用于语言特性。PHP 自 8.1 以来就有了纤程,但大多数开发者不使用它们,因为他们不认识纤程解决的问题。所以,下次当你面临涉及以下问题时:

  • 暂停和恢复执行
  • 在干净的 API 背后隐藏复杂性
  • 使异步代码感觉同步
  • 协作式多任务

问自己:"纤程能优雅地解决这个问题吗?"

你可能会惊讶于答案是肯定的频率。

总结

PHP 纤程(Fibers)自 PHP 8.1 引入以来,一直是一个被低估的特性。通过 PHP MCP SDK 的客户端通信功能(PR #109)这个实际案例,我们看到了纤程如何优雅地解决复杂的架构问题。

这个实现的精妙之处不在于其技术复杂度,而在于其设计理念------通过纤程将复杂的双向通信机制隐藏在简洁的同步风格 API 背后,让用户能够编写直观、易读的代码,而无需关心底层的挂起、恢复和状态管理。

关键要点

  1. 纤程不是异步机制,而是协作式多任务的实现,是管理执行流的工具
  2. 正确的抽象层次:让复杂性在库层面解决一次,所有用户受益
  3. 传输无关性:同一套纤程机制可以适配不同的 I/O 模型(阻塞、非阻塞、多进程)
  4. 用户友好:最好的技术是让问题对用户消失的技术

适用场景

纤程最适合:

  • 构建库和框架
  • 隐藏异步复杂性
  • 需要暂停和恢复执行上下文的场景
  • 桥接同步和异步代码

不适用于:

  • 简单的线性流程
  • 需要真正并行性的场景
  • 可以用简单回调或生成器解决的问题

选择正确的工具,理解工具的本质,是构建优雅软件的关键。PHP 纤程正是这样一个被低估但极其强大的工具。

相关推荐
JaguarJack18 小时前
推荐 PHP 属性(Attributes) 简洁读取 API 扩展包
后端·php·服务端
BingoGo18 小时前
推荐 PHP 属性(Attributes) 简洁读取 API 扩展包
php
JaguarJack2 天前
告别 Laravel 缓慢的 Blade!Livewire Blaze 来了,为你的 Laravel 性能提速
后端·php·laravel
郑州光合科技余经理2 天前
代码展示:PHP搭建海外版外卖系统源码解析
java·开发语言·前端·后端·系统架构·uni-app·php
QQ5110082852 天前
python+springboot+django/flask的校园资料分享系统
spring boot·python·django·flask·node.js·php
WeiXin_DZbishe2 天前
基于django在线音乐数据采集的设计与实现-计算机毕设 附源码 22647
javascript·spring boot·mysql·django·node.js·php·html5
longxiangam3 天前
Composer 私有仓库搭建
php·composer
上海云盾-高防顾问3 天前
DNS异常怎么办?快速排查+解决指南
开发语言·php
ShoreKiten3 天前
关于解决本地部署sqli-labs无法安装低版本php环境问题
开发语言·php
liliangcsdn3 天前
深入探索TD3算法的推理过程
开发语言·php