《Exploring in UE4》多线程机制详解

《Exploring in UE4》多线程机制详解

多线程是性能优化的重要方式之一,游戏也不例外。本文主要介绍了Unreal Engine 4中三种主要的实现方法:Runnable、AsyncTask和TaskGraph。不仅从实现方式和原理上对其做了分析,并且比较了各自的特点和适用场景。


目录
一.概述
二."标准"多线程
三.AsyncTask系统
3.1 FQueuedThreadPool线程池
3.2 Asyntask与IQueuedWork
3.3 其它相关技术细节
四.TaskGraph系统
4.1 从Tick函数谈起
4.2 TaskGraph系统中的任务与线程
4.3 TaskGraph系统中的任务与事件
4.4 其它相关技术细节
五.总结

一.概述

多线程是优化项目性能的重要方式之一,游戏也不例外。虽然经常能看到“游戏不适合利用多线程优化”的言论,但我个人觉得这句话更多的是针对GamePlay,游戏中多线程用的一点也不少,比如渲染模块、物理模块、网络通信、音频系统、IO等。下图就展示了UE4引擎运行时的部分线程,可能比你想象的还要多一些。

请输入图片描述
UE4运行时开启的线程

虽然UE4遵循C++11的标准,但是它并没有使用std::thread,而是自己实现了一套多线程机制(应该是从UE3时代就有了,未考证),用法上很像Java。当然,你如果想用std::thread也是完全没有问题的。

在UE4里面,我们可以自己继承FRunnable接口创建单个线程,也可以直接创建AsyncTask来调用线程池里面空闲的线程,还可以通过TaskGraph系统来异步完成一些自定义任务。虽然本质相同,但是用法不同,理解上也要花费不少时间,这篇文章会对里面的各个机制逐个分析并做出总结,但并不会深入讨论线程的实现原理、线程安全等内容。另外,由于个人接触多线程编程的时间不长,有一些内容可能不是很准确,欢迎大家一起讨论。

二.“标准”多线程

我们先从最基本的创建方式谈起,这里的“标准”只是一个修饰。其实就是创建一个继承自FRunnable的类,把这个类要执行的任务分发给其它线程去执行。FRunnable就是一个很简单的类,里面只有5、6个函数接口,为了与真正的线程区分,我这里称FRunnable为“线程执行体”。

//Runnable.h
class CORE_API FRunnable
{
public:
    /**
     * Initializes the runnable object.
     *
     * This method is called in the context of the thread object that aggregates this, not the
     * thread that passes this runnable to a new thread.
     *
     * @return True if initialization was successful, false otherwise
     * @see Run, Stop, Exit
     */
    virtual bool Init()
    {
        return true;
    }

    /**
     * Runs the runnable object.
     *
     * This is where all per object thread work is done. This is only called if the initialization was successful.
     *
     * @return The exit code of the runnable object
     * @see Init, Stop, Exit
     */
    virtual uint32 Run() = 0;

    /**
     * Stops the runnable object.
     *
     * This is called if a thread is requested to terminate early.
     * @see Init, Run, Exit
     */
    virtual void Stop() { }

    /**
     * Exits the runnable object.
     *
     * Called in the context of the aggregating thread to perform any cleanup.
     * @see Init, Run, Stop
     */
    virtual void Exit() { }

    /**
     * Gets single thread interface pointer used for ticking this runnable when multi-threading is disabled.
     * If the interface is not implemented, this runnable will not be ticked when FPlatformProcess::SupportsMultithreading() is false.
     *
    * @return Pointer to the single thread interface or nullptr if not implemented.
     */
    virtual class FSingleThreadRunnable* GetSingleThreadInterface( )
    {
        return nullptr;
    }

    /** Virtual destructor */
    virtual ~FRunnable() { }
};

看起来简单的一个类,我们是不是可以不继承它,单独写一个类再把这几个接口放进去呢?当然不行,实际上,在实现多线程的时候,我们需要将FRunnable作为参数传递到真正的线程里面,然后才能通过线程去调用FRunnable的Run,也就是我们具体实现的类的Run方法(通过虚函数覆盖父类的Run)。所谓真正的线程其实就是FRunnableThread,不同平台的线程都继承自它,如FRunnableThreadWin,里面会调用Windows平台的创建线程的API接口。下图给出了FRunnable与线程之间的关系类图:

请输入图片描述

