基於任務的並行程序設計
基於任務(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()
,這個函數幹了兩件事:
- 首先就是加載那個文本文件了;
- 創建了一個
FTaskCompletion_LoadFileToString
子任務,這個子任務負責執行“完成通知”; - 重點來了:我指定了
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()
代碼就很直接了當了,有兩個小點稍微說一下:
- 你看,我在 DoTask() 裏面寫了
check(IsInGameThread())
,確定是Game Thread。😃 - 我通過引擎提供的
MoveTemp
模板,實現了FString FileContent
的轉移拷貝,減少了內存拷貝;關於轉移語義可以看我之前的博客。
派發任務
就像上面那樣,定義好 “Task 類”之後就需要調用 TaskGraph 來派發這個任務了,就像下面這樣:
TGraphTask<FTask_LoadFileToString>::CreateTask().ConstructAndDispatchWhenReady(FilePath, TaskDelegate);
解釋一下上面這一行代碼:
TGraphTask
是一個模板類,它接收一個類型參數,就是我們前面定義的 “Task 類”- 首先是調用
TGraphTask::CreateTask()
函數,這個函數有兩個參數:FGraphEventArray* Prerequisites
:前置任務列表,默認值爲NULL;這個非常有用,後面我單獨;講ENamedThreads::Type CurrentThreadIfKnown
:當前線程,默認值爲ENamedThreads::AnyThread;- 這裏我直接使用了函數參數的默認值;
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);
}