Go Blog上最近發表了一篇文章,內容是Richard Hudson在ISMM 2018上面的"Getting to Go"的講座,包括keynote以及筆記,從中可以看到Go GC設計的考量,以及演化的脈絡,文章下面摘要一些內容出來。
Go調度的單位是輕量級的goroutine,goroutine被調度器調度到有限的幾個線程裡執行,每個goroutine都有自己的stack,所以Go會有成千上萬個的stack作為GC的Root,在GC safepoint需要去停止和遍曆。
Go支援實值型別,或者說主要是基於實值型別,實值型別沒有額外的object header之類的開銷。因為實值型別的存在,能夠控制layout,通過FFI和C++的互動速度也很快,對於GC來說棧上分配的實值型別可以降低GC壓力。實值型別可以分配在堆上,指向實值型別的指標,或者實值型別內部某個欄位的指標,能夠保持實值型別在GC中存活。
靜態AOT編譯。Go只做了靜態編譯,不支援JIT。好處是編譯出的結果具有可預測的、穩定的執行效能,不好的地方不能像JIT那樣利用執行時的反饋來最佳化編譯。
Go現在只有兩個可以控制GC的方式,這也反應了GC——或者Go語言設計上追求簡單的基調。
一個是很早就有的設定,可以設定一個自上次GC後新分配的記憶體的和上次GC後的使用的記憶體的比例,達到這個比例就觸發一次新的GC,預設是100。可以通過GOGC參數指定,也可以通過SetGCPercent在運行時指定。
另外一個SetMaxHeap,現在還只在內部使用,是Go可以使用的最大Heap的大小,類似於Java的-Xmx。增加這個參數是基於:如果GC已經無法把記憶體降下去了,就應該降低程式的負載,而不是一直分配更多的記憶體出來。
Go的GC在最初設計的時候,就以低延遲為主要的目標,這是一個無比正確的決定。現在JVM的GC也在從以輸送量為目標的GC轉向低延遲的G1,ZGC等。
一道簡單的數學題,如果能保證99%的系統GC延遲在10ms以下,使用者的瀏覽器需要向伺服器發送100個請求,或者訪問五次頁面,每次需要發送20個請求,那麼只有37%的使用者能夠享受到完全10ms以下的延遲——在服務化的架構中,這個使用者可以認為是一台調用其他服務的server,問題就更明顯了。
如果想要讓99%的使用者都能夠有10ms以下的延遲,那麼系統的就需要保證99.99%的GC延遲在10ms以下。Jeff Dean在2014年發表的論文, "The Tail at Scale",詳細闡述了這個稱為 tyranny of the 9s的問題。
2014年制定的的Go GC目標。2014年的時候其他語言的GC實現在延遲方面基本還都是災難性的,這個目標看著是給自己挖了很大的坑?
本來打算是做一個不需要read barrier 的,並行的,帶記憶體copying的GC,但是時間緊任務重,所以最後做的是沒有copying的GC。Read Barrier的開銷,低延遲,記憶體compaction,這三個目標權衡之下放棄了最後一個,通常捨棄compaction的後果是可能出現記憶體片段,降低記憶體配置速度。不過TCMalloc, Hoard, Intel's Scalable Malloc等這些在C中實現的allocator給了Go team信心,GC並不一定要做記憶體move。
當然目前來看實現的並發Copy的低延遲GC都是帶read barrier的,Go一開始的野心是大了點。
Write barrier是省不了的,並發標記需要write barrier的支援。因為write barrier只在GC的時候開啟,程式效能的影響被盡量的降低了。
Go GC記憶體配置的實現。記憶體劃分成span,每個span只分配同樣大小的記憶體,不同size的對象分配通過span互相隔離。這樣做的好處:
- 分配的記憶體大小都是固定的,如果指向對象內部某個field的指標,可以直接算出對象的起始地址。
- 低記憶體片段。即使GC的時候不做compaction也不會遭受嚴重的記憶體片段問題。
- 記憶體按size劃分後,分配記憶體的競爭很低,因而有較高的效能。
- 分配速度。雖然沒有像JVM那樣整理記憶體的GC後可以直接bump pointer來分配快,但是已經比C快了。
使用單獨的mark bits 記錄,記錄每個欄位是是不是指標這樣的元資訊。給GC標記和記憶體allocation使用。
1.6,1.7,1.8,連續三個版本GC大幅降低了GC的延遲,從40ms降低到了1ms的層級。
2014年的SLO中,10ms延遲的目標在1.6.3就達到了。如今是8012年,現在有了新的SLO,500微秒的STW時間看著是又給自己挖了坑。
Rick還講了一些失敗的工作,主要是面向請求的GC和分代GC。
ROC是針對大部分Request-Response型的線上應用情境,更高效率的回收一次請求期間短生命週期的對象。思路是,一個goroutine死掉的時候,把只有這個goroutine使用的對象,都回收了,因為回收的對象不被別的goroutine使用,所以是不需要同步的,這點會比分代回收要強。
然而這需要一直開著write barrier去記錄一個對象是只被當前的goroutine使用,還是被傳遞給其他goroutine了,而write barrier太慢了。
ROC失敗之後的下一步還是嘗試使用曆史悠久,也很成熟的分代GC。但是出於低延遲的目標,分代GC也不打算做copying,這就難辦了,不做copying怎麼做promotion呢?變通的方法是不區分old區和young區,而是用一個bit verctor來記錄地區內的每塊記憶體是old(1)還是young(0)。每次young gc,被old的指標指向的都標記成old,然後所有標記為0的都被回收。然後分配的時候從這個bitvetcor裡找下一個值為0的地區分配,直到下一次young gc。
這個方案依然需要writer barrier一直開著,但是非GC期間可以有個fast writer barrier的最佳化。分代GC最終的效能也不理想,fast writer barrier雖然快,但是還沒有足夠快。
另外一個分代GC不理想的原因,是因為Go是基於value類型,即使有用指標,只要逃逸分析發現範圍沒有逃逸,也會在棧上指派至。結果是Go的短壽命周期對象通常在棧上分配,讓young gc的收益變小。
使用Card Marking可以消除非GC時的writer barrier。Card Table是分代GC常用的最佳化方法,可以省去writer barrier的開銷,但要有個pointer hash的開銷。Card 記錄一定地區內pointer的hash值,如果有pointer變化,hash就會變化,card就被認為是dirty的。在現代支援AES((Advanced Encryption Standard)指令的硬體上,維護這樣的一個hash非常的快。
使用Card Marking的分代GC效能測試仍然不是特別理想,影響的因素很多。或許可以在GC中發現分代會比較快的話就開啟分代,否則就關閉。
看看硬體方面,RAM的容量增長很快,價格下降也快,也許不用在GC上這麼摳了嘞?