《Exploring in UE4》多線程機制詳解

這是侑虎科技第430篇文章,感謝作者Jerish供稿。歡迎轉發分享,未經作者授權請勿轉載。如果您有任何獨到的見解或者發現也歡迎聯繫我們,一起探討。(QQ羣465082844)

個人主頁:https://zhuanlan.zhihu.com/p/38881269
作者也是U Sparkle活動參與者,UWA歡迎更多開發朋友加入U Sparkle開發者計劃,這個舞臺有你更精彩!


目錄
一.概述
二."標準"多線程
三.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導致崩潰

 

最後,我們再來一張全家福吧!

 

請輸入圖片描述
多線程系統類圖(完整)

 

文末,再次感謝Jerish的分享,如果您有任何獨到的見解或者發現也歡迎聯繫我們,一起探討。(QQ羣:465082844)。
也歡迎大家來積極參與U Sparkle開發者計劃,簡稱"US",代表你和我,代表UWA和開發者在一起!

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章