Fork-Join 模型簡介
Fork-Join 是一種並行編程的設計模式,通過下面這個圖片可以有一個直觀的理解:
圖片來自維基百科
在上圖中,不同顏色的方塊代表着可並行執行的“任務”,它們可以根據需要從主線程中“分叉(fork)”出來執行,在需要順序執行的點上又“合併(join)”到主線程。
使用 TaskGraph 實現 Fork-Join 模型
虛幻4的 TaskGraph 可以爲每個“任務”指定“一個或多個前置任務”,也就是組成所謂的 Graph 啦!在這種框架下,Fork-Join 也是一種常用的任務組織的手法。
下面我還是通過一個簡單的例子,來看看具體的編程實現。
假定我們需要從一個 Json 格式的文本文件中讀取過去 20 年的上證指數數據,然後需要統計出:
- 最大值,最小值,平均值
最後把這個三個值顯示到一個 UMG 的界面上!
咋選了這麼個古怪的例子呢?呃,本來是想做一個異步加載 N 個 Static Mesh 模型之類的例子,但是異步加載資源的話,其實用 FStreamableManager 更合適。爲了避免誤解,就想弄個簡單計算的例子。
分拆任務
首先需要把上述需求分拆成多個小的任務,看看哪些可以並行執行:
任務 | 執行線程 | 說明 |
---|---|---|
加載並解析 Json | Any Thread | 加載和解析這兩個動作就放在一起了 |
計算最大值 | Any Thread | 加載之後即可執行 |
計算最小值 | Any Thread | 同上 |
計算平均值 | Any Thread | 同上 |
完成通知 | Game Thread | 通知界面更新 |
看下面這個圖可能更直觀一點:
Task Context 對象
在正式開始編寫任務之前,我們需要先解決數據在任務之間“傳遞”和“共享”的問題。
在這裏,我打算使用一個 Context 對象存儲所有數據,這種方式也是引擎中很多 TaskGraph 所使用的。
下面是一個任務數據的詳細分析:
數據項 | 主線程:任務發起 | 異步任務:加載並解析 | 異步任務:計算X3 | 主線程:UI更新 | 完成通知 |
---|---|---|---|---|---|
數據文件路徑 | 寫入 | 只讀 | NA | NA | NA |
完成回調 | 寫入 | NA | NA | NA | 只讀 |
Json 數據對象 | NA | 寫入 | 只讀 | NA | NA |
計算結果X3 | NA | NA | 寫入 | 只讀 | 只讀 |
經過上面的分析之後,我設計了下面的數據結構,這個對象將在主線程和幾個異步任務之間共享。結合前面那個圖片中的執行序列分析,我決定:不用給Context對象加鎖!
struct FStockAnalyzeContext
{
bool bRunning = false;
FString DataFilePath;
FTaskDelegate_StockAnalyzeComplete CompletionDelegate;
TArray<TSharedPtr<FJsonValue>> StockData;
FVector Result; // {X:max, Y:min, Z:average}
};
那個 Json 對象,使用“ TSharedPtr<FJsonObject, ESPMode::ThreadSafe> StockData”感覺更好一點,不過,引擎中的 JSON 代碼的參數寫死了,只支持上面那個指針類型。我只能非常謹慎的編碼,保證這些Json智能指針在訪問的時候,不產生指針的複製。😦 如果你有更好的寫法,請留言告訴我!
我們將在一個測試用的 Actor 對象裏面存儲一個 FStockAnalyzeContext 實例,然後在不同的 Task 之間共享它。
決定了這個 Context 數據結構之後,下面就是挨個實現每個 Task 了!
任務實現:異步加載 JSON
這個 Task 很簡單,基本上就是把前一篇博客:基於任務的並行編程與TaskGraph 中的 FTask_LoadFileToString
稍加改造,在 DoTask()
中加上 Json 解析,並去掉派發子任務邏輯即可:
class FTask_LoadFileToJson
{
FStockAnalyzeContext* Context;
public:
FTask_LoadFileToJson(FStockAnalyzeContext* InContext) : Context(InContext)
{}
TStatId GetStatId() const {
RETURN_QUICK_DECLARE_CYCLE_STAT(FTask_LoadFileToJson, STATGROUP_TaskGraphTasks);
}
static ENamedThreads::Type GetDesiredThread() { return CPrio_StockTasks.Get(); }
static ESubsequentsMode::Type GetSubsequentsMode() { return ESubsequentsMode::TrackSubsequents; }
void DoTask(ENamedThreads::Type CurrentThread, const FGraphEventRef& MyCompletionGraphEvent)
{
TSharedPtr<FJsonObject> JsonObject;
// load file from Content folder
FString FilePath = Context->DataFilePath;
FString FileContent;
FString FullPath = FPaths::Combine(FPaths::ProjectContentDir(), FilePath);
if (FPaths::FileExists(FullPath))
{
if (FFileHelper::LoadFileToString(FileContent, *FullPath))
{
TSharedRef< TJsonReader<> > Reader = TJsonReaderFactory<>::Create(FileContent);
FJsonSerializer::Deserialize(Reader, JsonObject);
}
}
// write resut to context
if (JsonObject)
Context->StockData = JsonObject->GetArrayField(TEXT("stock"));
}
};
爲了代碼簡單,我沒有做什麼錯誤處理啊~
任務實現:數據統計計算
對“上證指數”求最大值、最小值、平均值,就是從 Context 中讀取數據, 進行個簡單的計算啦:
class FTask_StockMax
{
FStockAnalyzeContext* Context;
public:
FTask_StockMax(FStockAnalyzeContext* InContext) : Context(InContext)
{}
TStatId GetStatId() const {
RETURN_QUICK_DECLARE_CYCLE_STAT(FTask_StockMax, STATGROUP_TaskGraphTasks);
}
static ENamedThreads::Type GetDesiredThread() { return CPrio_StockTasks.Get(); }
static ESubsequentsMode::Type GetSubsequentsMode() { return ESubsequentsMode::TrackSubsequents; }
void DoTask(ENamedThreads::Type CurrentThread, const FGraphEventRef& MyCompletionGraphEvent)
{
// process data
float Result = TNumericLimits<float>::Min();
int32 Count = Context->GetStockDataCount();
for (int32 i = 0; i < Count; i++)
{
float Value = Context->GetStockData(i);
if (Value > Result)
Result = Value;
}
// write resut to context
Context->Result.X = Result;
}
};
任務實現:完成通知
和前一篇博客一樣:基於任務的並行編程與TaskGraph 我還是使用一個指定在 Game Thread 執行的 Task 來調用藍圖實現的事件:
class FTaskCompletion_StockAnalyze
{
FStockAnalyzeContext* Context;
public:
FTaskCompletion_StockAnalyze(FStockAnalyzeContext* InContext) : Context(InContext)
{}
FORCEINLINE TStatId GetStatId() const {
RETURN_QUICK_DECLARE_CYCLE_STAT(FTaskCompletion_StockAnalyze, 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());
Context->CompletionDelegate.ExecuteIfBound(Context->Result);
Context->bRunning = false;
}
};
派發所有任務
重點來了!我們需要把任務的執行組織成下面這個圖片所示:
這個重點就是使用:TGraphTask::CreateTask()
函數的第一個個參數。
void AForkJoinDemo::AsyncAnalyzeStockData(const FString& FilePath)
{
if (TaskContext.bRunning)
return;
FTaskDelegate_StockAnalyzeComplete CompletionDelegate;
CompletionDelegate.BindUFunction(this, "OnAnalyzeComplete");
TaskContext = {};
TaskContext.bRunning = true;
TaskContext.CompletionDelegate = CompletionDelegate;
TaskContext.DataFilePath = FilePath;
FGraphEventRef LoadJson = TGraphTask<FTask_LoadFileToJson>::CreateTask().
ConstructAndDispatchWhenReady(&TaskContext);
// data process tasks
FGraphEventArray RootTasks = { LoadJson };
FGraphEventRef CalMax = TGraphTask<FTask_StockMax>::CreateTask(&RootTasks, ENamedThreads::AnyThread).
ConstructAndDispatchWhenReady(&TaskContext);
FGraphEventRef CalMin = TGraphTask<FTask_StockMin>::CreateTask(&RootTasks, ENamedThreads::AnyThread).
ConstructAndDispatchWhenReady(&TaskContext);
FGraphEventRef CalAverage = TGraphTask<FTask_StockAverage>::CreateTask(&RootTasks, ENamedThreads::AnyThread).
ConstructAndDispatchWhenReady(&TaskContext);
// compeletion
FGraphEventArray CalTasks = { CalMax, CalMin, CalAverage };
TGraphTask<FTaskCompletion_StockAnalyze>::CreateTask(&CalTasks, ENamedThreads::AnyThread).
ConstructAndDispatchWhenReady(&TaskContext);
}
小結
通過指定任務的依賴關係,可以很方便的使用 TaskGraph 實現 Fork-Join 模型。
相關的樣例工程在我的 GitHub :https://github.com/neil3d/UnrealCookBook/tree/master/MakingUseOfTaskGraph 。
本文相關的 Demo 完整源代碼也附上:
ForkJoinDemo.h
#pragma once
#include "CoreMinimal.h"
#include "GameFramework/Actor.h"
#include "Dom/JsonObject.h" // Json
#include "Dom/JsonValue.h" // Json
#include "ForkJoinDemo.generated.h"
DECLARE_DELEGATE_OneParam(FTaskDelegate_StockAnalyzeComplete, FVector);
struct FStockAnalyzeContext
{
bool bRunning = false;
FString DataFilePath;
FTaskDelegate_StockAnalyzeComplete CompletionDelegate;
TArray<TSharedPtr<FJsonValue>> StockData;
FVector Result; // {X:max, Y:min, Z:average}
int32 GetStockDataCount() const;
float GetStockData(int32 Index) const;
};
UCLASS()
class MAKINGUSEOFTASKGRAPH_API AForkJoinDemo : public AActor
{
GENERATED_BODY()
public:
// Sets default values for this actor's properties
AForkJoinDemo();
UFUNCTION(BlueprintCallable)
void AsyncAnalyzeStockData(const FString& FilePath);
UFUNCTION(BlueprintImplementableEvent)
void OnAnalyzeComplete(FVector Result);
protected:
FStockAnalyzeContext TaskContext;
};
ForkJoinDemo.cpp
FStockAnalyzeContext::GetStockData() 的效率有很大優化空間,這裏請忽略,咱們是談 TaskGraph 爲主。
#include "ForkJoinDemo.h"
#include "Misc/Paths.h"
#include "Misc/FileHelper.h"
#include "Math/NumericLimits.h"
#include "Async/TaskGraphInterfaces.h" // Core
#include "Serialization/JsonReader.h" // Json
#include "Serialization/JsonSerializer.h" // Json
int32 FStockAnalyzeContext::GetStockDataCount() const
{
return StockData.Num();
}
float FStockAnalyzeContext::GetStockData(int32 Index) const
{
const TSharedPtr<FJsonValue>& Element = StockData[Index];
const TSharedPtr<FJsonObject>& Stock = Element->AsObject();
const TSharedPtr<FJsonValue>* FieldPtr = Stock->Values.Find(TEXT("close"));
if (!FieldPtr)
return 0.0f;
const TSharedPtr<FJsonValue>& Field = *FieldPtr;
check(Field && !Field->IsNull());
return FCString::Atof(*(Field->AsString()));
}
FAutoConsoleTaskPriority CPrio_StockTasks(
TEXT("TaskGraph.TaskPriorities.StockTasks"),
TEXT("Task and thread priority for stock analyzation."),
ENamedThreads::HighThreadPriority,
ENamedThreads::NormalTaskPriority,
ENamedThreads::HighTaskPriority
);
class FTaskCompletion_StockAnalyze
{
FStockAnalyzeContext* Context;
public:
FTaskCompletion_StockAnalyze(FStockAnalyzeContext* InContext) : Context(InContext)
{}
FORCEINLINE TStatId GetStatId() const
{
RETURN_QUICK_DECLARE_CYCLE_STAT(FTaskCompletion_StockAnalyze, 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());
Context->CompletionDelegate.ExecuteIfBound(Context->Result);
Context->bRunning = false;
}
};
class FTask_StockMax
{
FStockAnalyzeContext* Context;
public:
FTask_StockMax(FStockAnalyzeContext* InContext) : Context(InContext)
{}
TStatId GetStatId() const {
RETURN_QUICK_DECLARE_CYCLE_STAT(FTask_StockMax, STATGROUP_TaskGraphTasks);
}
static ENamedThreads::Type GetDesiredThread() { return CPrio_StockTasks.Get(); }
static ESubsequentsMode::Type GetSubsequentsMode() { return ESubsequentsMode::TrackSubsequents; }
void DoTask(ENamedThreads::Type CurrentThread, const FGraphEventRef& MyCompletionGraphEvent)
{
// process data
float Result = TNumericLimits<float>::Min();
int32 Count = Context->GetStockDataCount();
for (int32 i = 0; i < Count; i++)
{
float Value = Context->GetStockData(i);
if (Value > Result)
Result = Value;
}
// write resut to context
Context->Result.X = Result;
}
};
class FTask_StockMin
{
FStockAnalyzeContext* Context;
public:
FTask_StockMin(FStockAnalyzeContext* InContext) : Context(InContext)
{}
TStatId GetStatId() const {
RETURN_QUICK_DECLARE_CYCLE_STAT(FTask_StockMin, STATGROUP_TaskGraphTasks);
}
static ENamedThreads::Type GetDesiredThread() { return CPrio_StockTasks.Get(); }
static ESubsequentsMode::Type GetSubsequentsMode() { return ESubsequentsMode::TrackSubsequents; }
void DoTask(ENamedThreads::Type CurrentThread, const FGraphEventRef& MyCompletionGraphEvent)
{
// process data
float Result = TNumericLimits<float>::Max();
int32 Count = Context->GetStockDataCount();
for (int32 i = 0; i < Count; i++)
{
float Value = Context->GetStockData(i);
if (Value < Result)
Result = Value;
}
// write resut to context
Context->Result.Y = Result;
}
};
class FTask_StockAverage
{
FStockAnalyzeContext* Context;
public:
FTask_StockAverage(FStockAnalyzeContext* InContext) : Context(InContext)
{}
TStatId GetStatId() const {
RETURN_QUICK_DECLARE_CYCLE_STAT(FTask_StockAverage, STATGROUP_TaskGraphTasks);
}
static ENamedThreads::Type GetDesiredThread() { return CPrio_StockTasks.Get(); }
static ESubsequentsMode::Type GetSubsequentsMode() { return ESubsequentsMode::TrackSubsequents; }
void DoTask(ENamedThreads::Type CurrentThread, const FGraphEventRef& MyCompletionGraphEvent)
{
// process data
float Result = 0;
int32 Count = Context->GetStockDataCount();
for (int32 i = 0; i < Count; i++)
{
float Value = Context->GetStockData(i);
Result += Value;
}
// write resut to context
Context->Result.Z = Result / Count;
}
};
class FTask_LoadFileToJson
{
FStockAnalyzeContext* Context;
public:
FTask_LoadFileToJson(FStockAnalyzeContext* InContext) : Context(InContext)
{}
TStatId GetStatId() const {
RETURN_QUICK_DECLARE_CYCLE_STAT(FTask_LoadFileToJson, STATGROUP_TaskGraphTasks);
}
static ENamedThreads::Type GetDesiredThread() { return CPrio_StockTasks.Get(); }
static ESubsequentsMode::Type GetSubsequentsMode() { return ESubsequentsMode::TrackSubsequents; }
void DoTask(ENamedThreads::Type CurrentThread, const FGraphEventRef& MyCompletionGraphEvent)
{
TSharedPtr<FJsonObject> JsonObject;
// load file from Content folder
FString FilePath = Context->DataFilePath;
FString FileContent;
FString FullPath = FPaths::Combine(FPaths::ProjectContentDir(), FilePath);
if (FPaths::FileExists(FullPath))
{
if (FFileHelper::LoadFileToString(FileContent, *FullPath))
{
TSharedRef< TJsonReader<> > Reader = TJsonReaderFactory<>::Create(FileContent);
FJsonSerializer::Deserialize(Reader, JsonObject);
}
}
// write resut to context
if (JsonObject)
Context->StockData = JsonObject->GetArrayField(TEXT("stock"));
}
};
// Sets default values
AForkJoinDemo::AForkJoinDemo()
{
}
void AForkJoinDemo::AsyncAnalyzeStockData(const FString& FilePath)
{
if (TaskContext.bRunning)
return;
FTaskDelegate_StockAnalyzeComplete CompletionDelegate;
CompletionDelegate.BindUFunction(this, "OnAnalyzeComplete");
TaskContext = {};
TaskContext.bRunning = true;
TaskContext.CompletionDelegate = CompletionDelegate;
TaskContext.DataFilePath = FilePath;
FGraphEventRef LoadJson = TGraphTask<FTask_LoadFileToJson>::CreateTask().
ConstructAndDispatchWhenReady(&TaskContext);
// data process tasks
FGraphEventArray RootTasks = { LoadJson };
FGraphEventRef CalMax = TGraphTask<FTask_StockMax>::CreateTask(&RootTasks, ENamedThreads::AnyThread).
ConstructAndDispatchWhenReady(&TaskContext);
FGraphEventRef CalMin = TGraphTask<FTask_StockMin>::CreateTask(&RootTasks, ENamedThreads::AnyThread).
ConstructAndDispatchWhenReady(&TaskContext);
FGraphEventRef CalAverage = TGraphTask<FTask_StockAverage>::CreateTask(&RootTasks, ENamedThreads::AnyThread).
ConstructAndDispatchWhenReady(&TaskContext);
// compeletion
FGraphEventArray CalTasks = { CalMax, CalMin, CalAverage };
TGraphTask<FTaskCompletion_StockAnalyze>::CreateTask(&CalTasks, ENamedThreads::AnyThread).
ConstructAndDispatchWhenReady(&TaskContext);
}
延伸閱讀
CalMin = TGraphTask<FTask_StockMin>::CreateTask(&RootTasks, ENamedThreads::AnyThread).
ConstructAndDispatchWhenReady(&TaskContext);
FGraphEventRef CalAverage = TGraphTask<FTask_StockAverage>::CreateTask(&RootTasks, ENamedThreads::AnyThread).
ConstructAndDispatchWhenReady(&TaskContext);
// compeletion
FGraphEventArray CalTasks = { CalMax, CalMin, CalAverage };
TGraphTask<FTaskCompletion_StockAnalyze>::CreateTask(&CalTasks, ENamedThreads::AnyThread).
ConstructAndDispatchWhenReady(&TaskContext);
}
## 延伸閱讀
- [Wiki: Fork-Join](http://en.wikipedia.org/wiki/Fork%E2%80%93join_model)