Go 內(nèi)存優(yōu)化與垃圾收集
Go提供了自動化的內(nèi)存管理機制,但在某些情況下需要更精細的微調(diào)從而避免發(fā)生OOM錯誤。本文將討論Go的垃圾收集器、應用程序內(nèi)存優(yōu)化以及如何防止OOM(Out-Of-Memory)錯誤。
Go中的堆(Heap)棧(Stack)
我不會詳細介紹垃圾收集器如何工作,已經(jīng)有很多關于這個主題的文章和官方文檔(比如A Guide to the Go Garbage Collector[2]和源碼[3])。但是,我會提到一些有助于理解本文主題的基本概念。
你可能已經(jīng)知道,Go的數(shù)據(jù)可以存儲在兩個主要的內(nèi)存存儲中: 棧(stack)和堆(heap)。
通常,棧存儲的數(shù)據(jù)的大小和使用時間可以由Go編譯器預測,包括函數(shù)局部變量、函數(shù)參數(shù)、返回值等。
棧是自動管理的,遵循后進先出(LIFO)原則。當調(diào)用函數(shù)時,所有相關數(shù)據(jù)都放在棧的頂部,函數(shù)結(jié)束時,這些數(shù)據(jù)將從棧中刪除。棧不需要復雜的垃圾收集機制,其內(nèi)存管理開銷最小,在棧中檢索和存儲數(shù)據(jù)的過程非常快。
然而,并不是所有數(shù)據(jù)都可以存儲在棧中。在執(zhí)行過程中動態(tài)更改的數(shù)據(jù)或需要在函數(shù)范圍之外訪問的數(shù)據(jù)不能放在棧上,因為編譯器無法預測其使用情況,這種數(shù)據(jù)應該存儲在堆中。
與棧不同,從堆中檢索數(shù)據(jù)并對其進行管理的成本更高。
棧里放什么,堆里放什么?
正如前面提到的,棧用于具有可預測大小和壽命的值,例如:
- 在函數(shù)內(nèi)部聲明的局部變量,例如基本數(shù)據(jù)類型變量(例如數(shù)字和布爾值)。
- 函數(shù)參數(shù)。
- 函數(shù)返回后不再被引用的返回值。
Go編譯器在決定將數(shù)據(jù)放在棧中還是堆中時會考慮各種細微差別。
例如,預分配大小為64 KB的數(shù)據(jù)將存儲在棧中,而大于64 KB的數(shù)據(jù)將存儲在堆中。這同樣適用于數(shù)組,如果數(shù)組超過10 MB,將存儲在堆中。
可以使用逃逸分析(escape analysis)來確定特定變量的存儲位置。
例如,可以通過命令行編譯參數(shù)-gcflags=-m來分析應用程序:
go build -gcflags=-m main.go
如果使用-gcflags=-m參數(shù)編譯下面的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]
}
結(jié)果是:
# 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數(shù)組被移動到堆中,因為大小超過了10MB,而arrayBefore10Mb仍然留在棧中(對于int變量,10MB等于10 * 1024 * 1024 / 8 = 1310720個元素)。
此外,sliceBefore64沒有存儲在堆中,因為它的大小小于64KB,而sliceOver64被存儲在堆中(對于int變量,64KB等于64 * 1024 / 8 = 8192個元素)。
要了解更多關于在堆中分配的位置和內(nèi)容,可以參考malloc.go源碼[4]。
因此,使用堆的一種方法是盡量避免用它!但是,如果數(shù)據(jù)已經(jīng)落在堆中了呢?
與棧不同,堆的大小是無限的,并且不斷增長。堆存儲動態(tài)創(chuàng)建的對象,如結(jié)構(gòu)體、分片和映射,以及由于其限制而無法放入棧中的大內(nèi)存塊。
在堆中重用內(nèi)存并防止其完全阻塞的唯一工具是垃圾收集器。
淺談垃圾收集器的工作原理
垃圾收集器(GC)是一種專門用于識別和釋放動態(tài)分配內(nèi)存的系統(tǒng)。
Go使用基于跟蹤和標記和掃描算法的垃圾收集算法。在標記階段,垃圾收集器將應用程序正在使用的數(shù)據(jù)標記為活躍堆。然后,在清理階段,GC遍歷所有未標記為活躍的內(nèi)存并復用。
垃圾收集器不是免費工作的,需要消耗兩個重要的系統(tǒng)資源: CPU時間和物理內(nèi)存。
垃圾收集器中的內(nèi)存由以下部分組成:
- 活躍堆內(nèi)存(在前一個垃圾收集周期中標記為"活躍"的內(nèi)存)
- 新的堆內(nèi)存(尚未被垃圾收集器分析的堆內(nèi)存)
- 存儲元數(shù)據(jù)的內(nèi)存,與前兩個實體相比,這些元數(shù)據(jù)通常微不足道。
垃圾收集器所消耗的CPU時間與其工作細節(jié)有關。有一種稱為"stop-the-world"的垃圾收集器實現(xiàn),它在垃圾收集期間完全停止程序執(zhí)行,導致CPU時間被花在非生產(chǎn)性工作上。
在Go里,垃圾收集器并不是完全"stop-the-world",而是與應用程序并行執(zhí)行其大部分工作(例如標記堆)。
但是,垃圾收集器的操作仍然有一些限制,并且會在一個周期內(nèi)多次完全停止工作代碼的執(zhí)行,想要了解更多可以閱讀源碼[5]。
如何管理垃圾收集器
在Go中可以通過某些參數(shù)管理垃圾收集器: GOGC環(huán)境變量或runtime/debug包中的等效函數(shù)SetGCPercent。
GOGC參數(shù)確定將觸發(fā)垃圾收集的新未分配堆內(nèi)存相對于活躍內(nèi)存的百分比。
GOGC的默認值是100,意味著當新內(nèi)存達到活躍堆內(nèi)存的100%時將觸發(fā)垃圾收集。
當新堆占用活躍堆的100%時,將運行垃圾收集器
我們以示例程序為例,通過go tool trace跟蹤堆大小的變化,我們用Go 1.20.1版本來運行程序。
在本例中,performMemoryIntensiveTask函數(shù)使用了在堆中分配的大量內(nèi)存。這個函數(shù)啟動一個隊列大小為NumWorker的工作池,任務數(shù)量等于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
}
跟蹤程序執(zhí)行的結(jié)果被寫入文件trace.out:
// Writing to the trace file.
f, _ := os.Create("trace.out")
trace.Start(f)
defer trace.Stop()
通過go tool trace,可以觀察堆大小的變化,并分析程序中垃圾收集器的行為。
請注意,go tool trace的精確細節(jié)和功能可能因go版本不同而有所差異,因此建議參考官方文檔,以獲取有關其在特定go版本中使用的詳細信息。
GOGC的默認值
GOGC參數(shù)可以使用runtime/debug包中的debug.SetGCPercent進行設置,GOGC默認設置為100%。
用下面命令運行程序:
go run main.go
程序執(zhí)行后,將會創(chuàng)建trace.out文件,可以使用go tool工具對其進行分析。要做到這一點,執(zhí)行命令:
go tool trace trace.out
然后可以通過打開web瀏覽器并訪問http://127.0.0.1:54784/trace來查看基于web的跟蹤查看器。
GOGC = 100
在"STATS"選項卡中,可以看到"Heap"字段,顯示了在應用程序執(zhí)行期間堆大小的變化情況,圖中紅色區(qū)域表示堆占用的內(nèi)存。
在"PROCS"選項卡中,"GC"(垃圾收集器)字段顯示的藍色列表示觸發(fā)垃圾收集器的時刻。
一旦新堆的大小達到活動堆大小的100%,就會觸發(fā)垃圾收集。例如,如果活躍堆大小為10 MB,則當當前堆大小達到10 MB時將觸發(fā)垃圾收集。
跟蹤所有垃圾收集調(diào)用使我們能夠確定垃圾收集器處于活動狀態(tài)的總時間。
GOGC=100時的GC調(diào)用次數(shù)
示例中,當GOGC值為100時,將調(diào)用垃圾收集器16次,總執(zhí)行時間為14 ms。
更頻繁的調(diào)用GC
如果我們將debug.SetGCPercent(10)設置為10%后運行代碼,將觀察到垃圾收集器調(diào)用的頻率更高?,F(xiàn)在,如果當前堆大小達到活躍堆大小的10%時,將觸發(fā)垃圾收集。
換句話說,如果活躍堆大小為10 MB,則當前堆大小達到1 MB時就將觸發(fā)垃圾收集。
GOGC = 10
在本例中,垃圾收集器被調(diào)用了38次,總垃圾收集時間為28 ms。
GOGC=10時的GC調(diào)用次數(shù)
可以觀察到,將GOGC設置為低于100%的值可以增加垃圾收集的頻率,可能導致CPU使用率增加并降低程序性能。
更少的調(diào)用GC
如果運行相同程序,但將debug.SetGCPercent(1000)設置為1000%,我們將得到以下結(jié)果:
GOGC = 1000
可以看到,當前堆的大小一直在增長,直到達到活躍堆大小的1000%。換句話說,如果活躍堆大小為10 MB,則當前堆大小達到100 MB時將觸發(fā)垃圾收集。
GOGC=1000時的GC調(diào)用次數(shù)
在當前情況下,垃圾收集器被調(diào)用一次并執(zhí)行2毫秒。
關閉GC
還可以通過設置GOGC=off或調(diào)用debug.SetGCPercent(-1)來禁用垃圾收集。
下面是禁用垃圾收集器而不設置GOMEMLIMIT時堆的行為:
當GC=off時,堆大小不斷增長。
可以看到,在關閉GC后,應用程序的堆大小一直在增長,直到程序執(zhí)行為止。
堆占用多少內(nèi)存?
在活躍堆的實際內(nèi)存分配中,通常不像我們在trace中看到的那樣定期和可預測的工作。
活躍堆隨著每個垃圾收集周期動態(tài)變化,并且在某些條件下,其絕對值可能出現(xiàn)峰值。
例如,如果由于多個并行任務的重疊,活躍堆的大小可以增長到800 MB,那么只有在當前堆大小達到1.6 GB時才會觸發(fā)垃圾收集。
現(xiàn)代開發(fā)通常在具有內(nèi)存使用限制的容器中運行應用。因此,如果容器將內(nèi)存限制設置為1 GB,并且總堆大小增加到1.6 GB,則容器將失效,并出現(xiàn)OOM(out of memory)錯誤。
讓我們模擬一下這種情況。例如,我們在內(nèi)存限制為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
幾秒鐘后,容器將崩潰,并產(chǎn)生與OOM相對應的錯誤。
exited with code 137
這種情況非常令人不快: GOGC只控制新堆的相對值,而容器有絕對限制。
如何避免OOM?
從1.19版本開始,在GOMEMLIMIT選項的幫助下,Golang引入了一個名為"軟內(nèi)存管理"的特性,runtime/debug包中名為SetMemoryLimit的類似函數(shù)(可以閱讀48409-soft-memory-limit.md[6]了解有關此選項的一些有趣的設計細節(jié))提供了相同的功能。
GOMEMLIMIT環(huán)境變量設置Go運行時可以使用的總體內(nèi)存限制,例如: GOMEMLIMIT = 8MiB。要設置內(nèi)存值,需要使用大小后綴,在本例中為8 MB。
讓我們啟動將GOMEMLIMIT境變量設置為8MiB的容器。為此,我們將環(huán)境變量添加到docker-compose文件中:
version: '3'
services:
my-app:
environment:
GOMEMLIMIT: "8MiB"
build:
context: .
dockerfile: Dockerfile
ports:
- 8080:8080
deploy:
resources:
limits:
memory: 10M
現(xiàn)在,當啟動容器時,程序運行沒有任何錯誤。該機制是專門為解決OOM問題而設計的。
這是因為啟用GOMEMLIMIT=8MiB后,會定期調(diào)用垃圾收集器,并將堆大小保持在一定限制內(nèi),結(jié)果就是會頻繁調(diào)用垃圾收集器以避免內(nèi)存過載。
運行垃圾收集器以使堆大小保持在一定的限制內(nèi)。
成本是什么?
GOMEMLIMIT是強有力的工具,但也可能適得其反。
在上面的堆跟蹤圖中可以看到這種場景的一個示例。
當總內(nèi)存大小由于活躍堆或持久程序泄漏的增長而接近GOMEMLIMIT時,將開始根據(jù)該限制不斷調(diào)用垃圾收集器。
由于頻繁調(diào)用垃圾收集器,應用程序的運行時可能會無限增加,從而消耗應用程序的CPU時間。
這種行為被稱為死亡螺旋[7],可能導致應用程序性能下降,與OOM錯誤不同,這種問題很難檢測和修復。
這正是GOMEMLIMIT機制作為軟限制起作用的原因。
Go不能100%保證GOMEMLIMIT指定的內(nèi)存限制會被嚴格執(zhí)行,而是會允許使用超出限制的內(nèi)存,并防止頻繁調(diào)用垃圾收集器的情況。
為了實現(xiàn)這一點,需要對CPU使用設置限制。目前,這個限制被設置為所有處理器時間的50%,CPU窗口為2 * GOMAXPROCS秒。
這就是為什么我們不能完全避免OOM錯誤,而是會將其推遲到很久以后發(fā)生。
在哪里應用GOMEMLIMIT和GOGC
如果默認垃圾收集器設置在大多數(shù)情況下是足夠的,那么帶有GOMEMLIMIT的軟內(nèi)存管理機制可以使我們避免不愉快的情況。
使用GOMEMLIMIT內(nèi)存限制可能有用的例子:
- 在內(nèi)存有限的容器中運行應用程序時,最好將GOMEMLIMIT設置為保留5-10%的可用內(nèi)存。
- 在運行資源密集型庫或代碼時,對GOMEMLIMIT進行實時管理是有好處的。
- 當在容器中以腳本形式運行應用程序時(意味著應用程序在一段時間內(nèi)執(zhí)行某些任務,然后終止),禁用垃圾收集器但設置GOMEMLIMIT可以提高性能并防止超出容器的資源限制。
避免使用GOMEMLIMIT的情況:
- 當程序已經(jīng)接近其環(huán)境的內(nèi)存限制時,不要設置內(nèi)存限制。
- 在無法控制的執(zhí)行環(huán)境中部署時,不要使用內(nèi)存限制,特別是在程序的內(nèi)存使用與其輸入數(shù)據(jù)成正比的情況下,例如CLI工具或桌面應用程序。
如上所述,通過深思熟慮的方法,我們可以管理程序中的微調(diào)設置,例如垃圾收集器和GOMEMLIMIT。然而,仔細考慮應用這些設置的策略無疑非常重要。
參考資料
- [1]Memory Optimization and Garbage Collector Management in Go: https://betterprogramming.pub/memory-optimization-and-garbage-collector-management-in-go-71da4612a960
- [2]A Guide to the Go Garbage Collector: https://tip.golang.org/doc/gc-guide
- [3]mgc.go: https://go.dev/src/runtime/mgc.go
- [4]malloc.go: https://go.dev/src/runtime/malloc.go
- [5]mgc.go: https://go.dev/src/runtime/mgc.go
- [6]48409-soft-memory-limit.md: https://github.com/golang/proposal/blob/master/design/48409-soft-memory-limit.md
- [7]Soft Memory Limit Death Spirals: https://github.com/golang/proposal/blob/master/design/48409-soft-memory-limit.md#death-spirals