MCP Client并发与异步设计:从事件循环到超时取消的工程实践 📅 发布时间:2026/9/16 4:17:39 👁 浏览次数: 做一个MCP Client的并发与异步设计听起来像是一个把请求发出去、等结果回来的小事真正动手之后才发现里面全是细节。我先说结论MCP协议本身基于JSON-RPC 2.0每个请求都有独立的id天然具备并发基础但因为Client要同时面对多个会话、多个Server、长耗时Tool和不可靠网络一旦把并发数提上去连接管理、调度策略、超时控制、取消传播这些事会一起压过来任何一个环节偷懒都会在高并发下现出原形。这篇东西是我在设计一个供内部Agent框架使用的MCP Client SDK时积累下来的实践总结从架构选型、调度逻辑、生命周期管理到压测验证和踩坑复盘都有涉及适合正在写MCP Client、或者打算把已有客户端改造成高并发形态的开发者参考。我会把每个设计决策背后的理由和踩过的坑一起讲清楚而不是只给一份能跑的代码。1. 并发从哪来MCP Client 的请求生命周期与瓶颈定位动手写并发之前先别急着铺线程池和队列我建议先把一个MCP请求从发起到返回的完整路径摊开找到哪些环节能并发、哪些环节天生串行、哪些环节才是真正的瓶颈。这步不做后面所有优化都是瞎调参。1.1 完整请求链路拆解一个典型的MCP请求长这样客户端序列化JSON-RPC消息写入传输层stdio管道或者HTTP流服务端解析消息、路由到对应的工具处理器工具执行完后把结果封装成JSON-RPC响应再沿原路返回客户端解析响应通过requestId找到当初发起请求的Future并唤醒它。发请求侧构造请求体 - 分配requestId - 登记到pending表 - 写入传输层 服务端侧读取消息 - 路由分发 - 执行Tool - 组装响应 - 写回传输层 收响应侧读取响应 - 按id查pending表 - 赋值Future - 唤醒调用方在这条链路上每个环节的并发能力完全不同。序列化和反序列化是CPU轻操作单线程完全够传输层写入需要保证消息完整性同一个连接上不能并发乱写必须串行化服务端的Tool执行是最容易成为瓶颈的地方——如果服务端是单worker串行消费客户端并发再高也没用响应回调是最容易出Bug的地方搞不好就跨了线程、丢了上下文。我做压测时遇到过一个很典型的现象同样的Client代码连本地Mock Server时并发200轻松跑满切到真实Server后并发50就开始大面积超时。原因就是真实Server的Tool内部加了一个串行的数据库锁导致服务端消费能力跟不上。这个排查过程让我意识到客户端并发设计再完美也必须在链路视角下和服务端能力匹配否则只是把压力从一个瓶颈挪到了另一个瓶颈。1.2 高并发场景的真实来源不是所有MCP Client都需要高并发我总结了实际中真正会push并发量上去的几类场景多会话网关一个Client进程同时服务多个用户的Agent会话每个会话都在独立推进自己的工具调用链并发度等于会话数乘以每会话并行请求数。单会话内批量工具调用Agent一次推理中可能同时调用查天气读数据库搜文档三个工具它们之间无依赖完全可以并行发起。数据分片抓取某个Tool支持按时间范围或ID区间分片客户端为了加速会把一个大任务拆成几十个并行子请求。上面三种场景对并发模型的要求不太一样。网关场景更看重会话隔离不能因为一个会话的慢请求拖垮其他会话批量工具调用更看重请求级别的独立超时和取消数据分片场景则更依赖并发上限控制和结果聚合。我在设计时先列了一张表把所有可能的瓶颈点标出来作为后续选型的依据链路环节是否可并发常见瓶颈缓解手段请求构造是CPU轻操作无复用对象、避免反射传输层写入同一连接串行管道拥塞、序列化耗时独立写队列、批量合并网络传输是带宽、RTTKeep-Alive、连接复用Server工具执行取决于Server串行worker、资源锁探测并适配服务器并发度响应回调是线程切换、上下文丢失事件循环内回调避免跨线程这张表我在设计过程中反复回看每次遇到并发上不去的问题都是先回到这张表定位是哪个环节出了问题而不是盲目加大Client侧的并发数。2. 会话与连接管理线程模型选型和多会话隔离MCP Client的并发设计第一个核心决策是线程模型。选对了后面的事顺理成章选错了你会陷入各种锁和共享状态的泥潭。2.1 为什么我选了单事件循环而不是线程池MCP的传输层形态主要有stdio和Streamable HTTP两种但消息模型都是JSON-RPC 2.0——请求和响应通过id一一对应消息之间天然无状态。这种模型和事件循环的契合度极高。我把整个Client分成三层API层提供给应用层的异步方法返回Future/CompletableFuture。核心调度层负责requestId分配、pending表维护、超时管理、取消传播。传输层负责具体协议的读写stdio走子进程管道HTTP走连接池。这三层全部跑在一个事件循环或者单线程调度器上。API层调用核心层时只做状态变更和IO注册不执行任何阻塞操作传输层的读写都是非阻塞的依赖事件循环的通知机制。为什么敢用单事件循环扛高并发因为一个MCP Client的瓶颈几乎永远不在CPU和内存上而在网络IO和Server端执行时间上。事件循环在等待IO期间可以去处理其他请求理论上单线程就能管理上千个在途请求这也符合我在多个语言生态看到的实践经验Node.js单事件循环扛高并发毫不在意Python asyncio同样如此Java里也可以用Netty的事件循环Go虽然有goroutine但你依然可以让所有连接复用同一个调度器。2.2 连接管理器核心数据结构连接管理器是Client的心跳它负责维护当前所有活跃连接的状态并保证每个连接上的消息严格有序写入。我给出一个简化版但足够说明问题的Python asyncio实现class ConnectionManager: def __init__(self, max_concurrency: int 50): self._connections: dict[str, MCPConnection] {} self._pending: dict[int, asyncio.Future] {} self._semaphore asyncio.Semaphore(max_concurrency) self._request_seq itertools.count(1) self._lock asyncio.Lock() async def call(self, conn_id: str, method: str, params: dict) - Any: async with self._semaphore: conn self._connections.get(conn_id) if conn is None: raise ConnectionNotFoundError(conn_id) request_id next(self._request_seq) fut asyncio.get_running_loop().create_future() async with self._lock: self._pending[request_id] fut try: await conn.send(request_id, method, params) return await fut finally: self._pending.pop(request_id, None) def on_response(self, conn_id: str, request_id: int, result: Any): fut self._pending.get(request_id) if fut and not fut.done(): fut.set_result(result) def on_error(self, conn_id: str, request_id: int, error: dict): fut self._pending.get(request_id) if fut and not fut.done(): fut.set_exception(MCPError.from_dict(error))这段代码里有几个值得细看的点。_semaphore决定了全局并发上限这是防止客户端无限发请求把Server打挂的保险丝。_pending是requestId到Future的映射相当于一个事务表响应回来时通过id定位到等待者。_lock保护_pending的读写因为多个协程可能同时往里注册。conn.send只负责把消息写入传输层真正等待回复是靠await fut挂起不占用事件循环。这里有一个关键设计写入和等待是分离的。写入侧保证同一连接上消息有序等待侧每个请求独享自己的Future两者互不干扰。这样即使有上千个在途请求事件循环也只需要在消息到达时做一次字典查找开销极低。2.3 多会话隔离避免请求串号事故网关型Client一次可能挂几十个MCP会话每个会话对应不同的用户上下文和不同的Server端状态。最忌讳的做法是共享同一个pending表把不同会话的请求混在一起。某次我图省事把所有连接的Future都塞进一个全局pending表结果生产环境出现了一个诡异问题A会话的请求响应居然被B会话的调用方收到了。排查半天发现是requestId只在连接维度唯一两个连接各自从1开始编号全局表一混就串了。后来我改成连接维度隔离连接管理器下面挂多个连接对象每个连接对象维护自己的pending表和requestId序列。请求归属哪个会话就从哪个连接对象发起响应也只会回到那个连接对象的pending表里天然隔离不需要加全局锁。class MCPConnection: def __init__(self, transport): self.transport transport self._pending: dict[int, asyncio.Future] {} self._request_seq itertools.count(1) self._write_lock asyncio.Lock()这样每个MCPConnection就是一个独立的异步上下文连接与连接之间不共享任何可变状态。会话A的请求再慢、再超时也不会影响会话B的请求。这也是做多租户Agent网关的必备基础。3. 请求调度与优先级异步API背后的排队逻辑异步API的好处是调用方发完请求就挂起不阻塞自身逻辑但这只是表象。真正决定系统并发质量的是API背后那套排队和调度逻辑。不把这层设计好异步API只是把阻塞从调用方转移到了内部系统该卡还是卡。3.1 从同步到异步调用模型对比先看一个简单的对比假设要调用三个无依赖的工具# 同步模型总耗时 t1 t2 t3 r1 client.call(tools/call, {name: tool_a}) r2 client.call(tools/call, {name: tool_b}) r3 client.call(tools/call, {name: tool_c}) # 异步并发模型总耗时 ≈ max(t1, t2, t3) f1 client.call_async(tools/call, {name: tool_a}) f2 client.call_async(tools/call, {name: tool_b}) f3 client.call_async(tools/call, {name: tool_c}) r1, r2, r3 await gather(f1, f2, f3)看起来只是多了一个gather但内部变化是本质性的同步模型每个请求都要占用一个线程并阻塞等待线程切换成本高而且线程池大小就是并发上限调大了内存飙升调小了吞吐上不去异步模型里请求注册后立即返回Future真正挂起的只是一个协程事件循环可以在等待期间处理其他请求同样的资源能支撑高一个量级的并发数。我做过一个直观的对比同样在本地起一个Mock Server每个Tool固定耗时200ms串行调用50个请求需要10秒左右改成并发50后总耗时降到0.4秒左右提升了20多倍而Client侧的CPU和内存几乎没有明显变化。3.2 优先级队列为什么生命周期消息不能被工具调用堵住并发请求多起来之后一个容易被忽略的问题是MCP的各类消息优先级并不相同。比如一个Agent正在发起50个工具调用此时Server因为某种原因要求客户端重新握手场景挺常见比如Server端状态过期如果重新握手要排在50个工具调用后面最坏情况下整个会话要被拖住几十秒。所以我在调度层加了一个双队列模型高优先级队列生命周期消息包括initialize、ping、notifications/cancelled、resources/list等元操作。普通优先级队列常规工具调用比如tools/call、prompts/get。调度器每次从高优先级队列取消息没消息时再从普通队列取。因为生命周期消息数量很少这种设计不会饿死普通请求又能保证关键消息始终优先。class PriorityScheduler: def __init__(self): self._high asyncio.Queue() self._normal asyncio.Queue() async def put(self, msg, high: bool False): if high: await self._high.put(msg) else: await self._normal.put(msg) async def next(self) - Message: if not self._high.empty(): return await self._high.get() return await self._normal.get()这个设计的依据是MCP协议的会话生命周期特征元操作是会话可用性的基础工具调用是在会话可用之上的业务行为。业务行为再重也不能本末倒置地阻塞基础协议行为。类似的想法也适用于普通HTTP客户端比如连接池健康检查和业务请求如果共用FIFO队列健康检查可能被拖到超时导致客户端错误地认为连接不可用。3.3 并发上限不是越大越好在调度层之外我还设计了显式的并发上限控制用信号量实现。默认值是50但这只是起步值真实取值应该结合压测结果和服务端声明来调。这里有一个比较反直觉的结论并发数存在一个拐点超过拐点后整体性能不升反降。原因多样——服务端线程池耗尽后请求排队、频繁的上下文切换、网络链路上排队、客户端自身的GC压力等等。无脑调大并发数只会换来更大的延迟和更多的超时而不是更高的吞吐。我把并发上限的确定看成两部分一部分是静态约束比如Server在initialize返回的capabilities里可能声明了maxConcurrencyClient必须尊重另一部分是动态探测通过压测脚本逐档测试不同并发下的P99延迟找到拐点后再预留20%的余量。4. 超时、取消与背压让系统在极端情况下依然可控并发上的天花板不是能并发多少而是并发上去后系统还稳不稳固。超时和取消是这一步绕不开的两个问题处理不好高并发下任何一个慢请求都能把整个Client拖入雪崩。4.1 分阶段超时而不是一个全局超时我见过不少客户端设计是在发请求时设一个全局超时比如30秒任何请求到了30秒就整体失败。这个设计的缺陷是它没法区分排队排了25秒和Server执行了25秒这两种情况。MCP的Tool执行天然有长耗时场景比如一个数据分析工具可能跑好几分钟。如果全局超时设为1分钟长任务必死如果设为10分钟那么Server挂掉的请求也要白白等10分钟才能失败。两难。我的做法是把一次调用拆成多个阶段各阶段独立计时阶段范围默认超时说明建连建立传输层连接10s连接挂起超过10秒视为失败握手initialize请求响应30sServer返回capabilities排队等待并发额度可配置优先级低的长任务允许排更久首返回等待第一个统计/结果事件5s防止Server收到后不响应流式执行等待后续事件120s空闲超时每次收到事件后重置计时器具体实现时每个阶段用独立定时器管理而不是一个总计时器。以流式执行为例如果Server已经稳定推流说明它活着就应该持续重置超时只有长时间没有新事件到达才判定卡死。提示长任务场景下宁可把流式空闲超时设得足够宽也不要让一次较长的静默处理误杀一个正常请求。误杀的代价不只是这次调用失败还可能因为取消传播打断Server端本可以完成的工作。4.2 取消传播让用户在点击停止之后真的能停用户发起一个长耗时的Tool调用后想反悔点了停止按钮。这个操作在MCP协议里对应两条路径本地路径是把Future标记为canceled让等待方立即返回远端路径是发送notifications/cancelled协议通知把取消意图传播给Server让正在执行的Tool有机会提前终止。本地Future的取消是必须做到的这部分我在每个Future上绑定了取消回调。但真正的难点是远端取消。MCP的notifications/cancelled消息携带requestId和reason我建议在Client内部实现一个CancelToken机制把用户取消、请求级取消、连接级取消统一注册到同一套机制里class CancelToken: def __init__(self): self._cancel_event asyncio.Event() self._cancelled False def cancel(self): self._cancelled True self._cancel_event.set()UUID类token可以传给用户API用户决定在什么时候触发取消底层把取消事件转换成notifications/cancelled消息发出去。这里有一个实现细节取消消息本身走高优先级队列确保Server能尽快收到而不是排在几十个工具调用后面。也不要因为协议发了取消就认为Server一定会中断执行。很多MCP Server是同步执行Tool的收到通知时Tool可能已经跑完了。所以取消传播是尽力而为的机制能救回多少算多少核心价值是让Server端的进度可以主动检查取消状态而不是让客户端幻觉般地认为Server一定会配合。4.3 背压防止Server把客户端打满并发和背压是一体两面。客户端发起请求时可以靠信号量限制并发但Server主动推送的数据比如进度通知、日志流、长任务中间结果不受客户端主动并发控制约束。如果Server是生产快、消费慢客户端的接收缓冲区会越积越大内存最终被打爆。处理方案是三管齐下有界接收队列每个连接的接收队列有最大长度超过后按策略处理丢弃旧事件、拉高水位告警、主动暂停连接。有界信号量对处理一个事件这个动作加并发限制处理能力饱和时新增事件排队而不是无限堆内存。消费速度上报如果传输层支持流量控制可以在消费慢时通知Server暂时降低推送频率。实现上受背压保护的事件处理循环大致是这样async def event_loop(self, conn: MCPConnection): while True: event await conn.receive(timeoutconn.idle_timeout) if event is None: continue await self._process_semaphore.acquire() try: await self._handle_event(event) finally: self._process_semaphore.release()这里的_process_semaphore就是显式限流点。没有它极端情况下一次高吞吐的Tool可能一次性向客户端灌入百万级进度事件客户端处理不完就在队列里堆积表面上是响应变慢本质上内存已经被吃掉了。5. 错误处理与连接重建并发场景下的失败隔离并发请求的麻烦在于错误往往会连锁放大。一个连接断开可能同时影响这个连接上所有在途请求更麻烦的是并发越高一个请求失败时越难判断该不该重试、该不该关连接。这一节重点聊聊错误分层、失败隔离和重试的边界。5.1 MCP错误分层协议层、传输层、工具层MCP基于JSON-RPC 2.0错误天然分两类协议错误和传输错误。协议错误来自Server返回的JSON-RPC错误对象携带错误码和描述传输错误则来自底层连接比如管道写入失败、连接被对方关闭、读超时。我建议把所有错误统一成层级类型每层处理策略不同错误类型例子是否关闭连接是否可重试JSON-RPC协议错误-32602 参数无效、-32000应用错误否一般不可重试生命周期错误initialize失败是限定次数重试连接中断管道关闭、握手超时是按幂等性判断客户端本地错误背压队列满、超时视情况视情况这里最关键的一条经验是协议错误绝不轻易关闭连接。一个Tool的参数错误只是它自己的问题把这个连接上其他正在执行的请求一起杀掉是典型的连带伤害。只有在传输层或者生命周期层确认连接已不可用时才关闭连接并统一fail所有pending Future。5.2 连接重建后在途请求怎么办当连接真的断了一个最基本的问题是这条连接上的pending Future全部要立即fail而不是继续傻等。我维护了一个连接状态机从connected到closed的同时遍历该连接的所有pending请求把它们统一标记为ConnectionClosedError。这步必须在事件循环内部同步完成千万不能异步慢慢清理。原因是Future不立即fail等待方就会继续挂着直到自己的超时兜底那样的话用户感知到的延迟会从毫秒级fail变成等了几十秒后才报错并发场景下这个延迟会放大为整体雪崩。重建连接后已经fail的请求不要无脑重放。MCP没有对普通Tool调用提供幂等语义一个请求到达Server后Server可能已经执行了一部分甚至全部客户端重放可能造成重复副作用。在我的设计里只有两类操作允许自动重试一是有明确幂等保证的元操作如ping二是连接建立阶段的initialize带上限的指数退避重试。其余请求一律fail到API层由上层业务逻辑决定是否重试。5.3 超时与重试的组合策略超时和重试放在一起会出现很有意思的联动问题。比如一个慢请求在Server正在执行阶段触发了流式空闲超时client判定超时后发取消通知。此时如果Client盲目重试Server可能还在处理第一个请求第二个请求只能继续排队结果两个请求都超时白耗资源。给一套我验证过的组合策略请求级超时使用分阶段设计阶段不同则超时不同。重试只针对可重试错误比如连接中断可重试错误用指数退避1s、2s、4s、8s上限10s并加入20%的随机抖动避免多个并发请求在重连后同时撞向Server。初始化阶段的重试最多3次超过后直接标记连接不可用。Tool调用不自动重试但上层可以通过传入retry_policy参数显式声明。这样做的核心思想是超时负责别无限等待重试负责能救则救两者通过错误的可重试性解耦而不是简单地把重试次数乘超时时间作为一个总预算。如果一个操作既不可重试又很慢那就让它按自己的节奏跑超时兜底。6. 实测压测数字背后的真实瓶颈设计完不算数跑过压测才算数。我做了一轮比较完整的压测核心目标是找到并发数与延迟、吞吐、资源消耗之间的关系。下面把过程和结果原样分享出来。6.1 测试环境与方法为了排除网络干扰我在本地起了一个MCP Server进程工具执行耗时模拟为200ms无外部依赖Client就是我设计的异步SDK跑在同一台机器上。压测脚本分别用Python asyncio和JMeter两种方式发请求JMeter负责模拟HTTP网关视角asyncio脚本负责直接测SDK的纯并发能力。测试变量是并发数从1逐档升到500每轮固定发500个请求记录总耗时、QPS、P99延迟和Client侧的内存占用。6.2 结果数据与曲线分析并发数总耗时(s)QPSP99延迟(ms)Client内存(MB)1100.25202821010.34834286502.42084811011001.53337931182001.241714201525001.63123548223这组数据有几个值得掰开揉碎看的点。第一并发从1涨到200QPS单调上涨但涨幅在明显收窄。50到100只涨了60%100到200只涨了25%典型的边际收益递减。第二P99延迟从并发50开始就明显变差500并发时P99已经到3.5秒将近工具本身执行时间的17倍。第三并发500时总耗时反而比200时更长QPS也跌了说明性能拐点就在200附近超过之后Client的调度开销、Server端的上下文切换和GC压力开始反噬。JMeter模拟100用户并发报告的结果也吻合在纯HTTP调用场景下100并发时Avg响应时间约340msP99约850ms和asyncio直接测SDK的数据基本对齐说明SDK本身没有引入额外的协议层退化。6.3 从压测结果反推设计参数压测数据直接指导了几个关键参数的选择默认并发上限50对于一个服务端工具耗时在200ms量级的系统50并发能在QPS和P99延迟之间取得平衡不会过早触发拐点。高并发模式下要单独放宽P99预期如果业务必须追求极限吞吐需要接受P99延迟的劣化而不是指望既快又高并发。流式空闲超时设为120秒压测中极少出现无事件静默超过2分钟的正常场景但长了容易拖住失败的请求短了会误杀慢任务。动态调节器Client运行时可以根据Server反馈比如频繁出现超时主动降低本地并发上限这是应对真实环境波动的最后一道防线。压测教会我的最重要一课是并发设计不是单纯把并发数调大而是找到系统所有环节共同支撑的均衡点。数字不会骗人但前提是你愿意花时间把数字背后的因果逻辑逐个看透。7. 踩坑记录设计迭代中几个值得复盘的细节最后分享几个真实踩过的坑。这些坑单独看都不起眼组合在一起差点让整个SDK返工写出来帮大家避雷。7.1 全局共享状态导致的会话串号最早版本把所有连接的pending表都放在一个全局字典里一度觉得代码写得真简洁。上线后遇到多会话场景出现了A会话调用工具、B会话收到结果的严重事故。排查时发现两个连接的requestId都从1开始编号全局表里后注册的请求把先注册的Future覆盖了。教训是连接维度的状态一定要归连接所有requestId序列、pending表、超时定时器都不能跨连接共享。这个看起来像实现细节的决策直接影响多租户场景的正确性。7.2 在事件循环里同步执行阻塞Tool早期某个版本的代码中我在收到Server请求后直接在事件循环里调用第三方SDK的同步方法就是一个简单的数据库查询。并发一高整个事件循环被阻塞所有连接的请求全部排队表现为一个慢查询拖垮全站。修复方案是引入执行器池把CPU密集或阻塞型操作丢到独立线程池结果再投递回事件循环。这里要特别强调不是所有操作都适合丢线程池纯粹的内存计算直接并发执行反而更快关键是区分IO阻塞和CPU计算前者靠异步后者靠多线程或进程池。7.3 把等待时间和执行时间混在一个超时里最早统一用一个30秒超时覆盖所有请求阶段结果长任务频繁被误杀短任务失败时又要傻等30秒。后来改成前面提到的分阶段超时模型才真正解决了长短任务共存的问题。具体实现时还有一个细节总超时不是各阶段超时的简单相加。因为有些阶段可能被跳过有些阶段会反复发生比如流式执行中每次心跳都算一次活动。我维护了一个deadline字段每个阶段开始时根据剩余时间动态计算而不是用固定的全局deadline。7.4 高并发下日志反而成了最大瓶颈有一次压测时发现并发200的时候Client端CPU占用突然飙升查了半天定位到日志库。因为我把每个请求的收发、每次取消、每个调度决策都打了info日志高并发时一个请求能产生十几条日志序列化和写盘本身成了最重的负担。这个问题在压测中十分普遍但实操里反而容易被忽视。优化方案很直接请求级日志全部降级为debug只保留连接级和调度级的关键事件生产环境用抽样日志比如1%的请求打全量配合全链路traceId定位问题。调整后同样并发下的CPU占用下降了30%还多。这些坑的共性在于单独看都是小事但高并发会放大每一个不够严谨的细节。并发设计不是把代码写出来然后等压测通过而是从第一行代码开始就要考虑每个状态对象的归属、每个定时器的生命周期、每个日志带来的开销。MCP Client的异步设计本质上是把这种严谨性落实到每一次请求的完整生命周期里。