WRY

Where Are You?
You are on the brave land,
To experience, to remember...

0%

Go内存管理模型

简介

Go语言和C++一样,是静态类型的编程语言,因此Go也不需要VM。Go应用程序中嵌入了一个小型运行时,可以处理诸如垃圾收集(GC)、调度和并发之类的语言功能。

Go internal memory structure

Go内部的内存结构介绍。

Go运行时将Goroutines(G)调度到逻辑处理器(P)上执行。每个P都有一台逻辑机器(M)。详细资料参阅《Go调度程序:Ms,Ps和Gs》对应本站

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

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,如下图所示

    容易发现,该结构和STL库中的内存分配模型比较相似,STL如下,也是将内存按照不同的大小块分别进行管理。

    Each span exists twice, one for objects with pointers (scan classes) and one for objects with no pointers (noscan classes). This helps during GC as noscan spans need not be traversed to look for live objects.

    针对上面这句话的解释,存在疑惑。我的理解是Span是可以知道自己存储是不是指针类型的数据。从而在垃圾回收过程中知道自己是不是叶子结点。scan class表明该Span是非叶子结点,其指向了其他的一些Span。no scan class表明该Span是叶子结点,其不再指向其他的Span

  • mcentral:将相同大小的Span统一管理,每个mcentral包含两个mSpanList

    • empty:Span的双向链表,管理了有数据的Span或者在mchache中缓存的Span,当其中的数据被释放掉,将会从empty移动到no-empty
    • no-empty:Span的双向链表,管理了所有空闲的(没有被使用)的Span,当向mcentral中申请一个Span,该Span将会从no-empty移动到empty。当no-empty没有可以提供的Span的时候,将会向mheap请求新的页
  • arean:堆在已分配的虚拟内存中根据需要增长或缩小。当需要更多内存的时候,mheap从虚拟内存中以每块64MB为单位获取内存,这块内存被称为arean,将会被划分为页,并映射成Span。

  • mcache:是一个块儿提供给P(Logic Processor)去存储<=32kb的数据,和线程栈(Thread Stack)很像,但它是堆的一部分,用于动态数据的存储。其中包含了scan和noscan两种类型的Span,并且包含所有种类的大小。Goroutines可以无锁从mcache中获取数据,因为P在某个时刻只会对应一个G。因此是更高效的,当mcache需要空间的时候会像mcentral申请。

Stack

每个G都有自己的一个栈内存区域,用于存储静态数据,包括函数栈帧,静态结构,原生类型值和指向动态结构的指针等。

值得一提的是,栈的管理是由操作系统负责的,而不是Go本身。

Go memory usage (Stack vs Heap)

下面将通过一个demo,展示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)
}
1
go build -gcflags '-m' gc.go  # -gcflags '-m' 查看编译细节
1
2
3
4
5
6
7
8
9
10
11
(base) ➜  learn go build -gcflags '-m' gc.go
# command-line-arguments
./gc.go:14:6: can inline getBonusPercentage
./gc.go:19:6: can inline findEmployeeBonus
./gc.go:20:39: inlining call to getBonusPercentage
./gc.go:27:32: inlining call to findEmployeeBonus
./gc.go:27:32: inlining call to getBonusPercentage
./gc.go:28:13: inlining call to fmt.Println
./gc.go:28:18: john.bonus escapes to heap
./gc.go:28:13: []interface {}{...} does not escape
<autogenerated>:1: .this does not escape

Go语言和其他带有垃圾回收机制的语言相比最大的不同点是,Go会将更多的对象直接分配在栈上。Go编译器使用了一种逃逸分析的技术,找出在编译时期生命周期就是已知的,将他们分配到栈上。代码执行动画,见此 若无法打开,可下载本站静态缓存

流程如下:

  • Main 函数首先被放入了栈中的一块Frame区域中
  • 每一个被调用的函数都被以Frame的形式放入了栈中
  • 包括参数和返回值在内的所有静态变量都被存储在了栈的函数Frame中
  • 无论类型如何,所有静态值都被直接放入了栈中,也适用于全局变量类型,例如本demo中的BONUS_PERCENTAGE
  • 所有动态类型都在堆上创建,并且被栈上的指针所引用。小于32k的对象,由mcache分配,同样适用于全局范畴
  • 具有静态数据的结构体保留在栈上,直到该位置将任何动态值加到该结构体,该结构体被移动到堆上(动态值与静态值的定义?)
  • 函数的调用和退出与C++的push到栈和pop出栈的逻辑相同。
  • 当主函数退出,在堆上的数据将会变成孤儿

Go Memory management

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

Memory Allocation

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

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

  • Tiny(size<16B):使用mcache的tiny allocator。多个对象的分配可以共用一个16byte的Span,十分高效

  • Small(size 16B~32KB):被分配到了对应G的对应的mcache的的对应大小的mSpan中(和tiny的区别是,对象之间不再共享一个Span)

    和Tidy类似,如果Span耗尽了,则向mheap申请一个页用于Span的使用,如果mheap是空的或者没有页则向操作系统申请。

  • Large(size>32kb):被直接分配到对应大小的mheap中。如果mheap是empty并且没有页了,则向操作系统申请足够的空间(最小1M)

文中完整PPT见此 Go_memory_allocation.pdf

Garbage collection

理解Go的垃圾回收机制,对于应用程序的性能非常重要。当程序尝试在堆上分配的内存大于可用内存时,我们会遇到内存不足的错误(out of memory)。不当的堆内存管理也可能导致内存泄漏。

Go通过垃圾回收机制管理堆内存。简单来说,它释放了孤儿对象(orphan object)使用的内存,所谓孤儿对象是指那些不再被栈直接或间接(通过另一个对象中的引用)引用的对象,从而为创建新对象的分配腾出了空间。

Go 1.12版本开始,Go使用了非分代的、并发的、基于三色标记和清除的垃圾回收器。收集过程大致如下所示,具体细节可参考系列文章

当一定比例的堆被分配了之后,GC就开始工作了,收集器将会在不同的工作阶段执行不同的工作

  • Mark Setup (Stop the world):收集器将打开写屏障,以便在下个阶段维护数据完整性。此步骤需要非常小的完全暂停(Stop the world)。
  • Marking (Concurrent):一旦写屏障被打开了,实际的标记过程将会并发执行,这个过程将会使用可用CPU的25%的算力,相关的P都会被保留,直到标记的结束。相关操作是使用专用的Goroutines完成的。这个过程标记了堆中的活动对象(被任何活动的Goroutines的栈中引用)。当该过程耗时过久时,会征用活动的Goroutine来辅助标记过程(被称为Mark Assist)。
  • Mark Termination (Stop the world):当标记完成,每个活动的Goroutines都会暂停,写屏障将会被关闭,清理任务开始执行。GC还会在此处计算下一个GC的目标,完成操作后,保留的Ps会释放会应用程序。
  • Sweeping (Concurrent):当完成收集并尝试分配后,清除过程将未标记为活动的对象回收。清除掉的内存将会立即可重新分配出去。

垃圾回收对应的动画图本站静态缓存

整体流程图以一个Goroutine回收为例子。从栈中的指针出发,遍历在mcache和mheap中仍存活的Span。既不像BFS也不像DFS,但其实就是图的遍历。Stop the World的操作耗时并不多,对程序运行效率的影响并不大。

参考资料