使用 Arena Allocator 的常见错误和实用技巧

Karl Zylinski

Mistakes and cool things to do with arena allocators

2025年4月4日 目录

在使用 Odin 进行编程时,你可以使用 arena allocator。如果你将 arena allocator 与动态数组结合使用,那么可能会遇到一些起初不太明显的陷阱。让我们来看看什么是 arena,当你天真地将它们与动态数组一起使用时,你可能会遇到哪些麻烦,以及你可以采取哪些替代方案。

什么是 arena?它如何工作?#

Arenas 和 arena allocators 对于_分组_具有相同生命周期的分配非常有用。这里的_生命周期_指的是可以同时释放的分配,这可以通过销毁分配给它们的 arena 来完成。

_arena_是保存内存的东西,而_arena allocator_说明了如何分配到 arena 中。该 allocator 的类型为runtime.Allocator,因此你可以将其传递给期望 allocator 的东西。

Odin 中有几种不同的 arenas 实现。以下是其中一些列表:

为了简单起见,让我们谈谈mem.Arena。你只需向其提供一个预先分配的内存块。arena allocator 将分配到该块中。如果预先分配的块已满,则无法再向其中分配任何内容。这是一个简单的例子:

package arena_example
import "core:fmt"
import "core:mem"
main :: proc() {
	arena_mem := make([]byte, 1*mem.Megabyte)
	arena: mem.Arena
	mem.arena_init(&arena, arena_mem)
	arena_alloc := mem.arena_allocator(&arena)
	number1 := new(int, arena_alloc)
	number2 := new(int, arena_alloc)
	fmt.printfln("Arena memory starts at address %p (%i)", &arena_mem[0], &arena_mem[0])
	fmt.printfln("number1 allocated at address %p (%i)", number1, number1)
	fmt.printfln("number2 allocated at address %p (%i)", number2, number2)
	// Destroys the arena
	delete(arena_mem)
}

copy

我们创建一个 1 MB 的内存切片,并使用它初始化 arena。此后,我们使用 arena allocator 动态分配两个类型为int的变量。这两个int将被分配到该 1 MB 的内存块中。

如果你运行该程序,它将打印如下内容:

Arena memory starts at address 0x206B51F7048 (2227831795784)
number1 allocated at address 0x206B51F7048 (2227831795784)
number2 allocated at address 0x206B51F7050 (2227831795792)

copy

正如你所看到的,number1具有与 arena 的内存块的第一个字节相同的地址:它位于 arena 的内存块的开头。number2的地址比number18个字节。

显示 number1 和 number2 如何在 arena 的内存中一个接一个地结束,每个占用 8 个字节

由此我们可以理解,此 arena 以_线性_方式分配。我们分配的东西按照分配的顺序一个接一个地进入 arena 的内存块。int的大小为8个字节(在 64 位机器上),因此number2最终位于 arena 起始位置后8个字节处,紧随number1之后。

请注意,不可能执行free(number1)free(number2)。这将只会返回错误Mode_Not_Implemented。你只能释放 arena 中的所有内容,而不能释放其中的各个部分。这是有道理的,因为 arena 旨在用于_具有相同生命周期的事物_。对于mem.Arena,你可以通过删除内存块本身来释放 arena:

delete(arena_mem)
copy

对于core:mem/virtual包中的 arenas,你可以使用特定的过程来销毁 arena,例如virtual.arena_destroy(&some_arena)。稍后我将回到一个涉及虚拟内存 arenas 的示例。

动态数组的问题#

考虑以下小程序。它使用 arena allocator 与动态数组结合使用。

package dynamic_array_mistake
import "core:fmt"
import "core:mem"
import "core:math/rand"
main :: proc() {
	arena_mem := make([]byte, 1*mem.Megabyte)
	arena: mem.Arena
	mem.arena_init(&arena, arena_mem)
	fmt.printfln("Arena starts at address: %p (%i)", &arena_mem[0], &arena_mem[0])
	arena_alloc := mem.arena_allocator(&arena)
	dyn_arr := make([dynamic]int, arena_alloc)
	append(&dyn_arr, 7)
	fmt.printfln("After 1 append, address of first element is: %p (%i)", &dyn_arr[0], &dyn_arr[0])
	for i in 0..<9999 {
		append(&dyn_arr, rand.int_max(100000))
	}
	fmt.printfln("After 10000 appends, address of first element is: %p (%i)", &dyn_arr[0], &dyn_arr[0])
	// Destroys the arena
	delete(arena_mem)
}

