1. 程式人生 > >理性看待Go語言的垃圾回收演算法

理性看待Go語言的垃圾回收演算法

原文:https://blog.csdn.net/qq_15427331/article/details/54613635

Go語言正在構建的垃圾收集器(GC),似乎並不像宣傳中那樣的,技術上迎來了巨大突破。那麼,與Java語言作對比之後,該怎麼選擇呢?寫在前面
最近,我讀到一些大肆宣傳Go語言最新垃圾回收器的文章,這些文章對垃圾回收器的描述讓我感到有些厭煩。這些文章有些是來自Go專案。他們宣稱GC技術正迎來巨大突破。

下面Go團隊在2015年8月釋出的新垃圾回收器的啟動宣告:https://blog.golang.org/go15gc

Go正在構建一個劃時代垃圾回收器,2015年,甚至到2025年,或者更久……Go 1.5的GC把我們帶入了一個新時代,垃圾回收停頓不再成為使用新語言的障礙。應用程式可以很容易地隨著硬體進行伸縮,而且隨著硬體越來越強大,GC不再是構建可伸縮軟體的阻礙。一個新的時代正在開啟。

Go團隊不僅宣稱他們已經解決了GC停頓問題,而且讓整件事情變得更加“傻瓜”化:

從更高層面解決效能問題的方式之一是增加GC選項(也就是GC配置引數),每個效能問題使用一個選項。程式設計師可以通過選項為他們的應用程式找到合適的設定。不過,這種方式的不足之處在於,選項數量會不斷增加,到最後很可能會需要一部“GC選項操作者就業草案”。Go不想繼續走這條路。相反,我們只提供了一個選項,也就是GOGC。

而且,因為不需要支援太多的選項,執行時團隊可以集中精力根據真實的使用者反饋來改進執行時。

我相信很多Go語言使用者對新的執行時還是很滿意的。不過我對之前的那些觀點有異議,對於我來說,它們是對市場的誤導。這些觀點在部落格圈內重複出現,我想是時候對它們進行深入分析了。

事實上,Go的GC並沒有真正實現任何新的想法或做出任何有價值的研究。他們在聲明裡也承認,他們的回收器是一種併發的標記並清除回收器,而這種想法在70年代就有了。他們的回收器之所以還值得一提,完全是因為它對停頓時間進行了改進,而這是以犧牲GC其它方面的特性為代價的。Go相關的技術討論和發行材料並沒有提到他們在這個問題上所做出的折衷,讓那些不熟悉垃圾回收技術的開發人員不知道這些問題的存在,還暗示Go的其它競爭者製造的都是垃圾。而Go也在強化這種誤導:

為了建立劃時代的垃圾回收器,我們在很多年前就採用了一種演算法。Go的新回收器是一種併發的、三基色的、標記並清除回收器,它的設計想法是由Dijkstra在1978年提出的。它有別於現今大多數“企業”級垃圾回收器,而且我們相信它跟現代硬體的屬性和現代軟體的低延遲需求非常匹配。

讀完這段宣告,你會感覺過去40年的“企業”級GC研究沒有任何進展。

GC理論基礎

下面列出了在設計垃圾回收演算法時要考慮的一些因素:

程式吞吐量:回收演算法會在多大程度上拖慢程式?有時候,這個是通過回收佔用的CPU時間與其它CPU時間的百分比來描述的。

GC吞吐量:在給定的CPU時間內,回收器可以回收多少垃圾?

堆記憶體開銷:回收器最少需要多少額外的記憶體開銷?如果回收器在回收垃圾時需要分配臨時的記憶體,對於程式的記憶體使用是否會有嚴重影響?

停頓時間:回收器會造成多長時間的停頓?

停頓頻率:回收器造成的停頓頻率是怎樣的?

停頓分佈:停頓有時候很短暫,有時候很長?還是選擇長一點但保持一致的停頓時間?

分配效能:新記憶體的分配是快、慢還是無法預測?

壓縮:當堆記憶體裡還有小塊碎片化的記憶體可用時,回收器是否仍然丟擲記憶體不足(OOM)的錯誤?如果不是,那麼你是否發現程式越來越慢,並最終死掉,儘管仍然還有足夠的記憶體可用?

併發:回收器是如何利用多核機器的?

伸縮:當堆記憶體變大時,回收器該如何工作?

調優:回收器的預設使用或在進行調優時,它的配置有多複雜?

預熱時間:回收演算法是否會根據已發生的行為進行自我調節?如果是,需要多長時間?

