DCC 线程安全
适用对象:适配器作者(
dcc-mcp-maya、dcc-mcp-blender等)以及在 DCC 宿主中运行长耗时计算的 skill 作者。一句话总结:任何修改场景的调用都必须在 DCC 的主线程上执行。
DeferredExecutor是 Tokio HTTP 工作线程与主线程之间唯一被官方支持的桥梁。 长耗时任务必须被切分为每帧(per-tick)的小块,并且必须使用poll_pending_bounded(max=N), 不要使用poll_pending()。如果是 Python 端的派发场景(在宿主 UI 线程上执行整段 skill 脚本 而不仅仅是 一段 Rust callable),见 可调用对象调度器 API —— 它与
DeferredExecutor互补,是每个嵌入式 Python 适配器 (mayapy、hython、unreal-python)应当继承的基础协议。
为什么主线程亲和性是强制的
所有主流 DCC 宿主都要求场景修改 API 只能在主线程上调用。运行时不会用锁保护你 —— 从工作线程调用这些 API 会破坏场景状态、让宿主崩溃,或者静默失败。每个 DCC 都提供了 一个标准的"派发到主线程"原语:
| DCC | 仅主线程可用的 API | 官方派发原语 |
|---|---|---|
| Maya | maya.cmds、OpenMaya、pymel | maya.utils.executeDeferred(fn) / maya.cmds.evalDeferred("expr") |
| Blender | bpy.ops、bpy.data、bpy.context | bpy.app.timers.register(fn, first_interval=0.0) |
| Houdini | hou.*(场景图、SOP、HDA) | hou.ui.addEventLoopCallback(fn) |
| 3ds Max | MaxPlus、pymxs.runtime、MAXScript | pymxs.runtime.execute("...") 只能在主线程调用(无 defer 原语,改用 Qt singleshot timer) |
这些原语共享相同的契约:
- 可调用对象被入队。
- DCC 事件循环在下一个安全时机从主线程调用它。
- 可调用对象同步运行直到返回,并阻塞 UI。
第 (3) 点就是本指南存在的理由:一旦你的回调超过约 16 ms,DCC UI 就会卡顿;超过几百 毫秒,宿主就会看起来"冻住"。
DeferredExecutor 如何桥接 Tokio 工作线程到主线程
McpHttpServer 在 Tokio 工作线程上接收 HTTP 请求。工作线程不能直接调用场景 API —— 它必须通过 DeferredExecutor 提交任务;后者把任务塞进一个 mpsc::channel 并返回 一个 future。DCC 事件循环在主线程上消费这个通道。
Tokio 工作线程 (HTTP handler) DCC 主线程
─────────────────────────── ─────────────────
handle.execute(task) ──── mpsc::channel ──► poll_pending_bounded(max=8)
│ (有界) │
│ │ 运行 task_fn()
▼ ▼
await oneshot ◄──────────── oneshot::channel ── send(result)该桥接的 Rust 权威源码简短到可以一次读完:
// crates/dcc-mcp-http/src/executor.rs (L23-L111)
use std::sync::Arc;
use tokio::sync::{mpsc, oneshot};
/// A boxed async-compatible task that runs on the DCC main thread.
pub type DccTaskFn = Box<dyn FnOnce() -> String + Send + 'static>;
// ...
impl DeferredExecutor {
pub fn poll_pending(&mut self) -> usize { /* drain all */ }
pub fn poll_pending_bounded(&mut self, max: usize) -> usize { /* drain <= max */ }
}上层适配器实现的 job-dispatcher 层位于:
// crates/dcc-mcp-process/src/dispatcher.rs (L1-L166)
pub enum ThreadAffinity { Main, Named(&'static str), Any }
pub struct JobRequest { /* request_id, affinity, timeout_ms, task */ }
pub trait HostDispatcher {
fn submit(&self, req: JobRequest) -> oneshot::Receiver<ActionOutcome>;
fn supported(&self) -> &[ThreadAffinity];
fn capabilities(&self) -> HostCapabilities;
}当一个 skill 工具被标记为 ThreadAffinity::Main 时,适配器会把它路由到 DeferredExecutor;ThreadAffinity::Any 的任务则直接在 Tokio 工作线程上执行。
Python 用法
from dcc_mcp_core._core import DeferredExecutor # 暂未进入公共 __init__
executor = DeferredExecutor(capacity=16)
# 从任意线程(例如 MCP HTTP 处理器):
executor.execute(lambda: maya.cmds.polySphere(radius=1.0))
# 在 DCC 空闲回调中:
executor.poll_pending_bounded(max=8) # 每帧有界 —— 见下文长耗时任务的规则
"长耗时任务"是指无法在单个 DCC tick(60 FPS 约 16 ms,30 FPS 约 33 ms)内完成的 工作。典型例子:playblast、批量渲染、几千次场景图编辑、USD 合成、复杂几何生成。
三条不可妥协的规则:
1. 把工作切分为每帧小块
每个 timer tick 渲染一帧,而不是一次调用里渲染全部帧。每批处理 N 个图元,而不是 处理整个网格。每块大小应至少给 DCC 留出 50% 的 tick 预算用于 UI。
# 好:Blender timer 每帧渲染一帧
frame_iter = iter(range(1, 241))
def render_next():
try:
frame = next(frame_iter)
except StopIteration:
return None # 注销 timer
bpy.context.scene.frame_set(frame)
bpy.ops.render.render(write_still=True)
return 0.0 # 立即重新调度
bpy.app.timers.register(render_next)2. 协作式检查点
在每块之间检查取消标志,并把控制权交还给 DCC。参见 issue #329 — check_cancelled() 了解计划中的协作式取消原语。
for batch in chunks(primitives, size=500):
if job.check_cancelled(): # #329
return skill_error("被用户取消")
create_primitives(batch)
# 在 batch 之间控制权会回到 DCC3. 使用 poll_pending_bounded(max=N),不要用 poll_pending()
poll_pending() 在返回前会排空所有已排队任务 —— 如果同时有 50 个任务到达, DCC 就会冻结它们运行时长之和的时间。poll_pending_bounded(max=N) 限制每次 pump 最多处理 N 个任务,从而让事件循环在批次之间有机会重绘。
# ❌ 不好 —— 无上限;任务突发会冻结 UI
executor.poll_pending()
# ✅ 好 —— 有界;每 tick 最多 8 个,最差延迟可控
executor.poll_pending_bounded(max=8)60 FPS 下合理的起点是 max=8;如果单个任务较重,请调小。
issue #332 — @chunked_job 中计划的分块任务装饰器落地后,会自动编码规则 (1) 和 (2)。
禁止的模式
在 DccTaskFn 中使用 time.sleep()
DccTaskFn 运行在 DCC 主线程上。time.sleep(n) 会阻塞该线程 —— 宿主会冻结 n 秒。
# ❌ 会让 Maya 冻结 5 秒
executor.execute(lambda: (time.sleep(5), cmds.polySphere()))如果需要延迟,使用 DCC 自带的 timer 原语(maya.utils.executeDeferred、 bpy.app.timers.register 等)重新调度,并把控制权还给事件循环。
从 skill 脚本里启动原生 OS 线程执行场景操作
启动 threading.Thread 并在其中调用 maya.cmds / bpy.ops 会完全绕过主线程契约。 即使在测试中看起来能工作,在负载下也会段错误或破坏状态。
# ❌ 未定义行为 —— Maya API 不是线程安全的
threading.Thread(target=lambda: cmds.polySphere()).start()请改用 DeferredExecutor.execute(...) —— 它是进入场景 API 的唯一线程安全路径。
在主线程上做阻塞 I/O
requests.get(url)、urllib.urlopen(...)、同步数据库调用、大文件读取 —— 都不应 出现在 DccTaskFn 中。它们像 time.sleep 一样会阻塞事件循环。
# ❌ 会让 DCC UI 冻结一整个 HTTP round-trip
executor.execute(lambda: json.loads(requests.get(url).text))在工作线程里先做 I/O(提交前),然后把已解析的数据传进 DccTaskFn:
# ✅ 在 worker 里做 I/O;只把场景调用 defer 到主线程
payload = requests.get(url).json() # Tokio worker
executor.execute(lambda: apply_to_scene(payload)) # 主线程另请参阅
- ADR 002 — DCC 主线程亲和性 —— 该设计的架构理由(英文)。
- 快速开始 → DeferredExecutor —— 最小 "hello world" 示例。
skills/integration-guide.md—— 各 DCC 的桥接模式(嵌入式 Python / WebSocket / WebView)。- Issue #329 —
check_cancelled()—— 分块任务的协作式取消。 - Issue #332 —
@chunked_job—— 将分块 + 检查点规则封装进的装饰器。