Go 内存优化与垃圾收集
Go 提供了自动化的内存管理机制,但在某些情况下需要更精细的微调从而避免发生 OOM 错误。本文介绍了如何通过微调 GOGC 和 GOMEMLIMIT 在性能和内存效率之间取得平衡,并尽量避免 OOM 的产生。原文: Memory Optimization and Garbage Collector Management in Go
本文将讨论 Go 的垃圾收集器、应用程序内存优化以及如何防止 OOM(Out-Of-Memory)错误。
Go 中的堆(Heap)栈(Stack)
我不会详细介绍垃圾收集器如何工作,已经有很多关于这个主题的文章和官方文档(比如A Guide to the Go Garbage Collector和源码)。但是,我会提到一些有助于理解本文主题的基本概念。
你可能已经知道,Go 的数据可以存储在两个主要的内存存储中: 栈(stack)和堆(heap)。
通常,栈存储的数据的大小和使用时间可以由 Go 编译器预测,包括函数局部变量、函数参数、返回值等。
栈是自动管理的,遵循后进先出(LIFO)原则。当调用函数时,所有相关数据都放在栈的顶部,函数结束时,这些数据将从栈中删除。栈不需要复杂的垃圾收集机制,其内存管理开销最小,在栈中检索和存储数据的过程非常快。
然而,并不是所有数据都可以存储在栈中。在执行过程中动态更改的数据或需要在函数范围之外访问的数据不能放在栈上,因为编译器无法预测其使用情况,这种数据应该存储在堆中。
与栈不同,从堆中检索数据并对其进行管理的成本更高。
栈里放什么,堆里放什么?
正如前面提到的,栈用于具有可预测大小和寿命的值,例如:
在函数内部声明的局部变量,例如基本数据类型变量(例如数字和布尔值)。
函数参数。
函数返回后不再被引用的返回值。
Go 编译器在决定将数据放在栈中还是堆中时会考虑各种细微差别。
例如,预分配大小为 64 KB 的数据将存储在栈中,而大于 64 KB 的数据将存储在堆中。这同样适用于数组,如果数组超过 10 MB,将存储在堆中。
可以使用逃逸分析(escape analysis)来确定特定变量的存储位置。
例如,可以通过命令行编译参数-gcflags=-m
来分析应用程序:
如果使用-gcflags=-m
参数编译下面的main.go
:
结果是:
可以看到arrayAfter10Mb
数组被移动到堆中,因为大小超过了 10MB,而arrayBefore10Mb
仍然留在栈中(对于int
变量,10MB 等于 10 * 1024 * 1024 / 8 = 1310720 个元素)。
此外,sliceBefore64
没有存储在堆中,因为它的大小小于 64KB,而sliceOver64
被存储在堆中(对于int
变量,64KB 等于 64 * 1024 / 8 = 8192 个元素)。
要了解更多关于在堆中分配的位置和内容,可以参考malloc.go源码。
因此,使用堆的一种方法是尽量避免用它!但是,如果数据已经落在堆中了呢?
与栈不同,堆的大小是无限的,并且不断增长。堆存储动态创建的对象,如结构体、分片和映射,以及由于其限制而无法放入栈中的大内存块。
在堆中重用内存并防止其完全阻塞的唯一工具是垃圾收集器。
浅谈垃圾收集器的工作原理
垃圾收集器(GC)是一种专门用于识别和释放动态分配内存的系统。
Go 使用基于跟踪和标记和扫描算法的垃圾收集算法。在标记阶段,垃圾收集器将应用程序正在使用的数据标记为活跃堆。然后,在清理阶段,GC 遍历所有未标记为活跃的内存并复用。
垃圾收集器不是免费工作的,需要消耗两个重要的系统资源: CPU 时间和物理内存。
垃圾收集器中的内存由以下部分组成:
活跃堆内存(在前一个垃圾收集周期中标记为"活跃"的内存)
新的堆内存(尚未被垃圾收集器分析的堆内存)
存储元数据的内存,与前两个实体相比,这些元数据通常微不足道。
垃圾收集器所消耗的 CPU 时间与其工作细节有关。有一种称为"stop-the-world"的垃圾收集器实现,它在垃圾收集期间完全停止程序执行,导致 CPU 时间被花在非生产性工作上。
在 Go 里,垃圾收集器并不是完全"stop-the-world",而是与应用程序并行执行其大部分工作(例如标记堆)。
但是,垃圾收集器的操作仍然有一些限制,并且会在一个周期内多次完全停止工作代码的执行,想要了解更多可以阅读源码。
如何管理垃圾收集器
在 Go 中可以通过某些参数管理垃圾收集器: GOGC
环境变量或runtime/debug
包中的等效函数SetGCPercent
。
GOGC
参数确定将触发垃圾收集的新未分配堆内存相对于活跃内存的百分比。
GOGC
的默认值是 100,意味着当新内存达到活跃堆内存的 100%时将触发垃圾收集。
当新堆占用活跃堆的 100%时,将运行垃圾收集器。
我们以示例程序为例,通过go tool trace
跟踪堆大小的变化,我们用 Go 1.20.1 版本来运行程序。
在本例中,performMemoryIntensiveTask
函数使用了在堆中分配的大量内存。这个函数启动一个队列大小为NumWorker
的工作池,任务数量等于NumTasks
。
跟踪程序执行的结果被写入文件trace.out
:
通过go tool trace
,可以观察堆大小的变化,并分析程序中垃圾收集器的行为。
请注意,go tool trace 的精确细节和功能可能因 go 版本不同而有所差异,因此建议参考官方文档,以获取有关其在特定 go 版本中使用的详细信息。
GOGC 的默认值
GOGC
参数可以使用runtime/debug
包中的debug.SetGCPercent
进行设置,GOGC
默认设置为 100%。
用下面命令运行程序:
程序执行后,将会创建trace.out
文件,可以使用go tool
工具对其进行分析。要做到这一点,执行命令:
然后可以通过打开 web 浏览器并访问http://127.0.0.1:54784/trace来查看基于 web 的跟踪查看器。
GOGC = 100
在"STATS"选项卡中,可以看到"Heap"字段,显示了在应用程序执行期间堆大小的变化情况,图中红色区域表示堆占用的内存。
在"PROCS"选项卡中,"GC"(垃圾收集器)字段显示的蓝色列表示触发垃圾收集器的时刻。
一旦新堆的大小达到活动堆大小的 100%,就会触发垃圾收集。例如,如果活跃堆大小为 10 MB,则当当前堆大小达到 10 MB 时将触发垃圾收集。
跟踪所有垃圾收集调用使我们能够确定垃圾收集器处于活动状态的总时间。
GOGC=100 时的 GC 调用次数
示例中,当GOGC
值为 100 时,将调用垃圾收集器 16 次,总执行时间为 14 ms。
更频繁的调用 GC
如果我们将debug.SetGCPercent(10)
设置为 10%后运行代码,将观察到垃圾收集器调用的频率更高。现在,如果当前堆大小达到活跃堆大小的 10%时,将触发垃圾收集。
换句话说,如果活跃堆大小为 10 MB,则当前堆大小达到 1 MB 时就将触发垃圾收集。
GOGC = 10
在本例中,垃圾收集器被调用了 38 次,总垃圾收集时间为 28 ms。
GOGC=10 时的 GC 调用次数
可以观察到,将 GOGC 设置为低于 100%的值可以增加垃圾收集的频率,可能导致 CPU 使用率增加并降低程序性能。
更少的调用 GC
如果运行相同程序,但将debug.SetGCPercent(1000)
设置为 1000%,我们将得到以下结果:
GOGC = 1000
可以看到,当前堆的大小一直在增长,直到达到活跃堆大小的 1000%。换句话说,如果活跃堆大小为 10 MB,则当前堆大小达到 100 MB 时将触发垃圾收集。
GOGC=1000 时的 GC 调用次数
在当前情况下,垃圾收集器被调用一次并执行 2 毫秒。
关闭 GC
还可以通过设置GOGC=off
或调用debug.SetGCPercent(-1)
来禁用垃圾收集。
下面是禁用垃圾收集器而不设置 GOMEMLIMIT 时堆的行为:
当 GC=off 时,堆大小不断增长。
可以看到,在关闭 GC 后,应用程序的堆大小一直在增长,直到程序执行为止。
堆占用多少内存?
在活跃堆的实际内存分配中,通常不像我们在 trace 中看到的那样定期和可预测的工作。
活跃堆随着每个垃圾收集周期动态变化,并且在某些条件下,其绝对值可能出现峰值。
例如,如果由于多个并行任务的重叠,活跃堆的大小可以增长到 800 MB,那么只有在当前堆大小达到 1.6 GB 时才会触发垃圾收集。
现代开发通常在具有内存使用限制的容器中运行应用。因此,如果容器将内存限制设置为 1 GB,并且总堆大小增加到 1.6 GB,则容器将失效,并出现 OOM(out of memory)错误。
让我们模拟一下这种情况。例如,我们在内存限制为 10 MB 的容器中运行程序(仅用于测试目的)。Dockerfile:
Docker-compose 描述:
让我们使用前面设置 GOGC=1000%的代码启动容器。
可以使用以下命令运行容器:
几秒钟后,容器将崩溃,并产生与 OOM 相对应的错误。
这种情况非常令人不快: GOGC 只控制新堆的相对值,而容器有绝对限制。
如何避免 OOM?
从 1.19 版本开始,在GOMEMLIMIT
选项的帮助下,Golang 引入了一个名为"软内存管理"的特性,runtime/debug
包中名为SetMemoryLimit
的类似函数(可以阅读48409-soft-memory-limit.md了解有关此选项的一些有趣的设计细节)提供了相同的功能。
GOMEMLIMIT
环境变量设置 Go 运行时可以使用的总体内存限制,例如: GOMEMLIMIT = 8MiB
。要设置内存值,需要使用大小后缀,在本例中为 8 MB。
让我们启动将GOMEMLIMIT
境变量设置为 8MiB 的容器。为此,我们将环境变量添加到 docker-compose 文件中:
现在,当启动容器时,程序运行没有任何错误。该机制是专门为解决 OOM 问题而设计的。
这是因为启用GOMEMLIMIT=8MiB
后,会定期调用垃圾收集器,并将堆大小保持在一定限制内,结果就是会频繁调用垃圾收集器以避免内存过载。
运行垃圾收集器以使堆大小保持在一定的限制内。
成本是什么?
GOMEMLIMIT
是强有力的工具,但也可能适得其反。
在上面的堆跟踪图中可以看到这种场景的一个示例。
当总内存大小由于活跃堆或持久程序泄漏的增长而接近GOMEMLIMIT
时,将开始根据该限制不断调用垃圾收集器。
由于频繁调用垃圾收集器,应用程序的运行时可能会无限增加,从而消耗应用程序的 CPU 时间。
这种行为被称为死亡螺旋,可能导致应用程序性能下降,与 OOM 错误不同,这种问题很难检测和修复。
这正是GOMEMLIMIT
机制作为软限制起作用的原因。
Go 不能 100%保证GOMEMLIMIT
指定的内存限制会被严格执行,而是会允许使用超出限制的内存,并防止频繁调用垃圾收集器的情况。
为了实现这一点,需要对 CPU 使用设置限制。目前,这个限制被设置为所有处理器时间的 50%,CPU 窗口为2 * GOMAXPROCS
秒。
这就是为什么我们不能完全避免 OOM 错误,而是会将其推迟到很久以后发生。
在哪里应用 GOMEMLIMIT 和 GOGC
如果默认垃圾收集器设置在大多数情况下是足够的,那么带有GOMEMLIMIT
的软内存管理机制可以使我们避免不愉快的情况。
使用GOMEMLIMIT
内存限制可能有用的例子:
在内存有限的容器中运行应用程序时,最好将
GOMEMLIMIT
设置为保留 5-10%的可用内存。在运行资源密集型库或代码时,对
GOMEMLIMIT
进行实时管理是有好处的。当在容器中以脚本形式运行应用程序时(意味着应用程序在一段时间内执行某些任务,然后终止),禁用垃圾收集器但设置
GOMEMLIMIT
可以提高性能并防止超出容器的资源限制。
避免使用GOMEMLIMIT
的情况:
当程序已经接近其环境的内存限制时,不要设置内存限制。
在无法控制的执行环境中部署时,不要使用内存限制,特别是在程序的内存使用与其输入数据成正比的情况下,例如 CLI 工具或桌面应用程序。
如上所述,通过深思熟虑的方法,我们可以管理程序中的微调设置,例如垃圾收集器和GOMEMLIMIT
。然而,仔细考虑应用这些设置的策略无疑非常重要。
你好,我是俞凡,在 Motorola 做过研发,现在在 Mavenir 做技术工作,对通信、网络、后端架构、云原生、DevOps、CICD、区块链、AI 等技术始终保持着浓厚的兴趣,平时喜欢阅读、思考,相信持续学习、终身成长,欢迎一起交流学习。为了方便大家以后能第一时间看到文章,请朋友们关注公众号"DeepNoMind",并设个星标吧,如果能一键三连(转发、点赞、在看),则能给我带来更多的支持和动力,激励我持续写下去,和大家共同成长进步!
版权声明: 本文为 InfoQ 作者【俞凡】的原创文章。
原文链接:【http://xie.infoq.cn/article/2bfb91a95a7631e289a1f122f】。
本文遵守【CC-BY 4.0】协议,转载请保留原文出处及本版权声明。
评论