頁釋放:回收演算法會把未使用的記憶體釋放回給作業系統嗎?如果會,會在什麼時候發生?

可移植性:回收器是否能夠在提供了較弱記憶體一致性保證的CPU架構上執行?

相容性:回收器可以跟哪些程式語言和編譯器一起工作?它可以跟那些並非為GC設計的程式語言一起工作嗎,比如C++?它要求對編譯器作出改動嗎?如果是,那麼是否意味著改變GC演算法就需要對程式和依賴項進行重新編譯?

可見,在設計垃圾回收器時需要考慮很多不同的因素,而且它們中有些還會影響到平臺生態系統的設計。我甚至都不敢確定以上給出的列表是否包含了所有因素。

設計領域的工作相當複雜,垃圾回收作為電腦科學的一個子領域,人們對它有著廣泛的理論研究。學院派和軟體行業會時不時地提出新的演算法和它們的實現。不過,目前還沒有人可以找出一種可以應付所有場景的演算法。

折衷無處不在
讓我們說得更具體一點。

第一批垃圾回收演算法是為單核機器和小記憶體程式而設計的。那個時候,CPU和記憶體價格昂貴,而且使用者沒有太多的要求,即使有明顯的停頓也沒有關係。這個時期的演算法設計更注重最小化回收器對CPU和堆記憶體的開銷。也就是說,除非記憶體不足,否則GC什麼事也不做。而當記憶體不足時,程式會被暫停,堆空間會被標記並清除,部分記憶體會被儘快釋放出來。

這類回收器很古老,不過它們也有一些優勢——它們很簡單,而且在空閒時不會拖慢你的程式,也不會造成額外的記憶體開銷。像Boehm GC這種守舊的回收器,它甚至不要求你對編譯器和程式語言做任何改動!這種回收器很適合用在桌面應用裡,因為桌面應用的堆記憶體一般不會很大。比如虛幻遊戲引擎,它會在記憶體裡存放資料檔案,但不會被掃描到。

計算機專業課程經常把會造成停頓(STW)的標記並清除GC演算法作為授課內容。在工作面試時,有時候我會問候選人一些GC相關的問題,他們要麼把GC看成一個黑盒,要麼對GC一竅不通,要麼認為現今仍然在使用這種老舊的技術。

標記並清除演算法存在的最大問題是它的伸縮性很差。在增加CPU核數並加大堆空間之後,這種演算法幾乎無法工作。不過有時候你的堆空間不會很大,而且停頓時間可以接受。那麼在這種情況下,你或許可以繼續使用這種演算法,畢竟它不會造成額外的開銷。

反過來說,或許你的一臺機器就有數百G的堆空間和幾十核的CPU,這些伺服器可能被用來處理金融市場的交易,或者執行搜尋引擎,停頓時間對於你來說很敏感。在這種情況下,你或許希望使用一種能夠在後臺進行垃圾回收,並帶來更短停頓時間的演算法,儘管它會拖慢你的程式。

不過事情並不會像看上去的那麼簡單!在這種配置高階的伺服器上可能執行著大批量的作業,因為它們是非互動性的,所以停頓時間對於你來說無關緊要,你只關心它們總的執行時間。在這種情況下,你最好使用一種可以最大化吞吐量的演算法,儘量提高有效工作時間和回收時間之間的比率。

問題是,根本不存在十全十美的演算法。沒有任何一個語言執行時能夠知道你的程式到底是一個批處理作業系統還是一個對延遲敏感的互動型應用。這也就是為什麼會存在“GC調優”——並不是我們的執行時工程師無所作為。這也反映了我們在電腦科學領域的能力是有限的。

分代理論假說

從1984年以來,人們就已知道大部分的記憶體物件“朝生夕滅”,它們在分配到記憶體不久之後就被作為垃圾回收。這就是分代理論假說的基礎,它是整個軟體產品線領域最貼合實際的發現。數十年來,在軟體行業,這個現象在各種程式語言上表現出驚人的一致性,不管是函數語言程式設計語言、指令式程式設計語言、沒有值型別的程式語言,還是有值型別的程式語言。

這個現象的發現是很有意義的,我們可以基於這個現象改進GC演算法的設計。新的分代回收器比舊的標記並清除回收器有很多改進:

GC吞吐量:它們可以更快地回收更多的垃圾。

分配記憶體的效能:分配新記憶體時不再需要從堆裡搜尋可用空間,因此記憶體分配變得很自由。