copy

我们再次使用 1 MB 的内存块初始化mem.Arena。之后,我们打印 arena 开始的地址。在我的计算机上,它打印:

Arena starts at address: 0x253D94C9048 (2559151214664)

copy

然后,我们创建一个可以分配到 arena 中的动态数组。我们将单个项目附加到动态数组中。如果我们打印第一个元素的地址,则会显示:

After 1 append, address of first element is: 0x253D94C9048 (2559151214664)

copy

这与我们的 arena 相同。因此,动态数组从 arena 的开头开始。

现在我们添加9999个项目。这将使动态数组增长多次。此后,我们再次打印第一个元素的地址:

After 10000 appends, address of first element is: 0x253D94E8D48 (2559151344968)

copy

这不是 arena 的开头!它离开头有多远?减去两个数字即可找出:0x253D94E8D48 - 0x253D94C9048 = 0x1FD00,或十进制:130304。因此,动态数组的第一个元素距离动态数组的起始位置130304个字节。

发生了什么?#

显示在 1 个追加操作之后和 9 个追加操作之后动态数组的内存使用情况。在 1 个追加操作之后,它位于 arena 的开头。在 9 个追加操作之后,它位于远离起点的更远的位置,并且它已将旧数据作为动态数组中的一个空洞留下

最初,动态数组根本没有分配内存。在 1 个追加操作之后,它将分配一些初始内存。此初始内存将具有 8 个项目的容量。这是动态数组的默认设置。想象一下,然后追加了 8 个项目。这将使动态数组耗尽容量,迫使其再次增长。新的容量可能约为 24。

每次动态数组增长时,它都会执行以下操作:

在内部,当动态数组尝试释放旧块时,allocator 会报告Mode_Not_Implemented错误。动态数组不在乎该错误。

因此,旧的内存块只是留在 arena 中。考虑一下如果动态数组增长多次会发生什么:你将浪费_大量_内存。这只是当前使用的块后面的一系列旧块的墓地。这就是为什么在 arena 的开头和动态数组的第一个项目之间有130304个字节的原因。

为什么 arena 不能简单地释放旧的内存块?#

正如我之前展示的,这种 arena 线性增长。它只是从 arena 的内存块的开头开始。当发生分配时,它会给出指向 arena 中当前位置的指针,并向前移动你要分配的字节数。下一次分配将发生在新的位置。

这是一个简单的 allocator:它只知道从哪里获取内存以及还剩下多少内存。它不会跟踪所有先前的分配。

因此,无法进行单独的释放:它不会保留足够的信息来执行此操作。

即使它确实跟踪了该信息,在这种线性内存块中进行释放也会迅速导致_内存碎片_:想象一下,如果你使用new(Some_Type, arena_allocator)进行分配,并将不同大小的分配混合在一起。任何释放都会留下该大小的空洞。很快,你会在内存中留下许多微小的无法使用的“空洞”。

一切都与生命周期有关#

在我们的示例中,动态数组在 arena 中同时具有旧的内存块和新的内存块。它尝试释放旧的块,但保留新的块。它甚至尝试这样做这一事实表明,这两件事具有不同的生命周期!

这是在使用 arenas 时单独分配没有任何意义的另一个原因。由于 arena 中的所有内容都应共享相同的生命周期,因此只有一种释放才有意义:释放 arena 中的所有内容。

这是 arena 的全部意义所在:你将应该同时销毁的东西放入其中。它不是让手动内存管理“神奇地变得容易”的银弹。它只是一种分组分配的方式,因此你不必为了清理而进行大量单独的freedelete调用。

替代方案:使用默认的 allocator#

通常,只需将默认的 allocatorcontext.allocator与你的动态数组一起使用。然后,它们可以按预期增长并释放其旧内存。如果你遇到它们的内存泄漏问题,请在开发版本中使用跟踪 allocator。像这样设置它:https://odin-lang.org/docs/overview/#tracking-allocator