在实现的时候,你需要继承FRunnable并重写它的那几个函数,Run()里面表示你在线程里面想要执行的逻辑。具体的实现方式网上有很多案例,这里给出UE4Wiki的教程链接:
https://wiki.unrealengine.com/Multi-Threading:_How_to_Create_Threads_in_UE4

三.AsyncTask系统

说完了UE4“标准”线程的使用,下面我们来谈谈稍微复杂一点的AsyncTask系统。AsyncTask系统是一套基于线程池的异步任务处理系统。如果你没有接触过UE4多线程,用搜索引擎搜索UE4多线程时可能就会看到类似下面这样的用法。

        //AsyncWork.h
        class ExampleAsyncTask : public FNonAbandonableTask
    {
        friend class FAsyncTask<ExampleAsyncTask>;

        int32 ExampleData;

        ExampleAsyncTask(int32 InExampleData)
         : ExampleData(InExampleData)
        {
        }

        void DoWork()
        {
            ... do the work here
        }

        FORCEINLINE TStatId GetStatId() const
        {
            RETURN_QUICK_DECLARE_CYCLE_STAT(ExampleAsyncTask, STATGROUP_ThreadPoolAsyncTasks);
        }
    };

    void Example()
    {

        //start an example job

        FAsyncTask<ExampleAsyncTask>* MyTask = new FAsyncTask<ExampleAsyncTask>( 5 );
        MyTask->StartBackgroundTask();

        //--or --

        MyTask->StartSynchronousTask();

        //to just do it now on this thread
        //Check if the task is done :

        if (MyTask->IsDone())
        {
        }

        //Spinning on IsDone is not acceptable( see EnsureCompletion ), but it is ok to check once a frame.
        //Ensure the task is done, doing the task on the current thread if it has not been started, waiting until completion in all cases.

        MyTask->EnsureCompletion();
        delete Task;
    }

没错,这就是官方代码里面给出的一种异步处理的解决方案示例。

不过你可能更在意的是这个所谓多线程的用法,看起来非常简单,但是却找不到任何带有“Thread”或“Runnable”的字样,那么它也是用Runnable的方式做的么?答案肯定是Yes。只不过封装的比较深,需要我们深入源码才能明白其中的原理。

注:Andriod多线程开发里面也会用到AsyncTask,二者的实现原理非常相似。

3.1 FQueuedThreadPool线程池

在介绍AsynTask之前先讲一下UE里面的线程池——FQueuedThreadPool。和一般的线程池实现类似,线程池里面维护了多个线程FQueuedThread与多个任务队列IQueuedWork,线程是按照队列的方式来排列的。在引擎PreInit的时候执行相关的初始化操作,代码如下:

 // FEngineLoop.PreInit   LaunchEngineLoop.cpp
if (FPlatformProcess::SupportsMultithreading())
{
    {
        GThreadPool = FQueuedThreadPool::Allocate();
        int32 NumThreadsInThreadPool = FPlatformMisc::NumberOfWorkerThreadsToSpawn();

        // we are only going to give dedicated servers one pool thread
        if (FPlatformProperties::IsServerOnly())
        {
            NumThreadsInThreadPool = 1;
        }
        verify(GThreadPool->Create(NumThreadsInThreadPool, 128 * 1024));
    }
#ifUSE_NEW_ASYNC_IO
    {
        GIOThreadPool = FQueuedThreadPool::Allocate();
        int32 NumThreadsInThreadPool = FPlatformMisc::NumberOfIOWorkerThreadsToSpawn();
        if (FPlatformProperties::IsServerOnly())
        {
            NumThreadsInThreadPool = 2;
        }
        verify(GIOThreadPool->Create(NumThreadsInThreadPool, 16 * 1024, TPri_AboveNormal));
    }
#endif// USE_NEW_ASYNC_IO

#ifWITH_EDITOR
    // when we are in the editor we like to do things like build lighting and such
    // this thread pool can be used for those purposes
    GLargeThreadPool = FQueuedThreadPool::Allocate();
    int32 NumThreadsInLargeThreadPool = FMath::Max(FPlatformMisc::NumberOfCoresIncludingHyperthreads() - 2, 2);
        
    verify(GLargeThreadPool->Create(NumThreadsInLargeThreadPool, 128 * 1024));
#endif
}

