Go語言的實時GC——理論與實踐

Go語言可以支持實時的,高併發的消息系統,在高達百萬級別的消息系統中可以將延遲下降到100ms如下,這一切很大一部分須要歸功於Go的高效的垃圾回收系統。html

對於實時系統而言,垃圾回收系統多是一個極大的隱患,由於在垃圾回收的時候須要將整個程序暫停。因此在咱們設計消息總線系統的時候,須要當心地選擇咱們的語言。Go一直在強調它的低延遲,可是它真的作到了嗎?若是是的,它是怎麼作到的呢?git

在這篇文章當中,咱們將會看到Go語言的GC是如何實現的(tricolor algorithm,三色算法),以及爲何這種方法可以達到如此之低的GC暫停,以及最重要的是,它是否真的有效(對這些GC暫停進行benchmar測試,以及同其它類型的語言進行比較)。github

From Haskell to Go

咱們用pub/sub消息總線系統爲例說明問題,這些系統在發佈消息的時候都是in-memory存儲的。在早期,咱們用Haskell實現了初版的消息系統,可是後面發現GHC的gabage collector存在一些基礎延遲的問題,咱們放棄了這個系統轉而用Go進行了實現。golang

這是咱們有關Haskell消息系統的一些實現細節,在GHC中最重要的一點是它GC暫停時間同當前的工做集的大小成比例關係(也就是說,GC時間和內存中存儲對象的數目有關)。在咱們的例子中,內存中存儲對象的數目每每都很是巨大,這就致使gc時間經常高達數百毫秒。這就會致使在GC的時候整個系統是阻塞的。算法

而在Go語言中,不一樣於GHC的全局暫停(stop-the-world)收集器,Go的垃圾收集器是和主程序並行的。這就能夠避免程序的長時間暫停。咱們則更加關注於Go所承諾的低延遲以及其在每一個新版本中所說起的延遲提高是否真的向他們所說的那樣。數組

並行垃圾回收是如何工做的?

Go的GC是如何實現並行的呢?其中的關鍵在於tricolor mark-and-sweep algorithm 三色標記清除算法。該算法可以讓系統的gc暫停時間成爲可以預測的問題。調度器可以在很短的時間內實現GC調度,而且對源程序的影響極小。下面咱們看看三色標記清除算法是如何工做的:併發

假設咱們有這樣的一段鏈表操做的代碼:高併發

var A LinkedListNode;
var B LinkedListNode;
// ...
B.next = &LinkedListNode{next: nil};
// ...
A.next = &LinkedListNode{next: nil};
*(B.next).next = &LinkedListNode{next: nil};
B.next = *(B.next).next;
B.next = nil;

第一步

var A LinkedListNode;

var B LinkedListNode;

// ...

B.next = &LinkedListNode{next: nil};

剛開始咱們假設有三個節點A、B和C,做爲根節點,紅色的節點A和B始終都可以被訪問到,而後進行一次賦值B.next = &C。初始的時候垃圾收集器有三個集合,分別爲黑色,灰色和白色。如今,由於垃圾收集器尚未運行起來,因此三個節點都在白色集合中。工具

gc 001

第二步

咱們新建一個節點D,並將其賦值給A.next。即:gitlab

var A LinkedListNode;

var B LinkedListNode;

// ...

B.next = &LinkedListNode{next: nil};
// ...
A.next = &LinkedListNode{next: nil};

須要注意的是,做爲一個新的內存對象,須要將其放置在灰色區域中。爲何要將其放在灰色區域中呢?這裏有一個規則,若是一個指針域發生了變化,則被指向的對象須要變色。由於全部的新建內存對象都須要將其地址賦值給一個引用,因此他們將會當即變爲灰色。(這就須要問了,爲何C不是灰色?)

http://www.chenquan.me/wp-content/uploads/2017/08/golang-gc2.001.jpeg

第三步

在開始GC的時候,根節點將會被移入灰色區域。此時A、B、D三個節點都在灰色區域中。因爲全部的程序子過程(process,由於不能說是進程,應該算是線程,可是在go中又不徹底是線程)要麼是程序正常邏輯,要麼是GC的過程,並且GC和程序邏輯是並行的,因此程序邏輯和GC過程應該是交替佔用CPU資源的。

golang-gc3.001.jpeg

第四步 掃描內存對象

在掃描內存對象的時候,GC收集器將會把該內存對象標記爲黑色,而後將其子內存對象標記爲灰色。在任一階段,咱們都可以計算當前GC收集器須要進行的移動步數:2*|white| + |grey|,在每一次掃描GC收集器都至少進行一次移動,直到達到當前灰色區域內存對象數目爲0。

