UE4异步编程专题 - TFunction
0. 關于這個專題
游戲要給用戶良好的體驗,都會盡可能的保證60幀或者更高的fps。一幀留給引擎的時間也不過16ms的時長,再除去渲染時間,留給引擎時間連10ms都不到,能做的事情是極其有限的。同步模式執行耗時的任務,時長不可控,在很多場景下是不能夠接受的。因此UE4提供了一套較為完備的異步機制,來應對這個挑戰。這個專題將深入淺出分析UE4中的解決方案,并分析其中的關鍵代碼。
1. 同步和異步
異步的概念在wiki和教科書上有很權威的解釋,這里就拿一些例子來打個比方吧。
每天下午2點,公司有一個咖啡小分隊去買咖啡喝。在小藍杯出來之前,我們都是去全家喝咖啡。一行人約好之后,就去全家排個小隊,向小哥點了幾杯大杯拿鐵后,就在一旁嘮嗑,等待咖啡制作完成。這是同步模式,我們向店員點了咖啡后就一直在等待咖啡制作完成。
?
同步買咖啡
去年小藍杯出來了,算不上精品咖啡,價格還不錯,而更重要的是我們可以異步了。在App上下單完成后,繼續做自己的事情,等到咖啡制作好的短信來了之后,再跟著咖啡小隊愉快地去拿咖啡。
?
異步買咖啡
2. 命令模式
在上一節提及的場景中,咖啡小隊買咖啡的行為,實際上是發出了一個制作咖啡的請求。咖啡小隊在全家買咖啡的時候,也就是同步模型下,咖啡小隊買咖啡會等待制作咖啡的過程,這里隱含了一層執行依賴的關系。但在向小藍杯買咖啡的時候,異步模型,買咖啡和制作咖啡的依賴關系消失了。雖然多一個響應咖啡制作完成,去拿咖啡的流程;但是這一層解耦,可以讓咖啡小隊剩下了等待咖啡制作的時間,提高了工作效率。當然,有時候咖啡小隊也想在外面多聊聊,而選擇去全家買咖啡(:逃
如果選擇使用異步模型,就必須要使用到命令模式來實現了。因為異步模型必須要將命令的請求者和實際的執行者分離開。咖啡小隊申請制作咖啡的請求,而咖啡制作的流程,調度及制作完成的通知,都是由小藍杯來決定的。這與在全家直接與店員要求制作咖啡有很大的不同。
命令模式兩個關鍵點:命令與調度。命令是提供給請求者使用的外觀,而調度則是執行者從收到命令請求到執行完成的策略,可以是簡單的單線程延遲執行,也可以是多線程的并發執行。這個系列會花第一篇的整個篇幅,來介紹與命令請求外觀相關的內容。對于調度方面的內容,會在后續的文章詳細探討。
3. 泛化仿函數
Modern Cpp Design,這本書介紹了泛化仿函數, generic functor. 泛化仿函數使用了類似函數式的編程風格,用于取代C++老舊的命令模式的實現,為命令請求的使用者提供了一個接口更友好,并且功能更強大的外觀。當然,這篇文章并不是為了布道函數式編程的優越性,并且泛化仿函數只是借鑒了函數式編程的風格,并不完全是函數式編程。鑒于其他語言中,函數作為第一類值類型已經廣泛被認可,并且C++11標準也補完了λ表達式,并提供了std::function基礎設施,我覺得這里還是很有必要討論一下,為什么從傳統的命令模式到現在的設計實現,是一種更好的設計思路。讓我們首先來回顧一下純C和面向對象的命令模式的外觀。
純C的命令外觀大概如下列代碼所示:
struct command_pure_c {int command_type;uint32_t struct_size;char data[0]; };也有大部分類庫會固定執行函數的簽名:
typedef int (*call_back_func)(void* data);struct command_pure_c {int command_type;uint32_t struct_size;call_back_func call_back;char data[0]; };Command會攜帶不同的狀態參數,在C語言的實現里面就不得不使用動態結構體來精確管理內存。執行者可以通過command_type或者call_back的函數指針來分派的正確的執行函數上。到了C++中,進入面向對象的時代,就有了如下面向對象的設計:
class ICommand { public:virtual int execute() = 0; };class MyCommand : public ICommand { public:MyCommand(int param) : param_(param) {}int execute() override final; private:int param_; };到了OOD,實現變得簡單了不少。類型可以攜帶參數,利用C++多態實現分派,也能利用C++類型的布局結構來精確控制內存。
上一個時代的設計,首先無形中引入了框架性的設計。例如OOD中,執行代碼要實現ICommand接口,執行函數體只能寫在execute中,或者說必須以execute為入口。
其次老舊的設計,只能在面對簡單的場景才能夠勝任的。簡單的場景,是指的命令執行完成后,只是簡單地收到成功與失敗的通知,沒有回調鏈的場景。因為這種設計最大的缺點,就是執行函數的實現與發起請求這兩個部分代碼的位置,并不是按照人類線性邏輯的習慣來組織的。也就是說,它需要我們的理解現有系統的運作機制,并讓我們推算出它們邏輯關系。當回調鏈是一個冗長而復雜的過程,它會給我們帶來巨大的心智負擔。
泛化仿函數優雅地解決了第一個問題,它可以攜帶狀態,并能夠統一不同的調用語義。文章后面的篇幅會提及,這實際上是一種類型擦除方法。從而使得執行的函數實現從框架性的設計中解放出來。
但是第二個問題,直到C++11標準引入λ表達式,才得以完全解決。通過匿名函數,我們可以直接把請求執行的函數體,內聯地(就地代碼而非inline關鍵字)寫在請求命令的位置,如下所示:
std::string file_name = "a.mesh"; request([file_name = std::move(file_name)]() {// ... file io// callback hell 在后續的文章中討論 });得益于C++11標準的完善,我們在C++中可以把函數對象當做第一類值對象來使用了,而且為我們的設計和抽象提供了強有力的基礎設施。
4. 泛化仿函數的實現原理
上一節我曾提到過,我們在C++中可以把函數對象當做第一類值來使用,但是C++也有沉重的歷史包袱,所以相比其他語言,在C++中使用函數對象有著C++特色的問題。
我們知道在C++中,有調用語義的類型有:
1. 函數(包括靜態成員函數)指針(引用)
2. 指向成員函數的指針,pointer to member function
3. 仿函數
4. λ表達式
值得提及的是,曾經的C++是把指向成員變量的指針,pointer to member data(PMD), 也當做具有調用語義的對象。因為PMD可以綁定成一個以類型作為形參,成員變量類型作為返回值的函數,并且std::result_of曾經一度也接受PMD類型作為輸入。
雖然這些具有調用語義的類型,都可以當做函數來使用,但是他們之間有著語義上的巨大差異,我們主要從兩個維度:是否帶狀態和是否需要調用者,來分析并列舉出了下表:
可以想象AA大神,當時看到C++此番情景的表情:
泛化仿函數的第一目標,就是抹平這些語義上的鴻溝,抽象出一個語義統一的callable的概念。先給出早期實現外觀代碼: (為了簡單起見,我們假定已經有了C++11的語法標準,因為C++98時代為了可變模板參數而使用的type_list會引入相當復雜的問題)
// 為避免引入function_traits,我們選擇較為直白的實現方式 template <typename Ret, typename ... Args> class function_impl_base { public:virtual ~function_impl_base() {}virtual Ret operator() (Args...) = 0;// TODO ... Copy & Move };template<typename FuncType> class function;template <typename Ret, typename ... Args> class function<Ret(Args...)> {// ... private:function_impl_base<Ret, Args...>* impl_; };為了抹平這些語義上的鴻溝,一個比較簡單的思路,就是逐個擊破。
4.1 處理函數指針,函數指針和λ表達式
為什么把這三個放在一起處理,因為他們有相同的調用語意。而函數指針無法攜帶狀態,也可以很好的解決。
仿函數和lambda實際上是同一個東西。lambda實際上也是一個class,只不過是編譯期會給它分配一個類型名稱。lambda絕大部分場景是出現在function scope當中,而成為一個local class. 這也是處理仿函數,會比處理普通函數指針略微復雜的地方,因為不同類型的仿函數會有相同的函數簽名。
template <typename Functor, typename Ret, typename ... Args> class function_impl_functor final : public function_impl_base<Ret, Args...> { public:using value_type = Functor;// constructorsfunction_impl_functor(value_type const& f): func_(f) {}function_iimpl_functor(value_type&& f): func_(std::move(f)) {}// override operator callRet operator()(Args... args) override{return func_(std::forward<Args>(args)...);}private:value_type func_; };值得提及的是,這個實現隱藏了一個編譯器已經幫我們解決的問題。仿函數中可能會有non-trivially destructible的對象,所以編譯器會在必要時幫我們合成正確析構functor的代碼,這也包含λ表達式中捕獲的變量(通常是值捕獲的)。
4.2 處理指向成員函數的指針
指向成員函數的指針,與前面三位同僚有著不同的調用語義。參考MCD中的實現,大概如下:
template <typename Caller, typename CallerIndeed, typename Ret, typename ... Args> class function_impl_pmf final : public function_impl_base<Ret, Args...> { public:using value_type = Ret(Caller::*)(Args...);// constructorfunction_impl_pmf(CallerIndeed caller, value_type pmf) : caller_(caller), pmf_(pmf) {// TODO... do some static check for CallerIndeed type here}// override operator callRet operator()(Args... args) override{return (caller_->*pmf_)(std::forward<Args>(args)...);}private:CallerIndeed caller_;value_type pmf_; };這樣的實現方案,是為了考慮繼承的情況,例如我們傳遞了基類的成員函數指針和派生類的指針,當然還有智能指針的情況。然而標準庫并沒有采取這種實現方式,而是需要我們使用std::bind或者套一層λ表達式來讓使用者顯式地確定caller的生命周期,才能夠綁定到一個std::function的對象中。
而筆者,更喜歡把一個指向成員函數的指針,扁平化成一個λ表達式,并多引入caller類型作為第一個參數:
/* Ret(Caller::*)(Args...) => [pmf](Caller* caller, Args ... args) -> Ret { return (caller->*pmf)(std::forward<Args>(args)...); } */4.3 集成
function作為外觀,就通過構造函數的重載來分派到創建三種不同語義的具體實現的創建中,只保存一個基類指針:
template <typename Ret, typename ... Args> class function<Ret(Args...)> { public:template <typename Functor, typename = std::enable_if_t<std::is_invocable_r_v<Ret, Functor, Args...>>>function(Functor&& functor): impl_(new function_impl_functor<std::remove_cv_t<std::remove_reference_t<Functor>>, Ret, Args...>{ std::forward<Functor>(functor) }){}template <typename Caller, typename CallerIndeed>function(Ret(Caller::*pmf)(Args...), CallerIndeed caller): impl_(new function_impl_pmf<Caller, CallerIndeed, Ret, Args...>{ pmf, caller }){}// TODO ... Copy and Move~function(){if(impl_){delete impl_;impl_ = nullptr;}}private:function_impl_base<Ret, Args...>* impl_ = nullptr; };4.4 優化
這個實現簡單粗暴,有兩個很明顯的缺點。
因此,某同x交友社區上出現了不少fast_function的實現。問題1的解決思路,就是進一步抹平語義的鴻溝,把caller和指向成員函數的指針先包成一個functor,再傳遞給function. 實現就不用考慮這種特殊情況了。問題2,如同std::string內部的預分配內存塊的思路一樣,當下的標準庫std::function,folly::Function,當然還有UE4的TFunction都有一個針對小函數對象的內聯內存塊,來盡可能的減少不必要的堆分配。具體的優化實踐,讓我們進入下一節,看看UE4是如何處理的。大家如果有興趣也可以去看看folly::Function的實現,它內部使用了一個小的狀態機,并對函數的const有更強的約束。
5. TFunction in UE4
UE4中有實現比較完備的的泛化仿函數,TFunction. 但是UE4并沒有選擇使用標準庫的std::function,通過閱讀源碼我總結了以下三個原因:
首先TFunction的實現幾乎全部在,UnrealEngine/Engine/Source/Runtime/Core/Public/Templates/Funciton.h中。
template <typename FuncType> class TFunction final : public //..... {};TFunction僅僅只是一個外觀模板,真正的實現都在基類模板UE4Function_Private::TFunctionRefBase當中。外觀只定義了構造函數,移動及拷貝語義和operator boolean. 值得一提的是TFunction的帶模板參數的構造函數:
/*** Constructor which binds a TFunction to any function object.*/ template <typename FunctorType,typename = typename TEnableIf<TAnd<TNot<TIsTFunction<typename TDecay<FunctorType>::Type>>,UE4Function_Private::TFuncCanBindToFunctor<FuncType, FunctorType>>::Value>::Type > TFunction(FunctorType&& InFunc);這個函數的存在是對FunctorTypes做了一個參數約束,與std::is_invocable_r是同樣的功能。首先FuncTypes不能是一個TFunction的實例化類型,因為可能會跟移動構造函數或者拷貝構造函數有語義沖突,導致編譯錯誤;并且不同類型的TFunction實例化類型之間的轉換也是不支持的。其次UE4還檢查了綁定的函數對象的簽名是否跟TFunction定義的簽名兼容。兼容檢查是較為松弛的,并不是簽名形參和返回值類型的一一對應。傳參支持隱式類型轉換和類型退化,返回值也支持隱式類型轉換,滿足這兩個條件就可以將函數對象綁定到TFunction上。這樣做的好處就是可以讓類型不匹配的編譯錯誤,盡早地發生在構造函數這里,而不是在更深層次的實現中。編譯器碰到此類錯誤會dump整個實例化過程,會出現井噴災難。
接下來是UE4Function_Private::TFunctionRefBase模板類:
template <typename StorageType, typename FuncType> struct TFunctionRefBase;template <typename StorageType, typename Ret, typename... ParamTypes> struct TFunctionRefBase<StorageType, Ret (ParamTypes...)> {// ... private:Ret (*Callable)(void*, ParamTypes&...);StorageType Storage;// ... };模板泛型沒有定義,只是一個前向申明,只有當FuncType是一個函數類型時的特化實現。這告訴我們TFunction只接受函數類型的參數。并且TFunctionRefBase是遵循基于策略的模板設計技巧,Policy based designed,把分配策略的細節從該模板類的實現中剝離開。
再來看看TFunction向基類傳遞的所有模板參數的情況:
template <typename FuncType> class TFunction final : public UE4Function_Private::TFunctionRefBase<UE4Function_Private::FFunctionStorage, FuncType > // ....UE4Function_Private::FFunctionStorage是作為TFunction的內存分配策略,它把控著TFunction的小對象內聯優化和堆分配策略的選擇。與之相關的代碼如下:
// In Windows x64 typedef TAlignedBytes<16, 16> FAlignedInlineFunctionType;typedef TInlineAllocator<2> FFunctionAllocatorType;struct FFunctionStorage : public FUniqueFunctionStorage { //... };struct FUniqueFunctionStorage {// ... private:FunctionAllocatorType::ForElementType<FAlignedInlineFunctionType> Allocator; };FFunctionStroage繼承自FUniqueFunctionStorage,主要是為了復用基類的設施,并覆蓋和實現了帶有拷貝語義的Storage策略。而它的基類,顧名思義,是沒有拷貝語義,唯一獨占的Storage策略。最開頭的兩個類型定義,是UE4在win平臺64位下開啟小對象內聯優化的兩個關鍵類型定義。
需要注意的是,本文提及的小對象內聯優化與UE4的USE_SMALL_TFUNCTIONS宏的意義是相反的。它所指明的Small Function是指的sizeof(TFunction<...>)較小的,也就是沒有內聯內存塊函數。開啟這個宏的時候只有堆分配的模式。
- FAlignedInlineFunctionType定義了大小為16bytes,16bytes對齊的一個內存單元
- FFunctionAllocatorType定義了2個內存單元
由此可以推斷FUniqueFunctionStorage的成員變量就定義了2個大小為16bytes并以16bytes對齊的存儲內存塊, 也就是說在此編譯選項下可以存儲的小函數對象的大小,不能超過32bytes. 舉個例子:
void foo() {int temp = 0;TFunction<int()> func_with_inline_memory = [temp]() { return 1; };std::array<int, 9> temp_array = { 0 };TFunction<int()> func_with_heap_allocation = [temp_array]() { return static_cast<int>(sizeof(temp_array)); }; }func_with_inline_memory綁定的lambda函數,僅捕獲了一個int大小的變量,所以它會使用TFunction中內聯的小對象內存塊。而func_with_heap_allocation,捕獲了一個元素個數為9的int數組,大小為36,所以它綁定在TFunction中,被分配在了堆上。
最后需要注意的是,UE4觸發分配行為的代碼,略不太直觀。它使用了user-defined placement new, 參看cppreference的第11至14條。對應的代碼如下:
struct FFunctionStorage {template <typename FunctorType>typename TDecay<FunctorType>::Type* Bind(FunctorType&& InFunc){// ...// call to user-defined placement newOwnedType* NewObj = new (*this) OwnedType(Forward<FunctorType>(InFunc));// ...} };// definition of user-defined placement new operator inline void* operator new(size_t Size, UE4Function_Private::FUniqueFunctionStorage& Storage) {// ... }簡單提及一下TFunctionRefBase的Callable成員,是在綁定的時候賦予TFunctionRefCaller<>::Call,而其內部實現就是類似std::invoke的實現,利用std::index_sequence展開形參tuple的套路。
那么UE4的TFunction的關鍵實現點,都已經介紹完畢了。UE4除了TFunciton還有TFunctionRef和TUniqueFunciton,都有著不同的應用場景。但本質上的不同就是Storage的策略,大家感興趣可以閱讀以下代碼和Test Cases.
6. 小結
本文是介紹UE4異步編程的第一篇。異步模型本質上是一個命令模式的實現。異步模型最重要的兩個關鍵點就是命令和調度。所以本文以第一個要點為線索,從舊時代的設計到現代編程語言設計變遷,討論了其中設計思路和實現細節。并以UE4的TFunction作為一個詳細的案例,對其源碼做了簡析。
命令的實現部分比較簡單易懂,但對于異步模型而言,更重要的是執行命令的調度策略。這個系列后續的篇幅,將會著重討論UE4在其中的取舍和實現細節。
總結
以上是生活随笔為你收集整理的UE4异步编程专题 - TFunction的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 证券公司理财产品安全吗?证券公司理财产品
- 下一篇: 二. 简单的NSIS安装包