虛幻4與現代C++:基於任務的並行編程與TaskGraph入門

基於任務的並行程序設計

基於任務(task-based)的並行編程可以說是現代 C++ 的一個重要發展方向。什麼是“基於任務的並行編程”呢?簡單來做個比較的話就很清晰了。

傳統的 C++ 並行編程是直接操作OS層面的線程(std::thread)、線程同步對象( std::mutex, std::condition 等),這種叫做**基於線程(thread-based)**的並行編程。在這種開發模式下,程序員必須非常仔細的處理線程間的同步、共享數據等問題,爲了避免條件競爭(race condition)和死鎖而殫精竭慮。虛幻4裏面的 FRunable 就是這種模式。

如果說“基於線程的並行編程”就像是在直接使用 D3D、OpenGL 在開發圖形程序,那麼“基於任務的並行編程”的模式就相當於在一個圖形渲染引擎基礎上做開發。我們可以更多的在自己的問題域裏面思考:

  • 如何把整個處理流程劃分成“任務”;
  • 哪些任務是可以並行的,哪些任務需要串行;
    然後通過“基於任務”的 API 來派發任務。這個小引擎內部的調度器會幫我們管理線程池等底層對象。

基於任務的模式已經相當成熟,比較有名的實現包括:

並且兩家已經在2015年一起發起了C++標準提案:N4411-Task Block,Herb Sutter 也是發起人之一。

虛幻4的 TaskGraph 簡介

虛幻4的 TaskGraph 就是“基於任務的並行編程”設計思想下的一種很棒的實現。它的最大特點就是:易用,後面你將看到。

TaskGraph 應該是虛幻4引擎中後期才加入的一個機制,但越來越多的系統開始使用它。它不是一個標準的並行編程框架,而一個專門針對虛幻4定製的。它的底層實現代碼有點繞,咱們就先不分析它,而是着重說說怎麼用好它

虛幻4的 TaskGraph 有以下常用特性:

  • 在創建任務時,可以指定一個或多個前置任務;這些任務就可以組成一個 Graph 啦;
  • 可以指定任務在哪個線程中執行;這個很實用,後面例子中可以看到;

至於任務之間如何共享數據等問題,TaskGraph 框架是不管的,責任在於開發 Task 的人。不過,不要害怕:你可能發現,任務分配的好的話,這個問題可以很大程度上簡單化。

通過實例上手 TaskGraph

我還是通過一個最簡單的例子來說明 TaskGraph 的基本用法:

  • 假定我們需要異步加載一個文本文件

下面是這個例子的測試接口定義:

  • 建立了一個 Actor 的派生類

  • 提供一個接口,用來發起異步加載的操作:void AsyncLoadTextFile(const FString& FilePath)

  • 提供一個藍圖事件,供上層來接收加載的文件內容:void OnFileLoaded(const FString& FileContent)

  • 完整代碼如下:FirstAsyncTask.h

UCLASS()
class MAKINGUSEOFTASKGRAPH_API AFirstAsyncTask : public AActor
{
	GENERATED_BODY()
public:
	UFUNCTION(BlueprintCallable)
		void AsyncLoadTextFile(const FString& FilePath);

	UFUNCTION(BlueprintImplementableEvent)
		void OnFileLoaded(const FString& FileContent);

};

實現代碼很簡單:

  • AsyncLoadTextFile() 函數中發起一個異步操作;

  • OnFileLoaded() 將在藍圖中實現,C++這裏沒有代碼;

  • 任務代碼是通過自定義的一個class實現的:FTask_LoadFileToString,這個Task的實現代碼後面詳說;

  • 完整代碼如下:FirstAsyncTask.cpp

void AFirstAsyncTask::AsyncLoadTextFile(const FString& FilePath)
{
	FTaskDelegate_FileLoaded TaskDelegate;
	TaskDelegate.BindUFunction(this, "OnFileLoaded");

	TGraphTask<FTask_LoadFileToString>::CreateTask().ConstructAndDispatchWhenReady(FilePath, TaskDelegate);
}

主線程的代碼就是這麼簡單!然後,我建立一個 class AFirstAsyncTask 的藍圖派生類,來測試它:

