目前网上关于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;
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任务图,是用来解决多线程中任务需要先后执行顺序的问题。
我们以游戏开发中工作流为例:
先创建两个任务,一个表示工作内容FWorkTask,一个表示汇报FReportTask。
DoWork代码实现如下:
首先是讲解类型FGraphEventRef是什么。FGraphEventRef是FGraphEvent的指针。FGraphEvent是用来传递任务完成状态的。还是上面那个例子,其实每个岗位的人并不需要知道上游岗位的人具体做了什么工作内容,只需要知道对方完成了没有。如果完成了那么开始我的工作,如果我完成了,我把我完成的事件传递给我的下游。这就是FGraphEvent的主要职责。
回到Dowork函数:
报告任务FReportTask就很简单了,调用自定义Actor类ATestTaskGraphActor的OnTaskComplete函数。
接下来创建自定义Actor类ATestTaskGraphActor,新建函数为CreatTask(创建任务)、FireTask(运行任务)、OnTaskComplete(任务完成回调)。
FTaskItem只是一个结构体,封装了一个FGraphEventRef和TGraphTask。
重点在CreatTask:
然后我们在蓝图里连线,因为一张图肯定塞不下,具体去github下载工程来看。
最后输出结果:
TaskGraph适合有依赖关系的多线程任务。指定使用哪个线程的时候要注意一些逻辑只能在GameThread上调用。如
另外源码使用案例参考USkeletalMeshComponent::DispatchParallelEvaluationTasks。
AsyncTask也可以实现多线程,它可以利用ue4底层的线程池机制来调度任务。
从这里开始包括后面的内容,我们将计算一个1到1000w的开根号,并求和,最后除以1000w的简单逻辑。并且计算主线程执行时长和逻辑计算总时长,来比较不同方法之间的差距。
如何计算代码执行时长?
使用FPlatformTime::Seconds()。
首先创建一个类继承自FNonAbandonableTask。
为什么要继承FNonAbandonableTask?
当线程池被销毁的时候,会调用Abandon函数。继承FNonAbandonableTask的话这个时候就不会丢弃而且等待执行完。如果需要丢弃则不继承,并且自己实现CanAbandon和Abandon函数。源码里可丢弃的任务参考:FAsyncStatsFile。
DoWork函数很简单:
然后在自定义Actor类ATestAsyncActor使用FAutoDeleteAsyncTask来传入我们刚才写的Task。FAutoDeleteAsyncTask顾名思义就是任务执行完就会自动删除。
还有StartBackgroundTask和StartSynchronousTask的区别:
可以看到只有Synchronous以后主线程是会等AsyncTask里面的逻辑执行完了之后才会继续往下走。而使用Background主线程不会阻塞。
既然StartSynchronousTask会阻塞主线程,那我用AsyncTask的意义何在呢?直接一开始就单线程不就完事了?
问得好,这个方法即使是在ue4源码里用到的地方也极少。我认为这个方法的意义在于给AsyncTask多了一点灵活性,当我们在使用多线程时发现部分逻辑代码只能跑在主线程或者它跑异步线程其实并没有变快,这个时候想把它改成单线程的时候就很方便。
AsyncTask系统实现的多线程与你自己字节继承FRunnable实现的原理相似,还可以利用UE4提供的线程池。当使用多线程不满意时也可以调用StartSynchronousTask改成主线程执行。
除了上述几种方式,还有AsyncTask、Async、AsyncThread、AsyncPool等几个全局方法。
AsyncTask最简单,里面就是调用GraphTask创建了一个立刻执行的任务。
用法如下:
这里需要注意即使改成GameThread执行,AsyncTask下面的代码也是不会阻塞的。这个时候还是单线程,只是传入的Lambda方法会在主线程一帧里的其他地方调用。不仅如此,它的主线程执行时长(0.0013ms)比AnyThread(0.003ms)的还快,尽管总逻辑时长是变慢了。
从这里可以看出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。
关于作者
CSDN博客:https://blog.csdn.net/j756915370
知乎专栏:https://zhuanlan.zhihu.com/c_1241442143220363264
游戏同行聊天群:891809847