原作者:Madokakaroto
原文链接:UE4 异步编程专题 - TFunction
0. 关于这个专题
游戏要给用户良好的体验,都会尽可能的保证 60 帧或者更高的 fps。一帧留给引擎的时间也不过 16 ms的时长,再除去渲染时间,留给引擎时间连 10 ms都不到,能做的事情是极其有限的。同步模式执行耗时的任务,时长不可控,在很多场景下是不能够接受的。因此 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";
/// @note 请求命令 request(...)
request([file_name = std::move(file_name)]()
{
// ... file io
// callback hell 在后续的文章中讨论
});
得益于 C++11 标准的完善,我们在 C++ 中可以把函数对象当做第一类值对象来使用了,而且为我们的设计和抽象提供了强有力的基础设施。
4. 泛化仿函数的实现原理
上一节我曾提到过,我们在 C++ 中可以把函数对象当做第一类值来使用,但是 C++ 也有沉重的历史包袱,所以相比其他语言,在 C++ 中使用函数对象有着 C++ 特色的问题。
我们知道在C++中,有调用语义的类型有:
- 函数(包括静态成员函数)指针(引用)
- 指向成员函数的指针(pointer to member function)
- 仿函数
- λ 表达式
值得提及的是,曾经的 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;
// constructors
function_impl_functor(value_type const& f)
: func_(f) {}
function_iimpl_functor(value_type&& f)
: func_(std::move(f)) {}
// override operator call
Ret 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...);
// constructor
function_impl_pmf(CallerIndeed caller, value_type pmf)
: caller_(caller), pmf_(pmf)
{
// TODO... do some static check for CallerIndeed type here
}
// override operator call
Ret 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 定义了大小为 16 bytes,16 bytes 对齐的一个内存单元
FFunctionAllocatorType 定义了 2 个内存单元
由此可以推断 FUniqueFunctionStorage 的成员变量就定义了 2 个大小为 16 bytes 并以 16 bytes 对齐的存储内存块, 也就是说在此编译选项下可以存储的小函数对象的大小,不能超过 32 bytes. 举个例子:
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 new
OwnedType* 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 在其中的取舍和实现细节。