在這裏插入圖片描述

看到這裏,你可能想要拍磚了:你在一個異步任務裏面調用藍圖 OnFileLoaded ,這個不是找死嗎?!且慢,磚可以先舉着,容我慢慢解釋!其關鍵就在於這個異步任務是如何定義的

定義任務

用戶定義的任務必須要滿足 TGraphTask 中對 Task 的接口需求。下面這個 class FTask_LoadFileToString 就是我寫的一個簡單的任務:

class FTask_LoadFileToString
{
	FTaskDelegate_FileLoaded TaskDelegate;
	FString FilePath;

	FString FileContent;
public:
	FTask_LoadFileToString(FString InFilePath, FTaskDelegate_FileLoaded InTaskDelegate) :
		TaskDelegate(InTaskDelegate), FilePath(MoveTemp(InFilePath))
	{}

	FORCEINLINE TStatId GetStatId() const	{
		RETURN_QUICK_DECLARE_CYCLE_STAT(FTask_LoadFileToString, STATGROUP_TaskGraphTasks);
	}

	static ENamedThreads::Type GetDesiredThread() { return CPrio_LoadFileToString.Get(); }
	static ESubsequentsMode::Type GetSubsequentsMode() { return ESubsequentsMode::TrackSubsequents; }

	void DoTask(ENamedThreads::Type CurrentThread, const FGraphEventRef& MyCompletionGraphEvent)	{
		// load file from Content folder
		FString FullPath = FPaths::Combine(FPaths::ProjectContentDir(), FilePath);
		if (FPaths::FileExists(FullPath))
		{
			FFileHelper::LoadFileToString(FileContent, *FullPath);
		}

		// create completion task
		FGraphEventRef ChildTask = TGraphTask<FTaskCompletion_LoadFileToString>::CreateTask(nullptr, CurrentThread).
			ConstructAndDispatchWhenReady(TaskDelegate, FileContent);
		MyCompletionGraphEvent->SetGatherThreadForDontCompleteUntil(ENamedThreads::GameThread);
		MyCompletionGraphEvent->DontCompleteUntil(ChildTask);
	}
};

挨個說一下這個類的幾個方法:

  • 構造函數是完全自定義的,有多少參數都可以;底層會通過“可變參數模板(Variadic Templates)”把所有參數全都轉發過來;
    • 引擎中有一句註釋說不支持引用類型的參數:CAUTION!: Must not use references in the constructor args; use pointers instead if you need by reference
    • 不過,我在引擎的代碼中發現了有使用引用類型參數的任務,目前還不確定;使用引用類型的話,確實是有很大的“懸空引用(dangling references)”的風險,建議還是不用;
  • GetStatId():返回一個 StatId,一般就按照這種固定寫法就好了;
  • GetDesiredThread():返回這個任務希望運行的線程;常用的寫法有:
    • 通過一個 class FAutoConsoleTaskPriority 對象來獲得一個當前合適的線程;
    • 指定某個線程,例如:ENamedThreads::GameThread
  • GetSubsequentsMode():有兩個可選值,TrackSubsequents 和 FireAndForget ;
  • DoTask():這個就是寫我們這個任務實際的工作的代碼了;

下面就着重看一下這個任務的實現代碼:FTask_LoadFileToString::DoTask(),這個函數幹了兩件事:

  1. 首先就是加載那個文本文件了;
  2. 創建了一個FTaskCompletion_LoadFileToString子任務,這個子任務負責執行“完成通知”;
  3. 重點來了:我指定了FTaskCompletion_LoadFileToString必須在 GameThread 執行

下面看一下class FTaskCompletion_LoadFileToString的完整代碼:

class FTaskCompletion_LoadFileToString
{
	FTaskDelegate_FileLoaded TaskDelegate;
	FString FileContent;
public:
	FTaskCompletion_LoadFileToString(FTaskDelegate_FileLoaded InTaskDelegate, FString InFileContent) :
		TaskDelegate(InTaskDelegate), FileContent(MoveTemp(InFileContent))
	{}

	FORCEINLINE TStatId GetStatId() const	{
		RETURN_QUICK_DECLARE_CYCLE_STAT(FTaskCompletion_LoadFileToString, STATGROUP_TaskGraphTasks);
	}

