Go 中的栈和堆
这篇文章不会深入研究垃圾收集器的内部工作原理,因为大量文章和官方文档已经涵盖了这个主题。除此之外,我将介绍关键概念来阐明本文探讨的主题。
在Go中,数据可以分为两种主要的内存存储:栈和堆。
一般来说,堆栈中存放的数据的大小和生命周期是 Go 编译器可以预期的。这包括局部函数变量、函数参数、返回值等等。
堆栈是自动管理的,遵循后进先出 (LIFO) 原则。当调用函数时,所有关联的数据都放置在堆栈顶部,函数完成后,这些数据将被删除。堆栈高效运行,将内存管理的开销降至最低。在堆栈上检索和存储数据的过程很快。
尽管如此,并非所有程序数据都可以驻留在堆栈中。在执行过程中动态变化的数据或需要超出函数范围的访问的数据不能容纳在堆栈中,因为编译器无法预测其使用情况。这些数据在堆中找到了自己的家。
与堆栈相比,从堆中检索数据及其管理是更消耗资源的过程。
堆栈与堆分配
正如前面提到的,堆栈提供可预测大小和寿命的值。此类值的示例包括在函数内声明的局部变量,如基本数据类型(例如数字和布尔值)、函数参数和函数返回值(如果它们在从函数返回后不再找到引用)。
Go 编译器在决定是在堆栈还是堆上分配数据时会采用各种细微差别。例如,大小最大为 64 KB 的预分配切片将分配给堆栈,而超过 64 KB 的切片将指定给堆。同样的标准适用于数组:超过 10 MB 的数组将分派到堆。
为了确定特定变量的分配位置,可以采用逃逸分析。为此,您可以通过使用以下标志从命令行编译应用程序来仔细检查应用程序-gcflags=-m
:
go
go build -gcflags=-m main.go
main.go
当使用以下标志编译以下应用程序时-gcflags=-m
:
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]
}
结果表明,arrayAfter10Mb
由于数组大小超过 10 MB,因此该数组被重新定位到堆中。相反,arrayBefore10Mb
保留在堆栈上。此外,sliceBefore64
由于其大小小于 64 KB,因此不迁移到堆,而sliceOver64
存储在堆中。
要更深入地了解堆分配,请参阅文档。
垃圾收集器:管理堆
处理堆的一种有效方法是避免使用它。但是,如果数据已经进入堆,该怎么办?
与堆栈相反,堆拥有无限的大小和持续增长。堆是动态生成的对象的所在地,例如结构、切片、映射和无法适应堆栈约束的大量内存块。
垃圾收集器是回收堆内存并防止其完全阻塞的唯一工具。
了解垃圾收集器
垃圾收集器(通常称为 GC)是一个专用系统,旨在识别和释放动态分配的内存。
Go 采用了一种植根于跟踪和标记和清除方法的垃圾收集算法。在标记阶段,垃圾收集器将应用程序主动使用的数据指定为活动堆。随后,在清理阶段,GC 会遍历未标记的内存,使其可供重用。
然而,垃圾收集器的操作是有代价的,消耗两个重要的系统资源:CPU 时间和物理内存。
垃圾收集器内的内存包括:
- 活动堆内存(在上一个垃圾收集周期中标记为"活动"的内存)。
- 新的堆内存(尚未被垃圾收集器分析的堆内存)。
- 元数据存储,与前两个实体相比通常微不足道。
垃圾收集器消耗的 CPU 时间取决于其操作模式。某些垃圾收集器实现(标记为"stop-the-world")会在垃圾收集期间完全暂停程序执行,从而导致 CPU 时间浪费在非生产性任务上。
在 Go 的上下文中,垃圾收集器并不完全是"停止世界",它的大部分工作(包括堆标记)与应用程序的执行并行。然而,它确实需要一些限制,并在一个周期内定期停止活动代码的执行。
到现在为止,让我们更进一步。
管理垃圾收集器
控制 Go 中的垃圾收集器可以通过特定参数来实现:GOGC 环境变量或其功能等效项 SetGCPercent,可在运行时/调试包中找到。
GOGC 参数指示与垃圾收集启动时的活动内存相关的新的、未分配的堆内存的百分比。
默认情况下,GOGC 设置为 100,表示当新内存量达到活动堆内存的 100% 时触发垃圾回收。
考虑一个示例程序,并通过 go 工具跟踪跟踪堆大小的变化。我们将使用 Go 版本 1.20.1 来执行该程序。
在此示例中,该performMemoryIntensiveTask
函数消耗了堆中分配的大量内存。该函数启动一个队列大小为NumWorker
且任务数量等于 的工作池NumTasks
。
go
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
}
// Imitation of latency.
time.Sleep(10 * time.Millisecond)
// Calculate the result.
result := 0
for eachValue := range data {
result += eachValue
}
return result
}
为了跟踪程序的执行,结果被写入文件trace.out
:
scss
// Writing to the trace file.
f, _ := os.Create("trace.out")
trace.Start(f)
defer trace.Stop()
通过利用go tool trace
,我们可以观察堆大小的波动并分析程序内垃圾收集器的行为。
请注意,不同 Go 版本的具体细节和功能go tool trace
可能有所不同,因此建议查阅官方文档以获取特定于版本的信息。
GOGC的默认值
debug.SetGCPercent
GOGC参数可以通过包中的函数设置runtime/debug
。默认情况下,GOGC 配置为 100%。
要运行我们的程序,请使用以下命令:
go
go run main.go
程序执行后,trace.out
会生成一个文件。要对其进行分析,请执行以下命令:
csharp
go tool trace trace.out
当 GOGC 值为 100 时,垃圾收集器被触发 16 次,在我们的示例中总共消耗了 14 毫秒。
增加 GC 频率
如果我们在设置为 10% 后运行代码debug.SetGCPercent(10)
,垃圾收集器会被更频繁地调用。在这种情况下,当当前堆大小达到活动堆大小的 10% 时,垃圾收集器将激活。
换句话说,如果活动堆大小为 10 MB,则当当前堆大小达到 1 MB 时,垃圾收集器将启动。
当 GOGC 值为 10 时,垃圾收集器被调用 38 次,总垃圾收集时间为 28 ms。
降低 GC 频率
以 1000% 运行相同的程序debug.SetGCPercent(1000)
会导致垃圾收集器在当前堆大小达到活动堆大小的 1000% 时触发。
在这种情况下,垃圾收集器被激活一次,执行 2 毫秒。
禁用GC
您还可以通过设置 GOGC=off 或使用来禁用垃圾收集器debug.SetGCPercent(-1).
关闭 GC 后,应用程序中的堆大小会持续增长,直到程序执行为止。
堆内存占用
在实时堆的实际内存分配中,该过程不会像跟踪中看到的那样定期且可预测地发生。
活动堆可以随着每个垃圾收集周期动态变化,并且在某些条件下,其绝对值可能会出现峰值。
为了模拟这种情况,在具有内存限制的容器中运行程序可能会导致内存不足 (OOM) 错误。
在此示例中,程序在内存限制为 10 MB 的容器中运行以进行测试。Dockerfile说明如下:
ini
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 的描述是:
yaml
version: '3'
services:
my-app:
build:
context: .
dockerfile: Dockerfile
ports:
- 8080:8080
deploy:
resources:
limits:
memory: 10M
启动容器会debug.SetGCPercent(1000%)
导致 OOM 错误:
docker-compose build
docker-compose up
容器崩溃并显示错误代码 137,指示内存不足的情况。
避免 OOM 错误
从 Go 1.19 版本开始,Golang 引入了带有 GOMEMLIMIT 选项的"软内存管理"。此功能使用 GOMEMLIMIT 环境变量来设置 Go 运行时可以使用的总体内存限制。例如,GOMEMLIMIT = 8MiB
,其中 8 MB 是内存大小。
该机制旨在解决 OOM 问题。启用 GOMEMLIMIT 后,会定期调用垃圾收集器以将堆大小保持在一定限制内,避免内存过载。
成本性能问题
GOMEMLIMIT 是一个功能强大但也是双刃剑的工具。它可能导致一种称为"死亡螺旋"的情况。当由于实时堆增长或持续的 Goroutine 泄漏而导致整体内存大小接近 GOMEMLIMIT 时,垃圾收集器将根据限制不断调用。
频繁的垃圾收集器调用可能会导致 CPU 使用率增加和程序性能下降。与 OOM 错误不同,死亡螺旋很难检测和修复。
GOMEMLIMIT 不提供 100% 保证严格执行内存限制,从而允许内存利用率超出限制。它还设置了 CPU 使用率限制,以防止过多的资源消耗。
在哪里申请 GOMEMLIMIT 和 GOGC
GOMEMLIMIT 在多种情况下都具有优势:
- 在内存有限的容器中运行应用程序,留下 5-10% 的可用内存是一个很好的做法。
- 在处理资源密集型代码时,GOMEMLIMIT 的实时管理可能会很有用。
- 当应用程序作为容器中的脚本运行时,禁用垃圾收集器但设置 GOMEMLIMIT 可以提高性能并防止超出容器的资源限制。
在以下情况下避免使用 GOMEMLIMIT:
- 当您的程序已经接近其操作环境的内存限制时,不要定义内存限制。
- 在您不监督的执行环境中部署程序时,请避免实施内存限制,特别是当程序的内存消耗与其输入数据直接相关时。这对于命令行界面或桌面应用程序等工具尤其重要。
显然,通过采取故意的方法,我们可以有效地控制特定的程序设置,包括垃圾收集器和 GOMEMLIMIT。尽管如此,彻底评估实施这些设置的方法至关重要。