UE 的多线程不是"开个线程跑函数"的裸线程模型,而是一套任务依赖图系统:TaskGraph(Runtime/Core/Public/Async/TaskGraphInterfaces.h,实现在 Runtime/Core/Private/Async/TaskGraph.cpp)。5.x 又叠了一层更现代的 UE::Tasks(Async/Tasks/Task.h)。两套共存,但底层调度器是同一个。这篇讲清任务怎么被调度、线程怎么分工,以及哪些事在哪个线程上做才是对的。
线程的固定角色:NamedThread 不是线程池
UE 启动时创建一组有名字的线程(FNamedThreadManager),每个线程有固定职责,不是通用的线程池:
| NamedThread | 职责 | 关键约束 |
|---|---|---|
GameThread |
游戏逻辑、UObject 操作、Tick | 唯一能安全碰大多数 UObject 的线程 |
RenderThread |
渲染命令生成(Draw Call 翻译前的场景遍历) | 只能读渲染场景数据,不能改 UObject |
RHIThread |
把渲染命令翻译成图形 API 调用(D3D12/Vulkan) | 最底层层,只碰 RHI 资源 |
AnyThread |
通用后台任务 | 默认碰不了 UObject,安全集合极小 |
TaskGraph 内部工作线程 |
执行 AnyThread 任务的实际载体 | 数量 = CPU 核数 - 保留 |
这个分工的硬性约束是UObject 的线程安全边界:GameThread 是唯一能自由读写 UObject 的线程。RenderThread 通过"场景代理"(FPrimitiveSceneProxy)拿到 GameThread 数据的只读快照,两线程间不共享可变状态。AnyThread 上碰 UObject 基本是未定义行为——除了少数明确标记线程安全的 API(IsValid 的低层检查、GetName 的部分路径)。
ENQUEUE_RENDER_COMMAND 宏(RenderCommand.h)就是这套分工的接口:GameThread 把渲染操作打包成一个 lambda 扔进 RenderThread 的命令队列,立即返回不等待。RenderThread 在自己的节拍里消费队列。这是生产者-消费者模型,不是函数调用——你在 ENQUEUE_RENDER_COMMAND 之后立刻读结果读不到,要用 FlushRenderingCommands 强制同步,但那是性能杀手,正常情况下应该设计成不需要立即读回。
TaskGraph 的核心:前置依赖(Prerequisites)驱动调度
TaskGraph 的任务不是"提交就执行",而是"所有前置任务完成才执行"。API 形态:
// TGraphTask 的创建与依赖声明(简化)
FGraphEventRef TaskA = TGraphTask<FMyTask>::CreateTask().ConstructAndDispatchWhenReady(...);
FGraphEventRef TaskB = TGraphTask<FAnotherTask>::CreateTask(TaskA).ConstructAndDispatchWhenReady(...);
// TaskB 的构造函数参数里的 TaskA 就是前置依赖
// TaskB 只在 TaskA 完成后才会被调度
内部的调度状态机(TaskGraph.cpp 里的 FTaskGraphImplementation)是:
CreateTask返回一个FGraphEventRef,代表"这个任务的完成事件"。- 任务被构造时登记前置依赖列表,同时把自己注册为前置任务的"后续任务"。
- 前置任务完成时,遍历它的后续任务,把后续任务的未完成前置计数减一;计数归零的后续任务被推进目标线程的就绪队列。
- 线程从自己的就绪队列取任务执行。
这是个典型的数据流调度(dataflow scheduling):不是线程在等任务,而是依赖计数在推动任务到线程。和"线程池 + 锁"的模型比,优点是零锁竞争(就绪队列是无锁或细粒度锁的)、天然表达任务拓扑;代价是调试栈很深——任务在哪个线程执行取决于谁最后把它的前置计数减到零,崩溃时调用栈跨线程,靠普通断点很难追。
FGraphEventRef 可以链式等待:FTaskGraphInterface::Get().WaitUntilTaskCompletes(TaskRef)。但在 GameThread 上 Wait 一个 AnyThread 任务是危险的——如果这个任务反过来等 GameThread 的某个资源,就死锁了。UE 里有专门的 FScopedSlowTask 和 FTaskBarScope 处理这种跨线程等待的 UI 反馈,别自己裸 Wait。
UE::Tasks:5.x 的替代接口
5.0 引入的 UE::Tasks(TTask、TTaskGraph、Launch、Pipe)是 TaskGraph 的现代包装,语法更干净:
// UE::Tasks 风格
UE::Tasks::TTask<void> Task = UE::Tasks::Launch(
UE_SOURCE_LOCATION,
[] { /* 后台工作 */ },
UE::Tasks::ETaskPriority::BackgroundNormal
);
// 依赖:Pipe 表达串行链,Prerequisites 表达依赖
UE::Tasks::TTask<int> Compute = UE::Tasks::Launch(UE_SOURCE_LOCATION, [] { return 42; });
UE::Tasks::Launch(UE_SOURCE_LOCATION, [Compute] { /* 用 Compute 的结果 */ },
UE::Tasks::Prerequisites(Compute));
UE::Tasks 底层仍然走 TaskGraph 的调度器,不是新线程模型。选择标准是:新代码用 UE::Tasks,读老代码认 TGraphTask。ParallelFor(Async/ParallelFor.h)在 5.x 也迁移到了 UE::Tasks 基础上,批量并行处理用它的 EParallelForFlags::BackgroundPriority 控制优先级。
并发安全:什么能做什么不能做
引擎代码里最常见的并发错误按出现频率排序:
- AnyThread 上创建/销毁 UObject:
NewObject/ConditionalBeginDestroy必须 GameThread。后台线程要创建 UObject 的正确姿势是把数据算好,FFunctionGraphTask或UE::Tasks::Launch切回 GameThread 再NewObject。 - 读写共享的 TArray 不加锁:UE 容器全部不是线程安全的。跨线程共享数据用
TQueue<T, EQueueMode::Mpsc>(多生产者单消费者无锁队列)或显式FCriticalSection+FScopeLock。 - 在 RenderThread 上碰 GameThread 的 UObject:RenderThread 只能读
SceneProxy快照,那个快照是 GameThread 在SendAllDynamicData阶段拷贝好的。直接读 UObject 成员在单线程 debug 下可能"碰巧对",多线程下就是数据竞争。 - 死锁:GameThread 等 AnyThread 任务,AnyThread 任务又等 GameThread 资源。识别方法是崩溃时看 GameThread 栈停在
WaitUntilTaskCompletes或FEvent::Wait。
// 后台算数据、回主线程建对象的标准模式
UE::Tasks::Launch(UE_SOURCE_LOCATION, [InputData] {
FComputedResult Result = HeavyCompute(InputData); // AnyThread:纯计算
// 切回 GameThread 做 UObject 操作
FFunctionGraphTask::CreateAndDispatchWhenReady([Result] {
UMyObject* Obj = NewObject<UMyObject>(); // GameThread:UObject 安全
Obj->Init(Result);
}, TStatId(), nullptr, ENamedThreads::GameThread);
}, UE::Tasks::ETaskPriority::BackgroundNormal);
实战结论
- 新代码用
UE::Tasks::Launch和UE::Tasks::Pipe,老代码维护时认TGraphTask/FGraphEventRef;ParallelFor批量处理数据,别自己拆任务循环。 - 后台线程的产物是"纯数据",UObject 的创建销毁必须切回 GameThread——
FFunctionGraphTask::CreateAndDispatchWhenReady(..., ENamedThreads::GameThread)是标准切换手段。 - 排查并发崩溃先看崩溃线程:AnyThread 上崩且栈里有 UObject 操作,九成是线程边界越了;GameThread 停在 Wait 相关函数,去查它等的那个任务是不是反过来要 GameThread。
- 任务优先级用
ETaskPriority显式指定,别让所有任务都跑默认优先级——加载、音频解码这类对延迟敏感的后台任务要给BackgroundHigh,纯预处理给BackgroundLow,避免和关键路径抢核。
任务调度解决的是"活怎么分",引擎的日志和断言解决的是"出问题怎么知道"。下一篇拆 UE_LOG/check/ensure 的实现和开销取舍。