	static ENamedThreads::Type GetDesiredThread() { return ENamedThreads::GameThread; }
	static ESubsequentsMode::Type GetSubsequentsMode() { return ESubsequentsMode::TrackSubsequents; }

	void DoTask(ENamedThreads::Type CurrentThread, const FGraphEventRef& MyCompletionGraphEvent){
		check(IsInGameThread());
		TaskDelegate.ExecuteIfBound(MoveTemp(FileContent));
	}
};

OK!磚舉累了吧,可以放下了:

  • 我是通過這個完成通知任務class FTaskCompletion_LoadFileToString來調用AFirstAsyncTask::OnFileLoaded那個藍圖事件的;
  • FTaskCompletion_LoadFileToString::GetDesiredThread() 返回值爲:ENamedThreads::GameThread,也就是要求它在 GameThread 執行!

這個任務的DoTask()代碼就很直接了當了,有兩個小點稍微說一下:

  1. 你看,我在 DoTask() 裏面寫了 check(IsInGameThread()),確定是Game Thread。😃
  2. 我通過引擎提供的 MoveTemp 模板,實現了FString FileContent轉移拷貝,減少了內存拷貝;關於轉移語義可以看我之前的博客

派發任務

就像上面那樣,定義好 “Task 類”之後就需要調用 TaskGraph 來派發這個任務了,就像下面這樣:

TGraphTask<FTask_LoadFileToString>::CreateTask().ConstructAndDispatchWhenReady(FilePath, TaskDelegate);

解釋一下上面這一行代碼:

  1. TGraphTask 是一個模板類,它接收一個類型參數,就是我們前面定義的 “Task 類”
  2. 首先是調用 TGraphTask::CreateTask() 函數,這個函數有兩個參數:
    • FGraphEventArray* Prerequisites:前置任務列表,默認值爲NULL;這個非常有用,後面我單獨;講
    • ENamedThreads::Type CurrentThreadIfKnown:當前線程,默認值爲ENamedThreads::AnyThread;
    • 這裏我直接使用了函數參數的默認值;
  3. TGraphTask::CreateTask() 返回一個對象,它的類型是:class TGraphTask::FConstructor
    • FConstructor 有兩個主要的方法:ConstructAndDispatchWhenReady()ConstructAndHold(),名字就說明它的作用了
    • 這兩個方法主要就是構造我們定義的 “Task 類”的實例,並且使用"可變參數模板(Variadic Templates)"把構造函數的參數轉發到 “Task 類的構造函數”

可以說這就是 TaskGraph 系統的常用 API 啦!非常非常的簡單易用!這麼好用的系統,EPIC竟然沒有文檔,難道是捨不得給大家用嗎?哈哈!

小結

這個例子相當於 Hello World 啦!在這個例子中,我使用“父子任務”的結構,來執行異步操作,並在 GameThread 中發送完成通知。這算是 TaskGraph 的用法之一,後續我會繼續分享 TaskGraph 的實戰經驗。TaskGraph 運用過程中的一些問題,也會逐步澄清。

相關的樣例工程在我的 GitHub :https://github.com/neil3d/UnrealCookBook/tree/master/MakingUseOfTaskGraph

這裏附上這個Demo相關的完整代碼:

FirstAsyncTask.h

#pragma once

#include "CoreMinimal.h"
#include "GameFramework/Actor.h"
#include "FirstAsyncTask.generated.h"

UCLASS()
class MAKINGUSEOFTASKGRAPH_API AFirstAsyncTask : public AActor
{
	GENERATED_BODY()

public:
	AFirstAsyncTask();

	UFUNCTION(BlueprintCallable)
		void AsyncLoadTextFile(const FString& FilePath);

	UFUNCTION(BlueprintImplementableEvent)
		void OnFileLoaded(const FString& FileContent);

};

FirstAsyncTask.cpp

#include "FirstAsyncTask.h"
#include "Misc/Paths.h"
#include "Misc/FileHelper.h"

//////////////////////////////////////////////////////////////////////////////////////////////////////////////
DECLARE_DELEGATE_OneParam(FTaskDelegate_FileLoaded, FString);