如果你在动态数组的内存移动时遇到问题,则可以查看下面有关虚拟增长型 Arena 的部分。

替代方案:预先分配最大尺寸#

如果你知道可以进入动态数组的最大事物数量,则可以执行以下操作:

dyn_arr := make([dynamic]int, 0, 2000, arena_alloc)
dyn_arr.allocator = mem.panic_allocator()

copy

这将构造一个具有2000个元素容量的动态数组。该数量的内存将立即分配到 arena 中。0是动态数组的_长度_。这是已使用多少个2000个元素。

这意味着你可以像往常一样append到此动态数组中。请注意这行代码dyn_arr.allocator = mem.panic_allocator()–如果动态数组尝试增长,这将使你的程序 panic(故意崩溃),因为这将以我们所讨论的方式使 arena 混乱。如果你遇到该崩溃,则也许你应该增加容量。

如果程序中动态数组的大小因用户的操作而差异很大,则可能不应该使用 arena。一个例子是如果你正在制作视频编辑软件:某些用户可能使用 10 MB 的内存,而其他用户可能使用 200 GB,具体取决于他们的项目大小。在这种情况下,为最坏的情况预先分配可能不是一个好主意。该软件以非常动态的方式使用,因此你必须以更动态的方式使用内存。

虚拟增长型 arena#

一种非常有用的 arena 是增长型虚拟 arena。它是一种使用内存块的 arena,当当前块已满时,它会分配一个新的块并将分配放入其中。

可以在不导致计算机实际内存使用量增加的情况下_保留_虚拟内存。虚拟 arena allocator 可以_提交_该保留内存的部分,以便将其映射到实际内存。我的书Understanding the Odin Programming Language更深入地探讨了虚拟内存 arenas。

如果你使用虚拟增长型 arena 重写我们的初始示例,它将如下所示:

package dynamic_array_virtual
import "core:fmt"
import vmem "core:mem/virtual"
import "core:math/rand"
main :: proc() {
	arena: vmem.Arena
	arena_alloc := vmem.arena_allocator(&arena)
	dyn_arr := make([dynamic]int, arena_alloc)
	append(&dyn_arr, 7)
	fmt.println("After 1 append to dynamic array, address of first element is:", &dyn_arr[0])
	for i in 0..<9999 {
		append(&dyn_arr, rand.int_max(100000))
	}
	fmt.println("After 10000 appends to dynamic array, address of first element is:", &dyn_arr[0])
}

copy

但是,如果你运行此程序,它将打印以下内容:

After 1 append to dynamic array, address of first element is: 0x1A682440038
After 10000 appends to dynamic array, address of first element is: 0x1A682440038

copy

等一下!在 10000 个追加操作之后,它仍然具有相同的地址!增长型虚拟 arena 有魔力吗?它是否以某种方式支持单独的释放?!第一个元素似乎总是位于相同的地址。

没有任何神奇的事情发生。此行为是由于虚拟增长型 arena 中的一种特殊情况。如果该分配仍然是进入 arena 的最新分配,它会重用相同的地址。

core:mem中的Arena没有这种特殊的重用地址行为。也许将来会添加它,在这种情况下,使用mem.Arena的早期示例将开始表现得像这样。

因此,如果你在动态数组增长的时刻之间进行了任何分配到 arena 中,这将不起作用。它将不得不进一步进入 arena,并因此留下旧数据。

那么这样做的理由充分吗?是的,因为动态数组中的元素在增长时不会在内存中移动。

有陷阱吗?是。如果动态数组超过 arena 的块大小,则必须移动到新的块。在下面的示例中,我添加了行arena_err := vmem.arena_init_growing(&arena, 4000)。这将使用4000字节的初始块大小初始化增长型 arena。动态数组的内容最终将超过该大小。这将导致创建一个新的块,并且动态数组将移动到该块。用代码表示:

package dynamic_array_virtual
import "core:fmt"
import vmem "core:mem/virtual"
import "core:math/rand"
main :: proc() {
	arena: vmem.Arena
	arena_err := vmem.arena_init_growing(&arena, 4000)
	assert(arena_err == nil)
	arena_alloc := vmem.arena_allocator(&arena)
	dyn_arr := make([dynamic]int, arena_alloc)
	append(&dyn_arr, 7)
	fmt.println("After 1 append to dynamic array, address of first element is:", &dyn_arr[0])
	for i in 0..<9999 {
		append(&dyn_arr, rand.int_max(100000))
	}
	fmt.println("After 10000 appends to dynamic array, address of first element is:", &dyn_arr[0])
}

