Golang實時GC的理論與實踐總結

banq發表於2016-12-03
本文是總結Go語言的低延遲垃圾回收機制GC突出之處。

Pusher是一個簡單的託管API,透過WebSockets整合到網路和移動應用程式或任何其他網際網路連線的裝置上,實現快速,輕鬆,安全地將實時雙向功能。每天,Pusher實時傳送數十億條訊息:從源傳送資訊到目標只需要100ms。我們如何實現這一目標? 一個關鍵因素是Go的低延遲垃圾回收器。

垃圾收集器是實時系統的緩慢的元兇,因為他們會暫停程式執行。所以在設計我們的新訊息匯流排時,我們仔細選擇了語言。 Go注重低延遲 ,但我們很謹慎:Go真正實現這一目標? 如果是,如何?

在這篇博文中,我們將看看Go的垃圾收集器。 我們將看看它是如何工作的(三色演算法),為什麼它這麼傑出的工作(實現這樣短的GC暫停),最重要的是,它是否真的這樣工作(對GC暫停進行基​​準測試,並與其他語言進行比較)。

從Haskell到Go

我們一直在構建的系統是一個將已釋出訊息儲存記憶體的pub / sub訊息匯流排。Go這個版本是我們在用Haskell完成後第一個實現的重寫。在發現GHC的垃圾收集器底層延遲問題後,我們在5月停止了對Haskell版本的工作。

GHC的暫停時間與工作集的大小(即記憶體中的物件數量)成正比。在我們的例子中,我們在記憶體中有很多物件,這導致了幾百毫秒的暫停時間。 這是一個問題,任何GC當它完成收集時會阻塞程式。

進入Go以後,它不像GHC的停止世界收集器,Go的收集器與程式同時執行,這使得避免更長的停頓成為可能。 我們被Go注重低延遲特性鼓勵,並發現它很有前途,特別是當我們讀到其每一個新版本都有關改善延遲時。

併發垃圾收集器如何工作?

Go的GC如何實現這種併發? 其核心是三色標記-清除(tricolor mark-and-sweep)演算法 。下面是一個動畫(見原文動畫),顯示了演算法的工作原理。 注意它讓GC與程式同時執行;


這意味著暫停時間成為排程問題。 排程程式可以配置為僅在短時間內執行GC集合,與程式交織。 這對我們的低延遲要求是個好訊息!


上面的動畫詳細顯示了標記階段。 GC仍然有兩個停止世界階段:對根物件的初始堆疊掃描,以及標記階段的終止。 令人興奮的是,這種終止階段,最近被淘汰 。 我們將在後面討論這個最佳化。 在實踐中,我們發現對非常大的堆這些階段的暫停時間為<1ms,。

使用併發GC,也有可能在多個處理器上並行執行GC。


延遲與吞吐量

如果併發GC針對很大的堆產生很低的延遲,為什麼要使用stop-the-world收集器?是不是Go的併發垃圾收集器比GHC的stop-the-world的收集器更好 ?

不一定。 低延遲是要付出成本的。 最重要的成本降低吞吐量 。併發性需要額外的工作來同步和複製,這使得程式可以做有用的工作。GHC的垃圾收集器針對吞吐量進行了最佳化,但Go針對了延遲最佳化。 在Pusher,我們關心延遲,所以對於我們這是一個很好的方案。

併發垃圾回收的第二個成本是不可預測的堆增長。程式可以在GC執行時分配任意數量的記憶體。這意味著GC必須在堆達到目標最大之前執行。 但是如果GC執行得太快,那麼將執行更多的收集。 這個代價是非常棘手。在Pusher,這種不可預測性不是一個問題;我們的程式傾向於以可預測的恆定速率分配儲存器。

它在實踐中如何執行?

到目前為止,Go的GC看起來很適合我們的延遲要求。 但它在實踐中如何執行?

今年早些時候,當調查Haskell實現中的暫停時間時,我們為測量暫停建立了一個基準測試。基準程式重複地將訊息推送到大小受限的緩衝區中。 舊訊息不斷地過期並變成垃圾。堆大小保持很大,這很重要,因為必須遍歷堆才能檢測哪些物件仍被引用。這就是為什麼GC執行時間與它們之間的活物件/指標的數量成比例。

這裡是Go中的基準,其中緩衝區被建模為陣列:

 package main

import (
        "fmt"
        "time"
)

const (
        windowSize = 200000
        msgCount   = 1000000
)

type (
        message []byte
        buffer [windowSize]message
)

var worst time.Duration

func mkMessage(n int) message {
        m := make(message, 1024)
        for i := range m {
                m[i] = byte(n)
        }
        return m
}

func pushMsg(b *buffer, highID int) {
        start := time.Now()
        m := mkMessage(highID)
        (*b)[highID%windowSize] = m
        elapsed := time.Since(start)
        if elapsed > worst {
                worst = elapsed
        }
}

func main() {
        var b buffer
        for i := 0; i < msgCount; i++ {
                pushMsg(&b, i)
        }
        fmt.Println("Worst push time: ", worst)
}
 


結合其它人關於Ocaml Racket 和Java的基準測試,下面是在我係統上的測試結果,右邊是最長暫停時間ms:

基準	                                                最長暫停 (ms)
OCaml 4.03.0 (map based) (manual timing)	        2.21
Haskell/GHC 8.0.1 (map based) (rts timing)	        67.00
Haskell/GHC 8.0.1 (array based) (rts timing) [1]	58.60
Racket 6.6 experimental incremental GC (map based)  144.21
(tuned) (rts timing)	
Racket 6.6 experimental incremental GC (map based)  124.14
 (untuned) (rts timing)	
Racket 6.6 (map based) (tuned) (rts timing) [2]	    113.52
Racket 6.6 (map based) (untuned) (rts timing)	    136.76
Go 1.7.3 (array based) (manual timing)	            7.01
Go 1.7.3 (map based) (manual timing)	            37.67
Go HEAD (map based) (manual timing)	                7.81
Java 1.8.0_102 (map based) (rts timing)	            161.55
Java 1.8.0_102 G1 GC (map based) (rts timing)	    153.89
<p class="indent">


很驚訝Java兩個測試表現很差,而OCaml表現非常好,不到〜3毫秒暫停時間,是由於為老一代使用增量的GC演算法。 (我們不選擇OCaml的主要原因是它支援多核並行性不好)。

如你所見,Go執行順利,暫停時間約為7ms。 這符合我們的要求。

..

結論

這項GC調查的關鍵是針對更低的延遲或更高的吞吐量進行最佳化。他們也可能執行更好或更差,這取決於您的程式堆使用率。 (有很多的物件嗎?他們有長或短的生命嗎?)

重要的是要了解底層的GC演算法,以決定它是否適合您的用例。 在實踐中測試GC實現也很重要。您的基準測試應該與您打算實現的程式具有相同的堆使用率。 這將在實踐中檢查GC實施的有效性。正如我們所看到的,Go的實現不是沒有故障,但在我們的情況下,問題是可以接受的。 我想在更多的語言中看到相同的基準,如果你想貢獻:)

儘管有一些問題,Go的GC相比其他GCed語言執行得很好。Go團隊一直在改進延遲,並繼續這樣做。 我們對Go的GC,理論和實踐感到滿意。

相關文章