Go 中的内存优化和垃圾回收器管理

举报
Rolle 发表于 2023/11/24 16:29:53 2023/11/24
【摘要】 这篇文章不会详细介绍垃圾收集器是如何工作的,因为已经有很多关于这个主题的文章和官方文档。但是,我想提一些基本概念,以便更好的理解​你可能已经知道,在 Go 中,数据可以存储在两个主要的内存存储中:堆栈和堆。通常,堆栈存储的数据的大小和使用时间可以由 Go 编译器预测。这包括局部函数变量、函数参数、返回值等。堆栈是自动管理的,并遵循后进先出 (LIFO) 原则。调用函数时,所有关联的数据都放置...

这篇文章不会详细介绍垃圾收集器是如何工作的,因为已经有很多关于这个主题的文章和官方文档。但是,我想提一些基本概念,以便更好的理解

你可能已经知道,在 Go 中,数据可以存储在两个主要的内存存储中:堆栈和堆。

image.png

通常,堆栈存储的数据的大小和使用时间可以由 Go 编译器预测。这包括局部函数变量、函数参数、返回值等。

堆栈是自动管理的,并遵循后进先出 (LIFO) 原则。调用函数时,所有关联的数据都放置在堆栈的顶部,当函数完成时,此数据将从堆栈中删除。堆栈不需要复杂的垃圾回收机制,并且内存管理的开销最小。在堆栈中检索和存储数据的速度非常快。

但是,并非所有程序数据都可以存储在堆栈中。在执行过程中动态更改或需要超出函数范围的访问的数据不能放在堆栈上,因为编译器无法预测其使用情况。此类数据存储在堆中。

与堆栈不同,从堆中检索数据并对其进行管理是成本更高的过程。

什么在堆栈中,什么在堆中?

正如我之前提到的,堆栈用于具有可预测大小和生命周期的值。此类值的一些示例包括:

  • 局部变量在函数内部声明,例如基本数据类型(例如数字和布尔值)的变量。
  • 函数参数。
  • 如果函数在从函数返回后不再引用这些值,则返回函数值。

Go 编译器在决定是将数据放在堆栈中还是堆中时会考虑各种细微差别。

例如,最大 64 KB 的预分配切片将存储在堆栈中,而大于 64 KB 的切片将存储在堆中。这同样适用于数组:如果数组超过 10 MB,它将存储在堆中。

可以使用转义分析来确定特定变量的存储位置。

例如,可以通过使用 -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 被移动到堆中,因为它的大小超过 10 MB,而 arrayBefore10Mb 保留在堆栈中(对于变量 int ,10 MB 等于 10 1024 1024 / 8 = 1310720 个元素)。

此外,由于其大小小于 64 KB,因此未发送到堆,而 sliceOver64 存储在堆中(对于 int 变量, sliceBefore64 64 KB 等于 64 * 1024 / 8 = 8192 个元素)。

因此,处理堆的一种方法是避免它!但是,如果数据已经落在堆中怎么办?

与堆栈不同,堆的大小不受限制,并且会不断增长。堆存储动态创建的对象,例如结构、切片和映射,以及由于其限制而无法放入堆栈中的大型内存块。

重用堆中的内存并防止其被完全阻塞的唯一工具是垃圾回收器。

关于垃圾回收器如何工作的一些信息

垃圾回收器(GC)是专门设计用于识别和释放动态分配的内存的系统。

Go 使用基于跟踪的垃圾回收算法和标记和扫描算法。在标记阶段,垃圾回收器将应用程序主动使用的数据标记为实时堆。然后,在扫描阶段,GC 遍历所有未标记为活动状态的内存并重用它。

垃圾回收器的工作不是免费的,因为它消耗了两个重要的系统资源:CPU 时间和物理内存。

垃圾回收器中的内存由以下部分组成:

  • 实时堆内存(在上一个垃圾回收周期中标记为“实时”的内存)
  • 新堆内存(垃圾回收器尚未分析堆内存)
  • 内存用于存储一些元数据,与前两个实体相比,这些元数据通常微不足道。

垃圾回收器消耗的 CPU 时间与其工作细节有关。有一些称为“stop-the-world”的垃圾回收器实现在垃圾回收期间完全停止程序执行,导致 CPU 时间花在非生产性工作上。

