揭秘内存管理:栈内存的奥秘✨

通过深入探索指针的奥秘,我们揭开了变量、数据与内存之间错综复杂的关系面纱。🔍🧠 这让我们对内存的布局有了初步的洞察,但关于如何高效管理数据及其占用的内存,我们仍未深入探讨。 对于那些生命周期短暂、任务简单的脚本而言,这或许无关紧要。🏃‍♂️💨 在 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 总结

在深入研究堆之前,我们要知道,对于来自垃圾回收语言的开发人员来说,这很可能会导致错误和挫败感。我们一定要掌握栈的概念,归根结底,就是要意识到数据的生命周期。

  • 13
    点赞
  • 8
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

xiaodeshi

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值