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

零壹技術棧發表於2019-02-12

前言

Go語言能夠支援實時的,高併發的訊息系統,在高達百萬級別的訊息系統中能夠將延遲降低到100ms以下,很大一部分需要歸功於Go高效的垃圾回收系統。

對於實時系統而言,垃圾回收系統可能是一個極大的隱患,因為在垃圾回收的時候需要將整個應用程式暫停。所以在我們設計訊息匯流排系統的時候,需要小心地選擇我們的語言。Go一直在強調它的低延遲,但是它真的做到了嗎?如果是的,它是怎麼做到的呢?

在這篇文章當中,我們將會看到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。初始的時候垃圾收集器有三個集合,分別為黑色,灰色和白色。現在,因為垃圾收集器還沒有執行起來,所以三個節點都在白色集合中。

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

2.2. 第二步

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

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

需要注意的是,作為一個新的記憶體物件,需要將其放置在灰色區域中。為什麼要將其放在灰色區域中呢?這裡有一個規則,如果一個指標域發生了變化,則被指向的物件需要變色。因為所有的新建記憶體物件都需要將其地址賦值給一個引用,所以他們將會立即變為灰色。(這就需要問了,為什麼C不是灰色?)

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

2.3. 第三步

在開始GC的時候,根節點將會被移入灰色區域。此時A、B、D三個節點都在灰色區域中。由於所有的程式子過程(process,因為不能說是程式,應該算是執行緒,但是在go中又不完全是執行緒)要麼事程式正常邏輯,要麼是GC的過程,而且GC和程式邏輯是並行的,所以程式邏輯和GC過程應該是交替佔用CPU資源的。

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

2.4. 第四步 掃描記憶體物件

在掃描記憶體物件的時候,GC收集器將會把該記憶體物件標記為黑色,然後將其子記憶體物件標記為灰色。在任一階段,我們都能夠計算當前GC收集器需要進行的移動步數:2*|white| + |grey|,在每一次掃描GC收集器都至少進行一次移動,直到達到當前灰色區域記憶體物件數目為0。

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

2.5. 第五步

程式此時的邏輯為,新賦值一個記憶體物件E給C.next,程式碼如下:

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

按照我們之前的規則,新建的記憶體物件需要放置在灰色區域,如圖所示:

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

這樣做,收集器需要做更多的事情,但是這樣做當在新建很多記憶體物件的時候,可以將最終的清除操作延遲。值得一提的是,這樣處理白色區域的體積將會減小,直到收集器真正清理堆空間時再重新填入移入新的記憶體物件。

2.6. 第六步 指標重新賦值

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

*(B.next).next = &LinkedListNode{next: nil};
// 指標重新賦值:
B.next = *(B.next).next;
複製程式碼

這樣做之後,如圖所示,C將不可達。

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

這就意味著,收集器需要將C從白色區域移除,然後在GC迴圈中將其佔用的記憶體空間回收。

2.7. 第七步

將灰色區域中沒有引用依賴的記憶體物件移動到黑色區域中,此時D在灰色區域中沒有其它依賴,並依賴於它的記憶體物件A已經在黑色區域了,將其移動到黑色區域中。

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

2.8. 第八步

在程式邏輯中,將B.next賦值為了nil,此時E將變為不可達。但此時E在灰色區域,將不會被回收,那麼這樣會導致記憶體洩漏嗎?其實不會,E將在下一個GC迴圈中被回收,三色演算法能夠保證這點:如果一個記憶體物件在一次GC迴圈開始的時候無法被訪問,則將會被凍結,並在GC的最後將其回收。

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

2.9. 第九步

在進行第二次GC迴圈的時候,將E移入到黑色區域,但是C並不會移動,因為是C引用了E,而不是E引用C。

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

2.10. 第十步

收集器再掃描最後一個灰色區域中的記憶體物件B,並將其移動到黑色區域中。

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

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

收集器再掃描最後一個灰色區域中的記憶體物件B,並將其移動到黑色區域中。

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

2.12. 第十二步 區域變色

這一步是最有趣的,在進行下次GC迴圈的時候,完全不需要將所有的記憶體物件移動回白色區域,只需要將黑色區域和白色區域的顏色換一下就好了,簡單而且高效。

Go語言實時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 - 三色標記演算法

正如表中顯示的,Go的GC暫停時間大約在7ms左右,表現也好,已經完全能夠滿足我們的要求。

總結

這次調查的重點在於GC要麼關注於低延遲,要麼關注於高吞吐。當然這些也都取決於我們的程式是如何使用堆空間的(我們是否有很多記憶體物件?每個物件的生命週期是長還是短?)

理解底層的GC演算法對該系統是否適用於你的測試用例是非常重要的。當然GC系統的實際實現也至關重要。你的基準測試程式的記憶體佔用應該同你將要實現的真正程式類似,這樣才能夠在實踐中檢驗GC系統對於你的程式而言是否高效。正如前文所說的,Go的GC系統並不完美,但是對於我們的系統而言是可以接受的。

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

參考

相關文章