本文翻译自 《Visualizing memory management in Golang》 https://deepu.tech/memory-management-in-golang/ .
"内存管理" 系列的一部分
在这个由多部分组成的系列文章中, 我旨在揭示内存管理背后的概念, 并对某些现代编程语言的内存管理机制做更深入的探究. 我希望该系列文章可以使您对这些语言在内存管理方面正在发生的事情能有所了解.
在本章中, 我们将研究 Go 编程语言 (Golang) 的内存管理. 和 C/C++,Rust 等一样, Go 是一种静态类型的编译型语言. 因此, Go 不需要 VM,Go 应用程序二进制文件中嵌入了一个小型运行时(Go runtime), 可以处理诸如垃圾收集(GC), 调度和并发之类的语言功能.
如果您还没有阅读本系列的 第一部分 , 请先阅读它, 因为在那篇文章中我解释了栈 (stack) 和堆 (heap) 内存之间的区别, 这对于理解本文很有用.
这篇文章基于 Go 1.13 的默认官方实现, 有些概念细节可能会在 Go 的未来版本中发生变化
Go 内部内存结构
首先, 让我们看看 Go 内部的内存结构是什么样子的.
Go 运行时将 Goroutines(G)调度到逻辑处理器 (P) 上执行. 每个 P 都有一台逻辑机器(M). 在这篇文章中, 我们将使用 P,M 和 G. 如果您不熟悉 Go 调度程序, 请先阅读 《Go 调度程序: Ms,Ps 和 Gs》 .
Goroutine 调度原理
每个 Go 程序进程都由操作系统 (OS) 分配了一些虚拟内存, 这是该进程可以访问的全部内存. 在这个虚拟内存中实际正在使用的内存称为 Resident Set(驻留内存). 该空间由内部内存结构管理, 如下所示:
Go 内部内存结构原理图
这是一个简化的视图, 基于 Go 使用的内部对象. 实际上, Go 将内存划分和分组为页(page), 就像这篇文章描述的那样.
这与我们在前几章中看到的 JVM https://deepu.tech/memory-management-in-jvm/ 和 V8 https://deepu.tech/memory-management-in-v8/ 的内存结构完全不同. 如您所见, 这里没有分代内存. 这样做的主要原因是 TCMalloc (线程缓存 Malloc),Go 自己的内存分配器正是基于该模型实现的.
让我们看看 Go 独特的内存构造是什么样子的:
页堆 page heap(mheap)
这里是 Go 存储动态数据 (在编译时无法计算大小的任何数据) 的地方. 它是最大的内存块, 也是进行垃圾收集 (GC) 的地方.
驻留内存 (resident set) 被划分为每个大小为 8KB 的页, 并由一个全局 mheap 对象 管理.
大对象 (大小> 32kb 的对象) 直接从 mheap 分配. 这些大对象申请请求是以获取中央锁 (central lock) 为代价的, 因此在任何给定时间点只能满足一个 P 的请求.
mheap 通过将页归类为不同结构进行管理的:
mspan:mspan 是 mheap 中管理的内存页的最基本结构. 这是一个双向链接列表, 其中包含起始页面的地址, span size class 和 span 中的页面数量. 像 TCMalloc 一样, Go 将内存页按大小分为 67 个不同类别, 大小从 8 字节到 32KB, 如下图所示
mspan 结构
每个 span 存在两个, 一个 span 用于带指针的对象(scan class), 一个用于无指针的对象(noscan class). 这在 GC 期间有帮助, 因为 noscan 类查找活动对象时无需遍历 span.
mcentral:mcentral 将相同大小级别的 span 归类在一起. 每个 mcentral 包含两个 mspanList:
empty: 双向 span 链表, 包括没有空闲对象的 span 或缓存 mcache 中的 span. 当此处的 span 被释放时, 它将被移至 non-empty span 链表.
non-empty: 有空闲对象的 span 双向链表. 当从 mcentral 请求新的 span,mcentral 将从该链表中获取 span 并将其移入 empty span 链表.
如果 mcentral 没有可用的 span, 它将向 mheap 请求新页.
arena: 堆在已分配的虚拟内存中根据需要增长和缩小. 当需要更多内存时, mheap 从虚拟内存中以每块 64MB(对于 64 位体系结构)为单位获取新内存, 这块内存被称为 arena . 这块内存也会被划分页并映射到 span.
mcache: 这是一个非常有趣的构造. mcache 是提供给 P(逻辑处理器)的高速缓存, 用于存储小对象(对象大小<= 32Kb). 尽管这类似于线程堆栈, 但它是堆的一部分, 用于动态数据. 所有类大小的 mcache 包含 scan 和 noscan 类型 mspan.Goroutine 可以从 mcache 没有任何锁的情况下获取内存, 因为一次 P 只能有一个锁 G. 因此, 这更有效. mcache 从 mcentral 需要时请求新的 span.
栈
这是栈存储区, 每个 Goroutine(G)有一个栈. 在这里存储了静态数据, 包括函数栈帧, 静态结构, 原生类型值和指向动态结构的指针. 这与分配给每个 P 的 mcache 不是一回事.
Go 内存使用(栈与堆)
现在我们已经清楚了内存的组织方式, 现在让我们看看程序执行时 Go 是如何使用 Stack 和 Heap 的.
我们使用下面的这个 Go 程序, 代码没有针对正确性进行优化, 因此可以忽略诸如不必要的中间变量之类的问题, 因此, 重点是可视化栈和堆内存的使用情况.
- package main
- import "fmt"
- type Employee struct {
- name string
- salary int
- sales int
- bonus int
- }
- const BONUS_PERCENTAGE = 10
- func getBonusPercentage(salary int) int {
- percentage := (salary * BONUS_PERCENTAGE) / 100
- return percentage
- }
- func findEmployeeBonus(salary, noOfSales int) int {
- bonusPercentage := getBonusPercentage(salary)
- bonus := bonusPercentage * noOfSales
- return bonus
- }
- func main() {
- var john = Employee{"John", 5000, 5, 0}
- john.bonus = findEmployeeBonus(john.salary, john.sales)
- fmt.Println(john.bonus)
- }
与许多垃圾回收语言相比, Go 的一个主要区别是许多对象直接在程序栈上分配. Go 编译器使用一种称为 "逃逸分析" 的过程来查找其生命周期在编译时已知的对象, 并将它们分配在栈上, 而不是在垃圾回收的堆内存中. 在编译过程中, Go 进行了逃逸分析, 以确定哪些可以放入栈(静态数据), 哪些需要放入堆(动态数据). 我们可以通过运行带有 -gcflags '-m' 标志的 go build 命令来查看分析的细节. 对于上面的代码, 它将输出如下内容:
- go build -gcflags '-m' gc.go
- # command-line-arguments
- temp/gc.go:14:6: can inline getBonusPercentage
- temp/gc.go:19:6: can inline findEmployeeBonus
- temp/gc.go:20:39: inlining call to getBonusPercentage
- temp/gc.go:27:32: inlining call to findEmployeeBonus
- temp/gc.go:27:32: inlining call to getBonusPercentage
- temp/gc.go:28:13: inlining call to fmt.Println
- temp/gc.go:28:18: john.bonus escapes to heap
- temp/gc.go:28:13: io.Writer(os.Stdout) escapes to heap
- temp/gc.go:28:13: main []interface {
- } literal does not escape
- <autogenerated>:1: os.(*File).close .this does not escape
让我们将其可视化. 单击下方图片下载幻灯片, 然后翻阅幻灯片, 以查看上述程序是如何执行的以及如何使用栈和堆存储器的:
可视化程序执行过程中栈和堆的使用
正如你看到的:
main 函数被保存栈中的 "main 栈帧" 中
每个函数调用都作为一个栈帧块被添加到堆中
包括参数和返回值在内的所有静态变量都保存在函数的栈帧块内
无论类型如何, 所有静态值都直接存储在栈中. 这也适用于全局范畴
所有动态类型都在堆上创建, 并且被栈上的指针所引用. 小于 32Kb 的对象由 P 的 mcache 分配. 这同样适用于全局范畴
具有静态数据的结构体保留在栈上, 直到在该位置将任何动态值添加到该结构中为止. 该结构被移到堆上.
从当前函数调用的函数被推入堆顶部
当函数返回时, 其栈帧将从栈中删除
一旦主过程 (main) 完成, 堆上的对象将不再具有来自 Stack 的指针的引用, 并成为孤立对象
您可以看到, 栈是由操作系统自动管理的, 而不是 Go 本身. 因此, 我们不必担心栈. 另一方面, 堆并不是由操作系统自动管理的, 并且由于其具有最大的内存空间并保存动态数据, 因此它可能会成倍增长, 从而导致我们的程序随着时间耗尽内存. 随着时间的流逝, 它也变得支离破碎, 使应用程序变慢. 解决这些问题是垃圾收集的初衷.
Go 内存管理
Go 的内存管理包括在需要内存时自动分配内存, 在不再需要内存时进行垃圾回收. 这是由标准库完成的(译注: 应该是运行时完成的). 与 C/C++ 不同, 开发人员不必处理它, 并且 Go 进行的基础管理得到了高效的优化.
内存分配
许多采用垃圾收集的编程语言都使用分代内存结构来使收集高效, 同时进行压缩以减少碎片. 正如我们前面所看到的, Go 在这里采用了不同的方法, Go 在构造内存方面有很大的不同. Go 使用线程本地缓存 (thread local cache) 来加速小对象分配, 并维护着 scan/noscan 的 span 来加速 GC. 这种结构以及整个过程避免了碎片, 从而在 GC 期间无需做紧缩处理. 让我们看看这种分配是如何发生的.
Go 根据对象的大小决定对象的分配过程, 分为三类:
微小对象(Tiny)(size <16B): 使用 mcache 的微小分配器分配大小小于 16 个字节的对象. 这是高效的, 并且在单个 16 字节块上可完成多个微小分配.
微小分配
小对象(尺寸 16B32KB): 大小在 16 个字节和 32k 字节之间的对象被分配在 G 运行所在的 P 的 mcache 的对应的 mspan size class 上.
小对象分配
在微小型和小型对象分配中, 如果 mspan 的列表为空, 分配器将从 mheap 获取大量的页面用于 mspan. 如果 mheap 为空或没有足够大的页面满足分配请求, 那么它将从操作系统中分配一组新的页(至少 1MB).
大对象(大小> 32KB): 大于 32 KB 的对象直接分配在 mheap 的相应大小类上(size class). 如果 mheap 为空或没有足够大的页面满足分配请求, 则它将从操作系统中分配一组新的页(至少 1MB).
大对象分配
注意: 您可以在 此处 找到以幻灯片形式记录的 GIF 图像
垃圾收集(GC)
现在我们知道 Go 如何分配内存了, 让我们再看看它是如何自动回收堆内存的, 这对于应用程序的性能非常重要. 当程序尝试在堆上分配的内存大于可用内存时, 我们会遇到内存不足的错误(out of memory). 不当的堆内存管理也可能导致内存泄漏.
Go 通过垃圾回收机制管理堆内存. 简单来说, 它释放了孤儿对象 (orphan object) 使用的内存, 所谓孤儿对象是指那些不再被栈直接或间接 (通过另一个对象中的引用) 引用的对象, 从而为创建新对象的分配腾出了空间.
从 Go 1.12 版本开始, Go 使用了非分代的, 并发的, 基于三色标记和清除的垃圾回收器. 收集过程大致如下所示, 由于版本之间的差异, 我不想做细节的描述. 但是, 如果您对此感兴趣, 那么我推荐这个很棒的 系列文章 .
当完成一定百分比 (GC 百分比) 的堆分配, GC 过程就开始了. 收集器将在不同工作阶段执行不同的工作:
标记设置(mark setup, stw):GC 启动时, 收集器将打开写屏障(write barrier), 以便可以在下一个并发阶段维护数据完整性. 此步骤需要非常小的暂停(stw), 因此每个正在运行的 Goroutine 都会暂停以启用此功能, 然后继续.
标记(并发执行的): 打开写屏障后, 实际的标记过程将并行启动, 这个过程将使用可用 CPU 能力的 25%. 对应的 P 将保留, 直到该标记过程完成. 这个过程是使用专用的 Goroutines 完成的. 在这个过程中, GC 标记了堆中的活动对象(被任何活动的 Goroutine 的栈中引用的). 当采集花费更长的时间时, 该过程可以从应用程序中征用活动的 Goroutine 来辅助标记过程. 这称为 Mark Assist .
标记终止(stw): 标记一旦完成, 每个活动的 Goroutine 都会暂停, 写入屏障将关闭, 清理任务将开始执行. GC 还会在此处计算下一个 GC 目标. 完成此操作后, 保留的 P 的会释放回应用程序.
清除(并发): 当完成收集并尝试分配后, 清除过程开始将未标记为活动的对象回收. 清除的内存量与分配的内存量是同步的(即回收后的内存马上可以被再分配了).
让我们在一个 Goroutine 中看看这个过程. 为了简洁起见, 将对象的数量保持较小. 单击下面图片, 可下载幻灯片, 然后翻阅幻灯片查看该过程:
xx
我们以一个 Goroutine 为例, 实际过程是对所有活动 Goroutine 都进行的. 首先打开写屏障.
标记过程选择 GC root 并将其着色为黑色, 并以深度优先的树状方式遍历该该根节点里面的指针, 将遇到的每个对象都标记为灰色
当它到达 noscan span 中的某个对象或某个对象不再有指针时, 它完成了这个根节点的标记操作并选取下一个 GC root 对象
当扫描完所有 GC root 节点之后, 它将选取灰色对象, 并以类似方式继续遍历其指针
如果在打开写屏障时, 指向对象的指针发生任何变化, 则该对象将变为灰色, 以便 GC 对其进行重新扫描
当不再有灰色对象留下时, 标记过程完成, 并且写屏障被关闭
当分配开始时(因为写屏障关闭了), 清除过程也会同步进行
我们看到这里有一些停止世界 (stop) 的过程, 但是通常这个过程非常快, 在大多数情况下可以忽略不计. 对象的着色在 span 的 gcmarkBits 属性中进行.
结论
这篇文章为您提供了 Go 内存结构和内存管理的概述. 这里不是全面详尽的说明, 有许多更高级的概念, 实现细节在各个版本之间都在不断变化. 但是对于大多数 Go 开发人员来说, 这些信息就已经足够了, 我希望它能帮助您编写出更好的, 性能更高的应用程序, 牢记这些, 将有助于您避免下一个内存泄漏问题.
参考文献
- blog.learngoprogramming.com https://blog.learngoprogramming.com/a-visual-guide-to-golang-memory-allocator-from-ground-up-e132258453ed
- www.ardanlabs.com https://www.ardanlabs.com/blog/2018/12/garbage-collection-in-go-part1-semantics.html
- povilasv.me https://povilasv.me/go-memory-management/
- medium.com/a-journey-with-go https://medium.com/a-journey-with-go/go-memory-management-and-allocation-a7396d430f44
- medium.com/a-journey-with-go https://medium.com/a-journey-with-go/go-how-does-the-garbage-collector-mark-the-memory-72cfc12c6976
- hub.packtpub.com https://hub.packtpub.com/implementing-memory-management-with-golang-garbage-collector/
- making.pusher.com https://making.pusher.com/golangs-real-time-gc-in-theory-and-practice/
- segment.com/blog https://segment.com/blog/allocation-efficiency-in-high-performance-go-services/
- go101.org https://go101.org/article/memory-block.html
我的网课 "Kubernetes 实战: 高可用集群搭建, 配置, 运维与应用 https://coding.imooc.com/class/284.html" 在慕课网上线了, 感谢小伙伴们学习支持!
我爱发短信 https://51smspush.com/ : 企业级短信平台定制开发专家 https://51smspush.com/
smspush : 可部署在企业内部的定制化短信平台, 三网覆盖, 不惧大并发接入, 可定制扩展; 短信内容你来定, 不再受约束, 接口丰富, 支持长短信, 签名可选.
著名云主机服务厂商 DigitalOcean 发布最新的主机计划, 入门级 Droplet 配置升级为: 1 core CPU,1G 内存, 25G 高速 SSD, 价格 5$/ 月. 有使用 DigitalOcean 需求的朋友, 可以打开这个 链接地址 https://m.do.co/c/bff6eed92687 :https://m.do.co/c/bff6eed92687 开启你的 DO 主机之路.
Gopher Daily(Gopher 每日新闻)归档仓库 - https://github.com/bigwhite/gopherdaily
我的联系方式:
微博: https://weibo.com/bigwhite20xx
微信公众号: iamtonybai
博客: tonybai.com
GitHub: https://github.com/bigwhite
微信赞赏:
商务合作方式: 撰稿, 出书, 培训, 在线课程, 合伙创业, 咨询, 广告合作.
© 2020,bigwhite. 版权所有.
来源: http://www.tuicool.com/articles/bM7raei