通过深入探索指针的奥秘,我们揭开了变量、数据与内存之间错综复杂的关系面纱。🔍🧠 这让我们对内存的布局有了初步的洞察,但关于如何高效管理数据及其占用的内存,我们仍未深入探讨。 对于那些生命周期短暂、任务简单的脚本而言,这或许无关紧要。🏃♂️💨 在 32GB 笔记本电脑的时代,你可以轻松启动一个程序,用几百兆内存来读取文件、解析 HTTP 响应,完成一些令人惊叹的操作,然后潇洒退出。程序一结束,操作系统便立即介入,回收它曾分配给程序的内存,以便其他程序使用。 然而,对于那些需要连续运行数天、数月甚至数年的程序来说,内存就显得尤为珍贵。🗓️💻 它变成了一种稀缺资源,随时可能被同一台机器上运行的其他进程所侵占。在这种环境下,我们无法奢望等到程序结束时才释放内存。这就凸显了垃圾回收器的重要性:它能够识别不再被使用的数据,并及时释放它们所占用的内存。在 Zig 语言的世界里,你就是那位肩负重任的垃圾回收器。 在我们的编程实践中,通常会用到三种内存区域。首先映入眼帘的是全局空间,它是存放程序常量(包括字符串字面量)的宝库。🌐🔒 所有这些全局数据都被巧妙地嵌入到二进制文件中,在编译时就已完全确定,且永不改变。它们伴随着程序的整个生命周期,无需我们操心内存的增减。唯一可能的影响就是略微增大二进制文件的体积,但这通常不是我们关注的焦点。
内存的第二个区域是调用栈,也是接下来我们要讨论的主题。
1.1 栈帧(Stack Frame)
迄今为止,我们所见的所有数据都是常量,存储在二进制的全局数据部分或作为局部变量。局部表示该变量只在其声明的范围内有效。在 Zig 中,范围从花括号开始到结束。大多数变量的范围限定在一个函数内,包括函数参数,或一个控制流块,比如 if。但是,正如所见,你可以创建任意块,从而创建任意范围。
在上一部分中,我们可视化了 main
和 levelUp
函数的内存,每个函数都有一个 User:
main: user -> ------------- (id: 1043368d0)
| 1 |
------------- (power: 1043368d8)
| 100 |
------------- (name.len: 1043368dc)
| 4 |
------------- (name.ptr: 1043368e4)
| 1182145c0 |-------------------------
levelUp: user -> ------------- (id: 1043368ec) |
| 1 | |
------------- (power: 1043368f4) |
| 100 | |
------------- (name.len: 1043368f8) |
| 4 | |
------------- (name.ptr: 104336900) |
| 1182145c0 |-------------------------
------------- |
|
............. empty space |
............. or other data |
|
------------- (1182145c0) <---
| 'G' |
-------------
| 'o' |
-------------
| 'k' |
-------------
| 'u' |
-------------
levelUp
紧接在 main
之后是有原因的:这是我们的简化版调用栈。当我们的程序启动时,main
及其局部变量被推入调用栈。当 levelUp
被调用时,它的参数和任何局部变量都会被添加到调用栈上。重要的是,当 levelUp
返回时,它会从栈中弹出。 在 levelUp
返回并且控制权回到 main
后,我们的调用栈如下所示:
main: user -> ------------- (id: 1043368d0)
| 1 |
------------- (power: 1043368d8)
| 100 |
------------- (name.len: 1043368dc)
| 4 |
------------- (name.ptr: 1043368e4)
| 1182145c0 |-------------------------
------------- |
|
............. empty space |
............. or other data |
|
------------- (1182145c0) <---
| 'G' |
-------------
| 'o' |
-------------
| 'k' |
-------------
| 'u' |
-------------
当一个函数被调用时,其整个栈帧被推入调用栈——这就是我们需要知道每种类型大小的原因之一。尽管我们可能直到特定的代码行执行时,才能知道我们 user
的名字的长度(假设它不是一个常量字符串字面量),但我们知道我们的函数有一个 User
类型的变量,除了其他字段,只需要 8 字节来存储name.len
和 8 字节来存储name.ptr
。
当函数返回时,它的栈帧(最后推入调用栈的帧)会被弹出。令人惊讶的事情刚刚发生:levelUp
使用的内存已被自动释放!虽然从技术上讲,这些内存可以返回给操作系统,但据我所知,没有任何实现会真正缩小调用栈(不过,在必要时,实现会动态增加调用栈)。不过,用于存储 levelUp
堆栈帧的内存现在可以在我们的进程中用于另一个堆栈帧了。
与全局数据一样,调用栈也由操作系统和可执行文件管理。程序启动时,以及此后启动的每个线程,都会创建一个调用栈(其大小通常可在操作系统中配置)。调用栈在程序的整个生命周期中都存在,如果是线程,则在线程的整个生命周期中都存在。程序或线程退出时,调用栈将被释放。我们的全局数据包含所有程序的全局数据,而调用栈只包含当前执行的函数层次的栈帧。这样做既能有效利用内存,又能简化堆栈帧的管理。
1.2 悬空指针
栈帧的简洁和高效令人惊叹。但它也很危险:当函数返回时,它的任何本地数据都将无法访问。这听起来似乎很合理,毕竟这是本地数据,但却会带来严重的问题。请看这段代码:
const std = @import("std");
pub fn main() void {
var user1 = User.init(1, 10);
var user2 = User.init(2, 20);
std.debug.print("User {d} has power of {d}\n", .{user1.id, user1.power});
std.debug.print("User {d} has power of {d}\n", .{user2.id, user2.power});
}
pub const User = struct {
id: u64,
power: i32,
fn init(id: u64, power: i32) *User{
var user = User{
.id = id,
.power = power,
};
return &user;
}
};
乍一看,我们预计会得到以下输出:
User 1 has power of 10
User 2 has power of 20
但实际上:
User 2 has power of 20
User 9114745905793990681 has power of 0
你可能会得到不同的结果,但根据我的输出,user1
继承了user2
的值,而user2
的值是无意义的。这段代码的关键问题是User.init
返回局部user
的地址&user
。这被称为悬空指针,是指引用无效内存的指针。它是许多段错误(segfaults)的源头。
当一个栈帧从调用栈中弹出时,我们对该内存的任何引用都是无效的。尝试访问该内存的结果是未定义的。你可能会得到无意义的数据或段错误。我们可以试图理解我的输出,但这不是我们想要或甚至可以依赖的行为。
这类错误的一个挑战是,在有垃圾回收器的语言中,上述代码完全没有问题。例如,Go 会检测局部变量 user
超出了 init 函数的作用域,并在需要时确保其有效性(Go 如何做到这一点是一个实现细节,但它有几个选项,包括将数据移动到堆中,这就是下一部分要讨论的内容)。
而另一个问题,很遗憾地说,它是一个难以发现的错误。在我们上面的例子中,我们显然返回了一个局部地址。但这种行为可以隐藏在嵌套函数和复杂数据类型中。你是否看到了以下不完整代码的任何可能问题:
fn read() !void {
const input = try readUserInput();
return Parser.parse(input);
}
无论Parser.parse
返回什么,它都将比变量input
存在更久。如果Parser
持有对 input
的引用,那将是一个悬空指针,等待着让我们的应用程序崩溃。理想情况下,如果 Parser
需要 input
生命周期尽可能长,它将复制 input
,并且该复制将与它自己的生命周期绑定。但此处没有执行这一步骤。Parser
的文档可能会对它对 input
的期望或它如何使用 input
提供一些说明。缺少这些信息,我们可能需要深入代码来弄清楚。
为了解决我们上面例子里的错误,有个简单的方法是改变 init
,使它返回一个 User
而不是*User
(指向 User
的指针)。我们可以使用 return user
而非 return &user
。但这并不总是可行的。数据经常需要超越函数作用域的严格界限,这个时候就需要引入堆的概念,会在下一节讲解。
1.3 总结
在深入研究堆之前,我们要知道,对于来自垃圾回收语言的开发人员来说,这很可能会导致错误和挫败感。我们一定要掌握栈的概念,归根结底,就是要意识到数据的生命周期。