4

一个 VLA (可变长度数组)的实现

 1 year ago
source link: https://blog.codingnow.com/2022/06/vla.html
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

云风的 BLOG

思绪来得快去得也快,偶尔会在这里停留

« RogueLike 原型开发工具 | 返回首页 | 用邻接表实现无向图 »

一个 VLA (可变长度数组)的实现

VLA (可变长度数组) 是 C 语言在 C99 之后加入的一个很方便的语言特性,但是 MSVC 已经明确不支持 VLA 了。而且 Linux 的内核代码中曾经使用过 VLA ,而现在已经移除了 VLA 。看起来,VLA 带来的安全问题比它的便利性要多。

但是,日常用 C 语言做开发时,经常还是需要变长数组的。既然直接用 C 语言的 VLA 有诸多问题,那么还是需要额外实现一个比较好。C 没有 C++ 那样的模板支持,一般的通用 VLA 实现很难做到类型安全。即使用 C++ ,STL 中的 vector ,这个最常用的 VLA 实现,也不总是切合应用场景的。比如,std::vector 它的数据一般还是分配在堆上,而不是栈上。相比原生创建在栈上的数组,它可能性能有影响且有可能制造更多的堆内存碎片。

我认为一个通用的 VLA 库,应该做到:

  1. 强类型支持,且不需要每次调用相关 API 时都指定数据类型。
  2. 当我们在栈上使用 VLA 时,应该尽量接近原生数组的性能,尽可能的避免从堆上分配内存。
  3. VLA 可以方便的在函数间传递引用。
  4. VLA 的引用可以持久保持。
  5. 访问 VLA 的数据可以被 inline ,尽可能的避免额外的函数调用。

由于我的 C 代码大量的和 Lua 交互,如果 VLA 可以利用 Lua 来管理内存,而不直接使用 C 的内存堆,会是一个不错的加分项。这样、在 Lua 函数中临时使用 VLA 时,小块的内存便可直接在 C Stack 上分配;大块内存可以利用 Lua 的临时 userdata ,然后交给 Lua 的 GC 回收。我们在退出函数调用时,就不必显式的销毁 VLA 对象。

之前我写过好几版 VLA 库都不太满意。最近找到了一些技巧,重新实现了一版,以上的需求基本都满足了。

第一个棘手的问题是,C 语言如何做到通用且类型安全的 VLA ?我是这样解决的:

对于一个 VLA 对象,其实分两个部分,其一是用于数据访问的指针,我称之为访问器,其二是 VLA 本身的数据,包括实际数据和元数据。元数据一般有数组的大小、容量等,实际数据一般储存在连续内存空间中。

访问器本身需要契合 VLA 内部数据的类型。而 C 语言在泛型支持方面很弱,传统的方法是用 void * 来指代数据区,这就导致了很多 VLA 实现的 API 难以使用。

我的 VLA 实现引入了两个概念。对于 VLA 对象本身,我用一个抽象的 vla_handle_t 类型来保持引用,而访问器则选用 raw 指针,这个指针的类型直接是内部数据的类型。因为访问器本身可以以非常低廉的成本从 handle 构造出来,所以它并不需要持久保存。这就为宏技巧提供了空间。

所以,最终的 API 就是 vla_using(name, type, handle) 。这是一个宏,它的作用是在栈上创建出一个名为 name 的访问器,并通过 handle 关联相关的 VLA 对象。下面是一个简化的示意版本(实际因为需要实现更多功能,比这个复杂的多):