在 Go 的情况下,垃圾回收器并没有完全“停止世界”,而是在应用程序执行的同时执行其大部分工作,例如堆标记。

但是,垃圾回收器仍然有一些限制,并在一个周期内多次完全停止工作代码的执行。

如何管理垃圾回收器

有一个参数允许您在 Go 中管理垃圾回收器: GOGC 环境变量或其功能等效项 SetGCPercent ,来自包。 runtime/debug

该 GOGC 参数确定新的、未分配的堆内存相对于将触发垃圾回收的实时内存的百分比。

默认值为 GOGC 100,这意味着当新内存量达到实时堆内存的 100% 时,将触发垃圾回收。

image.png

当新堆占用活动堆的 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 <- i
 }
 close(taskQueue)

 // Retrieve results from the queue.
 go func() {
  wg.Wait()
  close(resultQueue)
 }()

 // Process the results.
 for result := range resultQueue {
  fmt.Println("Result:", result)
 }

 fmt.Println("Done!")
}

// Worker function.
func worker(tasks <-chan int, results chan<- int, wg *sync.WaitGroup) {
 defer wg.Done()

 for task := range tasks {
  result := performMemoryIntensiveTask(task)
  results <- result
 }
}

// performMemoryIntensiveTask is a memory-intensive function.
func performMemoryIntensiveTask(task int) int {
 // Create a large-sized slice.
 data := make([]int, MemoryIntense)
 for i := 0; i < MemoryIntense; i++ {
  data[i] = i + task
 }

 // Latency imitation.
 time.Sleep(10 * time.Millisecond)

 // Calculate the result.
 result := 0
 for _, value := range data {
  result += value
 }
 return result
}

为了跟踪程序的执行,结果被写入文件 trace.out :

// Writing to the trace file.
f, _ := os.Create("trace.out")
trace.Start(f)
defer trace.Stop()

通过使用 ,可以观察堆大小的变化 go tool trace ,并分析程序中垃圾回收器的行为。

请注意,不同版本的 Go 的确切细节和功能 go tool trace 可能会有所不同,因此建议参考官方文档以获取有关其在特定 Go 版本中的用法的更具体信息。

GOGC 的默认值

可以使用运行时/调试包中的 debug.SetGCPercent 函数设置该 GOGC 参数。默认情况下, GOGC 设置为 100(百分比)。

使用以下命令运行我们的程序:

go run main.go

程序执行后,将创建一个 trace.out 文件,我们可以使用该 go tool 实用程序对其进行分析。为此,请执行以下命令:

go tool trace trace.out

然后,我们可以通过打开 Web 浏览器并导航到 http://127.0.0.1:54784/trace 来访问基于 Web 的跟踪查看器。

image.png

在“STATS”选项卡中,我们看到“堆”字段,该字段显示了在应用程序执行期间堆大小的变化情况。图上的红色区域表示堆占用的内存。

在“PROCS”选项卡中,“GC”(垃圾回收器)字段显示蓝色列,表示触发垃圾回收器的时刻。

一旦新堆的大小达到活动堆大小的 100%,就会触发垃圾回收。例如,如果实时堆大小为 10 MB,则当新堆大小达到 10 MB 时,将触发垃圾回收器。

通过跟踪所有垃圾回收器调用,我们可以确定垃圾回收器处于活动状态的总时间。

image.png

在示例中, GOGC 如果值为 100,则垃圾回收器被调用了 16 次,总执行时间为 14 毫秒。

更频繁地调用 GC

如果在设置为 debug.SetGCPercent(10) 10% 后运行代码,我们将观察到垃圾回收器调用的频率增加。现在,当当前堆大小达到实时堆大小的 10% 时,将触发垃圾回收器。

换句话说,如果实时堆大小为 10 MB,则当当前堆达到 1 MB 时,将触发垃圾回收器。

image.png

在本例中,垃圾回收器被调用了 38 次,总垃圾回收时间为 28 毫秒。

image.png

我们可以观察到,设置为 GOGC 低于 100% 的值会增加垃圾回收的频率,这可能会导致 CPU 使用率增加和程序性能下降。

调用 GC 的频率较低

