[译]Go的内存管理概览

725 阅读9分钟

原文链接

当程序运行时,它们会把对象写入内存。在某些时候,当这些对象不在被需要的时候,应该将其移除。这个过程叫做内存管理。这篇文章旨在内存管理的概述,然后深入了解在Go中,是如何通过垃圾回收实现的。在过去几年,可以看到Go很多内存管理的改变,而且在未来也许能看到更多。如果你正在阅读这篇文章,并且正在使用Go v1.16之后的版本,本文中的一些信息可能已经过时了。

手动管理内存

在像C这样的语言中,开发人员会调用如malloccalloc这样的函数去把对象写入内存。这些函数返回一个指向该对象在堆内存中位置的指针。当该对象不在需要,开发人员调用free函数去再次使用这个内存。这种内存管理的方式被称为显式解除分配,(功能)非常强大。它让开发人员更好操控正在使用内存,这允许(开发人员)做一些类型的简单优化,特别是在低内存环境中。但是,它会导致两类编程错误。

一是过早的调用free函数,创建了一个悬挂指针。悬挂指针是指那些不再指向内存中合法对象的指针。这非常糟糕,因为程序期望着指针指向一个已定义的值。当这个指针随后被访问,无法保证它所指向的值是否存在于对应的内存地址,那里可能什么都没有,或者完全是一些其他的值。二是没有完成释放内存。如果开发人员忘记释放一个对象,可能会造成内存泄漏,因为内存会被越来越多的对象填满。如果过度使用内存,可能导致程序变慢或者崩溃。当内存被显式管理可能会引入无法预测的bug到程序中。

自动管理内存

这是为什么像Go这样的语言会提供自动化的动态内存管理,或者简单来说,垃圾回收。使用带垃圾回收的语言有以下优点:

  • 安全性提升

  • 更好的跨平台移植性

  • 编写更少的代码

  • 代码的运行时检测

  • 数据边界检查

垃圾回收有性能上的开销,但不像人们想象中的那么多。这样做的好处在于,开发人员能够专注于他们程序业务逻辑并且确保它符合需求,而不需要关注内存的管理。

一个运行的程序会将对象存储在两个内存位置,。垃圾回收在堆上面操作,而不是栈。栈是一种存储函数值的LIFO数据结构。在一个函数内调用另一个函数,会将一个新的函数帧推到栈中,这个栈会包含函数的值等一些其他信息。当调用的函数返回,它的栈帧会从栈中弹出。你可能比较熟悉来自你正在调试的一个崩解程序中的栈。大多数语言的编译器都会返回栈的轨迹信息来帮助调试,这些轨迹信息展示了到这个点之前所有被调用的函数。

image.png

相对栈而言,堆包含了那些函数外被引用的值。例如,程序启动时定义的常量,或者更复杂的对象,如Go中的struct。当开发者在堆中的定义里一个对象,将会分配所需的内存并且返回一个指向内存的指针。堆是一个图,其中对象表示为节点,这些节点在代码中或由堆中的其他对象引用。当一个程序启动,除非清理堆,否则堆将会随着对象的增加越来越大。

image.png

Go中的垃圾回收

Go倾向于在栈上分配内存,因此大多数内存分(的过程)配会在那里结束。这意味着在Go中每一个goroutine拥有一个栈,且当可能的情况下,Go会把变量分配给这个栈。Go的编译器企图通过逃逸分析去判断这个对象是否逃离了这个函数,来证明在函数外部不需要这个变量。如果编译器能够决定一个变量的生命周期,它就会被分配给一个栈。如果,这个变量的生命周期无法预知,它讲被分配到堆上。总体来看,如果一个Go程序中存在一个指向一个对象的指针,那么这个对象将存储在堆上。看一下这个代码示例:

type myStruct struct {
   value int
}

var testStruct = myStruct{value: 0}

func addTwoNumbers(a int, b int) int {
   return a + b
}
func myFunction() {
   testVar1 := 123
   testVar2 := 456
   testStruct.value = addTwoNumbers(testVar1, testVar2)
}
func someOtherFunction() {
   // some other code
   myFunction()
   // some more code
}

为了理解该示例的目的,让我们想象这段代码是一个正在运行程序中的一部分,因为如果这是整段程序,Go的编译器将会优化,把变量分配到栈上。当程序启动:

1. testStruct被定义且分配到堆上一个可用的内存块

