UE4异步操作总结
虛幻本身有提供一些對異步操作的封裝,這里是對這段時間接觸到的“非同步”的操作進行的總結。
當前使用的UE4版本為4.18.2。
在虛幻的游戲制作中,如果不是特殊情況一般不會有用到線程的時候。但是由于實際上虛幻內部是有著許多線程機制的。
例如通常的游戲引擎中游戲線程和渲染線程都是獨立的,相互之間會存在一個同步的機制。
而物理線程與游戲線程之間的同步有時候也會導致游戲的表現與預期不一致。
通常會有線程同步需求的地方是網絡相關的操作,但是實際上UE4已經對網絡操作進行了封裝,無需關心這個問題。
而游戲線程、渲染線程、物理線程內部也都已經有了封裝,對游戲邏輯的構建基本是不可見的。
但是有時候還是會遇到需要使用線程相關邏輯的,這里就是這段時間內累計的“非同步”相關邏輯的總結。
Tick
這個其實關于Tick的,雖然Actor是有默認的Tick函數的,Component與UMG也有對應的Tick機制。
但是如果是自定義的UObject或者Slate,要使用Tick機制的話就會有些麻煩。
例如,想要讓自定義的Slate控件進行某種數據更新,而數據源本身并不提供通知機制的話就會有些麻煩。
雖然通過各種設計可以巧妙的繞過這個問題,但是有時候在類內部構建Tick機制才是最快速的解決方案。
TimerManager
通過使用引擎提供的定時器機制,就可以進行自定義的Tick了:
| 1 2 3 4 5 6 7 | GetWord()->GetTimerManager().SetTimer( ??m_hTimerHandle, ??this, ??&UNetPlayManager::TimerTick, ??1.0, ??true ); |
這里需要能夠獲得UWorld的指針,如果是自定義的類型的話,就必須想辦法提供有效的UWorld指針。
FTickableGameObject
還有另一個方法,就是使用FTickableGameObject。
任何繼承自FTickableGameObject的類型都會獲得Tick的能力,就算不是虛幻原生的類型也可以使用,相當的便利。使用時繼承自該類型,然后:
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 | public: ? /** <Tick接口函數 */ virtual void Tick(float DeltaTime) override; ? virtual bool IsTickable() const override { ??return true; } ? virtual bool IsTickableWhenPaused() const override { ??return true; } ? virtual TStatId GetStatId() const override { ??RETURN_QUICK_DECLARE_CYCLE_STAT(USceneCapturer, STATGROUP_Tickables); } |
繼承一下基本的函數就可以了。
線程同步
UE4對操作系統提供的線程同步相關接口進行了一定的封裝。
Atomics
基本的接口可以在FPlatformAtomics找到,針對不同的平臺,有不同的實現。
InterlockedAdd
InterlockedCompareExchange (-Pointer)
InterlockedDecrement (-Increment)
InterlockedExchange (-Pointer)
詳細的可以參看其源碼。也可以參看引擎內部的使用方式:
class FThreadSafeCounter { public: int32 Add( int32 Amount ) { return FPlatformAtomics::InterlockedAdd(&Counter, Amount); } private: volatile int32 Counter; };
| 1 2 3 4 5 6 7 8 9 10 | class FThreadSafeCounter { public: ??int32 Add( int32 Amount ) ??{ ????return FPlatformAtomics::InterlockedAdd(&Counter, Amount); ??} private: ??volatile int32 Counter; }; |
?
FCriticalSection
用于對非線程安全的區域進行保護。
| 1 | FCriticalSection CriticalSection; |
聲明之后在需要的地方進行鎖操作即可,有提供作用域保護的封裝:
FScopeLock Lock(&CriticalSection);
| 1 | FScopeLock Lock(&CriticalSection); |
這樣就不需要自己進行Lock和Unlock了,可以有效的防止誤操作導致的Bug的出現。
FSpinLock
鎖操作,提供Lock,Unlock以及BlockUntilUnlocked等便利的操作。
其實內部就是對FPlatformAtomics::InterlockedExchange的一個封裝。
構造函數的InSpinTimeInSeconds就是默認的鎖等待間隔,默認值為0.1。
FSemaphore
這個是對信號量的封裝,但是似乎不建議使用。
而且并不是對于所有的平臺都有實現的,通常建議使用FEvent進行代替。
FEvent
這個相當于UE4封裝的內部使用的互斥信號量機制,有基本的等待和喚醒操作。
FScopedEvent
對FEvnet的封裝,在注釋上能夠看到使用示例:
?
| 1 2 3 4 5 | { ????????FScopedEvent MyEvent; ????????SendReferenceOrPointerToSomeOtherThread(&MyEvent); // Other thread calls MyEvent->Trigger(); ????????// MyEvent destructor is here, we wait here. } |
這個操作就是將MyEvent發送到其他線程,直到在其他的地方MyEvnet->Trigger()被調用為止,都不會離開這個作用域繼續執行。
容器
包括TArray, TMap在內的幾乎大部分的容器都不是線程安全的,需要自己對同步進行管理。
當然也能看到一些線程安全的封裝,例如TArrayWithThreadsafeAdd。
TLockFreePointerList
這個是一系列的類型,在Task Graph系統中被使用到。如其名稱是LockFree的。
TQueue
也是LockFree的,在初始化時可以指定線程同步的類型EQueueMode,分為Mpsc(多生產者單消費者)以及Spsc(單生產者單消費者)兩種模式。
只有Spsc模式是contention free的。
仔細尋找的話UE4內部有實現很多便利的類型,例如TCircularQueue這種針對雙線程,一個消費一個生產的線程安全類型。
工具類
FThreadSafeCounter
就是前面例子中的線程安全的計數器。
FThreadSingleton
為每一個線程創建一個實例。
FThreadIdleStats
用于統計線程空閑狀態。
異步執行
UE4中對基本的線程操作進行了一定程度的封裝,使用相應的Helper就可以無需關心線程的創建這些問題。
AsyncTask
這個函數可以將一些簡單的任務扔到UE4的線程池中去進行,不必關心具體的線程同步問題。
| 1 2 3 4 5 6 7 8 9 10 11 | if(IsInGameThread()) { ??//….一些操作 } else { ??AsyncTask(ENamedThreads::GameThread, [=]() ??{ ????//….一些操作 ??}); } |
其中第一個參數是發送到的線程的名稱,通常一些工作線程是無法執行引擎中IsGameThread()保護或者其他隱形的游戲線程代碼的,通過這個操作將其發送到游戲線程的話使用GameThread就可以了。
其實基本上的游戲邏輯中使用最多的就是這個函數了。
RHICmdList
這是一組獨特的宏,用于將操作發送到渲染線程進行操作。
主要是對Texture之類的數據在GPU以及GPU相關的指令進行執行。
例如:
?
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 | if (IsInRenderingThread()) { ????// Initialize the vertex factory's stream components. ????FDataType NewData; ????NewData.PositionComponent = STRUCTMEMBER_VERTEXSTREAMCOMPONENT(InVertexBuffer, FPaperSpriteVertex, Position, VET_Float3); ????NewData.TangentBasisComponents[0] = STRUCTMEMBER_VERTEXSTREAMCOMPONENT(InVertexBuffer, FPaperSpriteVertex, TangentX, VET_PackedNormal); ????NewData.TangentBasisComponents[1] = STRUCTMEMBER_VERTEXSTREAMCOMPONENT(InVertexBuffer, FPaperSpriteVertex, TangentZ, VET_PackedNormal); ????NewData.ColorComponent = STRUCTMEMBER_VERTEXSTREAMCOMPONENT(InVertexBuffer, FPaperSpriteVertex, Color, VET_Color); ????NewData.TextureCoordinates.Add(FVertexStreamComponent(InVertexBuffer, STRUCT_OFFSET(FPaperSpriteVertex, TexCoords), sizeof(FPaperSpriteVertex), VET_Float2)); ????SetData(NewData); } else { ????ENQUEUE_UNIQUE_RENDER_COMMAND_TWOPARAMETER( ????????InitPaperSpriteVertexFactory, ????????FPaperSpriteVertexFactory*, VertexFactory, this, ????????const FPaperSpriteVertexBuffer*, VB, InVertexBuffer, ????????{ ????????????VertexFactory->Init(VB); ????????}); } |
這樣就可以保證只能在渲染線程執行的代碼不會被其他線程執行到。
渲染線程還有一些需要注意的是,UE4中有的代碼的執行其實是在渲染線程中的,如果沒有留意的話會造成隱形的線程同步問題。例如通常UMG的OnPaint。
FAsyncTask
這個是一組任務的封裝類,是基本的任務單元,最簡單的使用如下:
FAutoDeleteAsyncTask
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 | class ExampleAutoDeleteAsyncTask : public FNonAbandonableTask { ????friend class FAutoDeleteAsyncTask<ExampleAutoDeleteAsyncTask>; ? ????int32 ExampleData; ? ????ExampleAutoDeleteAsyncTask(int32 InExampleData) ????????: ExampleData(InExampleData) ????{ ????????UE_LOG(LogTemp, Log, TEXT("[ExampleAutoDeleteAsyncTask] Construct()")); ????} ? ????void DoWork() ????{ ????????UE_LOG(LogTemp, Log, TEXT("[ExampleAutoDeleteAsyncTask] DoWork()")); ????} ? ????FORCEINLINE TStatId GetStatId() const ????{ ????????RETURN_QUICK_DECLARE_CYCLE_STAT(ExampleAutoDeleteAsyncTask, STATGROUP_ThreadPoolAsyncTasks); ????} }; |
在完成定義后,可以有兩種使用方式:
| 1 2 3 4 5 | // 將任務扔到線程池中去執行 (new FAutoDeleteAsyncTask<ExampleAutoDeleteAsyncTask>(5))->StartBackgroundTask(); ? // 直接在當前線程執行操作 (new FAutoDeleteAsyncTask<ExampleAutoDeleteAsyncTask>(5))->StartSynchronousTask(); |
FAutoDeleteAsyncTask的一個優點是,在執行完成后會自動銷毀,無需進行額外的關注。通常文件寫入或者壓縮數據之類的無須進行過程管理的操作可以交付給他執行。
FAsyncTask
這個才是本尊,由于不會自動刪除,有需要進行額外操作的情況。
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 | 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; |
但是如果是使用StartBackgroundTask()的話依然不需要自己進行管理。
FRunnable
這個是交付給線程的執行體封裝,通常用于比AsyncTask更加復雜的操作。
分為Init(), Run(), Exit()三個操作,如果Init失敗就不會執行Run(),Run()執行完成就會執行Exit()。
?
| 1 2 3 4 5 6 7 8 9 10 11 | class FRunAbleTest : public FRunnable { ????virtual uint32 Run() override ????{ ????????UE_LOG(LogTemp, Log, TEXT("[FRunAbleTest] Run()")); ????????FPlatformProcess::Sleep(30); ????????UE_LOG(LogTemp, Log, TEXT("[FRunAbleTest] Run(): Comp")); ????????return 0; ????} ? }; |
通常也可以只指定Run(),然后交付給線程:
?
| 1 2 | FRunnable* tp_Runable = new FRunAbleTest(); mp_TestThread = FRunnableThread::Create(tp_Runable, TEXT("Test_01")); |
就可以了。
Async
這是另一個異步執行的宏,與AsyncTask有少許不同。
Async的簡單的使用方式在注釋中有提到
?
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 | ????// 使用全局函數 ????int TestFunc() ????{ ????????return 123; ????} ? ????TFunction<int()> Task = TestFunc(); ????auto Result = Async(EAsyncExecution::Thread, Task); ? ????// 使用lambda ????TFunction<int()> Task = []() ????{ ????????return 123; ????} ? ????auto Result = Async(EAsyncExecution::Thread, Task); ? ? ????// 使用inline lambda ????auto Result = Async<int>(EAsyncExecution::Thread, []() { ????????return 123; ????} |
第一個參數為執行的類型,TaskGraph是將其放到任務圖中去執行,Thread則是在單獨的線程中執行,TreadPool則是放入線程池中去執行。
這里并不能像AsyncTask一樣指定目標的線程。
同時Async會返回一個TFuture<ResultType>,而ResultType則是傳入的執行函數的返回值。
?
| 1 2 3 4 5 6 | TFunction<int()> My_Task= []() { ????return 123; }; ? auto Future = Async(EAsyncExecution::TaskGraph, My_Task); int Result = Future.Get(); |
類似這樣的調用即可。
總結
UE4提供的異步操作大體上分為TaskGraph和TreadPool的管理方式,通常較簡單的任務交付給TaskGraph,復雜的任務交付給Thread。
對于Task,引擎會有自己的管理,將其分配給空閑的Worker Thread。同時Task之間的依賴關系也會被管理,并按照需要的順序被執行。
其實TaskGroup和ThreadPool都是可以自己進行申請和管理的,但是并沒有實際的進行研究。
因為理論上,除非有需求,應當盡量的讓游戲邏輯保持簡潔。再加上線程同步是要支付額外的成本的,因此,要盡量避免對異步邏輯的使用,即使使用,也要盡量的保持邏輯單純。而且這兩個系統本身是虛幻為編輯器而設計的,雖然開放給用戶使用,但是就像GamePlayAbility系統一樣。本身每個程序員都有自己的實現思路,也沒有必要一定要使用這套系統。
畢竟游戲最終是用戶體驗,沒有用戶在意屏幕背后的邏輯實現是否”Geek”。
總結
- 上一篇: 3A和5A的数据线有什么区别?数据传输速
- 下一篇: 比宏光MINI EV还小 五菱Air E