C 语言中的 Arena 分配器

一个 C 语言里的小型 arena 分配器全解:从它解决的生命周期问题,到可运行实现的结构体、内存对齐和 realloc 路径。

中文
复制

众所周知,C 是一门非常底层的语言。它允许你(某种程度上也是强迫你)自己管理所使用的内存。这大概也是很多人日常编程时转向其他语言的原因之一。本文将介绍一种内存管理范式,它不仅性能极高,而且极其灵活。它让内存管理这件事轻松不少,也大幅降低了内存泄漏的概率。

你也许已经听说过它,也许没有,无论如何:我说的就是 arena 分配器。

接下来我会讲实现,但在此之前,先简要介绍一下 arena 分配器的概念。

什么是 Arena 分配器?

人们通常管理内存的方式是:每次需要内存时,就用 malloc 从堆上分配一小块——只分配需要的量。如果后来发现需要的内存比最初计划的多,就用 realloc 扩大已分配内存的大小。

用完之后,调用 free 告诉操作系统这块内存不再需要,可以挪作他用。

这很好,但有几个问题:

1.

程序员必须记得在用完内存后释放它。如果分配发生在循环里——比如游戏中——这就尤其危险,因为这种情况下操作系统会不断分配越来越多的内存,而这些内存永远不会被释放。

内存作为一种资源并非无限。到了某个时刻,你的计算机会耗尽内存,程序会被操作系统终止(也就是崩溃)。

2.

分配和释放大量小块内存很慢。

3.

如果你有这样一个函数:

i32 *create_array(usize len) {
    i32 *elements = (i32 *)malloc(sizeof(int));
    return elements;
}

那么请告诉我:调用这个函数的程序员怎么知道它内部做了内存分配?在这个例子里,或许可以靠函数名去猜它可能在做什么,但事情并不总是这么简单。

记住,这一点很重要,因为程序员需要知道什么时候该释放内存。

必须让程序员意识到:释放内存是他自己的责任。

可能还有更多我没考虑到的问题,但以上是我当场能想到的一些主要问题。

解决方案。

登场吧,arena allocator。

大多数生命周期(也就是某块内存在被释放之前会被使用多久)都可以归入少数几类:

  1. 内存的生命周期与整个程序相同
  2. 内存的生命周期持续到某些任务完成(比如游戏中的一帧,或一次 HTTP 请求的处理)
  3. 内存的生命周期仅限于某个函数的作用域

这样的分组通常够用了,但取决于你如何划分,可能还会有更多类别。无论如何,我们都可以直接分配一大块内存,用来处理生命周期相近的东西(也就是可以一起释放的东西)。

这样我们就拥有了一大块可用的内存。每当我们用掉其中一部分来做某件事,就推进一个指针,记住内存已经用到了哪里、空闲内存从哪里开始。

看到实现之后,这一点会更清楚。

当我们不再需要那块大内存中的任何数据时,可以直接一次性全部释放(或者——如果我们想复用这块内存——就把 memory arena 重新初始化到初始状态,从头开始分配)。

这省去了一次次单独的内存分配(记住,这些操作很慢),也让我们不必太操心内存管理,因为只需要关注几块不同的内存“块”,而不是为各个数组使用的一堆小块内存。

实现

说明

在开始之前,我用 C 的时候几乎总会在代码顶部包含这个文件。

它只是给一些内置类型 typedef,并提供了一些常用、实用的工具。我会直接包含 .c 文件,因为我用 unity build 来加快编译速度。

你完全可以自己写头文件,如果你更倾向那样做的话。

// types.c
#ifndef TYPES_C
#define TYPES_C

#include <stddef.h>
#include <stdint.h>

typedef uint8_t u8;
typedef uint16_t u16;
typedef uint32_t u32;
typedef uint64_t u64;

typedef int8_t i8;
typedef int16_t i16;
typedef int32_t i32;
typedef int64_t i64;

typedef float f32;
typedef double f64;

typedef size_t usize;

#define KiB(x) x * 1024
#define MiB(x) KiB(x) * 1024
#define GiB(x) MiB(x) * 1024

#define MIN(a, b) a < b ? a : b
#define MAX(a, b) a > b ? a : b

#endif // TYPES_C

arena.c

下面是 arena 的实际实现。

开头是 include guard(同样是因为 unity build),再引入几个要用到的 libc 头文件:

#ifndef ARENA_C
#define ARENA_C

#include "types.c"
#include <stdbool.h>
#include <stdlib.h>
#include <string.h>

// code goes here

#endif // ARENA_C

这是 arena 结构体的定义:

struct arena {
    void *data;
    usize size;
    usize used;
};

它记录了已用掉多少内存。也可以用另一个办法达到同样目的:用一个指针指向内存中尚未使用区域的起点。

size 字段的作用是判断分配时是否会超出 arena 的容量(内存用多了,这种情况很容易发生)。

data 则直接指向 arena 的起始位置。

到这里一切顺利,但接下来需要一个创建新 arena 的方法。如下:

struct arena_create_out {
    struct arena arena;
    bool success;
};
[[nodiscard]]
struct arena_create_out arena_create(usize size) {
    struct arena_create_out out = {0};
    out.arena.data = malloc(size);
    if (out.arena.data == NULL)
        return out;
    out.arena.size = size;
    out.arena.used = 0;
    out.success = true;
    return out;
}

返回多个值时,我不喜欢用输出参数——我觉得那样很绕,API 也不直观——更愿意直接返回结构体。

接着需要一个在 arena 里分配内存的方法。在此之前,先定义这个宏(我把它放在文件顶部,但到这里才真正用得上,所以现在才介绍):