#define vla_using(name, type, handle) \
    type * name; \
    vla_handle_t * name##_ref_ = &handle; \
    init_vla_accessor((void **)&name, name##_ref_);

在使用这个 api 时,栈实际多了两个变量。一个是用户指定名字的访问器,它就是一个原生指针,所以在访问数据时,和原生数组没有任何区别;同时,还在栈上记录了 VLA 对象本身的引用。当我们想对 VLA 对象操作时,访问它就可以了。比如,求 VLA size 的 API 也是一个宏:

#define vla_size(name) vla_size_(name##_ref_)

这个宏把指针访问器的操作转发到了对应的 VLA 对象 handle 上。

第二个问题是,如何兼顾堆和栈的内存使用。

如果只有一个 VLA 数据结构,当我们在栈上使用的时候,倾向于在数据结构中预留一块几百字节的临时空间。如果数据不超过这个空间,就不必申请堆内存。函数调用结束后,这些栈空间就立刻回收了。但如果我们需要持久引用 VLA 对象,这个预留的额外空间就显得太浪费了。当然可以显式的做两套实现,由使用的人根据场合分别用对应的方案。但还是需要把它们两者统一起来,这样才可以有一致的接口,当模块不关心细节时,可以一致的使用。

所以我选择用一个 handle 来表示不同实现的 VLA 。

第三个问题和 Lua 有关。在栈上的临时内存空间如果超出,我们就需要申请额外的内存维持更大体积的 VLA 。一般的实现中,这些额外的内存会从 C 的内存堆中分配。对应的,在函数返回时,需要释放这些内存。如果我们临时创建 lua 的 userdata 就没这个烦恼了。Lua 在退出 C 函数后,那些临时构造的 userdata 会失去引用,随后被 lua GC 回收。在 Lua 5.4 中,启用分代 GC 的话,这个回收非常及时。

所以,我们还需要第三种 VLA 实现:采用 Lua 管理内存。

Lua 的内存管理和传统 C 程序很不一样,它并不是配对的分配/释放调用,而是围绕引用进行的。我们在给 C 模块做 Lua binding 时也经常会遇到一个固定的 C Struct 中需要一个 VLA 对象的情况。这时,如果 VLA 也用 Lua 管理内存生命期的话,就不用给 C Struct 添加额外的 gc 元方法了,而只需要把 VLA 对象所使用的 userdata 通过 uservalue 引用在宿主对象上。

我们的 VLA 模块需要兼容这种用法。

综上所述,我初步实现了一版

云风 提交于 June 1, 2022 02:53 PM | 固定链接

Comments

近指针、远指针、巨指针,这部分涉及到linux内核编程的一些技术,尤其是x86系列的早期的优化技术,针对x86特有的MMU和MMX指令集,这部分跟内存的段页式访问技术有关,这个如果是VC,需要非常高超的编程水平,如果是linux系统,也需要深入理解GCC的编译选项,使得C程序在X86的CPU效率是最高的。

Posted by: rawa459 | (4) June 6, 2022 04:14 AM

如果你非要使用C编译器来操控这么大的动态数组,那就要用到编译器的优化部分,比如使用近指针、远指针、巨指针,这样增加的编程的工作量。C语言编译器不擅长这部分的工作。

Posted by: rawa459 | (3) June 3, 2022 05:41 AM

动态长度数组的问题,如果这个数组小,比如几百个字符(C语言标准的字符数组),这个是否动态,对性能影响不大,对于内存的存储空间也没啥压力。
关键是,如果使用数组存BMP的时候,一个char表达一个点的255个灰度和颜色,一帧的图高达1920*1080=2073600个数组项,这时候,实际上格式化数组就起到了关键作用,这个Clang的技术比C++要强大。
如果按照C编译器(ANSI C),这个数组遍历的方式是线性的,这样很多时候,就非常的消费CPU的寻址指令。
Clang的format和C++的vector很明显针对这个超大数组的问题,做了彻底的优化,在这个问题上,C++的vector在intel的CPU上是最快的,Clang在Arm上是最快的。

Posted by: rawa459 | (2) June 3, 2022 05:38 AM

这实现的更类似C++的vector了, 只是支持了小容量用栈保存.
不是运行时确定的固定长度, 一定在栈上分配的那种VLA数组.

Posted by: dwing | (1) June 2, 2022 10:07 AM

Post a comment

非这个主题相关的留言请到:留言本

名字:

Email 地址:

为了验证您是人类,请将六加一的结果(阿拉伯数字七)填写在下面:

URL:

记住我的信息?

留言:
(不欢迎在留言中粘贴程序代码)


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK