Mistakes and cool things to do with arena allocators
使用 Arena Allocator 的常见错误和实用技巧
Mistakes and cool things to do with arena allocators
2025年4月4日
目录
- 什么是 arena?它如何工作?
- 动态数组的问题
- 发生了什么?
- 为什么 arena 不能简单地释放旧的内存块?
- 一切都与生命周期有关
- 替代方案:使用默认的 allocator
- 替代方案:预先分配最大尺寸
- 虚拟增长型 arena
- 静态虚拟 arena
- 你也可以完全跳过动态内存
- 视频
- 感谢阅读!
在使用 Odin 进行编程时,你可以使用 arena allocator。如果你将 arena allocator 与动态数组结合使用,那么可能会遇到一些起初不太明显的陷阱。让我们来看看什么是 arena,当你天真地将它们与动态数组一起使用时,你可能会遇到哪些麻烦,以及你可以采取哪些替代方案。
什么是 arena?它如何工作?#
Arenas 和 arena allocators 对于_分组_具有相同生命周期的分配非常有用。这里的_生命周期_指的是可以同时释放的分配,这可以通过销毁分配给它们的 arena 来完成。
_arena_是保存内存的东西,而_arena allocator_说明了如何分配到 arena 中。该 allocator 的类型为
runtime.Allocator
,因此你可以将其传递给期望 allocator 的东西。
Odin 中有几种不同的 arenas 实现。以下是其中一些列表:
- 在
core:mem
中:mem.Arena
,mem.Dynamic_Arena
- 在
core:mem/virtual
中:virtual.Arena
(具有三种操作模式:growing、static、buffer) - 在
base:runtime
中:runtime.Arena
(仅由 temp allocator 使用,它是一种 arena allocator)
为了简单起见,让我们谈谈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
的地址比number1
晚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 个追加操作之后,它将分配一些初始内存。此初始内存将具有 8 个项目的容量。这是动态数组的默认设置。想象一下,然后追加了 8 个项目。这将使动态数组耗尽容量,迫使其再次增长。新的容量可能约为 24。
每次动态数组增长时,它都会执行以下操作:
- 告诉 arena allocator 为新的数据块分配内存:好的!它会给你那么多字节(只要它没有满)。
- 将旧数据复制到新的数据块:好的!
- 告诉 allocator 释放旧数据:不行!Arena allocators 不实现单独的分配。此步骤将不起作用。
在内部,当动态数组尝试释放旧块时,allocator 会报告Mode_Not_Implemented
错误。动态数组不在乎该错误。
因此,旧的内存块只是留在 arena 中。考虑一下如果动态数组增长多次会发生什么:你将浪费_大量_内存。这只是当前使用的块后面的一系列旧块的墓地。这就是为什么在 arena 的开头和动态数组的第一个项目之间有130304
个字节的原因。
为什么 arena 不能简单地释放旧的内存块?#
正如我之前展示的,这种 arena 线性增长。它只是从 arena 的内存块的开头开始。当发生分配时,它会给出指向 arena 中当前位置的指针,并向前移动你要分配的字节数。下一次分配将发生在新的位置。
这是一个简单的 allocator:它只知道从哪里获取内存以及还剩下多少内存。它不会跟踪所有先前的分配。
因此,无法进行单独的释放:它不会保留足够的信息来执行此操作。
即使它确实跟踪了该信息,在这种线性内存块中进行释放也会迅速导致_内存碎片_:想象一下,如果你使用new(Some_Type, arena_allocator)
进行分配,并将不同大小的分配混合在一起。任何释放都会留下该大小的空洞。很快,你会在内存中留下许多微小的无法使用的“空洞”。
一切都与生命周期有关#
在我们的示例中,动态数组在 arena 中同时具有旧的内存块和新的内存块。它尝试释放旧的块,但保留新的块。它甚至尝试这样做这一事实表明,这两件事具有不同的生命周期!
这是在使用 arenas 时单独分配没有任何意义的另一个原因。由于 arena 中的所有内容都应共享相同的生命周期,因此只有一种释放才有意义:释放 arena 中的所有内容。
这是 arena 的全部意义所在:你将应该同时销毁的东西放入其中。它不是让手动内存管理“神奇地变得容易”的银弹。它只是一种分组分配的方式,因此你不必为了清理而进行大量单独的free
和delete
调用。
替代方案:使用默认的 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_Array
:https://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