在Go语言中可视化内存管理

![[Pasted image 20220718233714.png]]

这是”内存管理”系列的一部分
  1. [[揭开现代编程语言中内存管理的神秘面纱]]
  2. [[在JVM中可视化内存管理(Java、Kotlin、Scala、Groovy、Clojure)]]
  3. [[在V8引擎中可视化内存管理 (Javascript、NodeJS、Deno、WebAssembly)]]
  4. [[在Go语言中可视化内存管理]]
  5. [[在Rust中可视化内存管理]]
  6. [[避免NodeJS中的内存泄露:性能最佳实践]]

在这个由多部分组成的系列中,我旨在揭开内存管理背后的概念的神秘面纱,并深入了解一些现代编程语言中的内存管理。我希望这个系列能让您对这些语言在内存管理方面发生的事情有所了解。

在本章中,我们将了解 Go 编程语言(Golang)的内存管理。 Go 是一种像 C/C++ 和 Rust 一样的静态类型和编译语言。因此 Go 不需要 VM,Go 应用程序二进制文件包含一个嵌入其中的小型运行时来处理垃圾收集、调度和并发等语言功能。

如果您还没有阅读本系列的第一部分,请先阅读它,因为我解释了栈和堆内存之间的区别,这将有助于理解本章。


本文基于 Go 1.13 默认官方实现,概念细节可能会在 Go 的未来版本中发生变化

Go 内存结构

首先,让我们看看 Go 的内部存储器结构是什么。

Go Runtime 将 Goroutines (G) 调度到逻辑处理器 (P) 上执行。每个 P 有一台机器 (M)。我们将在这篇文章中使用 P、M 和 G。如果你不熟悉 Go 调度器,请先阅读 Go 调度器:[[Part1-GoSchedulerMPG]] Go scheduler: Ms, Ps & Gs

![[Pasted image 20220718234741.png]]

每个 Go 程序进程都被操作系统(OS)分配了一些虚拟内存,这是该进程可以访问的总内存。在虚拟内存中使用的实际内存称为驻留集。该空间由内部存储器结构管理,如下所示:

![[Pasted image 20220719094353.png]]

这是一个基于Go使用的内部对象的简化视图。实际上,Go像这篇很好的文章中描述的那样,将内存划分为多个页面。this great article [[译文:Go 内存分配器可视化指南]].

这与我们在前面章节中看到的 JVM 和 V8 的内存结构有很大不同。如您所见,这里没有分代内存。造成这种情况的主要原因是 TCMalloc(Thread-Caching Malloc),这是 Go 自己的内存分配器的模型。

让我们看看不同的构造是什么:

Page Heap(mheap)

这是 Go 存储动态数据的地方(在编译时无法计算大小的任何数据)。这是最大的内存块,也是 垃圾收集(GC) 发生的地方。

驻留集被分成每页 8KB 的 page,并由一个全局 mheap 对象管理。

大对象(大小为> 32kb的对象)直接从mheap分配。这些大型请求以牺牲中央锁为代价,因此在任何给定的时间点只能处理一个P的请求。

mheap 管理分组为不同结构的页面,如下所示:

  • mspan: Mspan是管理mheap中内存页面的最基本结构。 它是一个双链接列表,保存起始页的地址、span size类和span中的页数。 像TCMalloc一样,Go也将内存页按大小划分为67个不同的类块,从8字节到32字节,如下图所示

![[Pasted image 20220721142911.png]]

每个 span 存在两类,一类用于有指针的对象(scan class),一类用于没有指针的对象(noscan class)。这在 GC 期间很有帮助,因为不需要遍历 noscan span 来查找活动对象。
mcentralmcentral 将相同大小的 span class 组合在一起。每个 mcentral 包含两个 mspanList
empty: 没有空闲对象或缓存在 mcache 中的 span 的双链表。当此处的 span 被释放时,它会被移动到非空列表中。
non-empty: 具有自由对象的跨度的双链表。当从 mcentral 请求一个新的 span 时,它会从非空列表中获取它并将其移动到空列表中。
当 mcentral 没有任何空闲跨度时,它会从 mheap 请求新的页面运行。
arena: 堆内存根据需要在分配的虚拟内存中增加或减少。当需要更多内存时,mheap将它们作为一个64MB的块(对于64位架构)从虚拟内存中取出,称为arena。页面被映射到这里的spans。
mcache: 这是一个非常有趣的结构。mcache是提供给P(逻辑处理器)的内存缓存,用于存储小对象(对象大小<=32Kb)。虽然这类似于线程栈,但它是堆的一部分,用于动态数据。对于所有类大小,mcache包含mspanscannoscan类型。Goroutines可以在没有任何锁的情况下从mcache获取内存,因为一个P一次只能有一个G。因此,这更有效率。mcache在需要时从mccentral请求新的spans。

