技术博客

TaskGraph 与并行:NamedThread/AnyThread 的任务调度

第 8 / 100 篇 · 2026-09-15 · ue5 · TaskGraph多线程并发源码分析

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)是:

  1. CreateTask 返回一个 FGraphEventRef,代表"这个任务的完成事件"。
  2. 任务被构造时登记前置依赖列表,同时把自己注册为前置任务的"后续任务"。
  3. 前置任务完成时,遍历它的后续任务,把后续任务的未完成前置计数减一;计数归零的后续任务被推进目标线程的就绪队列。
  4. 线程从自己的就绪队列取任务执行。

这是个典型的数据流调度(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 控制优先级。

并发安全:什么能做什么不能做

引擎代码里最常见的并发错误按出现频率排序:

  1. AnyThread 上创建/销毁 UObject:NewObject/ConditionalBeginDestroy 必须 GameThread。后台线程要创建 UObject 的正确姿势是把数据算好,FFunctionGraphTask 或 UE::Tasks::Launch 切回 GameThread 再 NewObject。
  2. 读写共享的 TArray 不加锁:UE 容器全部不是线程安全的。跨线程共享数据用 TQueue<T, EQueueMode::Mpsc>(多生产者单消费者无锁队列)或显式 FCriticalSection + FScopeLock。
  3. 在 RenderThread 上碰 GameThread 的 UObject:RenderThread 只能读 SceneProxy 快照,那个快照是 GameThread 在 SendAllDynamicData 阶段拷贝好的。直接读 UObject 成员在单线程 debug 下可能"碰巧对",多线程下就是数据竞争。
  4. 死锁: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);

TaskGraph 调度:前置计数驱动的数据流执行 NamedThread 分工(不是线程池,是固定角色) GameThread:UObject RenderThread:场景遍历 RHIThread:API 提交 AnyThread:后台纯计算 ENQUEUE_RENDER_COMMAND:GT 打包 lambda → RT 队列,不等待;读结果要 FlushRenderingCommands(性能杀手,应避免) 依赖计数驱动的调度循环 1. CreateTask 返回 FGraphEventRef(完成事件的句柄) 2. 任务登记前置依赖,前置完成时给后续任务的"未完成前置计数"减一 3. 计数归零 → 任务推进目标线程的就绪队列 → 线程取任务执行 死锁模式 GT Wait AnyThread 任务 + 该任务等 GT 资源 栈特征:GT 停在 WaitUntilTaskCompletes 跨线程安全模式 AnyThread 算数据 → FFunctionGraphTask 切回 GT GT 上 NewObject;共享数据用 TQueue 或 FScopeLock

实战结论

  • 新代码用 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 的实现和开销取舍。