尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

UE5 FAsyncTask与GetStatId:任务类实现与线程池正确用法

UE5 FAsyncTask与GetStatId:任务类实现与线程池正确用法 1. 为什么要用AsyncTask而不是直接开一个FRunnable线程先把场景聊透。很多刚接触UE5多线程的开发者第一反应是回去用C标准库的std::thread或者自己在引擎里new一个FRunnable再包一层FRunnableThread觉得这样“可控、灵活”。这种做法本身没有错但一旦放进UE5的框架里就会踩到一连串的坑线程生命周期管理、引擎关闭时的悬挂线程、与游戏线程的同步、统计信息的接入……每一样都要自己操心。而AsyncTask和FAsyncTask 这两个兄弟就是引擎帮你把这堆事情收拾得差不多之后拿出来的现成方案。AsyncTask是一个全局函数模板它接收一个Task参数这个Task必须满足“可以被执行”的条件也就是内部实现了DoWork()之类的接口。它在底层会调用GThreadPool或者后台IO线程池的对应方法把任务丢进去排队。FAsyncTask是一个类模板它更“重”一些允许你显式地创建任务对象、控制任务的启动时机、查询执行状态、等待任务完成甚至可以在任务执行过程中做取消或提前退出的判断。两者并不冲突反而是一套组合拳轻量场景用AsyncTask一锤子买卖需要精细控制生命周期和状态时才上FAsyncTask。我见过很多项目里团队一开始图省事把所有解算全丢进AsyncTask结果没做线程安全保护数据竞争崩溃一堆。也有团队相反事无巨细全部自己管理线程最后在打包退出的阶段被悬挂线程卡死。这两种极端都不健康。正确的做法是理解FAsyncTask提供的这层封装到底在你和操作系统线程之间垫了什么然后根据任务的类型、运行频率、与游戏线程的交互方式来选择合适的那一层。先记住一个结论FAsyncTask适合那种“明确知道自己要跑一段计算密集或阻塞型工作需要等待它结束且希望任务能挂在引擎统计系统里被看见”的场景。它不是万能的但它是单人开发者和中小团队最容易用对的多线程工具之一。接下来我们把它的类层级拆开来看。2. 从FRunnable到FAsyncTask的继承链拆解2.1 FNonAbandonableTaskFAsyncTask能跑起来的“身份证”FAsyncTask的模板参数Task在它的类定义里明确要求最终要继承自FNonAbandonableTask。也就是说FAsyncTask 中的Task并不是任意一个拥有DoWork方法的类它必须同时满足一个约束继承FNonAbandonableTask并且实现DoWork()、GetStatId()这两个关键接口。FNonAbandonableTask这个类的名字已经说明了一切不可放弃的任务。它在引擎里是一个十分轻量的基类内部只提供了两个纯虚函数class FNonAbandonableTask { public: virtual ~FNonAbandonableTask() {} virtual void DoWork() 0; virtual TStatId GetStatId() const 0; };一个是DoWork()任务的实际执行体。另一个是GetStatId()用于返回任务的统计ID。看到GetStatId()是纯虚函数很多同学就明白了如果不在子类里实现它编译直接报错。而这份作业里老师布置的“补充TStatId GetStatId()函数的写法”本质上就是在考察你是否知道这个纯虚函数存在的意义以及它应该怎么正确地填上。2.2 FRunnableFAsyncTask与线程对象之间的桥FAsyncTask本身真正继承的类是FRunnable。FRunnable是UE5中线程对象的运行体接口它定义了Init()、Run()、Stop()、Exit()四个虚函数。正常情况下你要开一个线程就得写一个类继承FRunnable然后交给FRunnableThread::Create去创建线程。但FAsyncTask这个模板类内部已经帮你把这些都实现了。我们可以把FAsyncTask的工作方式理解成你自己写了一个具体的任务类比如FMyTask让它继承FNonAbandonableTask然后你把FMyTask作为模板参数塞给FAsyncTask 。FAsyncTask 自动变成一个新的FRunnable子类。你调用它的Start()时它会去线程池申请一个工作线程该线程的Run()函数会调用你任务类的DoWork()。线程内部的循环跑完DoWork()后会自动处理完成状态整个线程池中的线程不会因为你某个任务结束就被销毁而是回到池中等待下一个活。所以继承链的全貌是这样一个结构FMyTask : FNonAbandonableTask FAsyncTaskFMyTask : FRunnableFAsyncTask 对象内部代理了FMyTask的所有调用——它初始化FRunnable时实际上是在构建FMyTask的实例它执行Run()时是在调FMyTask的DoWork()它返回统计ID时是在调FMyTask的GetStatId()。这种通过模板参数注入具体任务类的方式避免了在运行时用虚函数表做大量跳转编译期就能确定调用目标性能上比纯接口派生的方案更轻。2.3 引擎为什么这样设计组合优于继承有的同学可能会疑惑为什么不直接让FMyTask继承FRunnable然后自己处理线程创建这里涉及一个设计权衡。如果直接继承FRunnable你等于同时承担了“定义任务”和“管理线程生命周期”两件事。FRunnable本身的Run()是线程的入口你必须在里面写循环逻辑或者自己处理“只执行一次”的任务你还得考虑线程的启动/停止/中断这些一旦处理不当线程池中的线程就可能泄漏。引擎的做法是把“任务”和“线程”两个关注点分开你只负责在FNonAbandonableTask的子类里写“要做什么”FAsyncTask负责把它丢到合适的线程池并管理状态。这种组合式的设计让任务代码变得足够纯粹也便于在编辑器里统一调试。从源码里看FAsyncTask 在构造时会创建FMyTask的实例并在内部提供一个访问器外部通过GetTask()来获取这个实际执行任务的引用。这种“代理人”模式在UE中很常见外部操作的是FAsyncTask实际干活的是Task。了解了这个你就能明白为什么有些API比如IsDone()在FAsyncTask上而另一些比如DoWork()必须写在Task里。3. FAsyncTask几个关键API的调用时机与原理3.1 Start()一次启动不能重复FAsyncTask的核心API里面Start()是最常被调用的。它的内部实现大致如下void Start() { check(!bStartedAgain); bStartedAgain true; check(Thread nullptr); Thread FRunnableThread::Create(this, *ThreadName, 0, TPri_BelowNormal); }这里有几个信息量很大的地方。第一它调用了FRunnableThread::Create传入的this正是FAsyncTask这个FRunnable子类。Create之后操作系统层面的线程就真正创建出来了并在合适的时机调用FAsyncTask的Run()进而间接调用你Task里的DoWork()。第二TPri_BelowNormal是一个线程优先级。线程池中的任务不是一定跑在最高优先级FAsyncTask默认把线程优先级设得比普通线程低目的很明确避免后台计算任务把游戏主线程的资源抢走太多导致帧率波动。这一点在实际项目中非常重要尤其是同时有多个后台任务在跑的场合。第三bStartedAgain这个标志的存在意味着一个FAsyncTask对象只能调用一次Start()。在调用Start()之前bStartedAgain是false调用之后变成true再次调用就会触发check。这是一个硬性约束。实际开发中不少人遇到过这样的崩溃在Tick里反复检查任务是否完成如果未完成就再次调用Start()结果直接触发check崩溃。正确做法是判断IsDone()或IsWorkDone()只有确认任务处于未启动或已完成状态时才考虑重新调度。3.2 IsDone()与IsWorkDone()的区别FAsyncTask有两个看起来很相似的函数IsDone()和IsWorkDone()。IsDone()用来查询FAsyncTask是否整个线程执行完毕包括线程退出逻辑的收尾。IsWorkDone()则更精确——它只检查ToDoWork这个原子计数是否为0。也就是你的DoWork()执行完了吗DoWork()一完成IsWorkDone()立刻返回true。但此时FAsyncTask可能还没完全收尾内部线程可能还没彻底退出。实际使用中大多数人关心的是“我的计算跑完了没”所以用IsWorkDone()更直观。如果你要做的是在任务完成后立即从任务对象里取结果建议用IsWorkDone()判断。如果你打算复用这个任务对象在允许的情况下则要等IsDone()。另外还有一个EnsureCompletion()函数。它会阻塞当前线程直到任务完成。很多人把这个当成“安全的等待”来用但它有个隐藏陷阱如果当前线程是游戏线程而任务所在的线程池线程又需要和游戏线程通信就可能发生死锁。所以EnsureCompletion()我建议只在非游戏线程、或者你能100%确认任务不会反向依赖当前线程的情况下使用。3.3 Cancel()与任务内的停止响应FAsyncTask还有一个Cancel()方法。这个方法并不是“立刻终止线程”它将bCancelRequested置为true。而你的DoWork()内部如果不去检查这个标志任务依然会继续跑直到结束。真正的优雅取消需要你在DoWork()里主动轮询void DoWork() { while (bContinueWorking !IsCancelRequested()) { // 分块处理数据 } }IsCancelRequested()是FAsyncTask在将调用转发给Task时注入到内部的一个状态接口。你的Task类可以通过某种方式拿到FAsyncTask实例从而在DoWork()中判断取消请求。这里想强调一个原则在线程池中的任务不建议用“杀线程”的方式去取消。操作系统层面强行终止线程可能造成内存泄漏、锁未释放等问题UE不会做这种危险的事。正确做法永远是协作式取消让你的任务代码在合适的位置响应取消请求。4. TStatId GetStatId()函数的身份与正确写法4.1 为什么任务必须实现GetStatId()在FAsyncTask的继承链里FAsyncTask 本身实现了FRunnable的所有接口包括GetStatId()。而它的GetStatId()实现长这样TStatId GetStatId() const { return Task.GetStatId(); }也就是说FAsyncTask把自己的GetStatId()代理给了模板参数Task。Task如果没有实现GetStatId()编译时就会报错。这就是为什么不管你是不是在意统计信息只要你的Task继承自FNonAbandonableTask就必须把GetStatId()写出来。那这个函数到底干了什么UE的统计系统Stat System允许开发者通过控制台命令“stat xxx”查看各类性能数据。每个能产生统计数据的对象都要返回一个TStatId它是一个“统计ID”内部实际是一个指向FStatDescription的指针。引擎通过这个ID来注册和查询对应的统计项。如果返回值是空ID那么这个对象就不参与统计数据的输出。对多数普通任务来说我们并不想让它被Stat系统追踪或者不想为它专门声明一个统计组那么直接返回空的TStatId就是合法且常见的写法。4.2 两种正确写法与一种常见误解老师作业里通常要求手写这个函数最简形式是TStatId GetStatId() const { return FStatId(); }FStatId()是TStatId的默认构造函数等价于传入nullptr。这样返回的TStatId表示该任务不关联任何统计项。这种写法对于绝大多数自定义任务来说已经足够而且编译期开销小不依赖额外的统计宏。另一种写法是用UE的声明统计宏。假设你在任务类里这样写DECLARE_CYCLE_STAT(TEXT(FMyTask_DoWork), STAT_FMyTask_DoWork, STATGROUP_ThreadPoolAsyncTasks); TStatId GetStatId() const { RETURN_QUICK_DECLARE_CYCLE_STAT(FMyTask_DoWork, STATGROUP_ThreadPoolAsyncTasks); }这种写法会让该任务的DoWork()时间被计入STATGROUP_ThreadPoolAsyncTasks统计组你就能在控制台通过stat ThreadPoolAsyncTasks查看到它消耗了多少时间。适用于性能敏感且需要持续监控的任务。说到误解我见到不少初学者会把GetStatId()和GetTypeHash()之类的东西搞混或者在函数内写一些复杂的生成逻辑。记住TStatId是一个统计ID不是一个字符串更不是任务名称。你不应该在这里返回一个随机的、变化的ID。统计系统要求ID在整个运行期间保持稳定才能正确聚合数据。如果你在GetStatId()里依赖函数内局部变量或者临时对象那统计结果一定是乱套的。4.3 匿名命名空间的细节与编译器的“热情帮助”还有一个容易被忽略的点。如果你在一个.cpp文件的匿名命名空间中实现FMyTask而GetStatId()使用了RETURN_QUICK_DECLARE_CYCLE_STAT宏宏内部会创建一个静态变量。C编译规则规定匿名命名空间中的每个编译单元都有自己独立的内部符号这会导致多个.cpp里同名的统计项被当成不同项处理。解决方式有两个要么不把任务实现放入匿名命名空间要么在宏调用时传入一个包含独特前缀的变量名。当然如果你只返回空的FStatId()这个问题连遇到的机会都没有。这也是我建议大多数任务优先使用FStatId()的原因——不是因为它偷懒而是它天然绕开了一堆统计系统内部命名管理的坑。4.4 填错GetStatId()后会发生什么直接不写编译报错。因为FNonAbandonableTask定义了纯虚函数。返回一个每次都不一样的TStatId编译不会报错但Stat系统里的数据会异常显示为多个不可聚合的分项排查问题时非常痛苦。返回一个字符串强转来的TStatId这通常不会编译通过但如果你用了reinterpret_cast之类的强制手段运行时大概率直接崩溃因为TStatId内部是指向FStatDescription的指针不能随便拿字符串地址凑数。从这些失败案例来看最稳妥的做法始终是任务不需要统计监控时就老老实实写“return FStatId();”需要监控时在类内部使用标准统计宏生成稳定的统计ID。5. 老师代码举例一个可实际运行的任务类完整范例很多时候教科书里的例子都太碎片化只给你一个类的片段。这里我把一个可以实际放进UE工程里跑起来的完整写法贴出来注释我会写得尽量清楚。先在头文件里声明任务类// MyAsyncTask.h #pragma once #include CoreMinimal.h #include Async/AsyncWork.h /** * 一个不依赖统计监控的简单任务 */ class FMySimpleTask : public FNonAbandonableTask { public: FMySimpleTask(float InDuration) : Duration(InDuration) { } // 任务实际执行逻辑 void DoWork() { // 模拟耗时计算 float Sum 0.0f; for (int32 Index 0; Index 1000000; Index) { Sum FMath::Sin(Index * 0.001f) * Duration; } Result Sum; bFinished true; } // 统计ID TStatId GetStatId() const { return FStatId(); } float GetResult() const { return Result; } private: float Duration; float Result 0.0f; bool bFinished false; };在某个函数里启动并等待它#include Async/AsyncWork.h #include MyAsyncTask.h void RunMySimpleTask() { // 创建任务对象 FAsyncTaskFMySimpleTask* MyTask new FAsyncTaskFMySimpleTask(2.0f); // 启动 MyTask-Start(); // 等待完成这里假设你确定没有循环等待的死锁风险 MyTask-EnsureCompletion(); // 获取结果 float Result MyTask-GetTask().GetResult(); // 清理堆对象 delete MyTask; }注意这里我用了new来创建FAsyncTask对象。有人说FAsyncTask可以直接在栈上用FAsyncTaskFMySimpleTask MyTask(2.0f); MyTask.Start(); MyTask.EnsureCompletion();这样也可以前提是你的任务不会跨出这个栈作用域仍被引用。一旦任务可能被延迟执行、需要在别的线程回调中查询状态就一定要用堆对象并在确保任务彻底完成后释放内存。如果你希望任务跑得更“异步”——启动后不立即等待而是过一段时间再来查结果代码逻辑应该调整为FAsyncTaskFMySimpleTask* MyTask new FAsyncTaskFMySimpleTask(2.0f); MyTask-Start(); // 在别的地方比如Tick检查 if (MyTask-IsDone()) { float Result MyTask-GetTask().GetResult(); delete MyTask; MyTask nullptr; }这里还有一个常见的遗忘点delete与任务完成的竞态。如果IsDone()返回true说明任务内部已经完成收尾此时可以安全delete。但如果IsDone()返回false就delete那任务可能还在跑释放内存后线程访问已释放对象直接崩溃。6. 实际项目里用FAsyncTask最容易踩的几个坑6.1 返回值与线程安全的“结果共享”很多任务是要把计算结果传回游戏线程的。我的建议是任务类内部的成员变量只由任务自身写入外部通过独立的、线程安全的机制来读取。最常见的做法是任务完成后把一个TAtomic 或std::atomic 标志置为true游戏线程轮询这个标志。等标志为true时直接读取结果成员变量。因为此时任务已经写完游戏线程读的时机在写完之后不会产生数据竞争。不要把游戏线程需要的数据直接作为普通成员变量传入任务类中然后在游戏线程里一边读、任务线程一边写这样极容易出现读了一半的数据。你可能会觉得“就一个float无所谓”但多线程的坑从来不是以变量大小来区分的指令重排和缓存一致性问题在任何字节数上都存在。6.2 任务内部抛异常一个容易忽视的崩溃源C的异常如果在线程函数中向外抛出而没有对应的catch程序会直接调用std::terminate。UE的构建环境通常关闭了异常处理很多模块设置了“/EHsc-”之类选项异常在发布版中可能被包装成一条错误日志后直接终止进程。总之不要在DoWork()里抛异常不要依赖异常来做任务间的错误传递。需要提前退出时用协作式取消标志。6.3 lambda捕获与任务生命周期的纠缠有些人觉得写一个完整的FNonAbandonableTask类太麻烦想直接在AsyncTask里丢lambda。AsyncTask本身是能用lambda的AsyncTask(ENamedThreads::AnyBackgroundThreadNormalTask, []() { // 干点别的 });但这种“即用即走”的方式和FAsyncTask的区别在于你没有持有任务对象无法查询它的完成状态也无法在适合的时机获取返回结果。lambda中如果捕获了外部对象比如捕获了一个this指针而任务运行期间那个对象被销毁了那又是一个典型的悬垂指针崩溃。FAsyncTask的方式则不同你持有FAsyncTask对象显式管理它任务内部更加可控。很多团队最终会形成一条约定临时小任务用AsyncTask生命周期长的、需要反馈结果的用FAsyncTask。6.4 线程池耗尽别把FAsyncTask当无限并发来用FAsyncTask默认把任务交给GThreadPool。这个线程池的线程数是有限的通常是“处理器核心数”相关的量级。如果你循环创建几百个FAsyncTask每个任务还要跑很久线程池会被占满后续任务只能排队等待。这里没有报错也没有问题提示只是你的任务执行被拖延了。排查起来特别隐蔽——因为单看代码逻辑没问题。如何确认任务是否按照预期在并行执行一个经验是看同一时间内有多少任务真的在跑。可以用stat ThreadPoolAsyncTasks观察线程池利用率也可以在任务开头末尾打印线程ID与时间点。我在实际项目里就遇到过类似问题一个地图加载功能把所有区块的碰撞解算全部丢成AsyncTask预期10个并行结果线程池只有4个线程后面的6个任务在排队。看起来代码没问题但整体时间没比单线程快多少。解决办法很简单分批次提交任务每批数量控制在线程池可承载的范围并使用CompletionEvent之类的机制分批等待。或者按优先级分配任务低优先级任务不要一次性全塞进去。6.5 任务完成回调中的延迟与游戏线程调度有的开发者试图在任务内部执行“回调游戏线程更新UI”的代码。在DoWork()里你不能直接操作UObject或调用任何必须在游戏线程执行的API这违反线程规则。正确做法是在任务类里保存一个TFunction回调任务完成后由外部负责把该回调分发到游戏线程。举例class FMyCallbackTask : public FNonAbandonableTask { public: FMyCallbackTask(TFunctionvoid(float) InCallback) : Callback(InCallback) { } void DoWork() { // 计算 float Result 42.0f; // 不允许在这里直接调用Callback因为CallBack可能操作UObject // 只保存结果 ResultValue Result; } TStatId GetStatId() const { return FStatId(); } private: TFunctionvoid(float) Callback; float ResultValue 0.0f; };外部在确认任务完成后再通过AsyncTask(ENamedThreads::GameThread, Callback, Result { Callback(Result); })来把回调找机会切回游戏线程。这类代码一旦写成就要遵守“游戏线程能否访问对象”的完整规则。7. 作业演练一个带完整GetStatId统计的任务类假定作业要求是写一个任务类统计任务执行耗时并且能把统计ID接入引擎的Stat系统。我直接给出可以交作业的代码结构。// FStatTask.h #pragma once #include CoreMinimal.h #include Async/AsyncWork.h #include Stats/Stats.h class FStatTask : public FNonAbandonableTask { public: FStatTask(float InDuration) : Duration(InDuration) { } void DoWork() { SCOPE_CYCLE_COUNTER(STAT_StatTask_DoWork); float StartTime FPlatformTime::Seconds(); // 模拟耗时的计算 float Val 0.0f; for (int32 Index 0; Index 5000000; Index) { Val FMath::Sqrt(Index * Duration); } Result Val; ElapsedTime FPlatformTime::Seconds() - StartTime; } TStatId GetStatId() const { RETURN_QUICK_DECLARE_CYCLE_STAT(FStatTask_DoWork, STATGROUP_ThreadPoolAsyncTasks); } float GetElapsedTime() const { return ElapsedTime; } float GetResult() const { return Result; } private: float Duration; float Result 0.0f; float ElapsedTime 0.0f; }; // 在.cpp中 DECLARE_STATS_GROUP(TEXT(MyCustomStatGroup), STATGROUP_MyCustomStatGroup, STATCAT_Advanced); DECLARE_CYCLE_STAT(TEXT(FStatTask_DoWork), STAT_StatTask_DoWork, STATGROUP_MyCustomStatGroup);注意这里用了STATGROUP_MyCustomStatGroup而不是引擎自带的STATGROUP_ThreadPoolAsyncTasks。如果你希望自己的任务统计出现在自定义分组中就不应该依赖引擎已有的组。这个“自定义统计组”的方式在正式项目中更常见因为你可以把一批同类任务统一汇总。启动方式照旧FAsyncTaskFStatTask* Task new FAsyncTaskFStatTask(2.0f); Task-Start(); // 后台会执行DoWork()当你在控制台输入“stat MyCustomStatGroup”时就能看到FStatTask_DoWork这个统计项及其耗时数据。坦白说作业里关于GetStatId的写法真正的考点不是你能不能背出“return FStatId();”这行代码而是你是否理解了这个函数在整个FAsyncTask代理机制中的位置。FAsyncTask把自己所有的FRunnable接口都转嫁给内部Task你的Task因此必须充当一个完整可用的FRunnable体而不仅仅是一个会DoWork的工具人。想通这一点你就不会在GetStatId返回值的写法上再犯迷糊了。
返回列表