Stack

这是栈内存区域,每个 Goroutine(G) 有一个栈。这是存储静态数据的地方,包括函数帧、静态结构、原始值和指向动态结构的指针。这与分配给 P 的 mcache 不同`


Go 内存使用(堆栈与堆)Go memory usage (Stack vs Heap)

现在我们已经清楚了内存是如何组织的,让我们看看 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 会进行逃逸分析,以确定哪些可以进入 Stack(静态数据),哪些需要进入 Heap(动态数据)。我们可以在编译期间通过运行带有 -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

让我们看看上面的程序是如何执行的,以及如何使用栈和堆内存:

![[Pasted image 20220721230538.png]]
![[Pasted image 20220721230546.png]]
![[Pasted image 20220721230554.png]]
![[Pasted image 20220721230604.png]]
![[Pasted image 20220721230611.png]]
![[Pasted image 20220721230621.png]]
![[Pasted image 20220721230627.png]]
![[Pasted image 20220721230633.png]]
![[Pasted image 20220721230639.png]]
![[Pasted image 20220721230645.png]]
![[Pasted image 20220721230653.png]]
![[Pasted image 20220721230701.png]]
![[Pasted image 20220721230708.png]]

如你所见:

  • Main 函数保存在stack的 main frame
  • 每个函数调用都作为帧块添加到栈内存中
  • 所有静态变量包括参数和返回值都保存在栈上的函数帧块中
  • 所有静态值,无论类型,都直接存储在 Stack 上。这也适用于全局范围
  • 所有动态类型都是在堆上创建的,并使用栈指针从栈中引用。小于32Kb的对象被送到 Pmcache 中。这也适用于全局范围
  • 带有静态数据的 struct 被保存在栈上,直到添加任何动态值时,该 struct 被移动到堆中
  • 从当前函数调用的函数会push到 Stack 的顶部
  • 当函数返回时,函数帧块从 Stack 中移除
  • 一旦主进程完成,堆上的对象就不再有来自 Stack 的指针,变成孤儿

如您所见,栈是由操作系统自动管理的,而不是 Go 本身。因此,我们不必太担心栈。另一方面,堆不是由操作系统自动管理的,因为它是最大的内存空间并保存动态数据,它可能会呈指数增长,导致我们的程序随着时间的推移耗尽内存。随着时间的推移,它也会变得支离破碎,从而减慢应用程序的速度。这就是垃圾收集的用武之地。


Go的内存管理 Go Memory management

Go 的内存管理包括在需要内存时自动分配和在不再需要内存时进行垃圾收集。它是由标准库完成的。与 C/C++ 不同,开发人员不必处理它,Go 完成的底层管理得到了很好的优化和高效。

内存分配 Memory Allocation

许多采用垃圾收集的编程语言使用分代内存结构来提高收集效率,同时使用压缩来减少碎片。正如我们之前所见,Go 在这里采用了不同的方法,Go 的内存结构完全不同。 Go 使用线程本地缓存来加速小对象分配并维护扫描/非扫描 spans 以加速 GC。这种结构与过程一起避免了碎片化,使得GC期间压缩变得不必要。让我们看看这个分配是如何发生的。

Go 根据对象的大小来决定对象的分配过程,分为三类:

Tiny(size < 16B): 使用 mcache 的 tiny 分配器分配大小小于 16 字节的对象。这是高效的,并且多个微小的分配是在单个 16 字节块上完成的。

![[Kh26oVp.gif]]

Small(size 16B ~ 32KB): 大小在 16 字节和 32 千字节之间的对象分配在运行 G 的 P 的 mcache 上的相应大小类(mspan)上。

![[uLhLZMm.gif]]

在小分配和小分配中,如果 mspan 的列表为空,分配器将从 mheap 获取一系列页面以用于 mspan。如果 mheap 为空或没有足够大的页面运行,则它会从操作系统分配一组新的页面(至少 1MB)。

Large(size > 32KB): 大小大于 32 KB 的对象直接分配在 mheap 的相应大小类上。如果 mheap 为空或没有足够大的页面运行,则它会从操作系统分配一组新的页面(至少 1MB)。

![[PY4pZhq.gif]]

Note: You can find the above GIF images as slideshow here

垃圾回收 Garbage collection

现在我们知道了 Go 是如何分配内存的,让我们看看它是如何自动收集堆内存的,这对应用程序的性能非常重要。当一个程序试图在堆上分配比免费可用的更多的内存时,我们会遇到 out of memory errors。管理不正确的堆也可能导致内存泄漏。

Go 通过垃圾回收来管理堆内存。简单来说,它释放了孤儿对象使用的内存,即不再直接或间接(通过另一个对象中的引用)从栈中引用的对象,为创建新对象腾出空间。

从 1.12 版本开始,Golang 使用非分代并发三色标记和扫描收集器。收集过程大致如下,我不想详细说明,因为它会随着版本的变化而变化。但是,如果您对这些感兴趣,那么我推荐这个很棒的系列。

当堆分配完成一定百分比(GC percentage),收集器开始执行不同阶段的工作:
Mark Setup (Stop the world): 当GC启动时,收集器打开写屏障,以便在下一个并发阶段保持数据完整性。这个步骤需要一个非常小的暂停,因为每个正在运行的Goroutine都会暂停以启用它,然后继续。
Marking (Concurrent): 一旦打开写屏障,实际的标记进程将与使用25%可用CPU容量的应用程序并行启动。对应的 P 保留到标记完成。这是使用专用的Goroutines完成的。这里,GC标记活动的堆中的值(引用自任何活动的Goroutines的栈)。当收集需要更长的时间时,该过程可以从应用程序中使用活动的Goroutine来协助标记过程。这叫做Mark Assist 标记辅助。
Mark Termination (Stop the world): 标记完成后,每个活动的Goroutine暂停,关闭写屏障并启动清理任务。GC还在这里计算下一个GC目标。一旦完成这个操作,保留的 P 就被释放回应用程序。
Sweeping (Concurrent): 一旦收集完成并尝试分配内存,清除进程就开始从未标记为活动的堆中回收内存。扫描的内存数量与分配的内存数量是同步的。

让我们看看这些在单个 Goroutine 中的作用。为简洁起见,对象的数量保持较小。单击幻灯片并使用箭头键向前/向后移动以查看过程:

![[Pasted image 20220721233802.png]]
![[Pasted image 20220721233808.png]]
![[Pasted image 20220721233817.png]]
![[Pasted image 20220721233825.png]]
![[Pasted image 20220721233832.png]]
![[Pasted image 20220721233838.png]]
![[Pasted image 20220721233847.png]]
![[Pasted image 20220721233855.png]]
![[Pasted image 20220721233904.png]]
![[Pasted image 20220721233910.png]]
![[Pasted image 20220721233920.png]]
![[Pasted image 20220721233927.png]]
![[Pasted image 20220721233934.png]]
![[Pasted image 20220721233941.png]]
![[Pasted image 20220721233951.png]]
![[Pasted image 20220721233957.png]]
![[Pasted image 20220721234002.png]]
![[Pasted image 20220721234008.png]]
![[Pasted image 20220721234016.png]]
![[Pasted image 20220721234023.png]]
![[Pasted image 20220721234029.png]]
![[Pasted image 20220721234035.png]]
![[Pasted image 20220721234040.png]]
![[Pasted image 20220721234046.png]]
![[Pasted image 20220721234053.png]]
![[Pasted image 20220721234109.png]]
![[Pasted image 20220721234126.png]]
![[Pasted image 20220721234132.png]]
![[Pasted image 20220721234138.png]]
![[Pasted image 20220721234144.png]]
![[Pasted image 20220721234150.png]]
![[Pasted image 20220721234156.png]]

  1. 我们关注的是一个单独的Goroutine,实际的进程为所有活动的Goroutine做这个。首先打开写屏障。
  2. 标记过程选择一个GC根并将其涂成黑色,并以类似于深度优先树的方式遍历其中的指针,它将遇到的每个对象标记为灰色
  3. 当它到达一个 noscan 范围内的对象时,或者当一个对象没有更多的指针时,它会结束对根的访问并拾取下一个GC根对象
  4. 扫描完所有GC根后,它将选取一个灰色对象,并继续以类似的方式遍历其指针
  5. 在设置写屏障时,如果对象的指针发生了任何变化,那么该对象将被染成灰色,以便GC重新扫描它
  6. 当不再有灰色对象时,标记过程结束,并关闭写屏障
  7. 分配开始时将进行清扫

这有一些停止世界的过程,但它通常非常快,在大多数时候可以忽略不计。对象的上色发生在 span 上的 gcmarkBits 属性中。


总结 Conclusion

这篇文章应该给你一个 Go 内存结构和内存管理的概述。这并不是详尽无遗的,还有很多更高级的概念,并且实现细节随着版本的变化而不断变化。但是对于大多数 Go 开发人员来说,这个级别的信息就足够了,我希望它可以帮助你编写更好的代码,考虑到这些,以获得更高性能的应用程序,记住这些将帮助你避免下一个可能遇到的内存泄漏问题否则。

我希望你学得开心,请继续关注本系列的下一篇文章。


References



已发布

分类

来自

标签:

评论

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注