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 优化
这个实现简单粗暴,有两个很明显的缺点。
- 调用operator call的时候,是一个虚函数调用,有不必要的运行期开销;
- 对很小的函数对象,例如函数指针,使用了堆分配。
因此,某同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内存分配策略的需求,并且实现了小函数对象的内联优化;
- UE4有复杂的编译选项,并希望在不同的编译选项中对abi有完全的把控,使用标准库无法做到;
- UE4对TFunction有携带Debug信息的需求。
首先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在其中的取舍和实现细节。