程式的吞吐量:分配的記憶體空間幾乎相互鄰接,對快取的利用有顯著的改進。分代回收器要求程式在執行時要做一些額外的工作,不過這點開銷完全可以被快取的改進所帶來的好處抵消掉。

停頓時間:大多數時候(不是所有)停頓時間變得更短。

不過分代回收器也引入了一些缺點:

相容性:分代回收器需要在記憶體裡移動物件,在某些情況下,當程式對指標進行寫入時還需要做一些額外的工作。也就是說,GC必須跟編譯器緊緊地繫結在一起,這也就是為什麼C++裡沒有分代回收器。

堆記憶體開銷:分代回收器通過在記憶體空間裡移動物件實現垃圾回收。這個要求有額外的空間用來拷貝物件,所以這些回收器會帶來一些堆記憶體開銷。另外,它們需要維護指標對映表,從而帶來更大的開銷。

停頓時間分佈:儘管大部分GC停頓時間都很短,不過有一些仍然要求在整個堆內進行徹底的標記並清除操作。

調優:分代回收器引入了“年輕代”,或者叫“eden空間”,程式效能對這塊區域的大小非常敏感。

預熱時間:為了解決上述的調優問題,有一些回收器根據程式的執行情況來決定年輕代的大小,而如果是這樣的話,那麼GC的停頓時間就取決於程式的執行時間長短。在實際當中,只要不是作為基準,這個算不上什麼大問題。

因為分代演算法的優點,現代垃圾回收器基本上都是基於分代演算法。如果你能承受得起,就會想用它們,而一般來說你很可能會這樣。分代回收器可以加入其它各種特性,一個現代回收器將會集併發、並行、壓縮和分代於一身。

Go的併發回收器

Go是一種命令式的值型別程式語言,它的記憶體訪問模式跟C#類似。C#是基於分代理論假說的,所以很自然地,.NET使用的是分代回收器。

實際上,Go程式經常被用來處理請求和響應,就像HTTP伺服器一樣。也就是說,Go程式表現出了十足的分代行為。Go團隊正在發掘一種他們稱之為“面向請求的回收器”的東西。據悉,它很可能是一種重新命名過的分代回收器,只不過加入了經過調整的分代策略。

在其它語言執行時上可以模擬這種回收器,特別是那些請求和響應型別的處理器,只要確保年輕代大到可以容下請求所生成的垃圾。

不過,目前Go的回收器並不是分代的,它在後臺執行的仍然是老舊的標記並清除回收器。

Go這樣做有一個好處——它的停頓時間非常短,不過除此之外,其它方面會變得很糟糕。比如說呢?

GC吞吐量:清理堆記憶體垃圾所需要的時間隨著堆的大小而伸縮。簡單地說,程式使用越多的記憶體,記憶體就釋放得越慢,你的電腦因此需要花更多的時間在垃圾回收上。除非你的程式完全不進行並行處理,你的CPU核數可以無限制地讓給GC使用。

壓縮:因為沒有壓縮,堆空間最終會發生碎片化。後面我會講到堆的碎片化問題。因為碎片化,你將無法從快取使用中獲得任何好處。

程式吞吐量:因為GC每次需要做很多工作,會搶佔程式的CPU時間,從而拖慢程式。

停頓時間分佈:任何併發的垃圾回收器都會遇到Java世界的“併發模式故障”問題:程式製造垃圾的速度超過了GC執行緒清理垃圾的速度。在這種情況下,執行時只能暫停程式,等待當前GC結束。所以說,Go雖然宣稱它們的GC停頓時間很短暫,但這個說法只有在GC有足夠CPU時間的情況下才能成立。另外,Go的編譯器不具備可靠暫停執行緒的特性,這意味著停頓時間的長短很大程度上取決於程式的程式碼(例如,在Go子程式裡對大型二進位制物件進行BASE64解碼會讓停頓時間變長)。

堆記憶體開銷:通過標記並清除的方式來回收堆空間速度很慢,所以你需要額外的空間來確保不會出現“併發模式故障”問題。Go預設使用100%的堆記憶體開銷……也就是說,你的程式需要雙倍的記憶體來執行。

我們可以從golang-dev上的一些帖子中看到Go在這方面做出的折衷,比如:

https://groups.google.com/d/msg/golang-dev/Ab1sFeoZg_8/pv0Yg7tkAwAJ

Service 1比Service 2分配了更多的記憶體,所以停頓更加頻繁。不過兩個服務每次停頓的時間下降了一個數量級。我們可以看到,在對兩個服務進行切換以後,CPU的使用增長了大約20%。

