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