Go提供了自动化的内存管理机制,但在某些情况下需要更精细的微调从而避免发生OOM错误。本文将讨论Go的垃圾收集器、应用程序内存优化以及如何防止OOM(Out-Of-Memory)错误。
Go中的堆(Heap)栈(Stack)
我不会详细介绍垃圾收集器如何工作,已经有很多关于这个主题的文章和官方文档(比如A Guide to the Go Garbage Collector[2]和源码[3])。但是,我会提到一些有助于理解本文主题的基本概念。
你可能已经知道,Go的数据可以存储在两个主要的内存存储中: 栈(stack)和堆(heap)。
通常,栈存储的数据的大小和使用时间可以由Go编译器预测,包括函数局部变量、函数参数、返回值等。
栈是自动管理的,遵循后进先出(LIFO)原则。当调用函数时,所有相关数据都放在栈的顶部,函数结束时,这些数据将从栈中删除。栈不需要复杂的垃圾收集机制,其内存管理开销最小,在栈中检索和存储数据的过程非常快。
然而,并不是所有数据都可以存储在栈中。在执行过程中动态更改的数据或需要在函数范围之外访问的数据不能放在栈上,因为编译器无法预测其使用情况,这种数据应该存储在堆中。
与栈不同,从堆中检索数据并对其进行管理的成本更高。
栈里放什么,堆里放什么?
正如前面提到的,栈用于具有可预测大小和寿命的值,例如:
- 在函数内部声明的局部变量,例如基本数据类型变量(例如数字和布尔值)。
- 函数参数。
- 函数返回后不再被引用的返回值。
Go编译器在决定将数据放在栈中还是堆中时会考虑各种细微差别。
例如,预分配大小为64 KB的数据将存储在栈中,而大于64 KB的数据将存储在堆中。这同样适用于数组,如果数组超过10 MB,将存储在堆中。
可以使用逃逸分析(escape analysis)来确定特定变量的存储位置。
例如,可以通过命令行编译参数-gcflags=-m来分析应用程序:
go build -gcflags=-m main.go
如果使用-gcflags=-m参数编译下面的main.go:
package main
func main() {
var arrayBefore10Mb [1310720]int
arrayBefore10Mb[0] = 1
var arrayAfter10Mb [1310721]int
arrayAfter10Mb[0] = 1
sliceBefore64 := make([]int, 8192)
sliceOver64 := make([]int, 8193)
sliceOver64[0] = sliceBefore64[0]
}
结果是:
# command-line-arguments
./main.go:3:6: can inline main
./main.go:7:6: moved to heap: arrayAfter10Mb
./main.go:10:23: make([]int, 8192) does not escape
./main.go:11:21: make([]int, 8193) escapes to heap
可以看到arrayAfter10Mb数组被移动到堆中,因为大小超过了10MB,而arrayBefore10Mb仍然留在栈中(对于int变量,10MB等于10 * 1024 * 1024 / 8 = 1310720个元素)。
此外,sliceBefore64没有存储在堆中,因为它的大小小于64KB,而sliceOver64被存储在堆中(对于int变量,64KB等于64 * 1024 / 8 = 8192个元素)。
要了解更多关于在堆中分配的位置和内容,可以参考malloc.go源码[4]。
因此,使用堆的一种方法是尽量避免用它!但是,如果数据已经落在堆中了呢?
与栈不同,堆的大小是无限的,并且不断增长。堆存储动态创建的对象,如结构体、分片和映射,以及由于其限制而无法放入栈中的大内存块。
在堆中重用内存并防止其完全阻塞的唯一工具是垃圾收集器。
浅谈垃圾收集器的工作原理
垃圾收集器(GC)是一种专门用于识别和释放动态分配内存的系统。
Go使用基于跟踪和标记和扫描算法的垃圾收集算法。在标记阶段,垃圾收集器将应用程序正在使用的数据标记为活跃堆。然后,在清理阶段,GC遍历所有未标记为活跃的内存并复用。
垃圾收集器不是免费工作的,需要消耗两个重要的系统资源: CPU时间和物理内存。
垃圾收集器中的内存由以下部分组成:
- 活跃堆内存(在前一个垃圾收集周期中标记为"活跃"的内存)
- 新的堆内存(尚未被垃圾收集器分析的堆内存)
- 存储元数据的内存,与前两个实体相比,这些元数据通常微不足道。
垃圾收集器所消耗的CPU时间与其工作细节有关。有一种称为"stop-the-world"的垃圾收集器实现,它在垃圾收集期间完全停止程序执行,导致CPU时间被花在非生产性工作上。
在Go里,垃圾收集器并不是完全"stop-the-world",而是与应用程序并行执行其大部分工作(例如标记堆)。
但是,垃圾收集器的操作仍然有一些限制,并且会在一个周期内多次完全停止工作代码的执行,想要了解更多可以阅读源码[5]。
如何管理垃圾收集器
在Go中可以通过某些参数管理垃圾收集器: GOGC环境变量或runtime/debug包中的等效函数SetGCPercent。
GOGC参数确定将触发垃圾收集的新未分配堆内存相对于活跃内存的百分比。
GOGC的默认值是100,意味着当新内存达到活跃堆内存的100%时将触发垃圾收集。
当新堆占用活跃堆的100%时,将运行垃圾收集器
我们以示例程序为例,通过go tool trace跟踪堆大小的变化,我们用Go 1.20.1版本来运行程序。
在本例中,performMemoryIntensiveTask函数使用了在堆中分配的大量内存。这个函数启动一个队列大小为NumWorker的工作池,任务数量等于NumTasks。
package main
import (
"fmt"
"os"
"runtime/debug"
"runtime/trace"
"sync"
)
const (
NumWorkers = 4 // Number of workers.
NumTasks = 500 // Number of tasks.
MemoryIntense = 10000 // Size of memory-intensive task (number of elements).
)
func main() {
// Write to the trace file.
f, _ := os.Create("trace.out")
trace.Start(f)
defer trace.Stop()
// Set the target percentage for the garbage collector. Default is 100%.
debug.SetGCPercent(100)
// Task queue and result queue.
taskQueue := make(chan int, NumTasks)
resultQueue := make(chan int, NumTasks)
// Start workers.
var wg sync.WaitGroup
wg.Add(NumWorkers)
for i := 0; i < NumWorkers; i++ {
go worker(taskQueue, resultQueue, &wg)
}
// Send tasks to the queue.
for i := 0; i < NumTasks; i++ {
taskQueue