bgfx 学习笔记(5)- Handle 的作用和分配

782 阅读16分钟
原文链接: zhuanlan.zhihu.com

Handle

使用 bgfx 的 API 创建资源,会返回一个资源 Handle。比如

VertexBufferHandle bgfx::createVertexBuffer
IndexBufferHandle bgfx::createIndexBuffer
ProgramHandle bgfx::createProgram

其中各 Handle 的定义为

struct IndexBufferHandle { uint16_t idx; };
struct VertexBufferHandle { uint16_t idx; };
struct ProgramHandle { uint16_t idx; };

可以看到 Handle 是个结构,包装着一个整数。

handle 中文通常翻译成“句柄”,是个奇怪的翻译。单看中文翻译,很难理解它到底是什么。Handle 在英文中,有操作、处理、控制之类的意义。当作为一个名词,意思是指某个中间媒介,通过这个中间媒介去控制某样东西。

这样说还是抽象,举个具体例子,比如用 door handle 去控制(操作) door,这里的 door handle 是指门把手,通过门把手可以去控制门。但 door handle 并非 door 本身,只是一个中间媒介。

同样,在计算机领域,我们用 file handle 去操作 file, 通过这中间媒介去操作文件,但 file handle 也并非 file 本身。这里的 file handle 就很难翻译了,领悟到意思就行。

bgfx 就是通过 Handle 去操作资源。ProgramHandle 去操作 Program,VertexBufferHandle 去操作 VertexBuffer。

bgfx 的资源 Handle 其实只是一个 16 位的整数索引,内部维护了一个资源数组,通过索引去找到资源。注意,在其它场合,Handle 不一定是索引,不要简单将 Handle 理解成索引。Handle 可以有各种实现方式,比如分配 16 位的索引,再用 8 位密码将 16 位索引加密。之后将 4 位类型、4 位权限、8 位密码、16 位加密索引打包成一个 32 位的整数作为 Handle。这时说这个 Handle 是个索引就有点不适当了,用 Handle 如何操作真正的资源,是实现的细节。Handle 通常被实现为整数。

bgfx 的 Handle 只是个整数索引,为什么要用一个结构来包装这个索引呢?

struct VertexBufferHandle { uint16_t idx; };
struct ProgramHandle { uint16_t idx; };

这是 C/C++ 的一个小技巧,让不同资源的 Handle 有不同的类型,防止手误传错值了。

比如

IndexBufferHandle ibh = bgfx::createIndexBuffer
VertexBufferHandle vbh = bgfx::createVertexBuffer
xxxxxx
bgfx::setIndexBuffer(vbh);  // Compile Error

上面例子中,手误将 ibh 错写成 vbh。但因为 bgfx::setIndexBuffer 需要传入 IndexBufferHandle 类型,在编译的时候就会被发现。

但假如不用 struct 包装着索引,直接用整数。写成

uint16_t ibh = bgfx::createIndexBuffer
uint16_t vbh = bgfx::createVertexBuffer
xxxxxx
bgfx::setIndexBuffer(vbh);

这时不会编译报错,就隐藏了一个 bug 了。假如 ibh 和 vbh 通常相等,偶然不等,这个 bug 就会隐藏得很深,难以发现。

OpenGL 的接口设计中,所有资源都返回一个 GLuint 的整数 id, 这个 id 的作用跟 Handle 是一样的,只是叫法不同。OpenGL 使用裸露的整数,有时就会误传不同类型 id。

初次接触到 Handle(或者 id), 很多人都有个迷惑,为什么要用 Handle,而不直接用指针呢?

  1. 指针作用太强,可做的事情太多。可做的事情越多,就会越危险。接口设计中,功能刚刚好就够了,并非越多权限越好的,权限越多就越危险。
  2. Handle 只是个整数,里面实现可以隐藏起来,假如直接暴露了指针,也就暴露了指针类型,用户就会看到更多的细节。
  3. 所有资源在内部管理,通过 Handle 作为中间层,可以有效判断 Handle 是否合法,而防止了野指针的情况。
  4. Handle 只是个整数,所有的语言都有整数这种类型,但并非所有语言都有指针。接口只出现 Handle, 方便将实现绑定到各种语言。