这段代码我们可以看出,专有服务器的线程池GThreadPool默认只开一个线程,非专有服务器的根据核数开(CoreNum-1)个线程。编辑器模式会另外再创建一个线程池GLargeThreadPool,包含(LogicalCoreNum-2)个线程,用来处理贴图的压缩和编码相关内容。

在线程池里面所有的线程都是FQueuedThread类型,不过更确切的说FQueuedThread是继承自FRunnable的线程执行体,每个FQueuedThread里面包含一个FRunnableThread作为内部成员。

相比一般的线程,FQueuedThread里面多了一个成员FEvent* DoWorkEvent,也就是说FQueuedThread里面是有一个事件触发机制的。那么这个事件机制的作用是什么?一般情况下来说,就是在没有任务的时候挂起这个线程,在添加并分配给该线程任务的时候激活它,不过你可以灵活运用它,在你需要的时候去动态控制线程任务的执行与暂停。前面我们在给线程池初始化的时候,通过FQueuedThreadPool的Create函数创建了多个FQueuedThread,然后每个FQueuedThread会执行Run函数,里面有一段逻辑如下:

 //ThreadingBase.cpp
bool bContinueWaiting = true;
while(bContinueWaiting )
{               
    DECLARE_SCOPE_CYCLE_COUNTER(TEXT( "FQueuedThread::Run.WaitForWork" ), STAT_FQueuedThread_Run_WaitForWork, STATGROUP_ThreadPoolAsyncTasks );
    // Wait for some work to do
    bContinueWaiting = !DoWorkEvent->Wait( 10 );
}
//windows平台下的wait
bool FEventWin::Wait(uint32 WaitTime, const bool bIgnoreThreadIdleStats/*= false*/)
{
    WaitForStats();

    SCOPE_CYCLE_COUNTER(STAT_EventWait );
    check(Event );

    FThreadIdleStats::FScopeIdleScope(bIgnoreThreadIdleStats );
    return (WaitForSingleObject( Event, WaitTime ) == WAIT_OBJECT_0);
}

我们看到,当DoWorkEvent执行Wait的时候,如果该线程的Event处于无信号状态(默认刚创建是无信号的),那么wait会等待10毫秒并返回false,线程处于While无限循环中。如果线程池添加了任务(AddQueuedWork)并执行了DoWorkEvent的Trigger函数,那么Event就会被设置为有信号,Wait函数就会返回true,随后线程跳出循环进而处理任务。

注:FQueuedThread里的DoWorkEvent是通过FPlatformProcess::GetSynchEventFromPool();从EventPool里面获取的。WaitForSingleObject等内容涉及到Windows下的事件机制,大家可以自行到网上搜索相关的使用,这里给出一个官方的使用案例。

目前我们接触的类之间的关系如下图:

请输入图片描述

3.2 Asyntask与IQueuedWork

线程池的任务IQueuedWork本身是一个接口,所以得有具体实现。这里你就应该能猜到,所谓的AsynTask其实就是对IQueuedWork的具体实现。这里AsynTask泛指FAsyncTask与FAutoDeleteAsyncTask两个类,我们先从FAsyncTask说起。

FAsyncTask有几个特点:

  1. FAsyncTask是一个模板类,真正的AsyncTask需要你自己写。通过DoWork提供你要执行的具体任务,然后把你的类作为模板参数传过去;
  2. 使用FAsyncTask就默认你要使用UE提供的线程池FQueuedThreadPool,前面代码里说明了在引擎PreInit的时候会初始化线程池并返回一个指针GThreadPool。在执行FAsyncTask任务时,如果你在执行StartBackgroundTask的时候会默认使用GThreadPool线程池,当然你也可以在参数里面指定自己创建的线程池;
  3. 创建FAsyncTask并不一定要使用新的线程,你可以调用函数StartSynchronousTask直接在当前线程上执行任务;
  4. FAsyncTask本身包含一个DoneEvent,任务执行完成的时候会激活该事件。当你想等待一个任务完成时再做其它操作,就可以调用EnsureCompletion函数,它可以从队列里面取出来还没被执行的任务放到当前线程来做,也可以挂起当前线程等待DoneEvent激活后再往下执行。

FAutoDeleteAsyncTask与FAsyncTask是相似的,但是有一些差异:

  1. 默认使用UE提供的线程池FQueuedThreadPool,无法使用其它线程池;
  2. FAutoDeleteAsyncTask在任务完成后会通过线程池的Destroy函数删除自身或者在执行DoWork后删除自身,而FAsyncTask需要手动delete;
  3. 包含FAsyncTask的特点1和特点3。

