2021-01-15

Go 語言中對 GC 的觸發時機存在兩種形式:

  1. 主動觸發,通過調用 runtime.GC 來觸發 GC,此調用阻塞式地等待當前 GC 運行完畢。

  2. 被動觸發,分為兩種方式:

    • 使用系統監控,當超過兩分鐘沒有產生任何 GC 時,強制觸發 GC。

    • 使用步調(Pacing)算法,其核心思想是控制內存增長的比例。

通過 GOGC 或者 debug.SetGCPercent 進行控制(他們控制的是同一個變量,即堆的增長率 \rho)。整個算法的設計考慮的是優化問題:如果設上一次 GC 完成時,內存的數量為 H_m(heap marked),估計需要觸發 GC 時的堆大小 H_T(heap trigger),使得完成 GC 時候的目標堆大小 H_g(heap goal) 與實際完成時候的堆大小 H_a(heap actual)最為接近,即: \min |H_g - H_a| = \min|(1+\rho)H_m - H_a|

image.png

除此之外,步調算法還需要考慮 CPU 利用率的問題,顯然我們不應該讓垃圾回收器占用過多的 CPU,即不應該讓每個負責執行用戶 goroutine 的線程都在執行標記過程。理想情況下,在用戶代碼滿載的時候,GC 的 CPU 使用率不應該超過 25%,即另一個優化問題:如果設 u_g為目標 CPU 使用率(goal utilization),而 u_a為實際 CPU 使用率(actual utilization),則 \min|u_g - u_a|

求解這兩個優化問題的具體數學建模過程我們不在此做深入討論,有興趣的讀者可以參考兩個設計文檔:Go 1.5 concurrent garbage collector pacing[5] 和 Separate soft and hard heap size goal[6]。

計算 H_T 的最終結論(從 Go 1.10 時開始 h_t 增加了上界 0.95 \rho,從 Go 1.14 開始時 h_t 增加了下界 0.6)是:

  • 設第 n 次觸發 GC 時 (n > 1),估計得到的堆增長率為 h_t^{(n)}、運行過程中的實際堆增長率為 h_a^{(n)},用戶設置的增長率為 \rho = \text{GOGC}/100\rho > 0)則第 n+1 次出觸發 GC 時候,估計的堆增長率為:

h_t^{(n+1)} = h_t^{(n)} + 0.5 \left[ \frac{H_g^{(n)} - H_a^{(n)}}{H_a^{(n)}} - h_t^{(n)} - \frac{u_a^{(n)}}{u_g^{(n)}} \left( h_a^{(n)} - h_t^{(n)} \right) \right]

  • 特別的,h_t^{(1)} = 7 / 8u_a^{(1)} = 0.25u_g^{(1)} = 0.3。第一次觸發 GC 時,如果當前的堆小于 4\rho MB,則強制調整到 4\rho MB 時觸發 GC

  • 特別的,當 h_t^{(n)}<0.6時,將其調整為 0.6,當 h_t^{(n)} > 0.95 \rho 時,將其設置為 0.95 \rho

  • 默認情況下,\rho = 1(即 GOGC = 100),第一次觸發 GC 時強制設置觸發第一次 GC 為 4MB,可以寫如下程序進行驗證:

package main

import (
    "os"
    "runtime"
    "runtime/trace"
    "sync/atomic"
)

var stop uint64

// 通過對象 P 的釋放狀態,來確定 GC 是否已經完成
func gcfinished() *int {
    p := 1
    runtime.SetFinalizer(&p, func(_ *int) {
        println("gc finished")
        atomic.StoreUint64(&stop, 1) // 通知停止分配
    })
    return &p
}

func allocate() {
    // 每次調用分配 0.25MB
    _ = make([]byte, int((1<<20)*0.25))
}

func main() {
    f, _ := os.Create("trace.out")
    defer f.Close()
    trace.Start(f)
    defer trace.Stop()

    gcfinished()

    // 當完成 GC 時停止分配
    for n := 1; atomic.LoadUint64(&stop) != 1; n++ {
        println("#allocate: ", n)
        allocate()
    }
    println("terminate")
}

我們先來驗證最簡單的一種情況,即第一次觸發 GC 時的堆大小:

$ go build -o main
$ GODEBUG=gctrace=1 ./main
#allocate:  1
(...)
#allocate:  20
gc finished
gc 1 @0.001s 3%: 0.016+0.23+0.019 ms clock, 0.20+0.11/0.060/0.13+0.22 ms cpu, 4->5->1 MB, 5 MB goal, 12 P
scvg: 8 KB released
scvg: inuse: 1, idle: 62, sys: 63, released: 58, consumed: 5 (MB)
terminate

通過這一行數據我們可以看到:

gc 1 @0.001s 3%: 0.016+0.23+0.019 ms clock, 0.20+0.11/0.060/0.13+0.22 ms cpu, 4->5->1 MB, 5 MB goal, 12 P

  1. 程序在完成第一次 GC 后便終止了程序,符合我們的設想
  2. 第一次 GC 開始時的堆大小為 4MB,符合我們的設想
  3. 當標記終止時,堆大小為 5MB,此后開始執行清掃,這時分配執行到第 20 次,即 20*0.25 = 5MB,符合我們的設想

我們將分配次數調整到 50 次

for n := 1; n < 50; n++ {
    println("#allocate: ", n)
    allocate()
}

來驗證第二次 GC 觸發時是否滿足公式所計算得到的值(為 GODEBUG 進一步設置 gcpacertrace=1):

$ go build -o main
$ GODEBUG=gctrace=1,gcpacertrace=1 ./main
#allocate:  1
(...)

pacer: H_m_prev=2236962 h_t=+8.750000e-001 H_T=4194304 h_a=+2.387451e+000 H_a=7577600 h_g=+1.442627e+000 H_g=5464064 u_a=+2.652227e-001 u_g=+3.000000e-001 W_a=152832 goalΔ=+5.676271e-001 actualΔ=+1.512451e+000 u_a/u_g=+8.840755e-001
#allocate:  28
gc 1 @0.001s 5%: 0.032+0.32+0.055 ms clock, 0.38+0.068/0.053/0.11+0.67 ms cpu, 4->7->3 MB, 5 MB goal, 12 P

(...)
#allocate:  37
pacer: H_m_prev=3307736 h_t=+6.000000e-001 H_T=5292377 h_a=+7.949171e-001 H_a=5937112 h_g=+1.000000e+000 H_g=6615472 u_a=+2.658428e-001 u_g=+3.000000e-001 W_a=154240 goalΔ=+4.000000e-001 actualΔ=+1.949171e-001 u_a/u_g=+8.861428e-001
#allocate:  38
gc 2 @0.002s 9%: 0.017+0.26+0.16 ms clock, 0.20+0.079/0.058/0.12+1.9 ms cpu, 5->5->0 MB, 6 MB goal, 12 P

我們可以得到數據:

  • 第一次估計得到的堆增長率為 h_t^{(1)} = 0.875
  • 第一次的運行過程中的實際堆增長率為 h_a^{(1)} = 0.2387451
  • 第一次實際的堆大小為 H_a^{(1)}=7577600
  • 第一次目標的堆大小為 H_g^{(1)}=5464064
  • 第一次的 CPU 實際使用率為 u_a^{(1)} = 0.2652227
  • 第一次的 CPU 目標使用率為 u_g^{(1)} = 0.3

我們據此計算第二次估計的堆增長率:

\begin{align} h_t^{(2)} &= h_t^{(1)} + 0.5 \left[ \frac{H_g^{(1)} - H_a^{(1)}}{H_a^{(1)}} - h_t^{(1)} - \frac{u_a^{(1)}}{u_g^{(1)}} \left( h_a^{(1)} - h_t^{(1)} \right) \right] \ &= 0.875 + 0.5 \left[ \frac{5464064 - 7577600}{5464064} - 0.875 - \frac{0.2652227}{0.3} \left( 0.2387451 - 0.875 \right) \right] \ & \approx 0.52534543909 \ \end{align}

因為 0.52534543909 < 0.6\rho = 0.6,因此下一次的觸發率為 h_t^{2} = 0.6,與我們實際觀察到的第二次 GC 的觸發率 0.6 吻合。

?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容