【UE·引擎篇】Runnable、TaskGraph、AsyncTask、Async多线程开发指南

文章正文
发布时间:2024-09-26 02:23

目前网上关于ue4多线程的文章,大部分是讲源码讲原理,上来先把源码一丢、类图一丢,对初学者来说理解困难。而关于讲解实战用法的文章,也大都讲的不全面。目前在ue4里使用多线程有Runnable、TaskGraph、AsyncTask类这几种方式,同时还有AsyncTask、Async、AsyncThread、AsyncPool等几个全局方法。这篇文章将结合多个实际案例讲解怎么用、用哪种多线程。帮助初学者更好地在ue4里进行多线程开发。
工程源码:github地址

Runnable

Runnable来实现多线程是最基础的用法,与后面介绍的其他用法来说,它没有什么复杂的功能。

案例介绍

接下来我们写一个小案例:从自定义Actor子类ATestRunnableActor里获取一个数字,然后在多线程里实现一个计数器,当计数器大于这个数字时,线程退出。

代码实现

首先需要继承FRunnable实现我们的线程执行体:

class LEARNMULTITHREADING_API ATestRunnableActor : public AActor { ...... public: //从0开始的计数器 int32 TestCount; UPROPERTY(EditAnywhere) int32 TestTarget;

在这里插入图片描述


实现Run函数,Init和Exit则只是打印Log:

uint32 FTestRunnable::Run() { while (IsValid(Tester)) { #if true // thread sync 线程同步 FScopeLock Lock(&CriticalSection); #endif if (Tester->TestCount < Tester->TestTarget) { Tester->TestCount++; } else { break; } } return 0; }

这里需要注意如果我们同时在多个线程里去读和写Actor的数据会引起线程不同步的问题,需要加锁FScopeLock
然后创建一个线程类FRunnableThread来使用FTestRunnable:

void ATestRunnableActor::BeginPlay() { Super::BeginPlay(); FTestRunnable* Runnable1 = new FTestRunnable(TEXT("线程1"), this); FTestRunnable* Runnable2 = new FTestRunnable(TEXT("线程2"), this); FRunnableThread* RunnableThread1 = FRunnableThread::Create(Runnable1, *Runnable1->MyThreadName); FRunnableThread* RunnableThread2 = FRunnableThread::Create(Runnable2, *Runnable2->MyThreadName); } 总结

FRunnable(线程执行体)和FRunnableThread(线程类)是最简单的实现多线程方式,它只有创建、暂停、销毁、等待完成等基础功能。在实战中也较少用到。

TaskGraph

TaskGraph任务图,是用来解决多线程中任务需要先后执行顺序的问题

案例介绍

我们以游戏开发中工作流为例:

首先是策划提出需求案子

然后美术设计概念图

模型师根据概念图建模

动画师等建模完成后制作动画

程序在案子提出后开发特性

等上面全部完成后策划进行验收

在这里插入图片描述


这是最简单的情况,现在程序特性比较复杂,将分给三个程序员分别开发(即子任务)。

在这里插入图片描述


同时这个需求非常重要,老板很关心开发进度,上面的每一步做完之后都要跟他汇报才算真正完成。这个需要使用TaskGraph的DontCompleteUntil功能。

代码实现 FWorkTask和FReportTask

先创建两个任务,一个表示工作内容FWorkTask,一个表示汇报FReportTask。

在这里插入图片描述

GetStatId。固定写法。RETURN_QUICK_DECLARE_CYCLE_STAT第一个参数为类名。

GetDesiredThread。可以指定使用哪种线程。除了AnyThread还有GameThread、RHIThread等多种线程设置。

GetSubsequentsMode。任务完成模式。

ESubsequentsMode::TrackSubsequents 追踪完成状态,一般用这个

ESubsequentsMode::FireAndForget 做了以后无法得知是否完成,只有没有任何依赖的Task才用。

DoWork。任务要处理的事情。

DoWork代码实现如下:

在这里插入图片描述


首先是讲解类型FGraphEventRef是什么。FGraphEventRef是FGraphEvent的指针。FGraphEvent是用来传递任务完成状态的。还是上面那个例子,其实每个岗位的人并不需要知道上游岗位的人具体做了什么工作内容,只需要知道对方完成了没有。如果完成了那么开始我的工作,如果我完成了,我把我完成的事件传递给我的下游。这就是FGraphEvent的主要职责。

回到Dowork函数:

调用子任务的Unlock让它开始工作。

GraphTask<T>::CreateTask().ConstructAndDispatchWhenReady是创建任务,后面会细讲。

DontCompleteUntil表示只有别的任务完成了,我才算完成。

报告任务FReportTask就很简单了,调用自定义Actor类ATestTaskGraphActor的OnTaskComplete函数。

在这里插入图片描述

ATestTaskGraphActor

接下来创建自定义Actor类ATestTaskGraphActor,新建函数为CreatTask(创建任务)、FireTask(运行任务)、OnTaskComplete(任务完成回调)。

在这里插入图片描述


在这里插入图片描述


FTaskItem只是一个结构体,封装了一个FGraphEventRef和TGraphTask。

在这里插入图片描述


重点在CreatTask:

在这里插入图片描述


在这里插入图片描述

填充依赖任务事件数组FGraphEventArray

填充子任务数组TArray<TGraphTask<FWorkTask>*>

CreatTask传入FGraphEventArray

ConstructAndDispatchWhenReady表示任务创建后如果满足条件则立刻执行。可以传任意参数,作为TGraphTask<T>的T的参数。

ConstructAndHold表示任务创建后不立刻执行,调用Unlock才执行。可以传任意参数,作为TGraphTask<T>的T的参数。

然后我们在蓝图里连线,因为一张图肯定塞不下,具体去github下载工程来看。

在这里插入图片描述


最后输出结果:

在这里插入图片描述

总结

TaskGraph适合有依赖关系的多线程任务。指定使用哪个线程的时候要注意一些逻辑只能在GameThread上调用。如

创建、消耗Actor

Debug绘制函数

定时器 TimerManager

另外源码使用案例参考USkeletalMeshComponent::DispatchParallelEvaluationTasks

在这里插入图片描述

AsyncTask

AsyncTask也可以实现多线程,它可以利用ue4底层的线程池机制来调度任务。

案例介绍

从这里开始包括后面的内容,我们将计算一个1到1000w的开根号,并求和,最后除以1000w的简单逻辑。并且计算主线程执行时长和逻辑计算总时长,来比较不同方法之间的差距。

主线程执行时长的计算方式是创建多线程之前记录时间点,然后创建完之后记录时间点,两者相减。

逻辑计算总时长是多线程里计算出结果以后的时间点减去创建多线程之前的时间点。

如何计算代码执行时长?
使用FPlatformTime::Seconds()

代码实现

首先创建一个类继承自FNonAbandonableTask

在这里插入图片描述

为什么要继承FNonAbandonableTask?
当线程池被销毁的时候,会调用Abandon函数。继承FNonAbandonableTask的话这个时候就不会丢弃而且等待执行完。如果需要丢弃则不继承,并且自己实现CanAbandonAbandon函数。源码里可丢弃的任务参考:FAsyncStatsFile

DoWork函数很简单:

在这里插入图片描述


然后在自定义Actor类ATestAsyncActor使用FAutoDeleteAsyncTask来传入我们刚才写的Task。FAutoDeleteAsyncTask顾名思义就是任务执行完就会自动删除。

在这里插入图片描述


还有StartBackgroundTask和StartSynchronousTask的区别:

StartBackgroundTask会利用线程池里空闲的线程来执行。

StartSynchronousTask则是主线程执行。

在这里插入图片描述


可以看到只有Synchronous以后主线程是会等AsyncTask里面的逻辑执行完了之后才会继续往下走。而使用Background主线程不会阻塞。

既然StartSynchronousTask会阻塞主线程,那我用AsyncTask的意义何在呢?直接一开始就单线程不就完事了?
问得好,这个方法即使是在ue4源码里用到的地方也极少。我认为这个方法的意义在于给AsyncTask多了一点灵活性,当我们在使用多线程时发现部分逻辑代码只能跑在主线程或者它跑异步线程其实并没有变快,这个时候想把它改成单线程的时候就很方便。

总结

AsyncTask系统实现的多线程与你自己字节继承FRunnable实现的原理相似,还可以利用UE4提供的线程池。当使用多线程不满意时也可以调用StartSynchronousTask改成主线程执行

Async全局方法

除了上述几种方式,还有AsyncTask、Async、AsyncThread、AsyncPool等几个全局方法

AsyncTask

AsyncTask最简单,里面就是调用GraphTask创建了一个立刻执行的任务。

在这里插入图片描述


用法如下:

在这里插入图片描述


这里需要注意即使改成GameThread执行,AsyncTask下面的代码也是不会阻塞的。这个时候还是单线程,只是传入的Lambda方法会在主线程一帧里的其他地方调用。不仅如此,它的主线程执行时长(0.0013ms)比AnyThread(0.003ms)的还快,尽管总逻辑时长是变慢了。

在这里插入图片描述

Async

Async就比较复杂了。

在这里插入图片描述

从这里可以看出Async方法最大的亮点是返回值为TFuture<T>,它可以获得Lambda返回值,也可以判断Lambda的逻辑有没有执行完。同时还支持执行完成的函数回调。
用法如下:

在这里插入图片描述


这里需要注意调用Get函数虽然可以获得返回值,但是是会造成主线程阻塞的。当然也可以在Tick里调用FutureResult.IsReady等它准备好了再调用Get获取返回值。另外,当没有返回值的时候,它的主线程执行时长是稍差于TaskGraph和AsyncTask的。

除了Async之外,最后还有AsyncPool和AsyncThread全局方法分别是Async第一个参数为ThreadPool以及Thread的版本,不再赘述。

总结

AsyncTask方法是TaskGraph的简单版本。需要有返回值和回调函数的时候使用Async方法。Async性能较差没事不用它

最后,还有一个ParallelFor全局方法,它本质是TaskGraph创建了多个Task并行执行任务。在工程里我也使用了ParallelFor进行了测试,把1000w个计算拆成了10个并行执行,结果时间非常慢(是不使用的4~5倍)。

在这里插入图片描述


所以如果不是复杂的逻辑,不建议使用ParallelFor。源码使用案例在UEditorStaticMeshLibrary::BulkSetConvexDecompositionCollisionsWithNotification

学习资料

关于作者

我是水曜日鸡,喜欢ACG的游戏程序员。曾参与索尼中国之星项目《硬核机甲》的开发。 目前在某大厂做UE4项目。

CSDN博客:https://blog.csdn.net/j756915370
知乎专栏:https://zhuanlan.zhihu.com/c_1241442143220363264
游戏同行聊天群:891809847

首页
评论
分享
Top