总的来说,AsyncTask系统实现的多线程与你自己直接继承FRunnable实现的原理相似,不过它在用法上比较简单,而且还可以直接借用UE4提供的线程池,很方便。

最后我们再来梳理一下这些类之间的关系:

请输入图片描述
AsyncTask系统相关类图

3.3 其它相关技术细节

大家在看源码的时候可能会遇到一些疑问,这里简单列举并解释一下

1. FScopeLock
FScopeLock是UE提供的一种基于作用域的锁,思想类似RAII机制。在构造时对当前区域加锁,离开作用域时执行析构并解锁。UE里面有很多带有“Scope”关键字的类,如移动组件中的FScopedMovementUpdate,Task系统中的FScopeCycleCounter,FScopedEvent等,它们的实现思路是类似的。

2. FNonAbandonableTask
继承FNonAbandonableTask的Task不可以在执行阶段终止,即使执行Abandon函数也会去触发DoWork函数。

       // FAutoDeleteAsyncTask
    virtual void Abandon(void)
    {
        if (Task.CanAbandon())
        {
            Task.Abandon();
            delete this;
        }
        else
        {
            DoWork();
        }
    }
    // FAsyncTask
    virtual void Abandon(void)
    {
        if (Task.CanAbandon())
        {
            Task.Abandon();
            check(WorkNotFinishedCounter.GetValue() == 1);
            WorkNotFinishedCounter.Decrement();
        }
        else
        {
            DoWork();
        }
        FinishThreadedWork();
    }

3.AsyncTask与转发构造
通过本章节开始的例子,我们知道创建自定义任务的方式如下:
FAsyncTask*MyTask= new FAsyncTask(5);

括号里面的5会以参数转发的方式传到的ExampleAsyncTask构造函数里面,这一步涉及到C++11的右值引用与转发构造,具体细节可以自行查找。

  /** Forwarding constructor. */
template <typename Arg0Type, typename... ArgTypes>
FAsyncTask(Arg0Type&& Arg0, ArgTypes&&... Args)
    : Task(Forward<Arg0Type>(Arg0), Forward<ArgTypes>(Args)...)
{
    Init();
}

四.TaskGraph系统

说完了FAsyncTask系统,接下来我们再谈谈更复杂的TaskGraph系统(应该不会有比它更复杂的了)。Task Graph 系统是UE4一套抽象的异步任务处理系统,可以创建多个多线程任务,指定各个任务之间的依赖关系,按照该关系来依次处理任务。具体的实现方式网上也有很多案例,这里先给出UE4Wiki的教程链接:
https://wiki.unrealengine.com/Multi-Threading:_Task_Graph_System

建议大家先了解其用法,然后再往下阅读。

4.1 从Tick函数谈起

平时调试的时候,我们随便找个Tick断点一下都能看到类似下图这样的函数堆栈。如果你前面的章节都看懂的话,这个堆栈也能大概理解。World在执行Tick的时候,触发了FNamedTaskThread线程去执行任务(FTickFunctionTask),任务FTickFunctionTask具体的工作内容就是执行ACtorComponent的Tick函数。其实,这个堆栈也说明了所有Actor与Component的Tick都是通过TaskGraph系统来执行的。

请输入图片描述
组件Tick的函数堆栈

不过你可能还是会有很多问题,TaskGraph断点为什么是在主线程里面?FNamedTaskThread是什么意思?FTickFunctionTask到底是在哪个线程执行?答案在下一小节逐步给出。

4.2 TaskGraph系统中的任务与线程

既然是Task系统,那么应该能猜到它和前面的AsyncTask系统相似,我们可以创建多个Task任务然后分配给不同的线程去执行。在TaskGraph系统里面,任务类也是我们自己创建的,如FTickFunctionTask、FReturnGraphTask等,里面需要声明DoTask函数来表示要执行的任务内容,GetDesiredThread函数来表示要在哪个线程上面执行,大概的样子如下:

 class FMyTestTask
{
        public:
         FMyTestTask()//send in property defaults here
        {
        }
        static const TCHAR*GetTaskName()
    {
        return TEXT("FMyTestTask");
    }
    FORCEINLINE static TStatId GetStatId()
    {
        RETURN_QUICK_DECLARE_CYCLE_STAT(FMyTestTask, STATGROUP_TaskGraphTasks);
    }
    /** return the thread for this task **/
    static ENamedThreads::Type GetDesiredThread()
    {
        return ENamedThreads::AnyThread;
    }
 