还可以列举更多的理由。具体到 bgfx 的实现中,同一个 Handle 作为索引,可以访问多个不同的数组,而指针只能指向一个对象。bgfx 可以绑定到多个语言,Handle 更方便。bgfx 是 C 风格的 API。使用 Handle 而非指针,结构都是平坦类型,可以直接用 memcpy 拷贝。通常 C++ 写的代码,内部实现可以用各种 C++ 特性,但提供给外部的接口,最好用 C 的风格,容易跨模块和语言。bgfx 的接口有 C99 和 C++ 两套,但它的 C++ 接口,也是 C 风格的。

资源创建和销毁过程

在图形应用中,要避免反复释放内存。通常的策略,是分配一整块内存,之后复用。一来分配速度快,二来内存块连在一起,访问速度也会快些(内存局部性)。比如不知道数组具体有项,平常应用会使用 std::vector,而图形的应用通常会估算一个最大值,直接分配好。类似下面这种风格。

#define MAX_VALUE_COUNT 512
ValueT values[MAX_VALUE_COUNT];
size_t valueCount;

以下拿 Program 为例,分析资源的创建。Program 的最大值为 BGFX_CONFIG_MAX_PROGRAMS,定义在 cofig.h 中。

bgfx_p.h 中的 Context 类有这两个成员。

bx::HandleAllocT<BGFX_CONFIG_MAX_PROGRAMS> m_programHandle;
ProgramRef  m_programRef[BGFX_CONFIG_MAX_PROGRAMS];

Context 使用 m_programHandle 来分配 Handle,使用 m_programRef 来维护 ProgramHandle 和 ShaderHandle 间的引用关系,已经维护引用计数。

而 Metal 对应的渲染后端类。RendererContextMtl, 有这成员变量

ProgramMtl m_program[BGFX_CONFIG_MAX_PROGRAMS];

其中 Context 负责分配和管理 Handle, 维护 Handle 之间的引用关系,是所有渲染平台共用的。而 RendererContextMtl 才负责创建出真正的渲染资源。其它资源也有类似 Program 的数组。

创建 Program 的流程为

用户线程

  1. 用户调用 bgfx::createProgram 接口。
  2. bgfx 使用 m_programHandle 分配一个还没有使用过的 handle。
  3. bgfx 通过 handle 作为索引,访问数组 m_programRef,通过 ProgramRef 结构维护好引用计数,以及 program 和 shader 的引用关系。
  4. CommandBuffer::CreateProgram 命令压入命令队列,两个 shader handle 和新分配出来的 program handle 作为命令的参数。
  5. 将 handle 返回给用户,用户以后可以用这个 ProgramHandle 操作 Program。

注意整个用户线程,都只在分配和维护 Handle。还没有真正创建出渲染平台的资源,并且没有从堆动态分配内存,因此速度十分快。

渲染线程

  • 从命令队列中获取 CommandBuffer::CreateProgram 命令,以及三个命令参数,两个 shader handle 和一个 program handle。调用 RendererContextI 的 createProgram 接口,因为是 Metal 平台,也就调用了 RendererContextMtl 的接口。
  • 以 shader handle 作为索引, 从 m_shaders 数组中获取到对应的 ShaderMtl。
  • 以 program handle 作为索引,从 m_program 获取到对应的 ProgramMtl。
  • 以两个 ShaderMtl 为参数,调用 ProgramMtl 的 create 接口。

销毁 Program 的流程也类似。

用户线程

  1. 用户调用使用 ProgramHandle 调用 bgfx::destroyProgram 接口。
  2. 以 handle 作为索引,访问数组 m_programRef,减少引用计数。
  3. 当引用计数为 0 时,将 CommandBuffer::DestroyProgram 放到命令队列,并回收 program handle。

之后渲染线程从命令队列中取出 CommandBuffer::DestroyProgram 命令,再调用 RendererContextI 的 destroyProgram 接口,才真正删除平台相关的渲染资源。

回收 program handle 时候有个细节,并不会立即调用 m_programHandle.free 进行释放。分配 handle 时,会立即调用 m_programHandle.alloc, 而回收 handle 时将只使用 m_freeProgram 将 handle 记录下来,等到下一帧(调用 swap)再真正使用 m_programHandle.free 进行释放。

为什么要延迟到下一帧才真正释放 Handle 呢?

bgfx 有用户线程和渲染线程,用户线程放命令,渲染线程取命令。而命令通常会以 handle 作为参数。渲染线程可能用到了用户想释放的 handle。