2. myFunction函数被执行,同时在被执行时分配一个栈。testVar1testVar2都存储在栈上

  1. addTwoNumbers被调用,一个新的栈桢和两个参数被推入栈中

  2. addTwoNumbers结束执行,它的结果被返还到myFunction函数中,同时它的栈帧从栈中弹出,因为它不再被需要

  3. 跟随指向testStruct的指针到包含它的堆上的位置,同时更新value字段。

6. myFunction退出并清理为其创建的堆栈。testStruct的值保留在堆上,直到被垃圾回收

testStruct目前在堆上,且没有任何分析,Go的运行时不知道它是否还被需要。为此,Go依赖于垃圾回收器。垃圾回收器有两个重要的部分,mutator(赋值器)collector(收集器)。收集器执行回收逻辑同时查找需要释放内存的对象。赋值器执行应用代码,在堆上分配新的对象,同时也更新程序运行过程中剩余的对象,包括将一些不再被需要的对象变为无法访问。

image.png

Go垃圾回收器的实现

Go垃圾回收的特点有,非分代并发,三色标记清扫,让我们把这些词搞清楚。

分代理论假设生命周期较短的对象(如临时变量)最常被回收。因此,一个分代垃圾回收器专注于最近被分配的对象。但是,如我们之前提到,Go编译器的优化允许编译器将已知生命周期的对象分配到栈上。这表示堆上分配的对象越少,被垃圾回收的对象越少。这意味着在Go中,分代回收不是必要的。所以,Go采用一个非分代回收的方式。并行意味着收集器作为多赋值器线程同时运行。因此,Go采用非分代并行回收的方式。标记清扫是垃圾回收的一种方式,而三色标记清扫是这种方式的一种实现。

一个标记清扫回收器有两个阶段,毫不奇怪地命名为 mark(标记)sweep(清扫)。在标记阶段,收集器遍历整个堆并且标记那些不再需要的对象。接下来的清扫阶段将移除这些对象。标记清扫是一种间接的算法,因为它只标记存活的对象,而移除其他所有的对象。

gc.gif

Go分几步实现:

Go使用名为stop the world的进程让所有goroutine达到一个垃圾收集的安全时间点。它会暂停程序同时开启一个写屏障去维持堆数据的安全性。它通过允许goroutine和收集器同时运行来实现并发。

一旦所有goroutine打开写屏障,Go运行时便starts the world(恢复程序运行),并让工作程序执行垃圾收集工作。

基于三色算法实现标记。当标记开始,除了Roots(根对象)被标记为灰色之外,所有对象都被标记为白色。Roots是堆中其他所有对象的来源,并且被实力化作为允许程序的一部分。垃圾回收器通过扫描栈,全局变量和堆指针开始标记以了解正在使用的内存。当扫描到栈,worker线程暂定栈的goroutine,同时将从Roots向下遍历找到的所有对象标记为灰色。随后恢复goroutine

然后将灰色对象排入队列以变为黑色,这表明它们仍在使用中。一旦所有灰色对象被变为黑色,收集者将再一次stop the world,然后开始清理所有不再被需要的白色节点。程序现在可以继续运行,直到它需要再次清理更多内存。

tri-color.gif

一旦程序正在使用的内存被分配了额外比例的内存,这个过程将再次被初始化。GOGC这个环境变量定义了这个比例,并且默认设为100.Go的源码这样描述:

如果GOGC=100且我们已经使用了4M的内存,当正在使用的内存达到8M,我们将再次启动垃圾回收(该标记在 next_gc 变量中进行跟踪)。这使 GC 成本与分配成本成线性比例。 调整GOGC只会改变线性常数(以及使用的额外内存量)。

通过讲抽象内存到运行时,Go垃圾回收提升了你的编程效率。同时也是使得Go性能如此优越的一部分原因。Go拥有内建的工具允许你去优化,如何将垃圾回收在你的程序中触发。如果你感兴趣的话,你可以进一步调研。现在,我希望你收获了更多Go中关于垃圾回收工作原理和实现的知识。

引用

Garbage Collection in Go: Part 1

Getting to Go: The Journey of Go’s Garbage Collector

Go: How Does the Garbage Collector Mark the Memory?

Golang: Cost of using the heap

Golang FAQ

Google Groups discussion, comment by Ian Lance Taylor

Implementing memory management with Golang’s garbage collector

Memory Management Reference

Stack (abstract data type)

The Garbage Collection Handbook

Tracing garbage collection: Tri-color marking