Unity 编辑器进程的一切——GameObject.CreatePrimitiveAssetDatabase.SaveAssetsEditorWindow 操作——都被严格限定在主线程。但要把它暴露成一个能被外部 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.FindAssetDatabase.LoadAssetAtPath、甚至读 Selection.activeGameObject,都会抛 UnityException

这条约束没有例外,没有"小心一点就可以"的灰色地带。所有暴露 Unity 操作的工具方法,最终必须在 Editor 主线程的某个 tick 里被调用

2. 为什么 HttpListener 不能"也跑主线程"

反过来想,能不能把 HttpListener 也搬到主线程?答案是不行——HttpListener.GetContext() 是阻塞调用,主线程一旦阻塞,Editor 就卡死。GetContextAsync() 看似异步,但底层仍依赖 OS 完成端口/select,需要让出执行权给 IO 调度器。

主流做法是:

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 桥:

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); }
        }
    }
}

调用方在后台线程上写的代码是同步的:

// 在后台 HTTP 线程上
var result = await _bridge.RunOnMainThread(() =>
{
    return ExecuteToolMethod(toolName, arguments);  // 在主线程跑
});
await context.Response.WriteAsync(JsonConvert.SerializeObject(result));

TaskCompletionSourceEditorApplication.update 的 tick-based 模型桥接到 async/await。整个 marshal 对调用方是透明的——后台 HTTP 线程 await 一个 Task,主线程完成工作后 set result,await 在原线程上恢复。

ConcurrentQueue 保证多线程安全入队;DrainQueueEditorApplication.update 每帧调用,一次性把所有积压请求执行完。在 60Hz 的 update 频率下,请求平均延迟 < 16ms,对 MCP 工具调用是完全可接受的开销。

5. 异步工具方法的协调

部分 MCP 工具本身就是异步的——enter_play_mode 要等 Unity 真正进入 PlayMode,execute_code 要等代码编译完成。这些操作本身不可能在单个 update tick 里同步完成。

Funplay 的处理是允许工具方法返回 Task<object>

[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:

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:

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_modeTask.Delay 轮询、capture_game_viewScreenCapture.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.csEditor/MCP/Server/HttpMCPTransport.csEditor/State/MCPServerService.cs。MIT 协议。任何想自己往 Unity Editor 里塞 HTTP / WebSocket / gRPC 服务的项目,主线程边界处理这一层都能直接参考。

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