等等, 怎麼使用 SetMemoryLimit?
Go 1.19 中終於實現了SetMemoryLimit
的功能。Go 的 GC 並不像 Java 那樣提供了很多的參數可以調整,目前也就有GOGC
這麼一個參數,所以能增加一個可以調整 GC 的參數確實讓人興奮。
一直關注 Go 性能同學一定知道,最近幾年有兩個調整 Go GC 的 hack 方式:
-
ballast[1]: 壓艙石技術。使用一個 "虛假" 的內存佔用,讓 Go 運行時難以達到觸發 GC 的閾值,來實現減少 GC 的次數,從而提高性能。如果你的程序的內存佔用基本都會在某個閾值之下的話,這個技術非常有效,畢竟,Go 很大的一部分性能消耗都是在 GC 上。這是 twitch.tv 的工程師提供的一種技術。
-
GOGC tuner[2]: 通過自動調整 GOGC,來動態的調整 GC 的 target, 用來在內存足夠的時候調整 GOGC 來減少 GC 的次數,這也是一個非常有趣有效的技術,在 uber 公司的實踐中行之有效。這是 uber 工程師提供的一項技術,Uber 的工程師並沒有把它開源出來,不過曹大根據文章的原理實現了一個 cch123/gogctuner[3]。
現在, Go 1.19 提供了SetMemoryLimit
的功能,通過這個方法,可以替換ballast
的方案,部分替換GOGC Tuner
的方案。
談起這個功能的歷史,可以追溯到 2017 年 12 月的#23044[4],它提議增加一個方法,可以指定最小的目標堆大小。這個 issue 大家討論的熱火朝天,結果就是 2019 年 twitch.tv 的工程師實現了 ballast, 從工程的角度驗證了 GC 是可以優化,而且在實踐中也有效。
2021 年 Go team 的工程師 Michael Knyszek 發起一個提案#44309[5], 包括設計文檔 user configurable memory target[6]。這個提案的跟蹤 issue 最終歸於#48409[7]。
本來,這個提案預期在 Go 1.18 中實現,不過因爲提案遲遲沒有批准,所以最終會在 Go 1.19 中實現。
在撰寫本文的時候,Go 1.19 還在開發之中,不過這個提案的功能已經實現,剩下的是一些文檔和 bug 修復的工作了,所以我們可以使用 gotip[8] 來測試。
這個提案的實現原來就是要實現 (替換)ballast 的功能,所以一旦 Go 1.19 發佈, ballast 的方案就可以廢棄了。沒想到今年突然 Uber 的工程師來了一個自動調整 GOGC 的方案, 所以當前方案還不能完全代替 GOGC tuner, 畢竟 GOGC Tuner 可以更靈活的調整 GC 的 target, 而SetMemoryLimit
在設定的MemoryLimit
之下,還是會頻繁的進行 GC, 如果加上GOGC=off
的話,只能等待達到MemoryLimit
才能 GC, 和 GOGC Tuner 的方式還有有所不同的, 所以並不能完全替代 GOGC tuner。
詳細的 GC 調優指導的官方文檔 [9] 還沒有完成,大家也可以關注一下,看看官方的建議。
This page is currently a work-in-progress and is expected to be complete by the time of the Go 1.19 release. See this tracking issue[10] for more details.
即使官方文檔還沒有完成,依照提案的內容,我們還是可以早點了解這個提案的功能以及帶給我們的收益。
下面通過四個場景,觀察一下此功能對 GC 的影響:
-
SetMemoryLimit
+GOGC=off
+MemoryLimit
足夠大 -
SetMemoryLimit
+GOGC=off
+MemoryLimit
不足夠大 -
SetMemoryLimit
+GOGC=100
+MemoryLimit
足夠大 -
SetMemoryLimit
+GOGC=100
+MemoryLimit
不足夠大
基本例子
本文通過 Debian 的 benchmarks game 中的 btree 例子 [11] 演示這四個場景。
因爲這個例子會頻繁生成二叉樹,正適合內存分配和回收的場景。
package main
import (
"flag"
"fmt"
"sync"
"time"
)
type node struct {
next *next
}
type next struct {
left, right node
}
func create(d int) node {
if d == 1 {
return node{&next{node{}, node{}}}
}
return node{&next{create(d - 1), create(d - 1)}}
}
func (p node) check() int {
sum := 1
current := p.next
for current != nil {
sum += current.right.check() + 1
current = current.left.next
}
return sum
}
var (
depth = flag.Int("depth", 10, "depth")
)
func main() {
flag.Parse()
start := time.Now()
const MinDepth = 4
const NoTasks = 4
maxDepth := *depth
longLivedTree := create(maxDepth)
stretchTreeCheck := ""
wg := new(sync.WaitGroup)
wg.Add(1)
go func() {
stretchDepth := maxDepth + 1
stretchTreeCheck = fmt.Sprintf("stretch tree of depth %d\t check: %d",
stretchDepth, create(stretchDepth).check())
wg.Done()
}()
results := make([]string, (maxDepth-MinDepth)/2+1)
for i := range results {
depth := 2*i + MinDepth
n := (1 << (maxDepth - depth + MinDepth)) / NoTasks
tasks := make([]int, NoTasks)
wg.Add(NoTasks)
// 執行NoTasks個goroutine, 每個goroutine執行n個深度爲depth的tree的check
// 一共是n*NoTasks個tree,每個tree的深度是depth
for t := range tasks {
go func(t int) {
check := 0
for i := n; i > 0; i-- {
check += create(depth).check()
}
tasks[t] = check
wg.Done()
}(t)
}
wg.Wait()
check := 0 // 總檢查次數
for _, v := range tasks {
check += v
}
results[i] = fmt.Sprintf("%d\t trees of depth %d\t check: %d",
n*NoTasks, depth, check)
}
fmt.Println(stretchTreeCheck)
for _, s := range results {
fmt.Println(s)
}
fmt.Printf("long lived tree of depth %d\t check: %d\n",
maxDepth, longLivedTree.check())
fmt.Printf("took %.02f s", float64(time.Since(start).Milliseconds())/1000)
}
可以使用gotip build main.go
生成 Go 1.19 編譯的二進制文件。
後面的例子中我並沒有使用debug.SetMemoryLimit
設置MemoryLimit
, 而是使用環境變量GOMEMLIMIT
。
SetMemoryLimit
+ GOGC=off
+ MemoryLimit
足夠大
首先使用gotip build main.go
編譯出可執行的二進制文件soft_memory_limit
。
運行 GOMEMLIMIT=10737418240 GOGC=off GODEBUG=gctrace=1 ./soft_memory_limit -depth=21
查看效果:
這裏我設置的MemoryLimit
爲 10G, 整個程序中並沒有達到這個內存閾值,所以沒有 GC 發生。
是不是和設置 ballast 的效果一樣。
SetMemoryLimit
+ GOGC=off
+ MemoryLimit
不足夠大
我們將MemoryLimit
設置爲 1G, 看看 GC 的表現 (GOMEMLIMIT=1073741824 GOGC=off GODEBUG=gctrace=1 ./soft_memory_limit -depth=21
):
可以看到程序的運行過程內存佔用還是能夠觸達閾值 1G 的,這會導致幾次的垃圾回收,整體運行時間和 case1 差別不到,原因是 GC 回收僅僅幾次,可以忽略。
如果你把閾值設置更小,比如縮小 10 倍 (GOMEMLIMIT=107374182 GOGC=off GODEBUG=gctrace=1 ./soft_memory_limit -depth=21
), 可以看到更頻繁的垃圾回收,程序整體運行時間也顯著增加:
SetMemoryLimit
+ GOGC=100
+ MemoryLimit
足夠大
爲了達到 ballast 的效果,前面的 case 都把 GOGC 設置爲了off
, 如果我們設置爲默認值 100 呢?
GOMEMLIMIT=10737418240 GOGC=100 GODEBUG=gctrace=1 ./soft_memory_limit -depth=21
可以看到,會有大量的 GC 事件,並且很多並沒有達到閾值就發生 GC 了。這也是顯而易見的,因爲在沒有達到MemoryLimit
閾值的情況下,還是遵循 GOGC 的 target 決定要不要進行垃圾回收。
在這種情況下,可以使用 GOGC tuner 進行調優,避免這麼多次的垃圾回收。
SetMemoryLimit
+ GOGC=100
+ MemoryLimit
不足夠大
如果設置的MemoryLimit
不足夠大, 在內存觸達MemoryLimit
的時候也會觸發 GC, 只不過因爲沒有關閉 GOGC, 所以 GOGC 和觸達MemoryLimit
兩種情況下都有可能觸發 GC, 程序整體運行還是比較慢的。
綜上所述, 通過SetMemoryLimit
設置一個較大的值,再加上 GOGC=off
,可以實現 ballast 的效果。
但是在沒有關閉GOGC
的情況下,還是有可能會觸發很多次的 GC, 影響性能,這個時候還得 GOGC Tuner 調優,減少觸達MemoryLimit
之前的 GC 次數。
參考資料
[1]
ballast: https://blog.twitch.tv/en/2019/04/10/go-memory-ballast-how-i-learnt-to-stop-worrying-and-love-the-heap/
[2]
GOGC tuner: https://eng.uber.com/how-we-saved-70k-cores-across-30-mission-critical-services/
[3]
cch123/gogctuner: https://github.com/cch123/gogctuner
[4]
#23044: https://github.com/golang/go/issues/23044
[5]
#44309: https://github.com/golang/go/issues/44309
[6]
user configurable memory target: https://github.com/golang/proposal/blob/7f0d01687e030f21e8bdc36dfd9d5aac3a6f4a71/design/44309-user-configurable-memory-target.md
[7]
#48409: https://github.com/golang/go/issues/48409
[8]
gotip: https://pkg.go.dev/golang.org/dl/gotip
[9]
官方文檔: https://tip.golang.org/doc/gc-guide
[10]
tracking issue: https://tip.golang.org/doc/gc-guide#:~:text=This%20page%20is%20currently%20a%20work%2Din%2Dprogress%20and%20is%20expected%20to%20be%20complete%20by%20the%20time%20of%20the%20Go%201.19%20release.%20See%20this%20tracking%20issue%20for%20more%20details.
[11]
btree 例子: https://benchmarksgame-team.pages.debian.net/benchmarksgame/program/binarytrees-go-2.html
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/EIuM073G7VV1rIsnTXWyEw