原文:🚀 Visualizing memory management in Golang

在本章中, 我们将研究 Go 编程语言(golang)的内存管理。Go 是一种静态类型化和编译的语言,和 C/C++ 和 Rust一样。因此,Go 不需要 VM, 而 Go 应用程序二进制文件中嵌入了一个小型运行时程序,以处理诸如垃圾收集,调度和并发之类的语言功能。

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

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

Go内部存储结构

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

Go运行时将Goroutines(G)调度到逻辑处理器(P)上执行。每个 P 都有一台机器(M)。在这篇文章中,我们将使用P, MG。如果您不熟悉Go调度程序,请先阅读Go调度器:Ms,Ps和Gs

去调度程序

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

记忆结构

这是一个简化视图,基于Go使用的内部对象。实际上,Go如本文中所述将内存划分并分组为页面。

这与我们在前几章中针对JVMV8看到的内存结构完全不同。如您所见,这里没有世代记忆。这样做的主要原因是TCMalloc(线程缓存Malloc),它是Go自己的内存分配器的模型。

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

页堆(mheap

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

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

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

mheap 管理页分为以下不同的结构:

  • mspanmspan是在 mheap 中管理内存页的最基本结构。这是一个双向链接列表,其中包含起始页面的地址,span 大小类和 span 中的页数。像TCMalloc一样,Go还将内存页按大小分为67个不同类的块,大小从8个字节开始,最高到32 KB,如下图所示

    img

    每个 span 存在两次,一次用于带指针的对象(扫描类),另一个用于无指针的对象(**非扫描**类)。这在GC期间有帮助的,因为 noscan 的 span 无需遍历即可查找活动对象。

  • mcentralmcentral将相同大小级别的 span 分组在一起。每个mcentral包含两个mspanList

    • :span 的双向链表,没有空闲对象或 span 被缓存到 mcache 中。当这里的跨度被释放时,它将被移到非空列表。
    • 非空:带有释放对象的 span 的双链表。当从 mcentral 中请求新的 span 时,它将从非空列表中获取该 span 并将其移入空列表。

    如果mcentral没有可用的span,它将从 mheap 中请求新的运行页。

  • arena:堆内存在分配的虚拟内存中根据需要增长和缩小。当需要更多内存时,mheap从虚拟内存中将它们作为64MB大小的块(对于64位体系结构)拉出,被称为 arena。页在此处映射到span。

  • mcache:这是一个非常有趣的构造。mcache是提供给P(逻辑处理器)的内存缓存,用于存储小对象(对象大小<= 32Kb)。尽管这类似于线程栈,但它是堆的一部分,用于动态数据。所有类大小的mcache包含scannoscan类型mspan。因为 P 每次只能有一个G,Goroutine可以从mcache没有任何锁的情况下获取内存,。因此,这更有效。mcachemcentral需要时请求新的span。

这是栈存储区,每个Goroutine(G)有一个栈。在这里存储了静态数据,包括帧,静态结构,原始值和指向动态结构的指针。这与分配给Pmcache不一样

Go内存使用(栈与堆)

现在我们已经清楚了内存的组织方式,让我们看看Go在执行程序时如何使用Stack和Heap。

让我们使用下面的Go程序,代码没有针对正确性进行优化,因此可以忽略诸如不必要的中间变量之类的问题,因此,重点是可视化堆栈和堆内存的使用情况。

 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
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 看到这段细节。对于上面的代码,它将输出如下内容:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
❯ 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 

让我们将其可视化。单击幻灯片,然后使用箭头键向前/向后移动,以查看上述程序的执行以及如何使用栈和堆内存:

注意:如果幻灯片的边缘看起来被切掉,请单击幻灯片的标题或在此处直接在SpeakerDeck中打开它。

如你看到的:

  • Main函数保存在栈的“main frame”中
  • 每个函数调用都作为一个帧块添加到栈内存中
  • 所有静态变量(包括参数和返回值)都保存在栈上的函数帧块内
  • 无论类型如何,所有静态值都直接存储在栈中。这也适用于全局范围
  • 所有的动态类型都在堆上被创建,并使用栈指针从栈中引用。大小小于32Kb的对象转到mcacheP上。这也适用于全局范围
  • 具有静态数据的结构体将保留在栈上,直到任何动态值添加到该结构为止
  • 从当前函数调用的函数被推入栈顶
  • 函数返回时,将其帧从堆栈中删除
  • 一旦主进程完成,堆上的对象将不再具有来自Stack的指针,并成为孤立对象

如您所见,栈是由操作系统自动管理的,而不是Go本身。因此,我们不必担心栈。另一方面,Heap并不是由操作系统自动管理的,并且由于其最大的内存空间并保存动态数据,因此它可能呈指数增长,从而导致我们的程序随着时间耗尽内存。随着时间的流逝,它也变得支离破碎,使应用程序变慢。这是垃圾收集的来源。

Go内存管理

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

内存分配

许多采用垃圾收集的编程语言都使用代内存结构来使收集高效,同时进行压缩以减少碎片。正如我们前面所看到的,Go在这里采用了不同的方法,Go在构造内存方面有很大不同。Go使用线程本地缓存来加速小对象分配,并维护scan/noscan span来加速GC。这种结构以及整个过程避免了碎片,从而在GC期间无需压缩。让我们看看这种分配是如何发生的。

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

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

微小的分配

小(尺寸16B〜32KB):16个字节和32千字节之间的大小的对象被分配在对应的尺寸类(mspanmcache上,该 mcache位于正在运行 GP上。

小分配

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

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

大量分配

注意:您可以在此处找到上述GIF图像作为幻灯片放映

垃圾收集

现在我们知道Go如何分配内存,让我们看看它如何自动收集Heap内存,这对于应用程序的性能非常重要。当程序尝试在堆上分配的内存大于可用内存时,我们会遇到内存不足错误。管理不当的堆也可能导致内存泄漏。

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

从1.12版开始,Golang使用了非世代的并发三色标记和清除收集器。收集过程大致如下所示,由于版本之间的差异,我不想赘述。但是,如果您对此感兴趣,那么我推荐这个很棒的系列。

当完成一定百分比(GC百分比)的堆分配并且收集器执行不同的工作阶段时,该过程开始:

  • 标记设置(停止运行):GC启动时,收集器将打开**写屏障,**以便可以在下一个并发阶段维护数据完整性。此步骤需要非常小的暂停,因为每个正在运行的Goroutine都会暂停以启用此功能,然后继续。
  • 标记(并发):打开写屏障后,将使用25%的可用CPU容量与应用程序并行启动实际标记过程。P保留对应的,直到标记完成。这是使用专用Goroutines完成的。在这里,GC标记了活动堆中的值(从任何活动Goroutine的堆栈中引用)。当采集花费更长的时间时,该过程可以从应用程序中使用主动Goroutine来辅助标记过程。这称为标记辅助
  • 标记终止(停止工作):标记完成后,每个活动的Goroutine都会暂停,并关闭写屏障,并开始执行清理任务。GC还会在此处计算下一个GC目标。完成此操作后,保留P的会释放回应用程序。
  • 清除(并发):完成收集并尝试分配后,清除进程开始从未标记为活动的堆中回收内存。扫描的内存量与分配的内存量同步。

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

注意:如果幻灯片的边缘看起来被切掉,请单击幻灯片的标题或在此处直接在SpeakerDeck中打开它。

  1. 我们正在查看单个Goroutine,实际过程将对所有活动Goroutine执行此操作。首先打开写屏障。
  2. 标记过程选择GC根并将其着色为黑色,并以深度优先的树状方式遍历该指针,将遇到的每个对象标记为灰色
  3. 当它到达noscan跨度中的某个对象或某个对象不再有指针时,它将完成根操作并拾取下一个GC根对象
  4. 扫描完所有GC根之后,它将拾取灰色对象,并以类似方式继续遍历其指针
  5. 如果在打开写屏障时对对象的指针发生了任何变化,则该对象将变为灰色,以便GC对其进行重新扫描
  6. 当不再有灰色物体留下时,标记过程完成,并且写入屏障被关闭
  7. 分配开始时将进行扫描

这有一些停滞不前的过程,但是通常在大多数情况下可以忽略不计。对象的着色gcmarkBits在跨度的属性中进行。

结论

这篇文章应该为您提供Go内存结构和内存管理的概述。这不是详尽无遗的,有许多更高级的概念,实现细节在各个版本之间不断变化。但是对于大多数Go开发人员来说,这种信息水平就足够了,我希望它能帮助您编写出更好的代码(考虑到这些),以获得性能更高的应用程序,并牢记这些,将有助于您避免可能遇到的下一个内存泄漏问题。

希望您能从中学到快乐,请继续关注本系列的下一篇文章。

参考文献