#define ARENA_ALIGN 8

它用来对齐数据。不知为何,我们的计算机处理按 8 字节对齐的数据要高效得多。也就是说,每次新分配的起始位置必须是 8 的倍数。

malloc 会替我们处理这件事,但现在我们不是用 malloc 来分配,而只是在一块已经分配好的内存里划出一部分空间,所以必须自己对齐数据,以加快访问速度。

下面是分配函数:

struct arena_alloc_out {
    void *ptr;
    bool success;
};
[[nodiscard]]
struct arena_alloc_out arena_alloc(struct arena *arena, usize size) {
    struct arena_alloc_out out = {0};
    usize alignment = (ARENA_ALIGN - (arena->used % ARENA_ALIGN)) % ARENA_ALIGN;
    out.ptr = arena->data + arena->used + alignment;
    if (out.ptr + size > arena->data + arena->size)
        return out;
    arena->used += size + alignment;
    out.success = true;
    return out;
}

这里我们要算出需要在分配末尾补上多少“填充”(即 alignment 变量),才能让下一次分配对齐到 8 字节。

我习惯在函数返回类型里放一个 success 字段,出错时直接提前返回(因为它被 = {0} 初始化为全零,所以 success 会是 false)。

如果你不喜欢这种做法,把 API 改成别的约定应该不难。

大部分工作已经完成。我写了一个简单的包装函数来改变分配的大小:在 arena 末尾按新大小分配一块内存,再把数据复制过去。旧数据原样保留,等 arena 释放时一并释放(记住,我们无法单独释放某次分配,要么释放整个 arena,要么什么都不释放)。

你其实不需要它,但我发现它让我的代码更干净,所以放上来:

struct arena_realloc_out {
    void *ptr;
    bool success;
};
[[nodiscard]]
struct arena_realloc_out arena_realloc(struct arena *arena, usize new_size, void *src, usize old_size) {
    struct arena_realloc_out out = {0};
    struct arena_alloc_out alloc_out = arena_alloc(arena, new_size);
    if (!alloc_out.success)
        return out;

    out.ptr = memcpy(alloc_out.ptr, src, MIN(old_size, new_size));
    out.success = true;
    return out;
}

如果新大小比旧大小更小,末尾那部分数据会被截掉。另外,realloc 内部知道每个指针所对应的分配大小,所以你不必传旧大小,只传新大小即可。

由于我们的 arena 实现完全不记录分配的大小,我们只能把旧大小单独传给这个函数。arena 的使用者应当自己清楚每次分配的大小。

这一点也可以实现,但我想保持简单。

接下来两个函数是小型包装,只是为了方便,并非必需:

void arena_clear(struct arena *arena) { arena->used = 0; }

void arena_free(struct arena *arena) { free(arena->data); }

我的实现就到这里。

用法示例

我有一个简单的字符串库,是为自己的使用场景写的。它有两个概念:string view 和 owned string。

owned string 以 NUL 结尾,并且 owns 它的数据(也就是说,必须分配和释放)。这也意味着可以直接修改 owned string。

string view 只是查看数据,因此不能直接修改它——同一份数据可能存在多个 string view,修改它会导致其他字符串发生意料之外的变化。反过来,任何一块数据区域都只应存在一个 owned string。

关键在于,我需要一种把 string view 转换成 owned string 的方法。为此,我们必须新建一个 owned string(也就是为它分配内存),再把数据复制过去。这同样是为了确保对这个 owned string 的修改不会影响到其他可能正在查看它的 string view。

我们还会在自有字符串末尾加上 NUL 终止符(以兼容其他遗留 C API)。

为此,我们向函数传入一个 arena 分配器,并从该 arena 分配内存:

struct str_view {
    const u8 *data;
    usize len;
};

struct str_owned {
    u8 *data;
    usize len;
};

struct str_view_to_str_owned_out {
    struct str_owned str;
    bool success;
};
[[nodiscard]]
struct str_view_to_str_owned_out str_view_to_str_owned(
    struct arena *arena,
    struct str_view s
) {
    struct str_view_to_str_owned_out out = {0};
    struct arena_alloc_out alloc_out = arena_alloc(arena, s.len + 1);
    if (!alloc_out.success)
        return out;
    out.str.data = alloc_out.ptr;
    memcpy(out.str.data, s.data, s.len);
    out.str.len = s.len;
    out.str.data[out.str.len] = 0;
    out.success = true;
    return out;
}

这带来两个好处:

  1. 调用方立刻就能知道这个函数会分配内存(因为它的第一个参数就是 arena 指针)
  2. 调用方可以决定在哪里分配内存(也就是以什么生命周期分配)。

关于第 2 点,既然调用方通过传入相应的 arena 来决定生命周期,就不必操心直接释放内存——生命周期结束、arena 被丢弃(或清空)时,内存自然就释放了。这个时机可以是程序结束时(这种情况下释放内存根本没必要,操作系统会替我们处理),也可以是函数结束时,或者调用方确信不再需要这块内存的任何时候。

结语

我最近开始把 arena 纳入自己的编程习惯,不得不说效果让我非常满意。我已经把自己的字符串库迁移到 arena 上,我觉得你用 arena 也能获益不少。

开发者体验的提升有时大有时小,但可以说,作为程序员,把这一工具收入囊中绝对是净收益。

我并不自称专家,这个实现也只是我自己为个人使用而写的。它未必完美,但作为入门介绍应该够用了。

一如既往,有任何问题、反馈或想法,都可以通过邮件联系我:

info@eliasebner.com

来源: eliasebner.com← 返回首页