比如用户线程依次调用

ProgramHandle programHandle0 = bgfx::createProgram(xxx, xxx);
bgfx::submit(0, programHandle0);
xxxx
bgfx::destroyProgram(programHandle0);

ProgramHandle programHandle1 = bgfx::createProgram(xxx, xxx);
bgfx::submit(0, programHandle1);
xxx
bgfx::destroyProgram(programHandle1);

用户创建了 programHandle0(假设 idx = 1),之后提交,再之后释放。bgfx::submit 调用后,其实并还没有真正渲染,只是将必要信息记录下来,programHandle0 (idx = 1) 是必要信息。

假如用户调用 bgfx::destroyProgram 时,立即触发 m_programHandle.free,programHandle0 (idx = 1) 就马上释放了。这时再创建出 bgfx::createProgram 时,之前的 idx = 1 就可以重新被分配,于是就得到 programHandle1 (idx = 1)。

这样 programHandle0(idx = 1) 和 programHandle1(idx = 1)就冲突了。

渲染线程还会使用 handle,不能马上释放,需要等下一帧,这时渲染已经完成。释放 handle 就安全了。

HandleAlloc 代码分析

接下我们研究 Handle 分配和回收的算法,Handle 本质上只是一个整数,问题就是如何每次分配不同的整数,假如整数被回收后可以被再次使用。这实际在维护一个集合。最开始集合为空,有三个最基本的函数。

  • alloc (分配),就是生成一个不在集合中的整数,返回给用户,并将这个整数添加到集合中。
  • free (释放),就是检查要释放的整数是否在集合中,假如在集合中,就从集合中删除。
  • isValid (是否合法),就是判断这个整数是否在集合中。

alloc 返回的整数不一定非要从小到大,只要保证分配出去的整数不重复就行。

核心代码是 bg::HandleAlloc 类的 alloc、free、isValid 函数。

inline uint16_t HandleAlloc::alloc()
{
    if (m_numHandles < m_maxHandles)
    {
        uint16_t index = m_numHandles;
        ++m_numHandles;

        uint16_t* dense  = getDensePtr();
        uint16_t  handle = dense[index];
        uint16_t* sparse = getSparsePtr();
        sparse[handle] = index;
        return handle;
    }

    return kInvalidHandle;
}

inline void HandleAlloc::free(uint16_t _handle)
{
    uint16_t* dense  = getDensePtr();
    uint16_t* sparse = getSparsePtr();
    uint16_t index = sparse[_handle];
    --m_numHandles;
    uint16_t temp = dense[m_numHandles];
    dense[m_numHandles] = _handle;
    sparse[temp] = index;
    dense[index] = temp;
}

inline bool HandleAlloc::isValid(uint16_t _handle) const
{
	uint16_t* dense  = getDensePtr();
	uint16_t* sparse = getSparsePtr();
	uint16_t  index  = sparse[_handle];

	return index < m_numHandles
		&& dense[index] == _handle
		;
}

inline uint16_t* HandleAlloc::getDensePtr() const
{
    uint8_t* ptr = (uint8_t*)reinterpret_cast<const uint8_t*>(this);
    return (uint16_t*)&ptr[sizeof(HandleAlloc)];
}

inline uint16_t* HandleAlloc::getSparsePtr() const
{
    return &getDensePtr()[m_maxHandles];
}

dense,sparse 两个指针,分别指向一个数组。名字暂时别管,之后讨论。

HandleAlloc 的构造函数是私有,实际中并不会直接使用 HandleAlloc,而会使用 HandleAllocT。

class HandleAlloc {
    uint16_t m_numHandles;
    uint16_t m_maxHandles;
};

template <uint16_t MaxHandlesT>
class HandleAllocT : public HandleAlloc {
    uint16_t m_padding[2*MaxHandlesT];
};

HandleAllocT 继承 HandleAlloc, 有额外的内存,其内部布局为

上图左边的 m_padding 其实被拆分成如上图右边 dense、sparse 两个数组。getDensePtr() 的实现中,this 指针加上偏移 sizeof(HandleAlloc),就会指向 dense 数组。getSparsePtr() 指向 sparse 数组。

为什么要将 HandleAllocT 实现得这样奇怪呢?

