等等, 怎麼使用 SetMemoryLimit?

Go 1.19 中終於實現了SetMemoryLimit的功能。Go 的 GC 並不像 Java 那樣提供了很多的參數可以調整,目前也就有GOGC這麼一個參數,所以能增加一個可以調整 GC 的參數確實讓人興奮。

一直關注 Go 性能同學一定知道,最近幾年有兩個調整 Go GC 的 hack 方式:

現在, 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 的影響:

基本例子

本文通過 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