golang-gc4.001.jpeg

第五步

程序此時的邏輯爲,新賦值一個內存對象E給C.next,代碼以下:

var A LinkedListNode;

var B LinkedListNode;

// ...

B.next = &LinkedListNode{next: nil};
// ...
A.next = &LinkedListNode{next: nil};
//新賦值 C.next = &E
*(B.next).next = &LinkedListNode{next: nil};

按照咱們以前的規則,新建的內存對象須要放置在灰色區域,如圖所示:

golang-gc5.001.jpeg

這樣作,收集器須要作更多的事情,可是這樣作當在新建不少內存對象的時候,能夠將最終的清除操做延遲。值得一提的是,這樣處理白色區域的體積將會減少,直到收集器真正清理堆空間時再從新填入移入新的內存對象。

第六步 指針從新賦值

程序邏輯此時將 B.next.next賦值給了B.next,也就是將E賦值給了B.next。代碼以下:

var A LinkedListNode;
var B LinkedListNode;
// ...
B.next = &LinkedListNode{next: nil};
// ...
A.next = &LinkedListNode{next: nil};
*(B.next).next = &LinkedListNode{next: nil};
// 指針從新賦值:
B.next = *(B.next).next;

這樣作以後,如圖所示,C將不可達。

golang-gc6.001.jpeg

這就意味着,收集器須要將C從白色區域移除,而後在GC循環中將其佔用的內存空間回收。

第七步

將灰色區域中沒有引用依賴的內存對象移動到黑色區域中,此時D在灰色區域中沒有其它依賴,並依賴於它的內存對象A已經在黑色區域了,將其移動到黑色區域中。

golang-gc7.001.jpeg

第八步

在程序邏輯中,將B.next賦值爲了nil,此時E將變爲不可達。但此時E在灰色區域,將不會被回收,那麼這樣會致使內存泄漏嗎?其實不會,E將在下一個GC循環中被回收,三色算法可以保證這點:若是一個內存對象在一次GC循環開始的時候沒法被訪問,則將會被凍結,並在GC的最後將其回收。

golang-gc8.001.jpeg

第九步

在進行第二次GC循環的時候,將E移入到黑色區域,可是C並不會移動,由於是C引用了E,而不是E引用C。

golang-gc9.001.jpeg

第十步

收集器再掃描最後一個灰色區域中的內存對象B,並將其移動到黑色區域中。

http://www.chenquan.me/wp-content/uploads/2017/08/golang-gc10.001.jpeg

第十一步 回收白色區域

如今灰色區域已經沒有內存對象了,這個時候就講白色區域中的內存對象回收。在這個階段,收集器已經知道白色區域的內存對象已經沒有任何引用且不可訪問了,就將其當作垃圾進行回收。而在這個階段,E不會被回收,由於這個循環中,E纔剛剛變爲不可達,它將在下個循環中被回收。

golang-gc11.001.jpeg

第十二步 區域變色

這一步是最有趣的,在進行下次GC循環的時候,徹底不須要將全部的內存對象移動回白色區域,只須要將黑色區域和白色區域的顏色換一下就行了,簡單並且高效。

golang-gc12.001.jpeg

GC三色算法小結

上面就是三色標記清除算法的一些細節,在當前算法下仍舊有兩個階段須要 stop-the-world:一是進行root內存對象的棧掃描;二是標記階段的終止暫停。使人激動的是,標記階段的終止暫停將被去除。在實踐中咱們發現,用這種算法實現的GC暫停時間可以在超大堆空間回收的狀況下達到<1ms的表現。

延遲 VS 吞吐

若是一個並行GC收集器在處理超大內存堆時可以達到極低的延遲,那麼爲何還有人在用stop-the-world的GC收集器呢?難道Go的GC收集器還不夠優秀嗎?

這不是絕對的,由於低延遲是有開銷的。最主要的開銷就是,低延遲削減了吞吐量。併發須要額外的同步和賦值操做,而這些操做將會佔用程序的處理邏輯的時間。而Haskell的GHC則針對吞吐量進行了優化,Go則專一於延遲,咱們在考慮採用哪一種語言的時候須要針對咱們本身的需求進行選擇,對於推送系統這種實時性要求比較高的系統,選擇Go語言則是權衡之下獲得的選擇。

實際表現

目前而言,Go好像已經可以知足低延遲系統的要求了,可是在實際中的表現又怎麼樣呢?利用相同的benchmark測試邏輯實現進行比較:該基準測試將不斷地向一個限定緩衝區大小的buffer中推送消息,舊的消息將會不斷地過時併成爲垃圾須要進行回收,這要求內存堆須要一直保持較大的狀態,這很重要,由於在回收的階段整個內存堆都須要進行掃描以肯定是否有內存引用。這也是爲何GC的運行時間和存活的內存對象和指針數目成正比例關係的緣由。

