Go语言设计与实现 -- 栈空间管理

寄存器

stack-registers

图片来自于面向信仰编程

Go 语言的汇编代码包含 BP 和 SP 两个栈寄存器,它们分别存储了栈的基址指针和栈顶的地址,栈内存与函数调用的关系非常紧密,我们在函数调用一节中曾经介绍过栈区,BP 和 SP 之间的内存就是当前函数的调用栈。

线程栈

线程和进程都是代码执行的上下文,但是如果一个应用程序包含成百上千个执行上下文并且每个上下文都是线程,会占用大量的内存空间并带来其他的额外开销,Go 语言在设计时认为执行上下文是轻量级的,所以它在用户态实现 Goroutine 作为执行上下文。

逃逸分析

在 C 语言和 C++ 这类需要手动管理内存的编程语言中,将对象或者结构体分配到栈上或者堆上是由工程师自主决定的,这也为工程师的工作带来的挑战,如果工程师能够精准地为每一个变量分配合理的空间,那么整个程序的运行效率和内存使用效率一定是最高的,但是手动分配内存会导致如下的两个问题:

  1. 不需要分配到堆上的对象分配到了堆上 — 浪费内存空间;
  2. 需要分配到堆上的对象分配到了栈上 — 悬挂指针、影响内存安全;

与悬挂指针相比,浪费内存空间反而是小问题。在 C 语言中,栈上的变量被函数作为返回值返回给调用方是一个常见的错误,在如下所示的代码中,栈上的变量 i 被错误返回:

int *dangling_pointer() {
    int i = 2;
    return &i;
}

dangling_pointer 函数返回后,它的本地变量会被编译器回收,调用方获取的是危险的悬挂指针,我们不确定当前指针指向的值是否合法时,这种问题在大型项目中是比较难以发现和定位的。

在编译器优化中,逃逸分析是用来决定指针动态作用域的方法。Go 语言的编译器使用逃逸分析决定哪些变量应该在栈上分配,哪些变量应该在堆上分配,其中包括使用 newmake 和字面量等方法隐式分配的内存,Go 语言的逃逸分析遵循以下两个不变性:

  1. 指向栈对象的指针不能存在于堆中;
  2. 指向栈对象的指针不能在栈对象回收后存活;

escape-analysis-and-key-invariants

我们通过上图展示两条不变性存在的意义,当我们违反了第一条不变性时,堆上的绿色指针指向了栈中的黄色内存,一旦函数返回后函数栈会被回收,该绿色指针指向的值就不再合法;如果我们违反了第二条不变性,因为寄存器 SP 下面的内存由于函数返回已经释放,所以黄色指针指向的内存已经不再合法。

逃逸分析是静态分析的一种,在编译器解析了 Go 语言源文件后,它可以获得整个程序的抽象语法树(Abstract syntax tree,AST),编译器可以根据抽象语法树分析静态的数据流,我们会通过以下几个步骤实现静态分析的全过程:

  1. 构建带权重的有向图,其中顶点 cmd/compile/internal/gc.EscLocation 表示被分配的变量,边 cmd/compile/internal/gc.EscEdge表示变量之间的分配关系,权重表示寻址和取地址的次数;
  2. 遍历对象分配图并查找违反两条不变性的变量分配关系,如果堆上的变量指向了栈上的变量,那么该变量需要分配在堆上;
  3. 记录从函数的调用参数到堆以及返回值的数据流,增强函数参数的逃逸分析;

决定变量是在栈上还是堆上虽然重要,但是这是一个定义相对清晰的问题,我们可以通过编译器统一作决策。为了保证内存的绝对安全,编译器可能会将一些变量错误地分配到堆上,但是因为堆也会被垃圾收集器扫描,所以不会造成内存泄露以及悬挂指针等安全问题,解放了工程师的生产力。

栈空间内存

分段栈

当 Goroutine 调用的函数层级或者局部变量需要的越来越多时,运行时会调用 runtime.morestack:go1.2runtime.newstack:go1.2创建一个新的栈空间,这些栈空间虽然不连续,但是当前 Goroutine 的多个栈空间会以链表的形式串联起来,运行时会通过指针找到连续的栈片段:

segmented-stacks

图 7-45 分段栈的内存布局

一旦 Goroutine 申请的栈空间不在被需要,运行时会调用 runtime.lessstack:go1.2runtime.oldstack:go1.2释放不再使用的内存空间。

分段栈机制虽然能够按需为当前 Goroutine 分配内存并且及时减少内存的占用,但是它也存在两个比较大的问题:

  1. 如果当前 Goroutine 的栈几乎充满,那么任意的函数调用都会触发栈扩容,当函数返回后又会触发栈的收缩,如果在一个循环中调用函数,栈的分配和释放就会造成巨大的额外开销,这被称为热分裂问题(Hot split)
  2. 一旦 Goroutine 使用的内存越过了分段栈的扩缩容阈值,运行时会触发栈的扩容和缩容,带来额外的工作量

连续栈

连续栈可以解决分段栈中存在的两个问题,其核心原理是每当程序的栈空间不足时,初始化一片更大的栈空间并将原栈中的所有值都迁移到新栈中,新的局部变量或者函数调用就有充足的内存空间。使用连续栈机制时,栈空间不足导致的扩容会经历以下几个步骤:

  • 在内存空间中分配更大的栈内存空间
  • 将旧栈中的所有内容复制到新栈中
  • 将旧栈对应变量的指针重新指向新栈
  • 销毁并回收旧栈的内存空间