    /*
        namespace ESubsequentsMode
       {
        enum Type
        {
            // 存在后续任务
            TrackSubsequents,
            // 没有后续任务
            FireAndForget
        };
    }
    */
    static ESubsequentsMode::Type GetSubsequentsMode()
    {
        return ESubsequentsMode::TrackSubsequents;
    }
 
    void DoTask(ENamedThreads::Type CurrentThread, const FGraphEventRef& MyCompletionGraphEvent)
    {
        
    }
};

而线程在该系统里面称为FWorkerThread,通过全局的单例类FTaskGraphImplementation来控制创建和分配任务的,默认情况下会开启5个基本线程,额外线程的数量则由下面的函数NumberOfWorkerThreadsToSpawn来决定,FTaskGraphImplementation的初始化在FEngineLoop.PreInit里面进行。当然如果平台本身不支持多线程,那么其它的工作也会在GameThread里面进行。

FTaskGraphImplementation(int32)
{
    bCreatedHiPriorityThreads = !!ENamedThreads::bHasHighPriorityThreads;
    bCreatedBackgroundPriorityThreads = !!ENamedThreads::bHasBackgroundThreads;

    int32 MaxTaskThreads = MAX_THREADS;
    int32 NumTaskThreads = FPlatformMisc::NumberOfWorkerThreadsToSpawn();

    // if we don't want any performance-based threads, then force the task graph to not create any worker threads, and run in game thread
    if (!FPlatformProcess::SupportsMultithreading())
    {
        // this is the logic that used to be spread over a couple of places, that will make the rest of this function disable a worker thread
        // @todo: it could probably be made simpler/clearer
        // this - 1 tells the below code there is no rendering thread
        MaxTaskThreads = 1;
        NumTaskThreads = 1;
        LastExternalThread = (ENamedThreads::Type)(ENamedThreads::ActualRenderingThread - 1);
        bCreatedHiPriorityThreads = false;
        bCreatedBackgroundPriorityThreads = false;
        ENamedThreads::bHasBackgroundThreads = 0;
        ENamedThreads::bHasHighPriorityThreads = 0;
    }
    else
    {
        LastExternalThread = ENamedThreads::ActualRenderingThread;
    }
        
    NumNamedThreads = LastExternalThread + 1;

    NumTaskThreadSets = 1 + bCreatedHiPriorityThreads + bCreatedBackgroundPriorityThreads;

    // if we don't have enough threads to allow all of the sets asked for, then we can't create what was asked for.
    check(NumTaskThreadSets == 1 || FMath::Min<int32>(NumTaskThreads * NumTaskThreadSets + NumNamedThreads, MAX_THREADS) == NumTaskThreads * NumTaskThreadSets + NumNamedThreads);
    NumThreads = FMath::Max<int32>(FMath::Min<int32>(NumTaskThreads * NumTaskThreadSets + NumNamedThreads, MAX_THREADS), NumNamedThreads + 1);
        .......
}
//GenericPlatformMisc.cpp
int32 FGenericPlatformMisc::NumberOfWorkerThreadsToSpawn()
{
    static int32 MaxGameThreads = 4;
    static int32 MaxThreads = 16;

    int32 NumberOfCores = FPlatformMisc::NumberOfCores();//物理核数,4核8线程的机器返回的是4
    int32 MaxWorkerThreadsWanted = (IsRunningGame() || IsRunningDedicatedServer() || IsRunningClientOnly()) ? MaxGameThreads :MaxThreads;
    // need to spawn at least one worker thread (see FTaskGraphImplementation)
    return FMath::Max(FMath::Min(NumberOfCores - 1, MaxWorkerThreadsWanted), 1);
}

前面提到的FWorkerThread虽然可以理解为工作线程,但其实它不是真正的线程。FWorkerThread里面有两个重要成员,一个是FRunnableThread* RunnableThread,也就是真正的线程。另一个是FTaskThreadBase* TaskGraphWorker,即继承自FRunnable的线程执行体。FTaskThreadBase有两个子类,FTaskThreadAnyThread和FNamedTaskThread,分别表示非指定名称的任意Task线程执行体和有名字的Task线程执行体。我们平时说的渲染线程、游戏线程就是有名称的Task线程,而那些我们创建后还没有使用到的线程就是非指定名称的任意线程。

