如果你已经用虚幻4写了一阵子程序,一定会碰到过这种场景:主线程忙到爆,帧数往下掉,而CPU的其他核心却闲着没事干。这时候你可能会想到开线程,但在虚幻4里,直接开线程(FRunnable)很快会变成一场噩梦——死锁、竞态、资源清理、线程同步,每一项都会消耗你大量精力。然后你会去研究引擎自带的TaskGraph,也就是这套基于任务的并行系统。我的结论比较直接:在虚幻4里做并行编程,TaskGraph值得你花一个晚上入门,接下来很多年都用得上。
TaskGraph这个名字听起来有点学院派,其实理解起来并不复杂。它帮你把一份大的工作量拆成许多个小任务,再用一个线程池去自动调度这些任务。你不需要关心“谁在哪个线程上跑”“哪个线程什么时候被创建”,只需要描述任务之间的依赖关系,引擎会尽量把任务调度到空闲核心上。这不仅是虚幻4自己的做法,也是现代C++里比较推崇的“基于任务”的编程思路,和单纯写“基于线程”的代码是两码事。
这篇内容会从底层逻辑开始讲,再给一套可以直接照着写的Demo思路和代码骨架,最后把我在实际项目里踩过的坑一并列出来。不管你是在做游戏逻辑、AI批量计算,还是在做编辑器工具里的耗时操作,这套东西都能帮上忙。
1. 从线程到任务:一场开发思维的转换
1.1 线程模型为什么越来越不够用
很多刚接触并行的开发者,第一反应是“多线程”。虚幻4里的FRunnable也确实是官方给的线程方案。FRunnable本质上是让你自己管理一个线程,你需要自己创建线程对象、绑一个Run函数、设置循环条件、告知外部线程何时结束、在结束后安全退出。听起来不复杂,但实际在游戏里用起来,你很快会发现几个问题。
第一个问题是线程开销。创建一个操作系统线程,背后涉及系统调用、栈空间分配、线程调度器登记,这些成本都不低。如果你只是想让一个小计算并行跑,结果开线程的时间比任务本身还长。第二个问题是同步成本。一旦多个线程访问同一个数据,你就需要加锁。锁本身有性能损耗,锁用得不好还可能引发优先级反转、线程饥饿这些看起来很学术、实际很头疼的问题。第三个问题是心智负担。你得关心线程生命周期,关心Join还是Detach,关心线程安全退出。游戏逻辑本身已经很复杂了,再加上线程管理,代码很快会变得难懂、难查。
这让我想起一个经历:有次我做一个批量处理工具,处理一千份贴图数据,每份数据里的计算量不大不小。我用FRunnable写了一个工作线程池,结果代码写了两个小时,组装线程安全队列就占了一大半。最后项目需求一变动,要再加一个处理步骤,线程池逻辑又得跟着改,非常痛苦。
1.2 任务模型:把“在哪跑”交给调度器
TaskGraph换了一个角度,它不问“哪个线程去跑”,而是问“有哪些事情要做、这些事情之间谁依赖谁”。你把工作写成一个个任务,每个任务是一段可执行的函数,任务之间可以声明依赖。引擎的线程池看到这些任务后,会自行决定哪个任务去哪个线程上执行。任务互不依赖时,甚至可以并行执行。
这套思路用生活里的事务来类比就是“后厨出餐系统”。传统的多线程像你自己为每道菜请一个厨师,每个厨师只做一道菜,你要亲自安排厨师什么时候开工、什么时候休息,还要防止两个厨师抢一个炉子。而TaskGraph更像一个“订单板”,你只需要把菜名和顺序贴上去,后厨会动态安排现有厨师去接单,做完了就在板上勾掉,出餐由调度系统控制。
这个思维转换很关键。作为开发者,你不再去操纵“线程”这个底层实体,而是把精力放在“任务”的拆解上。现代C++里std::async、std::execution这套并行库也是沿着类似的思路设计的。这意味着你在虚幻4里学到的这套规则,放到其他C++项目里,很多直觉依然能迁移。
1.3 为什么说它是虚幻4“亲生的”
很多人担心TaskGraph是不是第三方库,其实不是。它是一个被引擎重度使用的内部系统。动画系统更新、物理碰撞查询、粒子系统模拟、网络数据包处理,这些底层模块里都在用TaskGraph。也就是说,你在项目里通过TaskGraph提交的任务,和引擎自身的并行任务会进入同一个调度体系,而不是孤岛式的“自己玩”。
这一点很实际。比如我在项目里做大批量单位寻路,把几千个单位分批次处理成任务后,单位AI任务和场景里的物理动画任务可以共享同一批工作线程,整体CPU占用曲线会平缓很多,不会出现“某个模块突然把某一颗核心跑满,其他核心闲着”的情况。虽然调度细节不是一个黑盒就能解释清楚的,但你长期使用后会发现,引擎确实比你自己开线程更懂全局。
2. TaskGraph系统的设计思路与核心API
2.1 先认识几个核心类型
TaskGraph本身不是一个单独的类,而是一组接口和调度器的合称。你在写代码时最常接触的,是以下几个概念:
- FGraphEventRef:一个任务事件引用,可以理解成“任务完成后的通知单”。
- FGraphEventArray:事件引用的数组,经常用来声明“我需要等待这一批事件全部完成”。
- FFunctionGraphTask:一个可以直接绑定函数、快速创建任务入口的类型。
- ENamedThreads:线程标识枚举,用来告诉调度器你“建议”这个任务在哪个类型的线程上执行。
我第一次看这些类型时也有点懵,看得多了就会发现,平时的使用就集中在创建任务和等待任务两个动作上。创建任务时,你Provide一个函数、一个依赖数组、一个可选的线程类型,系统返回一个FGraphEventRef。之后你可以继续创建依赖这个EventRef的任务,也可以在自己需要结果的地方等待这个EventRef完成。
你不需要去碰任何线程句柄,也不用关心内部线程池里的线程数量,因为引擎已经根据当前机器的处理器核心数做了初始化。
2.2 创建任务和等待任务
最简单的任务提交方式是调用FFunctionGraphTask::CreateAndDispatchWhenReady。一听到“DispatchWhenReady”,意思就是“准备就绪时立即分发”。当你传入依赖数组时,调度器会等数组里的所有事件都完成后再执行这个任务。下面是一段非常基础的示意:
#include "Async/Async.h" #include "Tasks/Task.h" // 声明一个任务事件引用作为“完成信号” FGraphEventRef FirstTask = FFunctionGraphTask::CreateAndDispatchWhenReady( []() { // 这里写你要做的耗时计算 DoHeavyCalculationA(); }, TStatId(), nullptr, ENamedThreads::AnyThread ); // 第二个任务依赖第一个任务,只有第一个跑完才开跑 FGraphEventRef SecondTask = FFunctionGraphTask::CreateAndDispatchWhenReady( []() { DoHeavyCalculationB(); }, TStatId(), FirstTask, ENamedThreads::AnyThread );看起来是不是很像“把函数丢进去排队”?代码里的TStatId一般是留给性能分析用的,不搞性能标记时可以传TStatId()空对象。nullptr位置是任务图等待机制里另一些配置参数,常见用法传nullptr即可。ENamedThreads::AnyThread意味着“允许在任何一个工作线程上跑”,这是最常用的选择。
如果想等待一系列任务全部完成,就用FGraphEventArray:
FGraphEventArray Tasks; Tasks.Add(FirstTask); Tasks.Add(SecondTask); // 等待这一批任务全部结束 FTaskGraphInterface::Get().WaitUntilTasksComplete(Tasks, ENamedThreads::GameThread);这段代码会阻塞当前线程,直到Tasks里的所有任务都完成。如果是游戏线程上等待,要小心不要等待太久,否则玩家会感觉到卡顿。如果是在一个后台线程里等待,那就比较安全。
2.3 任务图的“DAG”本质
把任务加上依赖关系之后,多个任务会形成一张有向无环图(DAG)。为什么强调“无环”?因为如果任务A依赖任务B,任务B又依赖任务A,调度器就会永远等不到一个可执行的任务,程序直接卡死。引擎在运行时并不会严格帮你检测这种循环依赖,操作系统不会报错,任务会一直挂在队列里,表现出来就是“某个操作永远不返回”。这个坑我后来单列了一节细讲,因为排查起来真的让人头大。
DAG的好处是可以把一串连续步骤拆成可并行的树状或网状结构。比如数据准备完成后,三个独立阶段可以同时进行,你可以让它们都依赖同一个准备任务,然后这三个阶段自己再往后推进。调度器会把三个阶段分别放到不同线程上,最终效果是总耗时从“三段相加”变成“最长那段”。这种并行能力,是普通顺序代码完全不具备的。
3. 第一个完整示例:把一份工作量拆成任务链
3.1 先设置一个模拟场景
为了让你能直接理解,我设定一个场景:假设现在要做一个离线处理Demo,需要读入20万条日志记录,每条记录要做一次字符串解析生成结构化字段,然后把所有结构体写入结果文件。如果只有一个线程处理,单机耗时可能在二十几秒左右,用户体验很差。拆成任务后,多核机器上可能5秒内就能完成。
这个场景可以看出TaskGraph的一个典型适用规律:工作量足够大、数据互相独立、不需要严格保持某种全局顺序。如果你的工作量太小,拆任务反而会让调度开销掩盖掉并行收益。关于任务粒度的把握,我在第4章单独讲。
在按任务拆解之前,我会先画出任务依赖图:
- 任务1:读取原始日志文件,生成字符串数组。
- 任务2/3/4:把字符串数组按大致比例切成三段,分别做解析,三段并行互不干扰。
- 任务5:等待任务2、3、4全部完成,把三批解析结果合并。
- 任务6:等待任务5完成,写入结果文件。
这个结构不是一个线性链,中间层出现了三个并行分支。效果是:读文件阶段只有一个任务在跑,三个解析任务并行占用三个核心,最终合并和写文件又回到单任务。整体耗时接近“读文件时间 + 最大解析段时间 + 写文件时间”,相比顺序执行有明显收益。
3.2 用代码把依赖链搭出来
下面代码是CoreMinimal、Async相关头文件包含后的一段示意,用TFunction和Lambda完成。启发贴一下搭建任务的骨架:
#include "Async/Async.h" void ProcessLogs() { // 用一个共享的结构把中间结果穿起来 TSharedRef<FProcessingContext> Context = MakeShared<FProcessingContext>(); // 任务1:读取文件 FGraphEventRef ReadEvent = FFunctionGraphTask::CreateAndDispatchWhenReady( [Context]() { Context->RawLines = ReadLinesFromFile("logs.txt"); }, TStatId(), nullptr, ENamedThreads::AnyThread ); // 构建三个并行解析任务,都依赖ReadEvent FGraphEventArray ParseEvents; const int32 NumSlices = 3; for (int32 SliceIndex = 0; SliceIndex < NumSlices; ++SliceIndex) { FGraphEventRef ParseEvent = FFunctionGraphTask::CreateAndDispatchWhenReady( [Context, SliceIndex, NumSlices]() { // 按SliceIndex切分Context->RawLines,做解析 ParseSlice(Context, SliceIndex, NumSlices); }, TStatId(), ReadEvent, ENamedThreads::AnyThread ); ParseEvents.Add(ParseEvent); } // 任务2:合并所有解析结果,依赖三个并行任务 FGraphEventRef MergeEvent = FFunctionGraphTask::CreateAndDispatchWhenReady( [Context]() { Context->MergedResults = MergeSlices(Context); }, TStatId(), ParseEvents, ENamedThreads::AnyThread ); // 任务3:写文件,依赖合并完成 FGraphEventRef WriteEvent = FFunctionGraphTask::CreateAndDispatchWhenReady( [Context]() { WriteResultsToFile(Context->MergedResults, "parsed.csv"); }, TStatId(), MergeEvent, ENamedThreads::AnyThread ); // 主调用处等待整条链结束 FTaskGraphInterface::Get().WaitUntilTasksComplete(WriteEvent, ENamedThreads::GameThread); }这里需要特别提醒:Context用TSharedRef传给多个Lambda,避免拷贝字符串数组,同时保证引用安全。TSharedRef在引擎的引用计数体系里很安全,所有任务持有同一个智能指针,最后一个任务结束后引用计数归零,不会产生悬空引用。
FGraphEventArray可以直接作为依赖传入CreateAndDispatchWhenReady的第三个实参。(上面CreateAndDispatchWhenReady重载支持传入FGraphEventRef或FGraphEventArray)调度器会在数组里所有事件完成后再触发任务。如果你传入的是一个空数组,任务会立即执行,这正好对应“没有前置依赖”。
3.3 数据结构怎么设计
在TaskGraph里,任务之间传递数据最自然的方式就是“共享上下文”。你不能直接从一个Lambda里return一个值给另一个任务,因为任务调度是非阻塞的,返回值没有直接接收者。所以你应该设计一个共享结构体,在里面放各个阶段都要访问的数据。局部性原理告诉我们,任务间共享的数据越少越好,因为每个核心写同一块内存会带来缓存竞争。
我通常这样组织:
struct FProcessingContext { TArray<FString> RawLines; TArray<FParsedRow> Slice0Results; TArray<FParsedRow> Slice1Results; TArray<FParsedRow> Slice2Results; TArray<FParsedRow> MergedResults; };每个切片任务只写自己对应的字段,不写共享状态。这样就避免了“在任务里加锁”的需求。如果你发现任务之间必须频繁共享一个计数器,那你首先要重新审视拆分方案,看能不能采用独立缓冲再合并的方式。
另外需要指出,任务里抛异常是不要的。虚幻4的构建配置里异常通常处于关闭状态,Lambda里一旦发生不可恢复错误,结果很难兜住。在任务代码里最好自行检查危险操作,遇到异常情况就标记错误状态,最后在所有任务完成后再统一处理错误提示。
4. 深入控制:任务粒度、依赖与线程归属
4.1 任务粒度怎么选
这是我在实际项目里吃过亏最多的地方。任务拆得太细,调度器花在分配任务上的时间比执行任务本身还多;任务拆得太粗,并行度又不够,几个核心还是闲着。
一个非常朴素的判断标准是:单个任务的执行时间超过50到100微秒,拆开来才可能划算。这不是一个绝对数值,但能帮助你过滤掉大部分无意义的拆分。为什么要提微秒级别?因为一次任务创建和调度的开销,通常落在几微秒到几十微秒这个范围。如果你每个任务只做“加一次数”,调度开销可能比计算大几百倍,总耗时反而变慢。
举个例子,我在项目中曾试图把每个单位AI的“视野检测”单独做成一个任务。这个检测本身只有几十微秒,但单位有上千个。结果并行版本比单线程版本还慢,后来我把若干个单位合并成一批,每批作为一个任务,总耗时才降下来。这个经验总结成一句话:数据量越大,每个任务包含的数据量也应该适当增大。
4.2 依赖链设计的前沿技巧
依赖关系不要只做成“单链表”。单链表每个任务必须等前一个完成,并行度为零,毫无意义。你应该尽量把无依赖关系的工作放成多个并行分支,然后在中游或下游做合并。
有经验的做法是“先盘点可并行点”。写下所有步骤,然后问一句:这一步必须等上一步的所有数据吗?如果只有部分依赖,可以考虑把上一步的输出拆成多份,让每一步只处理其中一份。系统动力学里有一句话说“瓶颈决定产出”,在TaskGraph里也一样,短板决定了整个任务链的总耗时。
还要提醒一个常见误区:不要在单个任务里再去创建并等待另一个任务。我在做AI流程编排时犯过这个错误,任务A内部创建一个子任务B,然后立刻等待B完成。如果任务A运行在工作线程池的某个线程上,而这个线程被等待占用了,同时线程池里可用线程又有限,极端情况下会造成线程饥饿。看起来没有循环依赖,但调度器已经没有可用线程去执行B,于是A永远等不到B。解决方式是把B从一开始就和A放在同一级依赖中,用FGraphEventArray表示“B先做完、A再做”,而不是“A一边跑一边等B”。
4.3 哪些线程类型可以选
ENamedThreads给了我们几个常用选项:
- ENamedThreads::GameThread:表示这个任务更适合在主线程执行,调度器会尽量把它放到游戏线程队列里,而不是完全照办。
- ENamedThreads::RenderingThread:渲染线程,通常用来准备与渲染相关数据。
- ENamedThreads::AnyThread:任意工作线程,最自由也最推荐。
- ENamedThreads::ThreadPool:引擎里的低优先级线程池,适合不紧要的后台任务。
这里要特别强调“并不是强制”。ENamedThreads本质上是给调度器提供一个执行位置的“建议”。你要求某个任务必须从游戏线程访问UObject时,不能只靠这一点任务归属来保证安全,还要确保调用点确实在游戏线程。如果调用点本身就在游戏线程,那任务也可以直接在当前线程执行,避免多一次线程切换。
我自己一般遵循一条原则:访问UObject、UDataTable这种游戏线程私有对象时,永远不在AnyThread任务里直接写。要么确保这个任务在GameThread队列中执行,要么把数据拷贝成普通结构体再传过去。因为引擎的很多对象并不是线程安全的,你以为只是读一下,但游戏线程可能正在改它的内部状态,崩溃有时候就是一瞬间的事。
5. 与现代C++的协同:什么时候用std::async,什么时候用TaskGraph
5.1 现代C++给TaskGraph带来的写代码体验
虚幻4虽然大量使用自定义类型,但它完全兼容现代C++语法,所以C++11带来的Lambda、移动语义、智能指针都能直接用在TaskGraph里。这极大降低了上手成本。你完全可以用Lambda去捕获上下文、传递参数,而不是像老代码那样,为了传入多参数还要特制一个Runnable类,再重写三个虚函数。
lambda的捕获方式建议尽可能按值捕获TSharedRef对象,而不是捕获裸指针或this。捕获this是很危险的,因为任务可能在你的对象析构之后才执行,悬空指针就这样出现了。改用TSharedRef或TWeakPtr,任务里先Lock再使用,可以在运行时安全地判断对象是否还活着。
现代C++的“移动语义”也能用来优化任务里的数据传递。你可以把大的字符串数组MoveTemp给任务里的局部变量,减少拷贝开销。不过这里要小心,同一个Context可能会被多个任务引用,移动之后其他任务拿到空数据别意外。所以更稳妥的做法还是设计成“每个切片的独立缓冲”。
5.2 std::async、std::future与FGraphEventRef的对比
C++标准库里有std::async和std::future,它们也能让你做异步任务。但在虚幻4的主线程世界里,std::future有一个很尴尬的痛点:你不知道结果什么时候能好,必须通过get()或wait()阻塞线程,这很容易卡住游戏线程。虽然std::async内部也会用线程池实现,但它的调度粒度比较“黑盒”,不像TaskGraph那样支持任务之间的依赖组网和精确的线程偏好。
如果你写的代码是纯工具类、不希望引入引擎头文件,当然可以用std::async。但在游戏运行时需要和游戏线程、渲染线程协作的场景,TaskGraph才是符合引擎架构的选择。一个很直观的原因:引擎自己的性能分析工具能捕捉TaskGraph发布的任务,你在Frame Insight或Unreal Insights里能看到每个任务落在哪个线程、耗时多少;std::async的任务对这种分析工具来说就相当透明,出了问题都不好查。
5.3 并行算法与ParallelFor
如果场景只是想把一个循环并行化,不需要那么复杂的依赖图,那引擎里的ParallelFor比TaskGraph更顺手。ParallelFor的底层其实就是借用了TaskGraph的工作分片能力,但暴露的是“你把数组遍历的操作给我,我帮你分成N块”的简洁接口。
ParallelFor(Data.Num(), [&Data](int32 Index) { ProcessItem(Data[Index]); } );但ParallelFor默认会等待所有调度项完成,属于阻塞式并行。如果你想“发起一批任务后先去干别的事,最后一起来收”,还是要用TaskGraph。工程上通常的做法是:简单循环用ParallelFor,复杂依赖链用TaskGraph。两者不是竞争关系,而是互补关系。
6. 踩坑实录:常见问题与排查套路
6.1 任务挂起却不报错,多半是依赖问题
任务一旦被创建后如果一直不执行,整个页面像卡死一样,而代码里又没有明显崩溃,最常见的原因就是依赖环。我自己在搭建AI状态机时写过一个循环:任务A依赖任务B,任务B依赖任务C,任务C依赖任务A。编译运行都不报错,调用WaitUntilTasksComplete却永远卡住。排查时是先用日志在任务入口打印,发现没有一个任务真正跑起来,然后检查依赖数组,才看到循环依赖。
建议养成一个习惯:在开发期给关键任务入口和完成点加Log,至少能看到依赖链有没有被触发。再加一个超时保护:如果项目有这种卡死,可以准备一个后台看门狗,在你等待任务完成时设置一个最大时长,超过就输出依赖信息,方便定位。
6.2 空依赖数组的迷惑行为
给一个任务传空的FGraphEventArray,意味着它没有前置依赖,会立刻执行。这本来不是问题,但如果你用同一个变量反复填充依赖,忘记清空上一次任务的依赖项,后续任务会意外地“继承”一堆旧依赖,导致任务迟迟不执行。
我见过一段代码:在for循环里重复使用一个局部FGraphEventArray,第一次循环Add了任务A,第二次循环没有Clear就Add了任务B,于是第二次循环的B任务被迫等待A完成。虽然这是个小问题,但一旦依赖很多,排查非常费时间。所以每次绑定依赖时,优先创建一个全新的数组,或者明确Reset()。
6.3 任务里碰UObject必须非常小心
不要因为任务声明了ENamedThreads::AnyThread,你就在里面直接查询某个Actor的位置。即使你只是读一个FVector,理论上也可能发生崩溃。Actor内部包含UObject,而UObject的内存管理是CDO、GC系统一起协作的,不是简单的引用计数。工作线程读的同时若游戏线程触发GC,对象被回收,任务里就拿到悬空指针。
安全做法是把需要的值先拷贝到普通结构体,再用TSharedRef或TWeakPtr传递。我的一个实际经验:做批量敌人生成计算时,需要每个敌人的初始位置和朝向。我没有在任务里访问Actor,而是提前在主线程从Actor拿到Transform数组,把它Copy到一个TArray ,再让多个任务读取这个数组。因为普通TArray不是UObject,任务里头只读,不发生写冲突,就安全了。
如果确实需要把结果提交给游戏线程做UI或Actor更新,可以在任务完成回调里通过AsyncTask(ENamedThreads::GameThread, ...)把更新逻辑放回主线程。不要试图在任务线程里直接调用SetActorLocation。
6.4 渲染相关的等待要选对等待线程
有些渲染数据的准备任务需要在渲染线程上下文里执行,如果你用GameThread去等待渲染线程任务,可能引发CPU空转和延迟。最好是让该任务在标题上明确使用RenderingThread,然后等待时用ENamedThreads::RenderingThread,保持“谁提交谁等待”的对应关系。
虚幻引擎的渲染资源创建有自己的一套队列机制。你在任务里调用了某些渲染命令,这些命令不一定立即执行,而是排队到渲染线程。如果要确保任务返回时,渲染命令真的已经完成,需要调用特定接口去Flush。这个细节只在你做渲染相关并行时才会碰到,但碰到了就非常棘手。建议在一开始做这类功能时,先用单一任务验证整条RenderCommand能正常跑通,再扩展成并行。
6.5 性能分析怎么看
TaskGraph的好消息是分析起来比std::async容易太多。虚幻4自带的分析工具能看到两种信息:r.TaskGraph.ShowStats这个控制台变量可以打印任务队列统计,Frame Insights里能看到任务在不同线程上的重叠时间。
我常用的诊断流程是这样:先看总耗时,再一项项关掉任务创建,对比耗时变化,判断调度开销占比。如果并行版本只快了10%,而逻辑核心又满了,基本就是任务粒度太碎,需要把更多工作塞进同一个任务。如果并行版本比单线程慢,先别说“并行没用”,十有八九是数据竞争和缓存抖动在拖后腿。
6.6 内存分配是隐形窃贼
任务里高频调用普通的TArray.Add或FString拼接,会触发大量动态内存分配。这个事情在单线程里也有成本,但在多线程里更严重,因为默认分配器会在全局堆上竞争锁。任务数量一多,锁竞争会让并行效率直线下降。
我的做法是任务开始前尽量为输出数组Reserve足够的空间。如果有切分切片,每个切片都先根据原始数据比例预留大小,减少中间Add导致的扩容。这看起来像C++老生常谈的优化,但在TaskGraph并行环境里,收益比单线程更明显。另外,字符串处理时尽量用TStringView和合理的缓存,减少临时字符串对象创建。
7. 从虚幻4到以后:这套思维能带走多少
说了这么多,如果只记住一句话,那就记住“任务之间只谈依赖,不谈线程归属”。这句话听起来简单,但真正做到需要项目管理级别的数据设计和链式思维。
我个人在实际操作中最喜欢的一个模式是“拆、并、合”:先把一个大数据集按块拆开,再用若干并行任务处理每个块,最后用一个合并任务把结果汇总。这个模式几乎覆盖了我在项目中遇到的大部分批量处理场景。它简单、直观、不出错,也能被框架内的分析工具清楚展示。
最后再分享一个小技巧:任务函数体不要写太长,最好控制在“一眼能看懂”的范围。碰到大函数,拆成多个独立命名的小函数,再让任务调用它们。这跟代码风格有关,但在TaskGraph里更重要,因为任务之间的并发和依赖关系已经让代码难度上升了一个台阶,函数再复杂,别人或者三周后的你自己,真的很难搞清楚整个任务图的走向。
如果你想继续深入,下一步可以去看引擎里运动学、蒙皮更新这些并行模块是怎么组织的。它们的源码里用了大量TaskGraph,会让你看到比我这套示意更复杂的依赖组合方式。学会TaskGraph不是终点,它只是让你从“线程思维”跳到一个更高效的“任务思维”的起点。