即使实现了MCP异步加载,用户仍然发现:
- 在MCP服务器启动过程中无法输入文字
- 只有在服务器启动的间隙才能输入
- 感觉像是有阻塞
具体现象:
启动MCP1... (输入被阻塞)
MCP1启动完成
↓ (短暂可以输入)
启动MCP2... (输入被阻塞)
MCP2启动完成
↓ (短暂可以输入)
启动MCP3... (输入被阻塞)
Node.js是单线程事件循环模型:
- 所有JavaScript代码在主线程执行
- 虽然有异步I/O,但子进程创建等操作仍会占用主线程
- 当主线程被占用时,无法处理用户输入
每个MCP服务器启动时会执行这些同步操作:
-
创建子进程 (
spawn):- 虽然是异步API,但进程创建本身是同步的
- 需要系统调用、内存分配等
-
设置stdio管道:
- 创建stdin、stdout、stderr管道
- 配置管道选项(如'pipe'模式)
- 这些操作占用事件循环
-
进程初始化:
- 设置环境变量
- 设置工作目录
- 加载进程可执行文件
之前的实现使用 Promise.all 并行启动:
const discoveryPromises = Object.entries(mcpServers).map(
([name, config]) => connectAndDiscover(name, config, ...)
);
await Promise.all(discoveryPromises);问题:
- ❌ 虽然是"并行",但所有进程几乎同时创建
- ❌ 多个子进程同时创建会集中占用事件循环
- ❌ 在所有进程创建完成前,事件循环无法处理输入
- ❌ 用户体验:长时间连续阻塞
不要同时启动所有MCP服务器,而是一个接一个启动,每次启动后让出事件循环。
export async function discoverMcpTools(
mcpServers: Record<string, MCPServerConfig>,
mcpServerCommand: string | undefined,
toolRegistry: ToolRegistry,
promptRegistry: PromptRegistry,
debugMode: boolean,
): Promise<void> {
mcpDiscoveryState = MCPDiscoveryState.IN_PROGRESS;
try {
mcpServers = populateMcpServerCommand(mcpServers, mcpServerCommand);
// ✅ 顺序启动MCP服务器
for (const [mcpServerName, mcpServerConfig] of Object.entries(mcpServers)) {
// 在启动每个服务器前,让出事件循环给UI渲染和用户输入
await new Promise(resolve => setImmediate(resolve));
// 启动当前服务器
await connectAndDiscover(
mcpServerName,
mcpServerConfig,
toolRegistry,
promptRegistry,
debugMode,
);
}
} finally {
mcpDiscoveryState = MCPDiscoveryState.COMPLETED;
}
}await new Promise(resolve => setImmediate(resolve));作用:
- 将后续代码推迟到下一个事件循环tick执行
- 让出当前tick给其他任务(如UI渲染、用户输入)
- 确保事件循环不会被长时间占用
for (const [name, config] of Object.entries(mcpServers)) {
await connectAndDiscover(...); // 等待当前完成再启动下一个
}效果:
- 一次只启动一个MCP服务器
- 每次启动完成后才开始下一个
- 避免多个进程同时创建
时间线:
0ms 启动MCP1、MCP2、MCP3(同时创建3个进程)
↓ ❌ 事件循环被占用200-500ms
500ms 3个进程创建完成
↓ ✅ 事件循环恢复,可以处理输入
用户体验:
- 500ms内完全无法输入
- 然后突然恢复响应
时间线:
0ms 让出事件循环
↓ ✅ 处理用户输入
10ms 启动MCP1
↓ ❌ 事件循环被占用100-200ms
200ms MCP1完成,让出事件循环
↓ ✅ 处理用户输入
220ms 启动MCP2
↓ ❌ 事件循环被占用100-200ms
420ms MCP2完成,让出事件循环
↓ ✅ 处理用户输入
440ms 启动MCP3
↓ ❌ 事件循环被占用100-200ms
640ms MCP3完成
用户体验:
- 每次只阻塞100-200ms
- 阻塞间隙可以输入
- 整体感觉更流畅
| 指标 | 并行启动 | 顺序启动 |
|---|---|---|
| 总启动时间 | ~500ms | ~600-700ms |
| 单次阻塞时长 | 500ms | 100-200ms |
| 可输入间隙 | 无 | 每个服务器之间 |
| 用户体验 | ❌ 长时间卡死 | ✅ 短暂停顿,可接受 |
虽然并行看起来"更快",但:
- ❌ 感知性能更差:长时间连续阻塞让用户觉得"卡死"
- ❌ 无法使用:在阻塞期间完全无法输入
- ❌ 糟糕体验:用户可能以为程序崩溃了
虽然总时间略长,但:
- ✅ 感知性能更好:短暂停顿,用户能感受到进度
- ✅ 基本可用:在间隙可以输入,UI保持响应
- ✅ 更好体验:用户知道程序在运行,只是在加载
根据用户体验研究:
- 100ms以下:感觉即时
- 100-300ms:感觉稍慢,但可接受
- 300-1000ms:感觉明显延迟,但仍在等待
- 1000ms以上:感觉卡死,可能放弃
顺序启动每次阻塞100-200ms,属于"可接受"范围。 并行启动一次阻塞500ms,接近"放弃"阈值。
根据服务器数量调整间隔:
const delayBetweenServers = Math.min(100, 500 / Object.keys(mcpServers).length);
await new Promise(resolve => setTimeout(resolve, delayBetweenServers));先启动常用的服务器:
const sorted = Object.entries(mcpServers).sort((a, b) =>
(a[1].priority || 0) - (b[1].priority || 0)
);使用Worker线程启动子进程(复杂度高):
const { Worker } = require('worker_threads');
const worker = new Worker('./mcp-launcher.js');小批量并行(如每次启动2个):
for (let i = 0; i < servers.length; i += 2) {
await Promise.all([
connectAndDiscover(servers[i]),
connectAndDiscover(servers[i+1])
]);
await new Promise(resolve => setImmediate(resolve));
}-
3个MCP服务器
- ✅ 验证每个服务器启动间隙可以输入
- ✅ 验证总启动时间略有增加但可接受
- ✅ 验证UI保持响应
-
1个MCP服务器
- ✅ 验证单个服务器启动不受影响
- ✅ 验证仍然可以输入
-
5个以上MCP服务器
- ✅ 验证顺序启动不会太慢
- ✅ 验证UI仍然保持基本响应
console.time('Total MCP Discovery');
for (const [name, config] of Object.entries(mcpServers)) {
console.time(`MCP ${name}`);
await connectAndDiscover(...);
console.timeEnd(`MCP ${name}`);
}
console.timeEnd('Total MCP Discovery');- 并行: ~500ms
- 顺序: ~600-700ms
- 增加: 100-200ms
这是可接受的权衡,用户体验提升远大于这点时间差。
如果MCP服务器之间有依赖关系,顺序启动反而更安全。
单个服务器失败不影响后续服务器:
for (const [name, config] of Object.entries(mcpServers)) {
try {
await connectAndDiscover(...);
} catch (error) {
console.error(`Failed to start ${name}:`, error);
// 继续启动下一个
}
}packages/core/src/tools/mcp-client.ts
2025-01-10