请输入图片描述
非指定名称的任意线程

在引擎初始化FTaskGraphImplementation的时候,我们就会默认构建24个FWorkerThread工作线程(这里支持最大的线程数量也就是24),其中里面有5个是默认带名字的线程,StatThread、RHIThread、AudioThread、GameThread、ActualRenderingThread,还有前面提到的N个非指定名称的任意线程,这个N由CPU核数决定。对于带有名字的线程,它不需要创建新的Runnable线程,因为它们会在其它的时机创建,如StatThread以及RenderingThread会在FEngineLoop.PreInit里创建。而那N个非指定名称的任意线程,则需要在一开始就手动创建Runnable线程,同时设置其优先级比前面线程的优先级要低。到这里,我们应该可以理解,有名字的线程专门要做它名字对应的事情,非指定名称的任意线程则可以用来处理其它的工作,我们在CreateTask创建任务时会通过自己写好的函数决定当前任务应该在哪个线程执行。

请输入图片描述
运行中所有的WorldThreads

现在我们可以先回答一下上一节的问题了,FTickFunctionTask到底是在哪个线程执行?答案是游戏主线程,我们可以看到FTickFunctionTask的Desired线程是Context.Thread,而Context.Thread是在下图赋值的,具体细节参考FTickTaskManager与FTickTaskLevel的使用。

/** return the thread for this task **/
FORCEINLINEENamedThreads::TypeGetDesiredThread()
{
    return Context.Thread;
}

请输入图片描述
Context线程类型的初始化

这里我们再思考一下,如果我们将多个任务投放到一个线程那么它们是按照什么顺序执行的呢?这个答案需要分两种情况解答,对于投放到FTaskThreadAnyThread执行的任务会在创建的时候按照优先级放到IncomingAnyThreadTasks数组里面,然后每次线程完成任务后会从这个数组里面弹出未执行的任务来执行,它的特点是我们有权利随时修改和调整这个任务队列。而对于投放到FNamedTaskThread执行的任务,会被放到其本身维护的队列里面,通过FThreadTaskQueue来处理执行顺序,一旦放到这个队列里面,我们就无法随意调整任务了。

请输入图片描述

4.3 TaskGraph系统中的任务与事件

虽然前面已经比较细致的描述了TaskGraph系统的框架,但是一个非常重要的特性我们还没讲到,就是任务依赖的实现原理。怎么理解任务依赖呢?简单来说,就是一个任务的执行可能依赖于多个事件对象,这些事件对象都触发之后才会执行这个任务。而这个任务完成后,又可能触发其它事件,其它事件再进一步触发其它任务,大概的效果是下图这样。

请输入图片描述
任务与事件的依赖关系图

每个任务结束分别触发一个事件,Task4需要等事件A、B都完成才会执行,并且不会接着触发其它事件。Task5需要等事件B、C都完成,并且会触发事件D,D事件不会再触发任何任务。当然,这些任务和事件可能在不同的线程上执行。

这里再看一下Task任务的创建代码,分析一下先决依赖事件与后续等待事件都是如何产生的。

FGraphEventRef Join=TGraphTask<FVictoryTestTask>::CreateTask(NULL, ENamedThreads::GameThread).ConstructAndDispatchWhenReady();

CreateTask的第一个参数就是该任务依赖事件数组(这里为NULL),如果传入一个事件数组的话,那么当前任务就会通过SetupPrereqs函数设置这些依赖事件,并且在所有依赖事件都触发后再将该任务放到任务队列里面分配给线程执行。

当执行CreateTask时,会通过FGraphEvent::CreateGraphEvent()构建一个新的后续事件,再通过函数ConstructAndDispatchWhenReady返回。这样我们就可以在当前的位置执行。

FTaskGraphInterface::Get().WaitUntilTaskCompletes(Join, ENamedThreads::GameThread_Local);

让当前线程等待该任务结束并触发事件后再继续执行,当前面这个事件完成后,就会调用DispatchSubsequents()去触发它后续的任务。WaitUntilTaskCompletes函数的第二个参数必须是当前的线程类型而且是带名字的。

请输入图片描述
Task系统相关类图

4.4 其它相关技术细节

1.FThreadSafeCounter

