目前网上关于ue4多线程的文章,大部分是讲源码讲原理,上来先把源码一丢、类图一丢,对初学者来说理解困难。而关于讲解实战用法的文章,也大都讲的不全面。目前在ue4里使用多线程有Runnable、TaskGraph、AsyncTask类这几种方式,同时还有AsyncTask、Async、AsyncThread、AsyncPool等几个全局方法。这篇文章将结合多个实际案例讲解怎么用、用哪种多线程。帮助初学者更好地在ue4里进行多线程开发。
工程源码:github地址
Runnable来实现多线程是最基础的用法,与后面介绍的其他用法来说,它没有什么复杂的功能。
案例介绍接下来我们写一个小案例:从自定义Actor子类ATestRunnableActor里获取一个数字,然后在多线程里实现一个计数器,当计数器大于这个数字时,线程退出。
代码实现首先需要继承FRunnable实现我们的线程执行体:
class LEARNMULTITHREADING_API ATestRunnableActor : public AActor { ...... public: //从0开始的计数器 int32 TestCount; UPROPERTY(EditAnywhere) int32 TestTarget;这里需要注意如果我们同时在多个线程里去读和写Actor的数据会引起线程不同步的问题,需要加锁FScopeLock。
然后创建一个线程类FRunnableThread来使用FTestRunnable:
FRunnable(线程执行体)和FRunnableThread(线程类)是最简单的实现多线程方式,它只有创建、暂停、销毁、等待完成等基础功能。在实战中也较少用到。
TaskGraphTaskGraph任务图,是用来解决多线程中任务需要先后执行顺序的问题。
案例介绍我们以游戏开发中工作流为例:
首先是策划提出需求案子
然后美术设计概念图
模型师根据概念图建模
动画师等建模完成后制作动画
程序在案子提出后开发特性
等上面全部完成后策划进行验收
先创建两个任务,一个表示工作内容FWorkTask,一个表示汇报FReportTask。
GetStatId。固定写法。RETURN_QUICK_DECLARE_CYCLE_STAT第一个参数为类名。
GetDesiredThread。可以指定使用哪种线程。除了AnyThread还有GameThread、RHIThread等多种线程设置。
GetSubsequentsMode。任务完成模式。
ESubsequentsMode::TrackSubsequents 追踪完成状态,一般用这个
ESubsequentsMode::FireAndForget 做了以后无法得知是否完成,只有没有任何依赖的Task才用。
DoWork。任务要处理的事情。
DoWork代码实现如下:
回到Dowork函数:
调用子任务的Unlock让它开始工作。
GraphTask<T>::CreateTask().ConstructAndDispatchWhenReady是创建任务,后面会细讲。
DontCompleteUntil表示只有别的任务完成了,我才算完成。
报告任务FReportTask就很简单了,调用自定义Actor类ATestTaskGraphActor的OnTaskComplete函数。
接下来创建自定义Actor类ATestTaskGraphActor,新建函数为CreatTask(创建任务)、FireTask(运行任务)、OnTaskComplete(任务完成回调)。
填充依赖任务事件数组FGraphEventArray
填充子任务数组TArray<TGraphTask<FWorkTask>*>
CreatTask传入FGraphEventArray
ConstructAndDispatchWhenReady表示任务创建后如果满足条件则立刻执行。可以传任意参数,作为TGraphTask<T>的T的参数。
ConstructAndHold表示任务创建后不立刻执行,调用Unlock才执行。可以传任意参数,作为TGraphTask<T>的T的参数。
然后我们在蓝图里连线,因为一张图肯定塞不下,具体去github下载工程来看。
TaskGraph适合有依赖关系的多线程任务。指定使用哪个线程的时候要注意一些逻辑只能在GameThread上调用。如
创建、消耗Actor
Debug绘制函数
定时器 TimerManager
另外源码使用案例参考USkeletalMeshComponent::DispatchParallelEvaluationTasks。
AsyncTask也可以实现多线程,它可以利用ue4底层的线程池机制来调度任务。
案例介绍从这里开始包括后面的内容,我们将计算一个1到1000w的开根号,并求和,最后除以1000w的简单逻辑。并且计算主线程执行时长和逻辑计算总时长,来比较不同方法之间的差距。
主线程执行时长的计算方式是创建多线程之前记录时间点,然后创建完之后记录时间点,两者相减。
逻辑计算总时长是多线程里计算出结果以后的时间点减去创建多线程之前的时间点。
如何计算代码执行时长?
使用FPlatformTime::Seconds()。
首先创建一个类继承自FNonAbandonableTask。
为什么要继承FNonAbandonableTask?
当线程池被销毁的时候,会调用Abandon函数。继承FNonAbandonableTask的话这个时候就不会丢弃而且等待执行完。如果需要丢弃则不继承,并且自己实现CanAbandon和Abandon函数。源码里可丢弃的任务参考:FAsyncStatsFile。
DoWork函数很简单:
StartBackgroundTask会利用线程池里空闲的线程来执行。
StartSynchronousTask则是主线程执行。
既然StartSynchronousTask会阻塞主线程,那我用AsyncTask的意义何在呢?直接一开始就单线程不就完事了?
问得好,这个方法即使是在ue4源码里用到的地方也极少。我认为这个方法的意义在于给AsyncTask多了一点灵活性,当我们在使用多线程时发现部分逻辑代码只能跑在主线程或者它跑异步线程其实并没有变快,这个时候想把它改成单线程的时候就很方便。
AsyncTask系统实现的多线程与你自己字节继承FRunnable实现的原理相似,还可以利用UE4提供的线程池。当使用多线程不满意时也可以调用StartSynchronousTask改成主线程执行。
Async全局方法除了上述几种方式,还有AsyncTask、Async、AsyncThread、AsyncPool等几个全局方法。
AsyncTaskAsyncTask最简单,里面就是调用GraphTask创建了一个立刻执行的任务。
Async就比较复杂了。
从这里可以看出Async方法最大的亮点是返回值为TFuture<T>,它可以获得Lambda返回值,也可以判断Lambda的逻辑有没有执行完。同时还支持执行完成的函数回调。
用法如下:
除了Async之外,最后还有AsyncPool和AsyncThread全局方法分别是Async第一个参数为ThreadPool以及Thread的版本,不再赘述。
总结AsyncTask方法是TaskGraph的简单版本。需要有返回值和回调函数的时候使用Async方法。Async性能较差没事不用它。
最后,还有一个ParallelFor全局方法,它本质是TaskGraph创建了多个Task并行执行任务。在工程里我也使用了ParallelFor进行了测试,把1000w个计算拆成了10个并行执行,结果时间非常慢(是不使用的4~5倍)。
关于作者
我是水曜日鸡,喜欢ACG的游戏程序员。曾参与索尼中国之星项目《硬核机甲》的开发。 目前在某大厂做UE4项目。
CSDN博客:https://blog.csdn.net/j756915370
知乎专栏:https://zhuanlan.zhihu.com/c_1241442143220363264
游戏同行聊天群:891809847