這是Go語言版本的基準測試代碼,這裏的buffer用數組實現:

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)
}

相同的邏輯,不一樣語言實現(Haskell/Ocaml/Racke<Gabriel Scherer>Java<Santeri Hiltune>),在同等測試條件下進行的測試結果以下:

Benchmark Longest pause (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) 58.60
Racket 6.6 experimental incremental GC (map based) (tuned) (rts timing) 144.21
Racket 6.6 experimental incremental GC (map based) (untuned) (rts timing) 124.14
Racket 6.6 (map based) (tuned) (rts timing) 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

使人驚訝的是Java,表現得很是通常,而OCaml則很是之好,OCaml語言可以達到約3ms的GC暫停時間,這是由於OCaml採用的GC算法是incremental GC algorithm(而在實時系統中不採用OCaml的緣由是該語言對多核的支持很差)。

正如表中顯示的,Go的GC暫停時間大約在7ms左右,表現也好,已經徹底可以知足咱們的要求。

一些注意事項

  1. 進行基準測試每每須要多加當心,由於不一樣的運行時針對不一樣的測試用例都有不一樣程度的優化,因此表現每每也有差別。而咱們須要針對本身的需求來編寫測試用例,對於基準測試應該可以知足咱們本身的產品需求。在上面的例子中能夠看到,Go已經徹底可以知足咱們的產品需求。
  2. Map Vs. Array: 最初咱們的基準測試是在map中進行插入和刪除操做的,可是Go在對大型的map進行GC的時候存在Bug。所以在設計Go的基準測試的時候用可修改的Array做爲Map的替代。Go map的Bug已經在1.8版本中獲得了修復,可是並非全部的基準測試都獲得了修正,這也是咱們須要正視的一些問題。可是無論怎麼說,沒有理由說GC時間將會由於使用map致使大幅度增加(除去bug和糟糕的實現以外)。
  3. manual timing Vs. rst timing :做爲另外一個注意事項,有些基準測試則在不一樣的計時系統下將會有所差別,由於有些語言不支持運行時時間統計,例如Go,而有些語言則支持。所以,咱們應該在測試時候都把計時方式設置爲manual timing。
  4. 最後一個須要注意的事項是測試用例的實現將會極大地影響基準測試的結果,若是map的插入刪除實現方式比較糟糕,則將會對測試結果形成不利影響,這也是用array的另外一個緣由。

爲何Go的結果不能再好點?

儘管咱們採用的map bugfixed版本或者是array版本的go實現可以達到~7ms的GC暫停表現,這已經很好了,可是根據Go官方發佈的"1.5 Garbage Benchmark Latency"](https://talks.golang.org/2015... , 在200MB的堆內存前提下,可以達到~1ms的GC暫停延時(經管GC暫停時間應該和指針引用數目有關而和堆所佔用的容量無關但咱們沒法獲得確切數據)。而Twitch團隊也發佈文章稱在Go1.7中可以達到約1ms的GC延遲

在聯繫go-nuts mail list以後獲得的答案是,這些暫停實驗多是由於一些未修復的bug致使的。空閒的標記worker可能會對程序邏輯形成阻塞,爲了肯定這個問題,我採用了go tool trace,一個可視化工具對go的運行時行爲進行了跟蹤。

go-tool-trace-gc-pause.png

正如圖所示,這裏有近12ms的後臺mark worker運行在全部的processor(CPU核?)中。這讓我更加確信是上述的bug致使的該問題。

總結

此次調查的重點在於GC要麼關注於低延遲,要麼關注於高吞吐。固然這些也都取決於咱們的程序是如何使用堆空間的(咱們是否有不少內存對象?每一個對象的生命週期是長仍是短?)

理解底層的GC算法對該系統是否適用於你的測試用例是很是重要的。固然GC系統的實際實現也相當重要。你的基準測試程序的內存佔用應該同你將要實現的真正程序相似,這樣纔可以在實踐中檢驗GC系統對於你的程序而言是否高效。正如前文所說的,Go的GC系統並不完美,可是對於咱們的系統而言是能夠接受的。

儘管存在一些問題,可是Go的GC表現已經優於大部分一樣擁有GC系統的語言了,Go的開發團隊針對GC延遲進行了優化,而且還在繼續。Go的GC確實是有可圈可點之處,不管是理論上仍是實踐中。

ref: Golang’s Real-time GC in Theory and Practice(en)
相關文章
相關標籤/搜索