在扩容过程中,最重要的是第三步,这一步能够保证指向栈的指针的正确性,因为栈中的所有变量内存都会发生变化,所以原本指向栈中变量的指针也需要调整。我们在前面提到过经过逃逸分析的 Go 语言程序的遵循以下不变性 —— 指向栈对象的指针不能存在于堆中,所以指向栈中变量的指针只能在栈上,我们只需要调整栈中的所有变量就可以保证内存的安全了。

continuous-stacks

因为需要拷贝变量和调整指针,连续栈增加了栈扩容时的额外开销,但是通过合理栈缩容机制就能避免热分裂带来的性能问题,在 GC 期间如果 Goroutine 使用了栈内存的四分之一,那就将其内存减少一半,这样在栈内存几乎充满时也只会扩容一次,不会因为函数调用频繁扩缩容。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
第1章 5个例子 1 1.1 开始 1 1.2 编辑、编译和运行 3 1.3 Hello Who? 6 1.4 大数字——二维切片 8 1.5 ——自定义类型及其方法 12 1.6 americanise示例——文件、映射和闭包 18 1.7 从极坐标到笛卡儿坐标——并发 28 1.8 练习 33 第2章 布尔与数值类型 35 2.1 基础 35 2.2 布尔值和布尔表达式 39 2.3 数值类型 40 2.3.1 整型 42 2.3.2 浮点类型 46 2.4 例子:statistics 53 2.4.1 实现一个简单的统计函数 54 2.4.2 实现一个基本的HTTP服务器 55 2.5 练习 58 第3章 字符串 60 3.1 字面量、操作符和转义 61 3.2 比较字符串 63 3.3 字符和字符串 65 3.4 字符串索引与切片 67 3.5 使用fmt包来格式化字符串 69 3.5.1 格式化布尔值 73 3.5.2 格式化整数 74 3.5.3 格式化字符 75 3.5.4 格式化浮点数 75 3.5.5 格式化字符串和切片 76 3.5.6 为调试格式化 78 3.6 其他字符处理相关的包 80 3.6.1 strings包 81 3.6.2 strconv包 86 3.6.3 utf8包 90 3.6.4 unicode包 91 3.6.5 regexp包 92 3.7 例子:m3u2pls 101 3.8 练习 106 第4章 集合类型 108 4.1 值、指针和引用类型 108 4.2 数组和切片 115 4.2.1 索引与分割切片 119 4.2.2 遍历切片 119 4.2.3 修改切片 121 4.2.4 排序和搜索切片 125 4.3 映射 128 4.3.1 创建和填充映射 129 4.3.2 映射查询 131 4.3.3 修改映射 132 4.3.4 键序遍历映射 132 4.3.5 映射反转 133 4.4 例子 134 4.4.1 猜测分隔符 134 4.4.2 词频统计 136 4.5 练习 141 第5章 过程式编程 144 5.1 语句基础 144 5.1.1 类型转换 147 5.1.2 类型断言 148 5.2 分支 149 5.2.1 if语句 150 5.2.2 switch语句 151 5.3 for循环语句 158 5.4 通信和并发语句 160 5.5 defer、panic和recover 166 5.6 自定义函数 171 5.6.1 函数参数 172 5.6.2 init()函数和main()函数 175 5.6.3 闭包 176 5.6.4 递归函数 178 5.6.5 运行时选择函数 181 5.6.6 泛型函数 183 5.6.7 高阶函数 187 5.7 例子:缩进排序 192 5.8 练习 197 第6章 面向对象编程 199 6.1 几个关键概念 199 6.2 自定义类型 201 6.2.1 添加方法 203 6.2.2 验证类型 207 6.3 接口 209 6.4 结构体 217 6.5 例子 224 6.5.1 FuzzyBool——一个单值自定义类型 224 6.5.2 Shapes——一系列自定义类型 229 6.5.3 有序映射——一个通用的集合类型 240 6.6 练习 248 第7章 并发编程 251 7.1 关键概念 252 7.2 例子 256 7.2.1 过滤器 256 7.2.2 并发的Grep 260 7.2.3 线程安全的映射 266 7.2.4 Apache报告 271 7.2.5 查找副本 278 7.3 练习 285 第8章 文件处理 287 8.1 自定义数据文件 287 8.1.1 处理JSON文件 290 8.1.2 处理XML文件 295 8.1.3 处理纯文本文件 301 8.1.4 处理Go语言二进制文件 307 8.1.5 处理自定义的二进制文件 309 8.2 归档文件 317 8.2.1 创建zip归档文件 317 8.2.2 创建可压缩的tar包 319 8.2.3 解开zip归档文件 321 8.2.4 解开tar归档文件 322 8.3 练习 324 第9章 包 326 9.1 自定义包 326 9.1.1 创建自定义的包 327 9.1.2 导入包 333 9.2 第三方包 334 9.3 Go命令行工具简介 335 9.4 Go标准库简介 336 9.4.1 归档和压缩包 336 9.4.2 字节流和字符串相关的包 336 9.4.3 容器包 337 9.4.4 文件和操作系统相关的包 339 9.4.5 图像处理相关的包 341 9.4.6 数学处理包 341 9.4.7 其他一些包 341 9.4.8 网络包 342 9.4.9 反射包 343 9.5 练习 346 附录A 后记 348 附录B 软件专利的危害 350 附录C 精选书目 353

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

胡桃姓胡,蝴蝶也姓胡

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

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

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

打赏作者

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

抵扣说明:

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

余额充值