Unity 编辑器进程的一切------GameObject.CreatePrimitive、AssetDatabase.SaveAssets、EditorWindow 操作------都被严格限定在主线程。但要把它暴露成一个能被外部 AI 客户端通过 HTTP 调用的 MCP server,绕不开"HttpListener 必须在后台线程接 socket"的事实。
这两条约束的撞车点是所有"在 Unity 内嵌 HTTP 服务"项目的核心技术难题。本文记录 Funplay Unity MCP 怎么处理这道边界------从后台线程接到的 JSON-RPC 请求,怎么走到主线程上执行 Unity API,再怎么把结果同步回去返回 HTTP 响应。
1. 为什么不能"全跑后台线程"
直观的想法是:让 HttpListener 收到请求后直接在后台线程里解析 + 执行 + 返回。但任何调用 Unity API 的尝试都会立刻撞墙:
UnityException: get_isLoaded can only be called from the main thread.
Unity 内部的对象生命周期、序列化、GUI 系统都基于"单线程访问"假设。从其他线程调用 GameObject.Find、AssetDatabase.LoadAssetAtPath、甚至读 Selection.activeGameObject,都会抛 UnityException。
这条约束没有例外,没有"小心一点就可以"的灰色地带。所有暴露 Unity 操作的工具方法,最终必须在 Editor 主线程的某个 tick 里被调用。
2. 为什么 HttpListener 不能"也跑主线程"
反过来想,能不能把 HttpListener 也搬到主线程?答案是不行------HttpListener.GetContext() 是阻塞调用,主线程一旦阻塞,Editor 就卡死。GetContextAsync() 看似异步,但底层仍依赖 OS 完成端口/select,需要让出执行权给 IO 调度器。
主流做法是:
csharp
private void StartListener()
{
_listener = new HttpListener();
_listener.Prefixes.Add($"http://127.0.0.1:{_port}/");
_listener.Start();
// 后台线程循环接 socket
_listenerThread = new Thread(ListenLoop) { IsBackground = true };
_listenerThread.Start();
}
private void ListenLoop()
{
while (_listener.IsListening)
{
try
{
var ctx = _listener.GetContext(); // 阻塞
ThreadPool.QueueUserWorkItem(_ => HandleRequest(ctx));
}
catch (HttpListenerException) { break; }
}
}
ListenLoop 跑在后台线程,每个请求再扔进 ThreadPool 处理。但 HandleRequest 里只要碰 Unity API,仍然需要 marshal 回主线程。
3. Marshal 策略对比
把"后台收到的请求"转交到"主线程执行"有 3 种常见路径:
HTTP 请求
后台线程
Marshal 策略
EditorApplication.update
每 tick 轮询 queue
EditorApplication.delayCall
单次主线程回调
SynchronizationContext.Post
Unity 主线程 sync ctx
频率: ~60Hz
延迟: <16ms
必须自管 queue + lock
频率: 一次性触发
延迟: 不确定(下个 main loop)
语义最干净
频率: 异步触发
依赖 Unity 是否设置 SyncContext
Unity 版本差异大
三者的取舍:
| 策略 | 优势 | 劣势 |
|---|---|---|
EditorApplication.update 轮询 |
控制权高、可批处理 | 必须维护线程安全 queue 与 lock |
EditorApplication.delayCall |
一行触发,无需 queue | 多次触发会"批合并",不适合 1:1 请求 |
SynchronizationContext |
标准 .NET 模式 | Unity 不同版本对 Editor 主线程 SyncContext 的设置不一致 |
Funplay Unity MCP 采用主路径 EditorApplication.update 轮询,配 TaskCompletionSource 桥接 async/await 的组合。这种方案在 Unity 2022.3 / 2023.x / 6000.x 全版本一致工作,且能精确表达"每个 HTTP 请求等一个主线程执行结果"的 1:1 语义。
4. Funplay 的实现:MCPExecutionBridge
简化版的 marshal 桥:
csharp
internal sealed class MCPExecutionBridge
{
private readonly ConcurrentQueue<Action> _mainThreadActions = new();
private readonly IEditorThreadHelper _threadHelper;
public MCPExecutionBridge(IEditorThreadHelper threadHelper)
{
_threadHelper = threadHelper;
EditorApplication.update += DrainQueue;
}
public Task<TResult> RunOnMainThread<TResult>(Func<TResult> work)
{
// 已经在主线程则直接跑
if (_threadHelper.IsMainThread)
return Task.FromResult(work());
var tcs = new TaskCompletionSource<TResult>(TaskCreationOptions.RunContinuationsAsynchronously);
_mainThreadActions.Enqueue(() =>
{
try { tcs.SetResult(work()); }
catch (Exception e) { tcs.SetException(e); }
});
return tcs.Task;
}
private void DrainQueue()
{
while (_mainThreadActions.TryDequeue(out var action))
{
try { action(); }
catch (Exception e) { Debug.LogException(e); }
}
}
}
调用方在后台线程上写的代码是同步的:
csharp
// 在后台 HTTP 线程上
var result = await _bridge.RunOnMainThread(() =>
{
return ExecuteToolMethod(toolName, arguments); // 在主线程跑
});
await context.Response.WriteAsync(JsonConvert.SerializeObject(result));
TaskCompletionSource 把 EditorApplication.update 的 tick-based 模型桥接到 async/await。整个 marshal 对调用方是透明的------后台 HTTP 线程 await 一个 Task,主线程完成工作后 set result,await 在原线程上恢复。
ConcurrentQueue 保证多线程安全入队;DrainQueue 在 EditorApplication.update 每帧调用,一次性把所有积压请求执行完。在 60Hz 的 update 频率下,请求平均延迟 < 16ms,对 MCP 工具调用是完全可接受的开销。
5. 异步工具方法的协调
部分 MCP 工具本身就是异步的------enter_play_mode 要等 Unity 真正进入 PlayMode,execute_code 要等代码编译完成。这些操作本身不可能在单个 update tick 里同步完成。
Funplay 的处理是允许工具方法返回 Task<object>:
csharp
[ToolProvider("PlayMode")]
internal static class PlayModeFunctions
{
public static async Task<object> EnterPlayMode(int timeoutSeconds = 10)
{
if (EditorApplication.isPlaying)
return Response.Success("Already in PlayMode");
EditorApplication.EnterPlaymode();
var deadline = DateTime.UtcNow.AddSeconds(timeoutSeconds);
while (DateTime.UtcNow < deadline)
{
if (EditorApplication.isPlaying && !EditorApplication.isPlayingOrWillChangePlaymode)
return Response.Success("Entered PlayMode");
await Task.Delay(100);
}
return Response.Error("PLAYMODE_TIMEOUT", "Failed to enter PlayMode");
}
}
Task.Delay 释放主线程,让出执行权给 Unity 主循环;await 之后回到主线程继续轮询 isPlaying。整个过程对 HTTP 客户端是同步------HTTP 响应在 Task 完成后才发回,客户端按 HTTP 请求超时计算。
这种 async 模式让"PlayMode 闭环"、"等编译完成"、"等域重载完成"这类需要时间等待的工具能在标准 MCP 调用接口下落地。
6. 域重载窗口的请求处理
Unity 域重载发生时,托管脚本域整个重启------HttpListener socket 会被 OS 关闭、MCPServerService 实例被销毁、所有 inflight 请求被中止。
Funplay 的处理策略是主动 reject 而非 buffer:
Domain Reload MCP Server AI 客户端 Domain Reload MCP Server AI 客户端 触发域重载 重载完成 → 新实例启动 tools/call execute_code 执行中... 连接中断 / 500 reload-interrupted tools/call get_reload_recovery_status { wasInterrupted: true, toolName: execute_code, ... } tools/call execute_code(重试) 正常返回
beforeAssemblyReload 事件触发时主动关闭 HttpListener、写入 SessionState 中断态、向所有 inflight TaskCompletionSource setException:
csharp
private void OnBeforeReload()
{
DomainReloadHandler.RecordInterruption(_currentTool, _currentRequestId, "reload");
_listener?.Stop();
_listener?.Close();
// 给所有 inflight 请求一个明确的失败信号
foreach (var tcs in _pendingTasks.Values)
{
tcs.TrySetException(new OperationCanceledException("Domain reload interrupted"));
}
}
客户端收到连接中断或 500 错误,next call 可以 get_reload_recovery_status 拿到结构化的中断摘要。这种透明性比"静默卡死"或"重载后偷偷继续旧请求"都更可控。
7. 端口配置变更的安全重启
用户在 Funplay → MCP Server 窗口里修改端口时,会触发 settings changed 事件。响应这个事件不能直接 _listener.Stop() + new HttpListener()------事件回调可能跑在 ThreadPool 线程,主线程 socket 操作必须 marshal:
csharp
private void OnSettingsChanged(FunplayMcpSettings newSettings)
{
if (newSettings.Port == _currentPort) return;
// 重启走 delayCall 强制 marshal 回主线程,不能直接在事件线程操作 socket
EditorApplication.delayCall += ScheduleRestart;
}
private void ScheduleRestart()
{
try
{
_listener?.Stop();
_listener?.Close();
_currentPort = _settings.Port;
StartListener();
}
catch (Exception e)
{
Debug.LogException(e);
}
}
EditorApplication.delayCall 在这里有两个作用------延迟(避免阻塞设置 UI 的事件处理)+ 强制主线程(marshal 到 Editor 主循环)。如果没有这一步,曾经出过 SocketException 偶发崩 Editor 的 bug。
8. 完整请求生命周期
把上面所有片段串起来,一次 MCP 工具调用的完整生命周期:
工具方法 EditorApplication.update 主线程 MCPExecutionBridge HttpListener 后台线程 AI 客户端 工具方法 EditorApplication.update 主线程 MCPExecutionBridge HttpListener 后台线程 AI 客户端 POST /mcp tools/call RunOnMainThread(work) 入 ConcurrentQueue DrainQueue tick 执行工具方法 调 Unity API 结果 TaskCompletionSource.SetResult await 恢复 200 application/json
关键点:
- 后台线程接 HTTP,不会阻塞主线程的 Editor 渲染
- 工具方法在主线程上执行,能无障碍调用所有 Unity API
await+TaskCompletionSource把 tick-based 主线程模型桥接到标准 async/await- 单次请求的端到端延迟 = HTTP 解析 + 主线程 queue 等待(<16ms)+ 工具实际执行时间
9. 与 PlayMode 工具的协同
上一篇记录的 PlayMode 视觉闭环------enter_play_mode / simulate_mouse_click / capture_game_view------全部依赖这条 marshal 通道。enter_play_mode 的 Task.Delay 轮询、capture_game_view 的 ScreenCapture.CaptureScreenshotAsTexture() 主线程调用、Console 日志读取的反射 API,每一个都需要在主线程被执行。
如果没有可靠的 marshal 桥,PlayMode 工具要么会因为"在错的线程"抛 UnityException 崩掉,要么会因为"占着主线程不让出"卡死 Editor。MCPExecutionBridge 的 tick 轮询 + await 桥接是让所有这些工具能落地的底层基础设施。
10. 实测延迟与性能
在一台 M2 Pro Mac 上的实测数据(Unity 2022.3.62f2):
| 操作 | 端到端延迟 |
|---|---|
| 空 tool call(仅 ping) | ~5 ms |
get_scene_info(读 active scene 状态) |
~8 ms |
find_game_objects(场景 ~100 个对象) |
~15 ms |
execute_code(30 行简单逻辑) |
~120 ms(含内存编译) |
enter_play_mode(轻量项目) |
~600 ms(不可压缩,等 Unity) |
capture_game_view(1920×1080) |
~50 ms |
主线程 marshal 本身的额外开销在 5-15ms 之间,相对工具实际执行成本可忽略。HttpListener 后台线程模型也没有明显瓶颈------在 10 RPS 的并发压力下,主线程 queue 长度始终 < 3。
11. 写在最后
把 HTTP server 跑进 Unity Editor 不是把通用 HTTP 服务搬进来------所有"Unity API 只能主线程调用"的约束、"域重载会清空一切"的现实、"PlayMode 进出有异步状态"的复杂性,都需要在 marshal 层一并处理。MCPExecutionBridge + EditorApplication.update + TaskCompletionSource + 域重载钩子,是这套组合的最小可靠形态。
完整实现在 FunplayAI/funplay-unity-mcp,源码主要在 Editor/MCP/Server/MCPExecutionBridge.cs、Editor/MCP/Server/HttpMCPTransport.cs、Editor/State/MCPServerService.cs。MIT 协议。任何想自己往 Unity Editor 里塞 HTTP / WebSocket / gRPC 服务的项目,主线程边界处理这一层都能直接参考。