首先,假如不在 HandleAllocT 中附加额外内存,而在 HandleAlloc 中使用 malloc 来动态分配内存,这当然可以,但速度就慢了。前面已经说过尽可能一次分配大块内存,速度会快些。将额外内存粘在结构后面,一次分配,是 C/C++ 的常见技巧。

那也可以将 HandleAllocT 实现成这样啊。

template <uint16_t MaxHandlesT>
class HandleAllocT {
public:
    uint16_t alloc();
    void free(uint16_t _handle);

private:
    uint16_t m_numHandles;
    uint16_t m_maxHandles;
    uint16_t m_dense[MaxHandlesT];
    uint16_t sparse[MaxHandlesT];
};

HandleAlloc 的实现直接搬到 HandleAllocT 模板类中,就只有一个类了,这样实现不是更清晰吗?

这样实现当然也可以,但 HandleAllocT 是模板类,C++ 中的模板类,每次实例化,会产生两份代码。比如

HandleAllocT<1024>
HandleAllocT<512>

就被实例化了两次,于是 alloc 和 free 的代码也根据 MaxHandlesT 的不同,产生了两份几乎相同的代码。这就引起了模板类的代码膨胀。

而拆分成 HandleAllocT 和 HandleAlloc 后,核心代码在 HandleAlloc 中,它并非模板类。核心代码编译后也只会有一份。这样实现虽然拐了个弯,但避免了代码膨胀。

解决了 dense,sparse 的内存分配问题,现在可以考虑数组内容本身了。先来看名字,dense 是稠密的意思,sparse 是稀疏的意思,是对反义词。最开始我将 dense 理解成装的元素多一些,sparse 装的元素少一些,但这样理解有点牵强。我认为 dense,sparse 这两个名字,在这里本身的意思根本就不重要,只是它们是一对反义词,就用于表示这两个数组是一对,dense 中放着 sparse 的索引,sparse 中放着 dense 的索引,两个数组是不可分离的一对。将 dense,sparse,改名为 from、to 也是可以的。只是表示它们是相互关联的一对数组。

初始化后,两个数组装的值如下。其中 x 表示没有被初始化,是上次内存残留的随机值。


连续调用 4 次 alloc, 依次分配了 0, 1, 2, 3,数值如下


调用 free(1), 释放了 1,两个数组内容如下


dense 数组放着所有的 Handle, numHandles 将数组划分成两部分,前面部分 [0, numHandles) 放着已经分配出去的 Handle, 后面部分 [numHandles, maxHandles) 放着还没有分配出去的 Handle。

设想一下,假如只有 dense 数组,而没有 sparse 数组,怎么写 alloc、free、isValid 三个基本函数呢?

首先 alloc 很简单,既然 [numHandles, maxHandles) 放着还没有被分配出去的 Handle, 只需要将 numHandles 指向的 Handle 分配出去,将 numHandles 向后移动一格。效率是 O(1)。

而 free 要麻烦一些,需要扫描 [0, numHandles) 中的 Handle, 判断是否在里面,找到对应的索引,将其释放。而释放 Handle,相当于将 Handle 从 [0, numHandles) 中移除,已分配的 Handle 是个集合,并不用保持原始位置,只需要将扫描的 Handle 最后位置的 Handle 交换位置就行,效率是 O(1)。因此总时间花在扫描查找索引上,扫描的效率是 O(numHandles), 已分配出去的 Handle 越多,扫描就越慢。

而 isValid 也需要扫描已分配的 Handle,效率跟 free 一样。

只使用 dense 数组自身,也可以分配和释放 Handle,但已分配出去的 Handle 越多,释放就越慢。

我们扫描已分配的 Handle, 是为了找到它在 dense 的索引。假如我们将 Handle 在 dense 数组的索引记录下来,就不用扫描了。假设我们有个 Map 映射,记录下索引。依次分配 0, 1, 2, 3 后,Map 中就有记录

Handle 0: 在 索引 0
Handle 1: 在 索引 1
Handle 2: 在 索引 2
Handle 3: 在 索引 3

释放 Handle 1 后,根据我们释放算法,扫描到的 Handle 1 的位置跟最后 Handle 3 位置交换,于是 Handle 3 索引为 1, Map 中就修改记录,变成

Handle 0: 在 索引 0
Handle 2: 在 索引 2
Handle 3: 在 索引 1

将上面的啰嗦写法,简单记成

