ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Unreal多线程为何不用std::thread?FRunnable、Async与TaskGraph选型指南

Unreal多线程为何不用std::thread?FRunnable、Async与TaskGraph选型指南 得先说一个听起来挺反直觉的事Unreal 引擎源码里搜std::thread搜出来的结果少得可怜而且基本都躺在平台抽象层某个犄角旮旯多数是为了兼容第三方库才留下的。你觉得引擎团队是看不起标准库其实不是是游戏引擎对线程的要求标准库从来没承诺过要管。这个系列聊的是“Unreal 对 C 做了什么”这一章我就专门讲多线程这块Unreal 为什么不用std::thread它自己发明的那套线程工具到底解决的是什么问题以及你实际写代码时该怎么选、怎么避坑。这篇文章适合两类人一类是刚接触 Unreal 多线程、被FRunnable、Async、ParallelFor、TaskGraph弄得眼花缭乱的初学者另一类是写过一堆std::thread、想搞明白引擎层的线程模型为什么要绕开的进阶用户。我会尽量把原理和代码示例放在一起讲你可以边看边对着自己的工程试。1. 为什么引擎宁可绕开一台裸线程也不直接用 std::thread很多刚入行的同学会觉得C11 给了std::threadUnreal 作为 C 引擎直接用不就完事了这个问题要拆开看。Unreal 不是“不能用”std::thread而是“不值得用”。引擎需要的是一个可管、可控、可观测的线程运行环境标准库线程在这些维度上的承诺太少了。1.1 线程池、优先级和生命周期标准库没承诺的事std::thread本质上就是一次系统调用帮你拉起一个线程线程跑完、join或detach之后就跟你没关系了。你要是自己管理一堆这样的线程很快就会发现三个痛点。第一是线程池缺失。游戏每帧可能产生几十上百个轻量任务比如物理射线检测、动画采样、资源解压。如果每个任务都临时create一个线程线程创建销毁的开销比任务本身还大上下文切换也会把帧时间打爆。Unreal 在引擎启动时就按 CPU 核心数建好了若干线程池你需要的是一个“把任务丢进去”的入口而不是“亲自去拉线程”的能力。第二是线程没有优先级。渲染线程、游戏线程、RHI 线程的执行优先级不同后台预计算任务的优先级必须明显低于前台逻辑。std::thread本身不提供优先级控制你想调就得拿native_handle()去调操作系统的 API而 Unreal 要跨 Windows、Linux、macOS、iOS、Android这种“各自为政”的写法会把平台层代码搞得没法维护。第三是生命周期没人管。引擎退出时所有后台线程必须在合适的时机停下来把资源清理干净。std::thread不会通知引擎“我这边还有什么活没干完”你得自己维护一个线程清单再在关闭逻辑里逐个处理。这事儿做一遍就知道有多痛。我自己接手过一个老项目里面用原生线程做异步存档结果每次退出程序都有 20% 概率崩在TerminateThread附近后来全部改成引擎线程模型才好。1.2 调试和崩溃定位没有名字的线程很难办做游戏开发崩溃日志和调试器是我们的老伙伴。std::thread创建出来的线程默认在调试器里叫Thread 1234在崩溃报告里也只有一个线程 ID。问题是Unreal 项目动辄几十条线程同时运行崩溃日志里给你一个 ID你还是不知道这条线程到底是在做物理、跑动画还是写网络。Unreal 给每条线程一个可读名字比如GameThread、RenderThread、RHIThread、TaskGraphThread 0。这些名字会注册到引擎的崩溃报告系统里一旦哪个线程崩了日志会直接标出线程名。排查效率完全不是一个级别。你用std::thread自己拉线程就失去了这套命名与标记机制所有信息都得自己打日志、自己维护映射表。1.3 GameThread 与 RenderThread线程的身份比线程本身重要Unreal 引擎的多线程架构不是“所有线程一视同仁”而是有明确分工的。游戏逻辑主要跑在 GameThread渲染命令在 RenderThreadRHI 调用在 RHIThread。很多 API 是线程绑定的比如UObject的创建和销毁、AActor的某些操作只能在 GameThread 做你要是从后台线程直接操作轻则断言崩溃重则随机花屏。std::thread不知道这些角色的存在。Unreal 则在每个线程的入口就登记好“我这个线程是谁、能干哪些活”配合IsInGameThread()、IsInRenderingThread()这类检查把“越权调用”在早期就暴露出来。这些功能看似小事实际是引擎稳定性的地基。明白了这一点你就知道 Unreal 的线程工具不只是“封装了一下”而是把线程建模成了引擎架构的一部分。2. 第一层包装FRunnable、Async 与 ParallelFor 分别解决什么当你理解了上面的背景再来看 Unreal 的具体接口会发现每个工具对应一类明确的使用场景。这一节我们按“从底层到快捷”的顺序过一遍。2.1 FRunnable手写线程的正确打开方式FRunnable是 Unreal 最接近“裸线程”的抽象它对应一条真正的独立线程。使用方式是实现一个继承FRunnable的类实现四个关键方法class FMyWorker final : public FRunnable { public: virtual bool Init() override { // 线程启动后、Run 之前执行在这里做准备工作 // 返回 false 表示初始化失败线程不会进入 Run return true; } virtual uint32 Run() override { // 线程主循环在这里执行实际任务 while (!bStopRequested) { // 处理工作... FPlatformProcess::Sleep(0.01f); // 适当让出 CPU } return 0; // 返回值是线程退出码 } virtual void Stop() override { // 外部要求线程停止设置停止标志 bStopRequested true; } virtual void Exit() override { // Run 返回后执行进行清理工作 } private: std::atomicbool bStopRequested{ false }; };创建线程的代码长这样TUniquePtrFRunnableThread Thread FRunnableThread::Create(MyWorker.Get(), TEXT(MyWorkerThread), 0, TPri_Normal);注意Create的参数第二个是线程名第三个是栈大小0 表示用默认值第四个是优先级TPri_Normal、TPri_AboveNormal等。创建成功后Thread对象管理这条线程。Run 跑完后线程并不会自动销毁自己你需要在合适的时机等待它结束if (Thread.IsValid()) { Thread-Kill(true); // 请求停止并等待线程退出 Thread.Reset(); // 释放线程对象 }FRunnableThread::Create在不同引擎版本里签名有差异新版本把bAutoDeleteSelf、bAutoDeleteRunnable这类参数并入了EThreadCreateFlags有些老项目代码里还会看到这些参数。写新代码时建议显式持有线程对象不要依赖隐式删除否则很容易出现“线程还活着管理对象已经没了”的悬垂问题。2.2 Async / AsyncThread临时的活不用开一条永久线程很多时候你只是想把一个耗时操作丢到后台去比如读取配置文件、压缩纹理数据并不想为此写一个完整的FRunnable类。这时候用Async是最省事的#include Async/Async.h TFuturebool Result Async(EAsyncExecution::ThreadPool, []() { // 在这里做耗时工作比如从磁盘读取文件 TArrayuint8 Data; LoadFileToArray(Data, TEXT(D:/config.bin)); return!Data.IsEmpty(); }); // 主线程可以做别的事之后用 Result.Get() 等待结果 bool bSuccess Result.Get();EAsyncExecution有几种取值选错会吃亏ThreadPool丢到全局线程池执行适合短小、频繁的任务。注意线程池线程数量有限如果你在任务里做长时间阻塞比如等待网络会把线程池占满别的任务全卡住。Thread每次调用都会创建一条新线程执行完销毁。适合那些必须要独立线程、且执行时间较长的任务但不适合高频调用因为线程创建销毁有开销。TaskGraphMain/TaskGraph以任务图的方式调度适合可以和其他任务并行、且依赖关系明确的任务。Async返回的TFuture可以Wait()阻塞等待也可以用.Then()链接后续任务。这里有个经典坑不要用[this]捕获裸指针尤其是对象可能在线程执行期间被销毁的场景。后台线程执行时对象已经析构接下来就是访问已释放内存崩溃随机发生。安全做法是捕获TWeakObjectPtr或共享指针TWeakObjectPtrAActor WeakActor ThisActor; Async(EAsyncExecution::ThreadPool, [WeakActor]() { if (WeakActor.IsValid()) { // 安全使用 } });2.3 ParallelFor数据并行的短路通道如果你的数据是一堆互相独立的元素想并行遍历处理Unreal 提供了比Async更直接的并行写法——ParallelForTArrayFVector Vertices ComputeHugeArray(); ParallelFor(Vertices.Num(), [Vertices](int32 Index) { // 对第 Index 个元素做处理 Vertices[Index] Vertices[Index] * 2.0f; });引擎会自动把[0, Num)的索引范围切分成多个块分派给多个线程执行。默认情况下块的大小和线程数由引擎根据 CPU 核心数决定你也可以传入额外的参数控制ParallelFor(Vertices.Num(), [Vertices](int32 Index) { // 处理逻辑 }, EParallelForFlags::BackgroundPriority // 以低优先级执行不抢占前台逻辑 );ParallelFor适合“数据密集、单元素任务量一致或大致一致”的场景。要注意两件事每个索引的工作必须互相独立不能依赖其他索引的计算结果。如果元素之间有依赖继续往下看 TaskGraph不要硬用ParallelFor。Lambda 捕获比较复杂。如果你捕获了某个大对象的引用并同时修改它会有数据竞争。我自己常用的方式是预先用TArray分配好结果槽位ParallelFor里只写自己负责的那个槽最后再合并。3. 同步才是真功夫锁、事件和原子操作的正确姿势线程调度讲完了接下来是并发编程里最容易翻车的部分同步。Unreal 提供了一套完整的同步原语虽然名字看起来跟标准库差不多但细节上有不少差异。3.1 临界区与 FScopeLock锁尽量挤在最小作用域FCriticalSection是 Unreal 的互斥锁用法上跟std::mutex类似但推荐配合FScopeLock使用。FScopeLock是一个 RAII 对象构造时加锁析构时解锁能保证异常安全不会出现“锁了忘解”的场面FCriticalSection DataMutex; TArrayint32 SharedData; void AddValue(int32 Value) { FScopeLock Lock(DataMutex); SharedData.Add(Value); } // 离开作用域时自动解锁我自己通常会在代码评审里盯死一条规则锁的范围要尽可能小。有人喜欢把锁加在整个函数开头然后在函数末尾手动 unlock中间还有 return这种代码迟早出事。FScopeLock发挥作用的前提就是作用域所以要把共享数据访问尽量圈在一个短代码块里。另一个教训是不要在持锁期间调用可能阻塞的操作比如FEvent::Wait。如果两个线程各自持锁等对方就是死锁。3.2 FEvent跨线程信号灯怎么用才不出事FEvent相当于跨线程的信号旗用来实现“线程 A 干完某件事线程 B 可以继续走”的同步。典型用法是生产者-消费者模型// 生产者线程 FEvent* DoneEvent FGenericPlatformProcess::GetSynchEventFromPool(); // 启动消费者线程... DoneEvent-Trigger(); // 通知消费者可以继续了// 消费者线程 DoneEvent-Wait(); // 阻塞等待信号GetSynchEventFromPool()从引擎维护的事件池里取一个事件对象用完要归还FGenericPlatformProcess::ReturnSynchEventToPool(DoneEvent);这里有两个高频坑我要特别提醒。第一个坑是“Trigger 之后再归还 Event”的时序问题。如果 A 线程Trigger()完就直接把 Event 归还到池子而 B 线程还没来得及Wait()这个 Event 可能被另一次GetSynchEventFromPool()拿走B 线程等到的就可能是别的线程的信号逻辑错乱。正确做法是让 B 线程在Wait()返回后主动通知 A“我已经拿到了”A 再归还或者干脆不要归还用引用计数管理。第二个坑是“信号丢失”。FEvent::Wait()是阻塞等待如果在调用Wait()之前Trigger()已经发生了那么Wait()会不会立刻返回取决于 Event 创建时是否设置了“自动重置”标志。Unreal 的FEvent在创建时传入bIsManualReset手动重置的事件在被 Trigger 后会保持信号状态直到有人调用Reset()自动重置的事件则会在唤醒一个等待线程后自动恢复无信号状态。用之前先确认你要哪种行为否则很容易出现“提前触发导致线程一直阻塞”的诡异现象。3.3 原子操作与内存顺序什么时候可以不加锁如果共享数据只是一个简单的整数或布尔值可以用原子操作避免加锁的成本。Unreal 提供了FPlatformAtomics和TAtomic老版本引擎中常见新代码里我其实更推荐直接用std::atomic因为引擎自身的现代代码也大量使用它性能与可移植性都有保障。std::atomicint32 Counter{ 0 }; void someThread() { Counter.fetch_add(1); }原子操作的核心是内存顺序。std::atomic默认使用memory_order_seq_cst这是最强的顺序保证也是最慢的。你在 Unreal 后台线程里写日志或做统计时通常用memory_order_relaxed就够了因为它不涉及对其他数据可见性的依赖std::atomicint32 NumProcessed{ 0 }; NumProcessed.fetch_add(1, std::memory_order_relaxed);不过要提醒一句原子变量只保证单个变量本身的操作不可分割不能保证多个变量之间的顺序关系。如果你想表达“先写数据 A再发布标志 B让其他线程看到 B 时保证能看到 A”那仍然需要配合release/acquire内存序或锁。这种细节在调试时极难发现我建议没有十足把握时不要手写复杂的内存序老老实实用锁。4. TaskGraph 和 UE::Tasks有依赖的并行怎么编排前面聊的Async和ParallelFor适合零散任务。但游戏里很多任务之间存在依赖关系比如“先解压模型资源再做碰撞体生成最后把结果交给渲染线程”。如果全靠手动编排开发复杂度会直线上升。Unreal 的答案是把任务建模成一张图。4.1 TaskGraph 的基本单元把任务画成一张依赖图TaskGraph 是 Unreal 引擎内部一直存在的一套任务调度基础设施。它的基本概念是每个任务是一个节点你可以声明“这个任务依赖哪些其他任务”调度器会保证只有在所有前置任务完成后后续任务才会被某个工作线程拾取。老版 TaskGraph 的用法比较繁琐需要实现任务类或使用FGraphEvent。我一般不推荐在新代码里碰它除非你维护的是老项目。如果你在老项目里见到这种写法FGraphEventRef Event FDelegateGraphTask::CreateAndDispatchWhenReady( FDelegateGraphTask::FDelegate::CreateLambda([]() { // 任务逻辑 }), TEXT(MyTask) );知道它是干什么的就行。新版引擎已经把这条路的重任交给了 UE::Tasks。4.2 UE::Tasks新一代低层任务编排从 UE 5.0 开始Unreal 逐步用UE::Tasks系统替代老版 TaskGraph。它把“任务”和“线程”彻底分离你定义任务、声明依赖、设置优先级调度器负责在合适的线程池上执行它们。最常用的Launch函数长这样#include Tasks/Task.h UE::Tasks::FTask MyTask UE::Tasks::Launch( TEXT(MyAwesomeTask), []() { // 干活 } ); // 等待任务完成 MyTask.Wait();声明依赖时用PrerequisitesUE::Tasks::FTask FirstTask UE::Tasks::Launch(TEXT(First), []() { /* 先做步骤一 */ }); UE::Tasks::FTask SecondTask UE::Tasks::Launch( TEXT(Second), []() { /* 再做步骤二 */ }, UE::Tasks::Prerequisites(FirstTask) // 只有 FirstTask 完成后才执行 );系统还支持嵌套依赖、失败取消、优先级设置甚至支持任务间传递返回值。写起来比老接口舒服太多。如果你开始一个新项目多线程相关的新代码建议优先考虑 UE::Tasks而不是继续堆FRunnable和Async。4.3 调度器的心态别把工作任务当成一条条线程用 TaskGraph 或 UE::Tasks 时的心态跟用std::thread时完全不一样。std::thread的思维方式是“我要给这个任务开一条线程让它自己跑”任务图的思维方式是“这个任务要执行它的前置条件是什么它的优先级是什么具体哪个线程去跑这件事由调度器决定”。这种抽象换来的收益很实际。比如你有一个低优先级的后台任务和一个高优先级的前台任务同时涌入系统会让高优先级任务优先被工作线程拾取而不是让所有线程先去处理先来的活。再比如短小任务可以合并到同一线程连续执行减少上下文切换。性能和响应性都能提升但前提是你不要在一个任务函数里放长阻塞操作。以下这种写法非常伤UE::Tasks::Launch(TEXT(BadTask), []() { // 在任务线程里 sleep 一秒钟 FPlatformProcess::Sleep(1.0f); });这个任务占着一个工作线程不放间接减少了线程池可用线程数。如果你有必须长时间阻塞的操作比如网络等待建议单独通过EAsyncExecution::Thread创建专属线程或者用专门处理阻塞 IO 的线程池而不是塞进任务系统。5. 选型建议与现场排障多线程那几条容易翻车的路到这里Unreal 多线程的核心工具都过了一遍。最后讲点实战层面的东西怎么选、怎么排错。5.1 一张表看懂选型逻辑这是我平时推荐给团队的一张选型表简单粗暴场景推荐工具理由需要一条长期独立运行的后台线程FRunnableFRunnableThread生命周期可控适合常驻服务一次性耗时任务任务量零散Async(EAsyncExecution::ThreadPool)短小轻量不占用永线单个任务执行时间长不想占线程池Async(EAsyncExecution::Thread)有专属线程避免拖垮全局线程池大量独立数据元素并行处理ParallelFor一句话拉起数据并行粒度自适应多个任务之间有依赖关系UE::Tasks::LaunchPrerequisites把依赖交给调度器避免手动等待线程间简单状态共享std::atomic无锁、开销小复杂共享数据结构的互斥访问FCriticalSectionFScopeLockRAII 安全作用域清晰跨线程信号通知FEvent简单可靠但注意归还时序选型的一个原则是能用ParallelFor不用Async能用Async不手写FRunnable能用UE::Tasks不自己维护依赖数组。越往下层代码越灵活但出错概率也越高。没有必须用哪个的硬性规定关键是匹配任务形态。5.2 多线程访问共享资源的红线区就算工具选对了线程安全还得自己把关。下面这几条是 Unreal 项目里高频出事的红线我每个都踩过。第一容器不是线程安全的。TArray、TMap、TQueue都不是自带的线程安全容器。你从两个线程同时读或写同一个TArray轻则数据错乱重则内存崩坏。最简单的做法是给所有共享容器加锁或者让每个线程持有独立的数据分片最后再合并。第二UObject 生命周期是多线程最大的敌人。后台线程别直接拿UObject*干活。AActor、UComponent可能在后台线程执行期间被主线程销毁。要用TWeakObjectPtr持有并在执行时检查IsValid()或上锁。哪怕只是“读一个 float”也不安全因为对象可能在你读之前已经被 GC 了。第三渲染线程的资源别碰。后台线程不能直接创建或修改渲染相关资源比如纹理、UPrimitiveComponent。要更新渲染数据用ENQUEUE_RENDER_COMMAND把命令扔给渲染线程执行不要自己在后台线程调渲染接口。第四锁的顺序必须全局一致。如果你有两个互斥锁 A 和 B线程 1 先锁 A 再锁 B线程 2 先锁 B 再锁 A那么死锁只是时间问题。解决方式有两种所有地方都按固定顺序加锁或者用FScopeLock一次只持有一把锁不要嵌套持锁。5.3 排查崩溃与挂起时先看线程名和数据竞争真到了排查现场我建议按以下顺序来。先确认崩溃线程的身份。Unreal 崩溃日志会打印线程名如果你的自定义线程没命名排查难度会明显上升。所以创建线程时务必给一个有意义的名字比如TextureStreamingWorker别叫Thread1。然后是复现路径。数据竞争的 bug 最擅长随机出现——可能 100 次里崩 1 次跑 Release 比 Debug 更容易出问题。遇到这种先尝试固定复现条件再用TSAN或 UE 自带的数据竞争检测工具跑一遍。UE 在 Debug 构建下很多容器会开启额外检查能帮助你提前发现问题。挂起问题程序卡死没反应则是另一套排查思路。用调试器暂停进程看各个线程停留在哪个栈上。如果发现两个线程都在等对方持有的锁就是死锁。如果发现某个任务线程卡在FEvent::Wait()上而触发方线程早就崩了或者没执行到那就去看信号逻辑有没有满足“先注册等待者再发送信号”的顺序。顺带分享一个我自己的排查技巧给关键线程的重要操作加UE_LOG并且带线程名。这样崩溃时能靠日志重建现场。我会在自定义FRunnable的Run()开头打一条带FPlatformTid()的日志一旦后续出问题对比日志就能判断任务执行到了哪一步。说回到工具本身。我之前在一个项目里接手过一批用std::thread写的老代码功能是运行时加载外部资源并解析。现象是加载完成后偶尔崩溃、偶尔花屏查了三天发现是后台线程直接改了渲染线程正在读的数据。换用 Unreal 的任务系统重写之后配合ENQUEUE_RENDER_COMMAND把结果交给渲染线程问题彻底消失。这给我留下的印象很深Unreal 的线程工具从来不是为了炫技而是把“该在哪个线程做什么事”这件最麻烦的事尽量变成引擎内置的约束。你顺着它的规则来多线程项目就好写得多。
返回列表