栈内存

原文地址:https://www.openmymind.net/learning_zig/stack_memory

通过深入研究指针,我们了解了变量、数据和内存之间的关系。因此,我们对内存的分布有了一定的了解,但我们还没有讨论如何管理数据以及内存。对于运行时间短和简单的脚本来说,这可能并不重要。在 32GB 笔记本电脑时代,你可以启动程序,使用几百兆内存读取文件和解析 HTTP 响应,做一些了不起的事情,然后退出。程序退出时,操作系统会知道,它给程序分配的内存可以被回收,并用于其他用途了。

但对于运行数天、数月甚至数年的程序来说,内存就成了有限而宝贵的资源,很可能会被同一台机器上运行的其他进程抢占。根本不可能等到程序退出后再释放内存。这就是垃圾回收器的主要工作:了解哪些数据不再使用,并释放其内存。在 Zig 中,你就是垃圾回收器。

我们编写的大多数程序都会使用内存的三个区域。第一个是全局空间,也就是存储程序常量(包括字符串字面量)的地方。所有全局数据都被嵌入到二进制文件中,在编译时(也就是运行时)完全已知,并且不可更改。这些数据在程序的整个生命周期中都存在,从不需要增加或减少内存。除了会影响二进制文件的大小外,我们完全不必担心这个问题。

内存的第二个区域是调用栈,也是本小节的主题。第三个区域是堆,将在下一小节讨论。

三块内存区域实际上没有真正的物理差别。操作系统和可执行文件创造了“内存区域”这个概念。

栈帧

迄今为止,我们所见的所有数据都是常量,存储在二进制的全局数据部分或作为局部变量。局部表示该变量只在其声明的范围内有效。在 Zig 中,范围从花括号开始到结束。大多数变量的范围限定在一个函数内,包括函数参数,或一个控制流块,比如 if。但是,正如所见,你可以创建任意块,从而创建任意范围。

在上一部分中,我们可视化了 mainlevelUp 函数的内存,每个函数都有一个 User:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
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 后,我们的调用栈如下所示:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
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
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
const std = @import("std");

pub fn main() void {
	const user1 = User.init(1, 10);
	const 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;
	}
};

粗瞥一眼,预期会有下面的输出:

1
2
User 1 has power of 10
User 2 has power of 20

但实际上:

1
2
User 2 has power of 20
User 9114745905793990681 has power of 0

你可能会得到不同的结果,但根据我的输出,user1继承了user2的值,而user2的值是无意义的。这段代码的关键问题是User.init返回局部user的地址&user。这被称为悬空指针,是指引用无效内存的指针。它是许多段错误(segfaults)的源头。

当一个栈帧从调用栈中弹出时,我们对该内存的任何引用都是无效的。尝试访问该内存的结果是未定义的。你可能会得到无意义的数据或段错误。我们可以试图理解我的输出,但这不是我们想要或甚至可以依赖的行为。

这类错误的一个挑战是,在有垃圾回收器的语言中,上述代码完全没有问题。例如,Go 会检测局部变量 user 超出了 init 函数的作用域,并在需要时确保其有效性(Go 如何做到这一点是一个实现细节,但它有几个选项,包括将数据移动到堆中,这就是下一部分要讨论的内容)。

而另一个问题,很遗憾地说,它是一个难以发现的错误。在我们上面的例子中,我们显然返回了一个局部地址。但这种行为可以隐藏在嵌套函数和复杂数据类型中。你是否看到了以下不完整代码的任何可能问题:

1
2
3
4
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。但这并不总是可能的。数据经常需要超越函数作用域的严格界限。为此,我们有了第三个内存区域–堆,这也是下一部分的主题。

在深入研究堆之前,我们要知道,在本指南结束之前,我们还将看到最后一个关于悬挂指针的示例。到那时,我们已经掌握了足够多的语言知识,可以给出一个不太复杂的示例。我之所以想重提这个话题,是因为对于来自垃圾回收语言的开发人员来说,这很可能会导致错误和挫败感。这一点你会掌握的。归根结底,就是要意识到数据的生命周期。