通过调用不同平台的原子操作来实现线程安全的计数:

int32 Add( int32 Amount )
{
    return FPlatformAtomics::InterlockedAdd(&Counter, Amount);
}

2. Task的构造方式

我们看到相比AsyncTask,TaskGraph的创建可谓是既新奇又复杂,首先要调用静态的CreateTask,然后又要通过返回值执行ConstructAndDispatchWhenReady。那么这么做的目的是什么呢?按照我个人的理解,主要是为了能把想要的参数都传进去。其实每创建一个任务,都需要传入两套参数,一套参数指定依赖事件,属于任务系统的自身特点,另一套参数传入玩家自定义任务的相关参数。为了实现这个效果,UE先通过工厂方法创建抽象任务把相关特性保存进去,然后通过内部的一个帮助类FConstructor构建一个真正的玩家定义的任务。如果C++玩的不溜,这样的方法还真难想出来。(这是我个人猜测,如果你有更好的理解欢迎留言评论)

3. FScopedEvent

在上一节讲过,带有Scope关键字的基本都是同一个思想,在构造的时候初始化析构的时候执行某些特殊的操作。FScopedEvent作用是在当前作用域内等待触发,如果没有激活该事件,就会一直处于Wait中。

4. WaitUntilTaskCompletes的实现机制

顾名思义,该函数的功能就是在任务结束之前保持当前线程的等待。不过它的实现确实很有趣,第一个参数是等待的事件Event,第二个参数是当前线程类型。如果当前的线程没有任何Task,它会判断传入的事件数组是否都完成了,完成即可返回,没有完成就会构建一个FReturnGraphTask类型的任务,然后执行ProcessThreadUntilRequestReturn等所有的依赖事件都完成后才会返回。

// named thread process tasks while we wait
TGraphTask<FReturnGraphTask>::CreateTask(&Tasks, CurrentThread).ConstructAndDispatchWhenReady(CurrentThread);
ProcessThreadUntilRequestReturn(CurrentThread);

如果当前的线程有Task任务,它就创建一个ScopeEvent,并执行TriggerEventWhenTasksComplete等待前面传入的Tasks都完成后再返回。

FScopedEvent Event;
TriggerEventWhenTasksComplete(Event.Get(), Tasks, CurrentThreadIfKnown);

五.总结

到这里,我们已经看到了三种使用多线程的方式,每种机制里面都有很多技术点值得我们深入学习。关于机制的选择这里再给出一点建议:

对于消耗大的,复杂的任务不建议使用TaskGraph,因为它会阻塞其它游戏线程的执行。即使你不在那几个有名字的线程上执行,也可能会影响到游戏的其它逻辑。比如物理计算相关的任务就是在非指定名称的线程上执行的。这种复杂的任务,建议你自己继承Runnable创建线程,或者使用AsynTask系统。

而对于简单的任务,或者想比较方便的实现线程之间的依赖等待关系,直接扔给TaskGraph就可以了。

另外,不要在非GameThread线程内执行下面几个操作:

Spawn / Modify/ delete UObjects or AActors;
使用定时器 TimerManager;
使用任何绘制接口,例如DrawDebugLine。

一开始我也不是很理解,所以就在其它线程里面执行了Spawn操作,然后就崩在了下面的地方。可以看到,SpawnActor的时候会执行物理数据的初始化,而这个操作是必须要在主线程里面执行的,我猜其它的位置肯定还有很多类似的宏。至于原因,我想就是我们最前面提到的“游戏不适合利用多线程优化”,游戏GamePlay中各个部分非常依赖顺序,多线程没办法很好的处理这些关系。再者,游戏逻辑如此复杂,你怎么做到避免“竞争条件”呢?到处加锁么?我想那样的话,游戏代码就没法看了吧。

请输入图片描述
在其它线程Spawn导致崩溃

最后,我们再来一张全家福吧!

请输入图片描述
多线程系统类图(完整)


这是侑虎科技第430篇文章,感谢作者Jerish供稿。欢迎转发分享,未经作者授权请勿转载。如果您有任何独到的见解或者发现也欢迎联系我们,一起探讨。QQ群:793972859(原群已满员)

个人主页:https://zhuanlan.zhihu.com/p/38881269,作者也是U Sparkle活动参与者,UWA欢迎更多开发朋友加入U Sparkle开发者计划,这个舞台有你更精彩!