1. 程式人生 > >《Exploring in UE4》多執行緒機制詳解[原理分析]

《Exploring in UE4》多執行緒機制詳解[原理分析]

目錄
一.概述
二."標準"多執行緒
三.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的教程連結:
Multi-Threading: How to Create Threads in UE4
​wiki.unrealengine.com

三.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有幾個特點,
FAsyncTask是一個模板類,真正的AsyncTask需要你自己寫。通過DoWork提供你要執行的具體任務,然後把你的類作為模板引數傳過去
使用FAsyncTask就預設你要使用UE提供的執行緒池FQueuedThreadPool,前面程式碼裡說明了在引擎PreInit的時候會初始化執行緒池並返回一個指標GThreadPool。在執行FAsyncTask任務時,如果你在執行StartBackgroundTask的時候會預設使用GThreadPool執行緒池,當然你也可以在引數裡面指定自己建立的執行緒池
建立FAsyncTask並不一定要使用新的執行緒,你可以呼叫函式StartSynchronousTask直接在當前執行緒上執行任務
FAsyncTask本身包含一個DoneEvent,任務執行完成的時候會啟用該事件。當你想等待一個任務完成時再做其他操作,就可以呼叫EnsureCompletion函式,他可以從佇列裡面取出來還沒被執行的任務放到當前執行緒來做,也可以掛起當前執行緒等待DoneEvent啟用後再往下執行
FAutoDeleteAsyncTask與FAsyncTask是相似的,但是有一些差異,
預設使用UE提供的執行緒池FQueuedThreadPool,無法使用其他執行緒池
FAutoDeleteAsyncTask在任務完成後會通過執行緒池的Destroy函式刪除自身或者在執行DoWork後刪除自身,而FAsyncTask需要手動delete
包含FAsyncTask的特點1和特點3
總的來說,AsyncTask系統實現的多執行緒與你自己位元組繼承FRunnable實現的原理相似,不過他在用法上比較簡單,而且還可以直接借用UE4提供的執行緒池,很方便。
最後我們再來梳理一下這些類之間的關係:
AsyncTask系統相關類圖
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的教程連結:
Multi-Threading: Task Graph System
​wiki.unrealengine.com
建議大家先了解其用法,然後再往下閱讀。

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::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導致崩潰
最後,我們再來一張全家福吧~
在這裡插入圖片描述
多執行緒系統類圖(完整)