如果我们运行相同的程序,但 debug.SetGCPercent(1000) 设置为 1000%,我们将得到以下结果:

image.png

可以看到,当前堆会不断增长,直到达到等于活动堆大小的 1000% 的大小。换句话说,如果实时堆大小为 10 MB,则当当前堆大小达到 100 MB 时,将触发垃圾回收器。

image.png

在当前情况下,垃圾回收器被调用一次并执行了 2 毫秒。

GC is Turned Off GC 已关闭

还可以通过设置 GOGC=off 或使用 来禁用垃圾回收器 debug.SetGCPercent(-1) 。

以下是在不使用 GOMEMLIMIT 的情况下禁用垃圾回收器时堆的行为方式:

image.png

我们可以看到,在关闭 GC 的情况下,应用程序中的堆大小会不断增长,直到程序被执行。

堆占用多少内存?

在实时堆的实际内存分配中,它通常不会像我们在跟踪中看到的那样定期和可预测地工作。

活动堆可以随着每个垃圾回收周期而动态变化,并且在某些情况下,其绝对值可能会出现峰值。

例如,如果由于多个并行任务的重叠,活动堆的大小可以增长到 800 MB,则只有在当前堆大小达到 1.6 GB 时才会触发垃圾回收器。

image.png

现代开发通常在具有内存使用限制的容器中运行大多数应用程序。因此,如果我们的容器将内存限制设置为 1 GB,并且总堆大小增加到 1.6 GB,则容器将失败并出现 OOM(内存不足)错误。

让我们模拟一下这种情况。例如,我们在内存限制为 10 MB 的容器中运行我们的程序(仅用于测试目的)。Dockerfile 说明:

FROM golang:latest as builder


WORKDIR /src
COPY . .


RUN go env -w GO111MODULE=on


RUN go mod vendor
RUN CGO_ENABLED=0 GOOS=linux go build -mod=vendor -a -installsuffix cgo -o app ./cmd/


FROM golang:latest
WORKDIR /root/
COPY --from=builder /src/app .
EXPOSE 8080
CMD ["./app"]

Docker-compose的描述:

version: '3'
services:
 my-app:
   build:
     context: .
     dockerfile: Dockerfile
   ports:
     - 8080:8080
   deploy:
     resources:
       limits:
         memory: 10M

让使用前面的代码启动容器,其中我们设置了 GOGC=1000%。

若要运行容器,可以使用以下命令:

docker-compose build
docker-compose up

几秒钟后,我们的容器将崩溃,并出现与 OOM(内存不足)相对应的错误。

exited with code 137

情况变得不乐观: GOGC 只控制新堆的相对值,而容器有绝对限制。

image.png

如何避免OOM?

从 1.19 版本开始,Golang 引入了一个名为“软内存管理”的功能,借助该 GOMEMLIMIT 选项或包 SetMemoryLimit 中的 runtime/debug 类似功能(您可以在此处阅读有关此选项的一些有趣的设计细节)。

GOMEMLIMIT 环境变量设置 Go 运行时可以使用的总内存限制,例如: GOMEMLIMIT = 8MiB .为了设置内存值,使用大小后缀,在我们的例子中为 8 MB。

让我们启动 GOMEMLIMIT 环境变量设置为 8MiB 的容器。为此,我们将环境变量添加到 docker-compose 文件中:

version: '3'
services:
 my-app:
    environment:
      GOMEMLIMIT: "8MiB"
   build:
     context: .
     dockerfile: Dockerfile
   ports:
     - 8080:8080
   deploy:
     resources:
       limits:
         memory: 10M

现在,在启动容器时,程序运行没有任何错误。此机制是专门为解决 OOM 问题而设计的。

发生这种情况是因为在启用 GOMEMLIMIT=8MiB 后,垃圾回收器会定期调用,并将堆大小保持在一定限制内。这会导致频繁调用垃圾回收器以避免内存过载。

image.png

消耗是多少?

GOMEMLIMIT 是一个强大而有用的工具,也可能适得其反。

在上面的堆跟踪图中可以看到此类场景的示例。

当由于实时堆的增长或持续的 goroutine 泄漏而接近 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 .但是,仔细考虑应用这些设置的策略无疑很重要。

【版权声明】本文为华为云社区用户原创内容,转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息, 否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。