Go語言實時GC - 三色標記算法

前言

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

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

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

正文

1. 從Haskell到Go

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

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

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

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

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;
複製代碼

2.1. 第一步

var A LinkedListNode;
var B LinkedListNode;

// ...

B.next = &LinkedListNode{next: nil};
複製代碼

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

2.2. 第二步

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

var D LinkedListNode;
A.next = &D;
複製代碼

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

2.3. 第三步

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

2.4. 第四步 掃描內存對象

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

2.5. 第五步

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

var E LinkedListNode;
C.next = &E;
複製代碼

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

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

2.6. 第六步 指針從新賦值

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

*(B.next).next = &LinkedListNode{next: nil};
// 指針從新賦值:
B.next = *(B.next).next;
複製代碼

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

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

2.7. 第七步

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

2.8. 第八步

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

2.9. 第九步

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

2.10. 第十步

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

2.11. 第十一步 回收白色區域

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

2.12. 第十二步 區域變色

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

3. GC三色算法小結

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

4. 延遲 VS 吞吐

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

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

5. 實際表現

目前而言,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)
}
複製代碼

相同的邏輯,不一樣語言實現下進行的測試結果以下:

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

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

總結

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

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

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

參考

相關文章
相關標籤/搜索