一文理清Zig中的Allocator
TOC
What is Allocator#
分配器是zig中非常重要的概念。zig在编程语言中的定位与C类似。这意味手动管理内存(准确点说,基本是堆内存)是非常重要。
我看zig的官方文档有这样一句话:
no hidden-memory allocations
没有任何隐藏的内存分配。这意味这门语言不会偷偷摸摸在标准库或者某些运算中帮你分配内存,“所见即所得”。如何某些方法确实需要分配内存,那么它会让你传入一个Allocator实例。这意味着该方法会进行某些内存操作。
Allocator就是分配内存的工具。把它想象成一个结构体,带上了类似malloc, free等方法即可。
zig中所有的内存分配都是通过Allocator来完成的。
Why Allocator#
每次在Zig中调用函数时,都会在栈中为该函数调用预留一个空间。但栈有一个关键限制:存储在栈中的每个对象都必须具有已知的固定长度。
然而现实中,有两种非常常见的情况会让栈的这种“固定长度限制”成为致命问题:
- 函数内部创建的对象可能在函数执行期间增大尺寸。
- 有时无法预先知道将接收多少输入,或这些输入会有多大。
此外,还有另一种情况您可能需要使用分配器,即当您希望编写一个返回指向局部对象指针的函数时。若该局部对象存储在栈中,则无法实现这一操作。但若该对象存储在堆中,您便可以在函数结束时返回指向该对象的指针。因为作为程序员,您掌控着所分配堆内存的生命周期,由其决定该内存何时被销毁或释放。
以下是栈不适用的一些常见情况。正因为如此,你需要一种不同的内存管理策略来在函数中存储这些对象。你需要使用一种可以随着对象增长而扩展的内存类型,或者能够控制其生命周期的内存。堆内存正好符合这一描述。
在堆上分配内存通常被称为动态内存管理。当你创建的对象在程序运行期间不断增大时,你可以通过在堆中分配更多内存来扩展可用内存空间,以存储这些对象。在 Zig 语言中,你可以通过分配器(allocator)对象来实现这一点。
The different Allocators in Zig#
正如我前面所说,“把它想象成一个结构体,带上了类似malloc, free等方法即可。”
在zig中,Allocator是一个接口(interface)。它定义了一组方法,用于分配和释放内存。有接口就意味着可以有不同的实现。zig标准库中提供了几种不同的Allocator实现,适用于不同的使用场景。
简单介绍下几种常见的Allocator实现。
General Purpose Allocator#
顾名思义,GeneralPurposeAllocator()是一个“通用”分配器。你可以将其用于任何类型的任务。主要是开发或调试阶段使用。
const std = @import("std");
pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
const allocator = gpa.allocator();
const num = try allocator.create(u32);
defer allocator.destroy(num);
num.* = 42;
std.debug.print("{d}\n", .{num.*});
}
上面代码中,我们创建了一个GeneralPurposeAllocator实例,并使用它来分配和释放一个u32类型的内存。
这里提一嘴,分配器实例通常是通过std.heap模块创建的。申请内存后记得释放。
Page Allocator#
page_allocator() 是一个在堆内存中分配完整内存页的分配器。换句话说,每次使用 page_allocator() 分配内存时,都会在堆中分配一整页内存,而不仅仅是其中的一小部分。
该内存页的大小取决于您所使用的系统。大多数系统在堆中使用 4KB 的内存页大小,因此 page_allocator() 在每次调用时通常会分配这么多内存。正因如此,page_allocator() 在 Zig 中被认为是一个快速但“浪费”的分配器。因为它在每次调用时都会分配大量内存,而您的程序很可能并不需要这么多内存。
Buffer Allocator#
Buffer Allocator 有两个实现,分别是 FixedBufferAllocator 和 ThreadSafeFixedBufferAllocator。
后者故名思议,是线程安全的版本。这里我们只拿前者举例。Buffer Allocator这种分配器需要你先提供一个固定大小的缓冲区。然后它会在这个缓冲区内进行内存分配。
const std = @import("std");
pub fn main() !void {
var buffer: [5]u8 = undefined;
// 初始化缓冲区, 都为0
@memset(&buffer, 0);
// 使用FixedBufferAllocator
var fba = std.heap.FixedBufferAllocator.init(&buffer);
const allocator = fba.allocator();
const arr = try allocator.alloc(u8, 5);
defer allocator.free(arr);
}
上面代码中,我们创建了一个大小为5的缓冲区,并使用FixedBufferAllocator在这个缓冲区内进行内存分配。
对了,你还记得我再上面讲过大部分分配器都是在堆上分配内存的吗? Buffer Allocator是个例外。它是基于你提供的缓冲区来分配的,如果提供的缓冲区在栈上,那么它分配的内存也是在栈上。
上面的例子中,我们提供的缓冲区buffer是在栈上分配的(因为它是main函数的局部变量,main跑完里面所有的东西都会被释放),因此FixedBufferAllocator分配的内存也是在栈上。
如果你想要Buffer Allocator在堆上分配内存,那么你需要提供一个在堆上分配的缓冲区。比如你可以先用别的分配器申请一段内存,然后把它当成缓冲区再传入FixedBufferAllocator。
Arena Allocator#
Arena Allocator需要你先传入一个分配器,然后Arena Allocator会持有你传入的分配器,并在其上进行内存分配。(做了一层封装)
它最大的特点就是你可以使用其分配多次内存,最后一次性释放掉所有分配的内存。不用每申请一段就调用下defer xxx.free(...)。
const std = @import("std");
pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
var aa = std.heap.ArenaAllocator.init(gpa.allocator());
// 这里调用deinit来一次性释放所有通过ArenaAllocator分配的内存
defer aa.deinit();
const arr1 = try aa.allocator().alloc(u8, 2);
const arr2 = try aa.allocator().alloc(u8, 5);
const arr3 = try aa.allocator().alloc(u8, 10);
_ = arr1;
_ = arr2;
_ = arr3;
}
通过统一调用deinit()方法,我们一次性释放了所有通过ArenaAllocator分配的内存。
有个生动的例子:Arena Allocator本质上就是一个包工头,它承包了一个工地(传入的分配器)。你可以让它多次分配材料(内存),等到工程完工时,你只需要一次性结算(释放内存)即可。
The methods of Allocator#
Allocator接口有4个方法:alloc, free, create, destroy。
长话短说(写累了o_K):
- 你要分配一段连续的内存,就用
alloc方法。然后用free释放。 - 你要分配一个单独的对象,就用
create方法。然后用destroy释放。
const std = @import("std");
const User = struct {
id: i64,
name: []const u8,
// 构造函数
pub fn init(id: i64, name: []const u8) User {
return .{
.id = id,
.name = name,
};
}
pub fn print(self: *const User) void {
std.debug.print("User id: {d}, name: {s}\n", .{ self.id, self.name });
}
};
pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
const allocator = gpa.allocator();
// 这里调用create分配好User实例所需的内存
const u = try allocator.create(User);
defer allocator.destroy(u);
// 给u这个指针赋值
u.* = User.init(1, "Leslie");
u.print();
}
这里我们使用create方法分配了一个User实例所需的内存,然后使用destroy释放。