目前网上关于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的话这个时候就不会丢弃而且等待执行完。如果需要丢弃则不继承,并且自己实现CanAbandon和Abandon函数。源码里可丢弃的任务参考: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就比较复杂了。
- EAsyncExecution。要用哪种方式执行多线程,源码写得很清楚了不赘述。
- decltype和Forward关键字。这两个都是C++11特性。decltype是自动类型推导,与auto区别在于不需要赋值就可以推导。Forward是完美转发。
- TPromise和TFuture是用来接受传进来的Lambda表达式的返回值的。
从这里可以看出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。
学习资料
- 《Exploring in UE4》多线程机制详解[原理分析]
- UE4/UE5的TaskGraph
- b站:【合集】UE4 C++进阶[进行中]
- C++ decltype类型推导完全攻略
- C++11完美转发及实现方法详解
- 2w字 + 40张图带你参透并发编程!
关于作者
- 我是水曜日鸡,喜欢ACG的游戏程序员。曾参与索尼中国之星项目《硬核机甲》的开发。 目前在某大厂做UE4项目。
CSDN博客:https://blog.csdn.net/j756915370
知乎专栏:https://zhuanlan.zhihu.com/c_1241442143220363264
游戏同行聊天群:891809847