class FTaskCompletion_LoadFileToString
{
	FTaskDelegate_FileLoaded TaskDelegate;
	FString FileContent;
public:
	FTaskCompletion_LoadFileToString(FTaskDelegate_FileLoaded InTaskDelegate, FString InFileContent) :
		TaskDelegate(InTaskDelegate), FileContent(MoveTemp(InFileContent))
	{}

	FORCEINLINE TStatId GetStatId() const
	{
		RETURN_QUICK_DECLARE_CYCLE_STAT(FTaskCompletion_LoadFileToString, STATGROUP_TaskGraphTasks);
	}

	static ENamedThreads::Type GetDesiredThread() { return ENamedThreads::GameThread; }

	static ESubsequentsMode::Type GetSubsequentsMode() { return ESubsequentsMode::TrackSubsequents; }

	void DoTask(ENamedThreads::Type CurrentThread, const FGraphEventRef& MyCompletionGraphEvent)
	{
		check(IsInGameThread());

		TaskDelegate.ExecuteIfBound(MoveTemp(FileContent));
	}
};

FAutoConsoleTaskPriority CPrio_LoadFileToString(
	TEXT("TaskGraph.TaskPriorities.LoadFileToString"),
	TEXT("Task and thread priority for file loading."),
	ENamedThreads::HighThreadPriority,
	ENamedThreads::NormalTaskPriority,
	ENamedThreads::HighTaskPriority 
);

class FTask_LoadFileToString
{
	FTaskDelegate_FileLoaded TaskDelegate;
	FString FilePath;

	FString FileContent;
public:
	FTask_LoadFileToString(FString InFilePath, FTaskDelegate_FileLoaded InTaskDelegate) :
		TaskDelegate(InTaskDelegate), FilePath(MoveTemp(InFilePath))
	{}

	FORCEINLINE TStatId GetStatId() const
	{
		RETURN_QUICK_DECLARE_CYCLE_STAT(FTask_LoadFileToString, STATGROUP_TaskGraphTasks);
	}

	static ENamedThreads::Type GetDesiredThread() { return CPrio_LoadFileToString.Get(); }

	static ESubsequentsMode::Type GetSubsequentsMode() { return ESubsequentsMode::TrackSubsequents; }


	void DoTask(ENamedThreads::Type CurrentThread, const FGraphEventRef& MyCompletionGraphEvent)
	{
		// load file from Content folder
		FString FullPath = FPaths::Combine(FPaths::ProjectContentDir(), FilePath);
		if (FPaths::FileExists(FullPath))
		{
			FFileHelper::LoadFileToString(FileContent, *FullPath);
		}

		// create completion task
		FGraphEventRef ChildTask = TGraphTask<FTaskCompletion_LoadFileToString>::CreateTask(nullptr, CurrentThread).
			ConstructAndDispatchWhenReady(TaskDelegate, FileContent);
		MyCompletionGraphEvent->SetGatherThreadForDontCompleteUntil(ENamedThreads::GameThread);
		MyCompletionGraphEvent->DontCompleteUntil(ChildTask);
	}
};

//////////////////////////////////////////////////////////////////////////////////////////////////////////////

// Sets default values
AFirstAsyncTask::AFirstAsyncTask()
{
}

void AFirstAsyncTask::AsyncLoadTextFile(const FString& FilePath)
{
	FTaskDelegate_FileLoaded TaskDelegate;
	TaskDelegate.BindUFunction(this, "OnFileLoaded");

	TGraphTask<FTask_LoadFileToString>::CreateTask(nullptr, ENamedThreads::GameThread).ConstructAndDispatchWhenReady(FilePath, TaskDelegate);
}

irstAsyncTask()
{
}

void AFirstAsyncTask::AsyncLoadTextFile(const FString& FilePath)
{
FTaskDelegate_FileLoaded TaskDelegate;
TaskDelegate.BindUFunction(this, “OnFileLoaded”);

TGraphTask<FTask_LoadFileToString>::CreateTask(nullptr, ENamedThreads::GameThread).ConstructAndDispatchWhenReady(FilePath, TaskDelegate);

}

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