Go讓停頓時間下降了一個數量級,但卻是以更慢的垃圾回收為代價。這是一種更好的折衷嗎?或者說停頓時間已經足夠好了嗎?這些問題在帖子裡並沒有得到回答。

不過在這種情況下,通過增加更多的硬體換取更短的停頓時間已經毫無意義。如果你的伺服器停頓時間從10毫秒下降到1毫秒,你的使用者會感覺得到嗎?如果你需要為此投入雙倍的機器,你還會這麼做嗎?

Go不斷優化停頓時間以便保證GC的吞吐量,它似乎不惜以拖慢程式的速度為代價,哪怕可以縮短一點點的停頓時間。

與Java的比較

HotSpot虛擬機器提供了幾種GC演算法,可以通過命令列來選擇使用哪一種演算法。這些演算法的停頓時間不像Go所宣稱的那麼短,畢竟它們要在各個因素間做出平衡。通過比較不同的演算法可以對它們有一個直觀的感受。重啟程式可以切換GC演算法,因為編譯工作在程式執行之時就已完成,不同演算法所需要的各種屏障可以根據具體需要被新增到編譯的程式碼裡。

現代計算機使用的預設演算法是吞吐量回收演算法。這種演算法是為批處理作業而設計的,預設情況下不對停頓時間做任何限制(不過可以通過命令列指定)。跟這種預設行為相比較,人們會覺得Java的GC簡直有點糟糕了:預設情況下,Java試圖讓你的程式執行儘可能的快,使用盡可能少的記憶體,但停頓時間卻很長。

如果你很在意停頓時間,或許可以使用併發標記並清除回收器(CMS)。這種回收器跟Go的回收器最為接近。不過CMS也是分代回收器,所以它的停頓時間仍然會比Go的要長一些:在年輕代被壓縮時,程式會被暫停,因為回收器需要移動物件。

CMS裡有兩種停頓,第一種是快速的停頓,可能會持續2到5毫秒,第二種可能會超過20毫秒。CMS是自適應的,因為它的併發性,它需要預測何時需要啟動垃圾回收(類似Go)。你需要對Go的堆記憶體開銷進行配置,而CMS會在執行時自適應調整,避免發生併發模式故障。不過CMS仍然使用標記並清除策略,堆記憶體仍然會出現碎片化,所以還是會出現問題,程式還是會被拖慢。

最新的Java回收器叫作“G1”(Garbage First)。在Java 8裡G1不是預設的回收器,不過在Java 9裡它將會是預設的回收器。它是一種“一刀切”的回收演算法,它會盡量滿足我們的各種需求。它幾乎集併發、分代和堆空間壓縮於一身。它在很大程度上可以自我調節,不過因為它無法知道你的真正需求(這個跟其它所有的回收器一樣),所以你仍然可以對它做出折衷:告訴它可用的最大記憶體和預期的停頓時間(以毫秒為單位),它會通過自我調節來達到預期的停頓時間。

預設的預期停頓時間是100毫秒,只有指定了更低的預期停頓時間才能看到更好的效果:G1會優先考慮程式的執行速度,而不是停頓時間。不過停頓時間並非一直保持一致,大部分情況下都非常短(少於1毫秒),在壓縮堆空間時停頓會長一些(超過50毫秒)。G1具有良好的伸縮性,有人在TB級別的堆上使用過G1。G1還有一些很有意思的特性,比如堆內字串去重。

Red Hat開發了一種新的演算法Shenandoah,並把它貢獻給OpenJDK,不過它並不會被用在Java 9裡,除非自己從Red Hat編譯一個特別版的Java。不管堆有多大,該演算法都會保證很短的停頓時間,同時可以對堆空間進行壓縮。

這種演算法會使用額外的堆記憶體和更多的屏障:在程式執行的同時在堆內移動物件,並維護指標的讀寫操作。在這方面,它跟Azul的“無停頓”回收器有點類似。

寫在最後

這篇文章的目的並不在於說服你使用某種語言或工具。只是希望你能意識到,垃圾回收是一個很複雜的問題,而且相當複雜,一大堆電腦科學家已經為此研究了數十年。如果有任何所謂的突破性進展,一定要謹慎看待。它們可能看起來很奇怪,或者更像是對摺衷的偽裝,到最後只會暴露無遺。

不過如果你願意犧牲其它方面來獲得最小化的停頓時間,那麼可以看看Go的GC。