copy

上面的程序将打印:

After 1 append to dynamic array, address of first element is: 0x2745AB00038
After 10000 appends to dynamic array, address of first element is: 0x2745AD30038

copy

正如你所看到的,地址已更改。因此,由于块大小较小,我们又回到了动态数组在 arena 中移动的情况。

注意:WASM 不支持虚拟内存。如果需要在 WASM 上增长型 arena,则可以使用core:mem中的Dynamic_Arena。它也使用块,但是它们不是虚拟分配的,因此即使它们未满,这些块始终使用与其大小一样多的物理内存。我在我的 Handle Map 实现中将Dynamic_Arena用作 WASM 的后备:https://github.com/karl-zylinski/odin-handle-map

静态虚拟 arena#

与我们之前使用panic_allocator的示例类似,你也可以使用_静态虚拟 arena_来使动态数组尝试超过当前块大小成为错误。静态虚拟 arena 也使用虚拟内存,但它只有一个块。用代码表示:

package dynamic_array_virtual_static
import "core:fmt"
import vmem "core:mem/virtual"
import "core:math/rand"
main :: proc() {
	arena: vmem.Arena
	arena_err := vmem.arena_init_static(&arena, 4000)
	assert(arena_err == nil)
	arena_alloc := vmem.arena_allocator(&arena)
	dyn_arr := make([dynamic]int, arena_alloc)
	append(&dyn_arr, 7)
	fmt.println("After 1 append to dynamic array, address of first element is:", &dyn_arr[0])
	for i in 0..<9999 {
		_, err := append(&dyn_arr, rand.int_max(100000))
		fmt.assertf(err == nil, "Error when adding to dynamic array: %v", err)
	}
	fmt.println("After 10000 appends to dynamic array, address of first element is:", &dyn_arr[0])
}

copy

上面的代码将返回错误:

Error when adding to dynamic array: Out_Of_Memory

copy

由于append尝试超过 arena 的末尾。换句话说,它能够一遍又一遍地重用相同的地址,但最终会填满该块。然后它必须移动。但是它不能,因为静态虚拟 arena 只有一个块。

但是,当我们谈论虚拟内存时,我提供给arena_init_static的数字4000非常_小_。每个应用程序都有整个 64 位虚拟寻址空间可供使用。因此,你可以轻松地在此处使用类似1*mem.Gigabyte的东西,而永远不必担心重新分配。保留的数量不会使程序的内存使用量增加:仅当实际_提交_内存时,意味着它用于实际分配时,使用量才会增加。此提交以称为_页面_的块完成。在许多系统上,页面的大小为4096字节。这也是_增长型_虚拟 arena 的默认块大小相当大的原因:它是 1 MB。它保留该大小的块,然后随着 arena allocator 要求越来越多的内存而通过提交它们来填充它们。

当我说“64 位虚拟寻址空间”时,我指的是 64 位指针可用的地址空间。该地址实际上并不使用所有 64 位,而是更常见的 48 位。

你也可以完全跳过动态内存#

如果你不使用动态内存,那么你永远不必释放任何东西。

有关类似动态数组的结构,请参见Small_Arrayhttps://pkg.odin-lang.org/core/container/small_array/

有关根本不使用动态内存的一系列不同数据结构,请参见 Jakub 的“静态数据结构”:https://github.com/jakubtomsu/sds

视频#

我制作了一个视频,其中内容与本文基本相同:

感谢阅读!#

如果你喜欢本文,那么你可能喜欢我的书“Understanding the Odin Programming Language”。在此处阅读免费示例here

我的 Discord 服务器上提问。

你可能还想注册我的新闻通讯,我在其中按月总结我的所有内容。

你可以通过成为赞助人来支持我的博客、我的 YouTube 频道和我的开源项目。

祝你愉快!/Karl Zylinski

分享此帖子

© 2025 Karl Zylinski Powered by Hugo & PaperMod