0 -> 0
2 -> 2
3 -> 1

我们的 Map, key 为 Handle,不可能大于 maxHandles。于是我们就可以将这个 Map 拉平,变成个数组,数组索引就是 Key。

这个拉平后,变成数组的 Map, 实际上就是那个 sparse 的数组。

  • sparse 数组记录了 Handle 在 dense 的位置(索引),就避免了扫描。
  • dense 数组记录了 Handle 的值,sparse 使用 Handle 作为 key。

两者结合起来,就有了这个对称的写法

  • sparse[handle] = index
  • dense[index] = handle

通过将内存翻倍,增加了 sparse 数组,alloc、free、isValid 都可以在常数 O(1) 完成。

其它 Handle 分配

bx::HandleAlloc 可以在 O(1) 的情况实现 alloc, free, isValid 三个基本函数,很聪明的做法。在这种图形应用很适当,时间固定,每一帧平稳,不会忽快忽慢。但不代表在所以情况下都是最好的。

比如它花的内存,是两倍 MaxHandlesT ,MaxHandlesT 越大,花的内存越多。另外它会复用 handle,比如分配 0、1、2、3、后,释放了 3,再次分配还会重新分配 3。这种 handle 复用其实有点危险的。比如某个 class A,有个成员变量 m_handleA = 3, 调用 free 后应该立即将 m_handleA 设置成 kInvalidHandle,但却忘记了,因而 m_handleA 一直等于 3。另外的 class B,要分配 handle,因为 3 可以复用,于是 m_handleB = 3。这样 m_handleA 和 m_handleB 实际都可以操作同一个资源。用户在 class A 中无意中用 m_handleA 修改了资源状态,也检查不出来,因为 m_handleA 对于系统来说还是合法的。假如 class A 和 class B 看起来毫无关联,相距很远。class A 的代码写错了,却导致 class B 看起来有 bug。这种 bug 就比较隐蔽了。

因此某些场合下,为了更安全,我们有时希望 handle 一直递增,尽量不复用,或者要隔很长时间才会回绕再次被复用。这样 m_handleA = 3 一旦被释放,短时间内使用 handle = 3 去访问系统,系统也可以判断出 handle 不合法,抛个 assert 出来,隐患就立即被发现了。这时就不能用简单数组,而需要用个 hashtable 去记录下已分配的 handle。

另外有时我们会希望 handle = 0 不合法,因为我们经常会使用 memset 去将结构清 0。0 通常作为默认值。对于 handle 来说,默认值应该是 kInvalidHandle 非法。因此 kInvalidHandle 应该等于 0。不然就很容易结构清 0 之后,忘记将 handle 设置成 kInvalidHandle。假如 handle = 0 非法,handle 的分配就应该从 1 开始,计算索引的时候麻烦一些写成 index = handle - 1。

有时我们只是想分配一个不重复的 handle(ID) 作为标识,并不用判断 handle 是否非法。比如用同一个 socket 去发送网络包,连续发送 req0, req1, req2, 服务器将每个请求分包异步处理,之后发回三个响应 rep0、rep2、rep1。因为是异步处理,响应包到底的顺序是不固定的。这样就需要某种方式,将请求包和响应包匹配起来。最简单的做法是每次请求,都分配一个整数 ID,发给服务器,响应包中带回这个 ID。通过整数 ID, 请求和响应就可以匹配。这种情况下,需要保证每次 ID 都不重复,但只用于匹配,不需要判断是否非法。这时最简单的实现,就是有个整数,一直递增就行,没有必要记录已经分配的 ID。

Handle 分配还有其它实现方式,比如有两个链表,alloclist 记录下已分配的 ID。freelist 记录下已释放的 ID。

或者建立一个 bool 数组,初始为 false,代表没有被分配。当需要分配的时候,就去扫描非 false 的索引,这个索引就是 Handle。之后将位置设置为 true,表示已经被分配。判断是否合法,就是判断数组中对应的值是否为 true。

bool 数组的变种就是使用 bitset(位数组),因为每个 byte 有 8 bit, 内存可以减少一些。分配 handle, 就是查找为 bit = 0 的位置。这时拿 (uint64_t)-1 去做位运行,可以一次判断 64 位。之后去算这个 uint64_t 中第一个 bit = 0 的位置,这种方式应该有些位运算黑魔法的。