【USparkle專欄】如果你深懷絕技,愛“搞點研究”,樂於分享也博採眾長,我們期待你的加入,讓智慧的火花碰撞交織,讓知識的傳遞生生不息!
UE遊戲包與編輯器中都有眾多線程,多線程可以充分利用CPU多核特性,提升遊戲表現,而且現代CPU核數越來越多,遊戲多線程就更有必要了。
一、線程類型
線程可分為專用線程和線程池中線程。
專用線程為GameThread,RenderThread,StatsThread等,它們各自都幹專門的事情,比如GameThread用於驅動遊戲邏輯,RenderThread用於渲染,StatsThread用於乾性能分析。在事情幹完後會進入阻塞狀態,不消耗CPU資源。
線程池線程包括PoolThread和TaskGraphThread,每個線程用於幹多種異步任務。遊戲中許多任務併發量大,但持續時間短,比如求解動畫藍圖,每幀都開幾個線程求解,求解完再銷燬線程無疑很浪費。另外有很多瑣碎的多線程任務,單獨為它們開線程也很浪費。因此UE使用了線程池,池中線程會循環利用,不斷執行不同的異步任務,當沒有任務時也處於阻塞狀態。
使用多線程時,UE已對底層平台接口進行了封裝,開發者無需關注平台差異,直接使用UE提供的統一接口即可。
常用的多線程方式包括:RunnableThread、ThreadPool 和 TaskGraph。這三者在底層線程的實現機制上有所不同,對外提供的接口種類豐富,部分接口還支持通過參數指定所使用的線程實現方式。
二、FRunnable & FRunnableThread
基本的線程使用方式為FRunnable與FRunnableThread的組合,用於創建專用線程,比如AsyncLoadingThread與渲染線程等。FRunnable負責邏輯,FRunnableThread負責具體線程,線程承載了邏輯,兩者一一對應,好處是上層邏輯與底層平台接口分離。
FRunnable
FRunnable類本身代表一個抽象的“可運行”對象,只有幾個接口,不涉及線程細節,是我們寫邏輯的地方,理論上可以在任意線程執行。
接口如下:
- Init Runnable的初始化,初始化可能成功,可能失敗,失敗會立即返回,線程結束。
- Run執行邏輯主體,初始化成功後執行。
- Exit Run中任務執行完後的正常退出接口,執行清理操作。
- Stop由其他線程調用,用於中途停止該Runnable以及背後的線程,但具體如何停止要我們自己實現。
- GetSingleThreadInterface,當UE強制單線程模式時,返回用於Tick執行的實例,用的不多。
上述接口不用我們調用,對應線程創建好後會自動調用。
示例FAsyncloadingThread:
該類用於處理異步資源加載,會另開一個線程加載資源,繼承了FRunnable,內部的Thread變量存儲線程。
Init函數沒做什麼,Run函數如下:
主體是一個while循環,StopTaskCounter是循環退出條件,為Atomic計數器,當未被設置時就不斷處理加載,被設置後即退出。
Stop函數如下,會設置StopTaskCounter變量:
創建線程,啓動Run邏輯。
只需要下面一行代碼即可,詳細會在下文介紹:
FRunnableThread
FRunnableThread是平台線程的抽象,也是基類,與平台相關的線程操作由多個子類完成,包括:
- FRunnableThreadWin
- FRunnableThreadUnix
- FRunnableThreadApple
- FRunnableThreadAndroid
我們不需要繼承和修改這些類,使用即可。
成員變量:
- FString ThreadName:線程名。
- FRunnable* Runnable:對應Runnable。
- FEvent* ThreadInitSyncEvent:同步的Event。
- EThreadPriority ThreadPriority:線程優先級,UE自己抽象了幾個枚舉。
接口:
- Kill:結束線程,UE建議不要用操作系統的Kill接口,強殺線程會導致泄露和死鎖,應該調用Runnable的Stop方法。
- WaitForCompletion:忙等,直到線程執行完。
- Suspend:讓線程掛起或繼續執行。
- SetThreadPriority:設置線程優先級。
FRunnableThreadWin
看下常見的Windows平台子類如何實現。
Windows平台的線程為內核對象,通過HANDLE持有索引,這裏的Thread就是底層的線程。
Kill方法內調用了Runnable的Stop,該函數由我們自己實現,然後可選忙等,最後調用操作系統的CloseHandle方法釋放線程內核對象。
Windows有TerminateThread方法可以直接結束線程,但平常不推薦調用,有以下幾個原因:
- 線程函數中C++對象的析構函數不會被執行;
- 線程棧不會被清理,除非調用TerminateThread的線程結束。這是Windows有意為之,加入其他在運行的線程要引用被“殺死”線程堆棧上的值,就會引起非法內存訪問;
- DLL通常會在線程終止時收到通知,但TerminateThread會導致DLL收不到通知,從而不執行正常的清理工作。
Suspend方法調用兩個操作系統接口,掛起和恢復:
SetThreadPriority方法同樣調用了操作系統接口,只是要做優先級轉換,Windows平台線程優先級為0-31,31最高。
Windows中WaitForSingleObject可以實現WaitForCompletion效果:
三、創建線程
使用靜態方法Create可創建FRunnableThread和底層線程:
- InStack為線程棧大小;
- InThreadPri為線程優先級;
- InThreadAffinityMask為線程的CPU運行偏好,一般用默認值;
- InCreateFlags也一般用默認值。
函數內部首先創建NewThread對象,Windows平台即FRunnableThreadWin。如果當前設置了強制單線程模式,還可選創建FakeThread,通過Tick驅動執行。
之後進入CreateInternal函數,調用操作系統接口創建線程。把Runnable屬性設置為傳入的InRunnable,然後把線程相關參數轉化為適配當前操作系統的參數,調用CreateThread Win32API創建線程,線程執行的函數為_ThreadProc。同時注意到CREATE_SUSPENDED參數,線程創建後默認為掛起狀態,執行了後面的ResumeThread,才會讓改線程運行。ThreadInitSyncEvent用於等待線程的Init執行完畢,執行完後調用線程才會繼續。
_ThreadProc函數先向UE的線程管理類註冊改線程,然後進入FRunnableThreadWin::Run函數,真正開始邏輯,注意這個Run函數和FRunnable的Run毫無關係。
首先調用Runnable的Inti函數,之後觸發ThreadInitSyncEvent,通知調用線程繼續。然後執行最主要的Runnable->Run,等Run自動結束了,再調用Runnable->Exit做清理。最後返回ExitCode,改線程終止。
四、使用方式
Runnable有多種使用方式:
1. 手動創建FRunnable和FRunnableThread
可參考前面的FAsyncLoadingThread,適合一個長期任務,而且工作量大。
2. Async函數
有時我們只想在其他線程中執行一個短期任務,線程生命週期不長,此時專門創建一個Runnable子類,並手動創建一個Thread有些繁瑣。引擎提供了Async函數,可以只提供我們想要執行的Lambda函數,引擎為我們創建一個Thread,或者從線程池中選擇一個Thread來執行邏輯。
使用例子:
第一個參數用於指定線程模式。
Async函數定義如下:
第一個參數為線程執行方式,第二個為傳入的函數對象,第三個為執行完的回調。
線程執行方式Execution有如下幾種取值:
- TaskGraph:在TaskGraph框架下執行,會在線程池選一個線程,適合短任務。
- TaskGraphMainThread:與上面類似,但會用主線程。
- Thread:創建一個新線程執行,適合長任務。
- ThreadIfForkSate:不知。
- ThreadPool:在GlobalThreadPool中選一個線程執行。
- LargeThreadPool:與上面類似,在LargeThreadPool選線程執行,僅Editor下可用。
這裏我們只關注Thread模式,處理分支如下:
創建了一個TAsyncRunnable對象,把Function和Promise傳入其中,Promise可理解為上面的CompletionCallback,然後通過FRunnableThread::Create接口創建新線程,執行該Runnable。
TAsyncRunnable是一種特殊類型,它接收一個Function、Promise或Future作為參數。觀察其Run方法:在SetPromise時會執行我們指派的任務。這個FRunnable和FRunnableThread對象是匿名的,外部代碼僅進行New而不Delete,其生命週期由TAsyncRunnable自身託管。具體的清理做法是將刪除操作提交至任務隊列(TaskGraph)。之所以不立即Delete,是因為此時Run函數可能尚未執行完畢,立即Delete自身可能導致異常情況。
3. AsyncThread函數
和Async函數類似,內部都用TAsyncRunnable實現,不過它是專門創建匿名線程來執行任務的,因此參數中增加了線程優先級選項,這種用法引擎中不多。
五、線程同步工具
多線程環境下線程同步是個問題,UE提供了多種線程同步工具。
Atomics
Atomics可以原子的改變一個變量,相比鎖是更輕量的線程同步工具,線程不需要切換狀態,提供更好的性能。從底層視角看,原子操作也是有鎖的,現代多核CPU會通過電路信號鎖Cache的方式來實現原子操作,只是這個過程很快。原子操作是一些多線程安全類型的實現基石。
使用場景
常見使用場景為實現多線程安全的計數器,比如SharedPtr裏的引用計數,下面的SharedReferenceCount類型就是std::atomic。
UE引擎提供了幾個類型,是對操作系統和C++ Atomic功能的封裝。
FPlatformAtomics
可對一個地址進行原子操作,如Add,Exchange。在不同平台上會調用各自原子操作接口,Windows為Win32Api的_InterlockedExchange等接口。
示例
TAtomic
類似std::atomic,底層使用FPlatformAtomics實現,提供相似接口。在UE5中,已被標記為DEPRECATED,推薦直接使用std::atomic。
FThreadSafeCounter
封裝的線程安全計數器,提供Increment、Decrement等接口,底層同樣使用FPlatformAtomics實現。
六、鎖
鎖可以創建一個臨界區,在臨界區內的代碼只允許一個線程執行,其他線程等待。根據臨界區執行時間,以及平台實現,鎖可能使線程從運行態切換到阻塞態,讓出CPU,這個狀態切換也需要線程從用户模式切換到內核模式,切換時間大概1000個CPU週期。因此鎖是較重的線程同步工具。
FCriticalSection
UE提供了FCriticalSection作為各平台鎖的封裝。
Windows平台底層使用CriticalSection實現,稱為關鍵段,Windows平台上也有互斥量Mutex,但CriticalSection相比Mutex速度更快。進入臨界區需要調用EnterCriticalSection,其內部會先用原子操作interlocked檢查是否能訪問資源,如果能訪問,就接着運行,這個速度很快。如果不能訪問,通常先Spin忙等一小段時間,若還不能訪問資源,再使用Event內核對象進入阻塞態。當臨界區比較短時,可以避免用户模式到內核模式的切換。因此Windows平台會用CriticalSection。
Linux平台底層使用pthread_mutex_t實現。
Windows實現:
初始化CriticalSection,4000表示未獲取到鎖忙等的CPU週期。
加鎖
解鎖
示例
通常使用FScopeLock配合FCriticalSection使用,可以通過構造函數與析構函數機制,使作用域內的代碼成為臨界區。
Event
Event用於多線程的同步,比如上文介紹的Windows線程創建,主線程在調用CreateThread後,調用ThreadInitSyncEvent->Wait(),等待新線程完成初始化工作,新建線程初始化後執行ThreadInitSyncEvent->Trigger(),通知主線程繼續執行。
Windows平台底層使用Event內核對象實現Event。Event有自動重置和手動重置概念,自動重置時,一個線程執行Trigger後,只有一個Wait的線程會被喚醒繼續執行,手動重置時,所有Wait的線程都會被喚醒,後續要手動調用重置函數,才能使Event變為未觸發狀態。通常都使用自動重置。
FEvent
UE使用FEvent類型表示一個Event,有Wait、Trigger、Reset等接口。而且Event是內核對象,創建比較昂貴,因此UE使用EventPool來管理這些Event,根據ManualReset分成兩個Pool。
EventPool接口:
WindowsRunnableThread中使用方式:
這是侑虎科技第1908篇文章,感謝作者南京周潤發供稿。歡迎轉發分享,未經作者授權請勿轉載。如果您有任何獨到的見解或者發現也歡迎聯繫我們,一起探討。(QQ羣:793972859)
作者主頁:https://www.zhihu.com/people/xu-chen-71-65
再次感謝南京周潤發的分享,如果您有任何獨到的見解或者發現也歡迎聯繫我們,一起探討。(QQ羣:793972859)