SmallJobFunction
实现一个简单的 Function Wrapper, 让传给 Job System 的 Callable 在 Runtime 打包
主要解决问题
我们在使用 Job System 的时候, 大概率会这样调 ——
jobs.Schedule("Physics", [] {
UpdatePhysics();
});
jobs.Schedule("Load", [path] {
LoadAsset(path);
});
jobs.Schedule("Something", [ptr = std::move(ptr)] {
ptr->Run();
});用 Lmabda 很方便,但是她们都是不同的类型, 所以比较不容易进行在 Runtime 存储, 或许确乎是可以使用std::function<void()>这个强大的工具来进行封装
不过这会遇到一些问题 ——
- 它要求封装的 callable 是可复制的
- 它的实现通常有 SBO,但超出实现提供的小对象缓冲区后,可能产生 heap allocation
所以此处使用了一个 128-Byte 并且 Move-Only 的一个数据结构
而仅允许移动可以解决两件事情 ——
- 捕获仅允许移动的 Callable (比如带
unique_ptr的 Lambda) - 让资源符合「调用者 -> JobDesc -> JobStorage -> JobSlot」 的移动,并且不是乱复制,而是每一次的转交都是转交所有权
核心设计
实际上里面打包的成员变量只有 ——
alignas(std::max_align_t)
std::byte m_storage[InlineSize]{};
void (*m_invoke)(void*) = nullptr;
void (*m_move)(void*, void*) = nullptr;
void (*m_destroy)(void*) = nullptr;
static constexpr size_t InlineSize = 128;(超级简化后的手写 vTable)
对象存储
首先这个 m_storage 是为了在传入 Callable 的时候进行原地构造然后存储 ——
using Stored = std::remove_cvref_t<Function>;
new (m_storage) Stored(std::forward<Function>(function));同时在构造前的时候进行检查 ——
static_assert(sizeof(Stored) <= InlineSize, "Job callable exceeds SmallJobFunction inline capacity");
static_assert(alignof(Stored) <= alignof(std::max_align_t), "Job callable requires unsupported alignment");
static_assert(std::is_nothrow_move_constructible_v<Stored>, "Job callable must be nothrow move constructible");分别保证了 ——
- Callable <= 128 bytes
- storage alignment 足够
- Callable 必须 nothrow move constructible
其中第三点比较有意思 —— 因为在自身执行赋值和移动构造的时候声明了 noexcept, 所以如果传入类型的构造会产生 exception 则和当前的声明冲突, 不能保证之后可以安全 move. 所以在此处也进行一个断言
函数指针
先看 m_invoke, 最重要的类型擦除就藏在此处 ——
m_invoke = [](void* storage) {
(*static_cast<Stored*>(storage))();
};拆解一下, m_invoke 是记录一个无返回值的,传入一个地址的函数指针
然后看 lambda 内部 —— 传入一个地址,然后转化成 Stored*类型,再解引用进行 Callable 的调用. 比较妙的一点是 Stored 类型只在构造的时候会被我们接收,所以只能在此时进行记录,就是使用一个这样的Lambda 进行记录. 最后在之后调用的时候就不需要考虑 Callable 的具体类型, (类型擦除), 而是直接使用 m_invoke 进行调用即可( 而且 Callable 的地址也被我们的 m_storage 所记录, 并且存在此处, 于是仅 m_invoke(m_storage) 就可以完成对传进来的 callable 进行调用, 不赖)
(btw, 无捕获 lambda 可以隐式转换为对应的普通函数指针, 所以可以被当前这个函数指针记录)
接着看 m_destroy ——
m_destroy = [](void* storage) {
static_cast<Stored*>(storage)->~Stored();
};之所以需要手动实现一个 Destroy 来调用析构函数, 是因为在编译的时候,编译器只知道我们有一个std::byte类型的数组,但是并不知道其中存了一个 Callable, 所以在编译器实现析构的时候大概率是 default, 从而仅析构掉了std::byte这个东西,但是不会执行 Callable 的析构, 这可能会导致内存泄露的问题 —— 比如 Callable 中声明了一个 std::unique_ptr<int> p,那么 p 所指向的堆区内存就还会存在, 而不会被释放. 这很坏. 不过更多可能是生命周期管理的问题吧 —— 这个通过 placement new 创建的对象因为不会执行自己的析构逻辑, 从而破坏 RAII 语义
而还是我们仅在构造函数的时候会知道 Stored 的类型,从而可以访问到其析构函数, 于是便在此时记录函数指针, 在 SmallJobFunction 析构的时候手动先执行一下 (RAII封装)
Move
手动实现了 Move ——
m_move = [](void* source, void* destination)
{
new (destination) Stored(std::move(*static_cast<Stored*>(source)));
static_cast<Stored*>(source)->~Stored();
};
void MoveFrom(SmallJobFunction&& other) noexcept
{
m_invoke = other.m_invoke;
m_move = other.m_move;
m_destroy = other.m_destroy;
if (m_move)
m_move(other.m_storage, m_storage);
other.m_invoke = nullptr;
other.m_move = nullptr;
other.m_destroy = nullptr;
}分开了说, 先看 m_move 这个函数指针, 它负责把 source 为首地址记录的内存块中的 Callable 使用移动构造, 存储到 destination 中, 然后再析构掉 source
值得注意的是, 此处移动的不是整个 SmallJobFunction 对象,而仅仅是其中被类型擦除掉的 Callable 对象.
此处也是借助构造的时候可以知道 Stored 类型, 这样在 Runtime 的时候就可以直接借助函数指针进行操作.
接着在真实的使用当中,出发移动构造的时候 ——
SmallJobFunction(SmallJobFunction&& other) noexcept
{
MoveFrom(std::move(other));
}所以此时我们继续看 MoveFrom 函数 —— 它的职责就是把整个 SmallJobFunction other 的内容转移到当前 SmallJobFunction
前面赋值和调用 move 比较容易懂, 后面把 other 的三个指针置空的作用 —— 生命周期管理!
重点说下 other.m_destroy, 如果不把它重置掉, 那么在 other 结束其声明周期的时候, 会调用自己的析构函数执行一次 m_destroy(m_storage);, 而 m_move 已经显式析构了 other.m_storage 中原本的 Stored 对象。如果还保留 other.m_destroy,那么 other 后续析构时会再次把同一块 storage 当成一个活着的 Stored 并执行析构,造成 double destruction 和 UB
这样的设计十分甚至九分的符合移动语义下的移交生命周期管理.
模板和引用转发
template <typename Function>
requires(
!std::same_as<
std::remove_cvref_t<Function>,
SmallJobFunction
>
)
explicit SmallJobFunction(Function&& function)先看模板 —— 简单来说, 我们需要传入一个类型, 把 Function 顶层的 const/volatile 和引用修饰去掉以后如果得到的类型是 SmallJobFunction 本身, 就禁止这个模板构造函数参与匹配
引用转发就比较常见了, 这样左右值都可以作为参数传递, 然后具体的转发在上文有写到(
不过依旧提供例子 ——
auto callable = [] {};
SmallJobFunction a(callable);
SmallJobFunction b([] {});