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 优化

这个实现简单粗暴,有两个很明显的缺点。

  1. 调用operator call的时候,是一个虚函数调用,有不必要的运行期开销;
  2. 对很小的函数对象,例如函数指针,使用了堆分配。

因此,某同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,通过阅读源码我总结了以下三个原因:

  1. 有定制TFunction内存分配策略的需求,并且实现了小函数对象的内联优化;
  2. UE4有复杂的编译选项,并希望在不同的编译选项中对abi有完全的把控,使用标准库无法做到;
  3. 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在其中的取舍和实现细节。

ue4 classuobject没有成员beginplay_UE4异步编程专题 - TFunction相关推荐

  1. UE4异步编程专题 - TFunction

    0. 关于这个专题 游戏要给用户良好的体验,都会尽可能的保证60帧或者更高的fps.一帧留给引擎的时间也不过16ms的时长,再除去渲染时间,留给引擎时间连10ms都不到,能做的事情是极其有限的.同步模 ...

  2. ue4 classuobject没有成员beginplay_UE4中蓝图函数的泛型

    在UE4中,用UFUNCTION(BlueprintCallable) 可以将函数导出给蓝图使用 例如这两个函数 对应的蓝图调用节点 常规的蓝图函数调用过程 默认的就是由UHT来生成Thunk调用. ...

  3. UE4异步编程专题 - 线程池FQueuedThreadPool

    1. FQueuedThreadPool & IQueuedWork FQueuedThreadPool是UE4中抽象出的线程池.线程池由若干个Worker线程,和一个同步队列构成.UE4把同 ...

  4. UE4异步编程专题 - 多线程

    专题的第二篇,我们聊聊UE4中的多线程的基础设施.UE4中最基础的模型就是FRunnable和FRunnableThread,FRunnable抽象出一个可以执行在线程上的对象,而FRunnableT ...

  5. ue4 classuobject没有成员beginplay_给社团新成员准备的教学大纲

    ## 授课内容(概括) 安卓app开发 静态网站搭建 小程序开发 游戏开发 授课内容(略详) 安卓开发 使用工具:AndroidStdio  官网https://developer.android.g ...

  6. ue4 classuobject没有成员beginplay_关于UE4使用的一些想法(一)

    今天,在单步调试程序时,对照引擎,突然有了些想法.以前没注意到,是因为以前都是用引擎的dll,并没有这么调试过. 1,每个引擎都是有自己的初始化和帧循环,使用引擎的过程,就是相当于把引擎当做工厂,自己 ...

  7. 【C++】多线程与异步编程【四】

    文章目录 [C++]多线程与异步编程[四] 0.三问 1.什么是异步编程? 1.1同步与异步 1.2 **阻塞与非阻塞** 2.如何使用异步编程 2.1 使用全局变量与条件变量传递结果 实例1: 2. ...

  8. 并发编程专题——第一章(深入理解java内存模型)

    说到并发编程,其实有时候觉得,开发中真遇到这些所谓的并发编程,场景多吗,这应该是很多互联网的在职人员,一直在考虑的事情,也一直很想问,但是又不敢问,想学习的同时,网上这些讲的又是乱七八糟,那么本章开始 ...

  9. java 如何只暴露接口_Java并发异步编程,原来十个接口的活现在只需要一个接口就搞定...

    什么?对你没有听错,也没有看错 ..多线程并发执行任务,取结果归集~~ 不再忧愁-. 引言 先来看一些APP的获取数据,诸如此类,一个页面获取N多个,多达10个左右的一个用户行为数据,比如:点赞数,发 ...

  10. .N“.NET研究”ET中的异步编程(二)- 传统的异步编程

    在上一篇文章中,我们从构建响应灵敏的界面以及构建高可伸缩性的服务应用来讨论我们为什么需要异步编程,异步编程能给我们带来哪些好处.那么知道了好处,我们就开始吧,但是在异步编程上海徐汇企业网站制作这个方面 ...

最新文章

  1. grep -v、-e、-E
  2. 什么时候用activity什么时候用fragment
  3. DPDK pci设备初始化(十七)
  4. ORA-600(qerltcInsertSelectRop_bad_state)错误
  5. 行业观察丨激荡二十年——货代软件1999—2019
  6. MySQL读写分离中间件解决
  7. CUDA学习笔记之 CUDA存储器模型
  8. 二三星缩水软件手机版_还在抱怨三星手机不好用?用这些软件立马解决
  9. 【讨论】从吉日的一段话说起+寻找WinForm架构的最佳实践
  10. html 修改按回退键的url,location.hash保存页面状态的技巧
  11. mysql外建名是随机的吗_创建角色随机名字(mysql抽取随机记录)和mysql游标的使用_MySQL...
  12. vue设置isactive_Vue 编码风格指南!
  13. couchbase java 手册_couchBase在java中使用的基本知识
  14. max-min 不等式(minimax inequality)
  15. 计算机专业在线作图工具
  16. 安卓动画入门教程 Animation in Android(1)
  17. [阿毛]Ubuntu 16安装CH340串口驱动
  18. dataGrid使用deleteRow删除数据时会出现 ‘id‘ of undefined
  19. 中文·软件工程类·业务流程图、E-R图和IPO图·实践笔记
  20. 越来越快的jsRuntime——Bun

热门文章

  1. 打开带图片的EXCEL文档反应慢 案例解析
  2. netmon中解析非1433端口的TDS协议
  3. weblogic too many open files 问题解决集锦
  4. 5.Prometheus 监控技术与实践 --- PromQL
  5. 5.微服务:从设计到部署 --- 事件驱动数据管理
  6. 4. Linux 高性能服务器编程 --- TCP/IP 通信
  7. 20.经典抽象数据类型
  8. css3中的border-image用法
  9. 反射类 Method类的使用
  10. 记一次oracle安装错误:INFO: //usr/lib64/libstdc++.so.5: undefined reference to `memcpy@GLIBC_2.14'...