虛幻4與現代C++:使用TaskGraph實現Fork-Join模型

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