現代垃圾收集策略 —— Go 的 GC 策略
在 Hacker News[1] 和 Reddit[2] 你可以找到相關討論
我最近看過很多文章,它們以令我困擾的方式推廣 Go 語言最新的垃圾收集器。其中一些文章來自 Go 官方項目本身。他們聲稱這意味着 GC 技術已經有根本性的突破。
這是版本(Go 1.5)垃圾收集器的首次公告 [3]:
Go 正在構建一個垃圾收集器(GC),不止適用於 2015 年,甚至適用於 2025 年,甚至更長時間 ... Go 1.5 及以後的版本,重點解決 gc 中 stop-the-world 問題不再是安全可靠語言的障礙。未來應用程序可以毫不費力地隨硬件一起擴展,隨着硬件變得越來越強大,GC 不再是成爲更好、更具可擴展性的軟件的障礙。適用於未來十年甚至更久。
Go 官方團隊聲稱不僅解決了 GC 暫停的問題,而且還讓整個事情變得無腦:
在更高的層次上,解決性能問題的一種方法是添加 GC 旋鈕(knob),每個性能問題添加一個。然後,程序員可以旋轉旋鈕(knob)以搜索適合其應用的設置。不利的一面是,每年使用一個或兩個新旋鈕(knob),十年之後,你最終會得到 GC 旋鈕(knob)特納就業法案(Turner Employment Act 應該的意思是:需要詳細的文檔描述說明各個 knob 的詳細信息)。Go 不會走那條路。相反,我們只提供了一個叫做 GOGC 的旋鈕(knob)。
此外,沒有持續支持數十個旋鈕(knob)的影響,負責運行時開發、優化工作的團隊可以專注於根據真實客戶應用程序的反饋改進提高運行時系統性能。
我毫不懷疑許多 Go 用戶對新 GC 的運行時間非常滿意。但是我對這些說法有所質疑——在我看來,這就像是一種誤導性的營銷方式。隨着這些聲明在博客圈中重複出現,現在是時候深入瞭解它們了。
事實上,Go 的 GC 並沒有真正實現任何新辦法或者新的研究進展。正如他們的公告所承認的那樣,它是基於 20 世紀 70 年代的方法,是一個直接的併發 標記 / 清除 收集器。值得注意的只是因爲它的設計目的是優化暫停時間,但代價卻是犧牲 GC 中所有其他期望的的特性。Go 的技術講座 [4] 和營銷材料似乎沒有提及任何這些平衡,讓不熟悉垃圾收集技術的開發人員認爲不存在這樣的平衡,並且暗示,Go 的競爭對手只是糟糕的垃圾堆。而 Go 鼓勵這種看法:
爲了創造適用於未來十年的垃圾收集器,我們轉向了幾十年前的算法。Go 新的垃圾收集器是一個併發的,三色,標記 - 清除收集器,這個想法被 Dijkstra[5] 於 1978 年率先提出。這與當今大多數 “企業級” 垃圾收集器的不同,但我們認爲它非常適合現代硬件的屬性和現代軟件的延遲要求。
閱讀上述公告,如果你認爲企業級” GC 在過去 40 年的研究根本沒有取得任何成果,是可以被原諒的;
GC 理論入門
設計垃圾收集算法時,需要考慮以下不同的因素:
-
程序吞吐量:您的算法減慢了多少程序?這有時表示爲垃圾收集與有用工作所花費的 CPU 時間的百分比。
-
GC 吞吐量:在給定固定的 CPU 時間量的情況下,收集器可以清除多少垃圾?
-
堆開銷:收集器理論上至少需要多少額外內存?如果你的算法在收集時分配臨時結構,是否會使你的程序的內存使用非常緊張?
-
暫停時間:你的收集器停止一次的時間有多長?
-
暫停頻率:您的收集器多久停止一次?
-
暫停分佈:您喜歡多數情況都是短暫的暫停,偶爾很長的暫停時間?還是時間更長但是更穩定的暫停時間?
-
分配性能:新內存分配是快?是慢?還是不可預測?
-
緊湊:在有足夠的可用空間來滿足需求情況下,是否會因爲該空間已經分散在堆中的小塊中,您的收集器是否會報告內存不足(OOM)錯誤?如果沒有,你可能會發現你的程序變慢並最終死亡,即使它實際上有足夠的內存。
-
併發:您的收集器在多核機器的表現如何?
-
伸縮性:對大小規模不等的 heap ,gc 表現怎麼樣
-
配置:收集器的配置很複雜嗎?是否可以開箱即用並獲得最佳性能?
-
預熱時間:您的算法是否根據行爲進行自我調整?如果是,需要多長時間才能達到最佳狀態?
-
內存頁釋放:您的算法是否會將未使用的內存釋放回操作系統?如果是的話,何時?
-
可移植性:您的 GC 是否在 CPU 體系結構上工作,提供比 x86 更弱的內存一致性保證?
-
兼容性:您的收集器使用哪些語言和編譯器?是否可以使用非 GC 設計的語言運行,比如 C ++?它需要編譯器修改嗎?如果是這樣,更改 GC 算法是否需要重新編譯所有程序和依賴項?
你可以發現,設計垃圾收集器需要考慮很多不同的影響因素,其中一些因素會影響您平臺周圍更廣泛的生態系統的設計。我不確定是否列出了所有的因素。
由於設計影響因素太多,必然複雜,垃圾收集是計算機科學研究論文中的一個子領域。學術界和工業界都在以穩定的速度提出並實施新算法。不幸的是,還沒有人找到適合所有情況的單一算法。
平衡,平衡各種因素
下邊讓我們更具體一點。
第一個垃圾收集算法是爲單處理器機器和具有小堆的程序設計的。當時,CPU 和 RAM 價格昂貴,而且用戶要求不高,所以可見暫停是允許的。因此,設計的算法優先考慮最小化收集器的 CPU 和堆開銷。這意味着:內存分配完了才讓 GC 工作。然後程序將暫停,並對堆進行一次完整的 標記 / 清除,以儘可能快地標記空閒部分
這些類型的收集器雖古老,但仍然具有一些優點——簡單,不收集程序時不會減慢速度,也不會增加任何內存開銷。對於像 Boehm GC[6] 這樣古老的收集器,他們甚至不需要更改編譯器或編程語言!這通常可以使它們適用於具有小堆的桌面應用程序,包括 3A 視頻遊戲 [7],其中大部分 RAM 由不需要清除的數據文件佔據。
Stop-the-world(STW)標記 / 清除是本科計算機科學課程中最常用的 GC 算法。在做面試時我有時候會讓候選人談談 GC,而且幾乎總是,他們要麼把 GC 視爲一個黑盒而根本不知道它,或者認爲它現在仍然使用這個非常古老的技術。
問題是簡單的 STW 標記 / 清除伸縮性非常差。隨着 CPU 核數增加、堆分配速率更大,此算法將無法正常運行。但是,有時你確實有一個小堆,並且來自上述簡單 GC 的暫停時間也足夠好!在這種情況下,也許您仍然希望使用這種方法將內存開銷保持在較低水平。
另一方面,也許你在擁有數十個內核的機器上使用數百 GB 的堆。也許您的服務器正在進行金融市場交易或運行搜索引擎,因此低停頓時間對您來說非常重要。在這些情況下,您可能願意使用一種算法,該算法爲實現在後臺以低暫停時間進行垃圾回收,會降低程序速度。
這並非簡單的堆大小問題。在更高維度,你可能有大量的批處理作業(job)。由於他們是非交互式程序,暫停時間並不重要,僅關心垃圾回收總時間。在這種情況下,您最好使用一種將吞吐量最大化的算法,即所做的有用工作與用於收集的時間之比。
問題在於沒有一種算法在所有方面都是完美的。語言運行時也不能知道您的程序是批處理作業還是交互式延遲敏感程序。這就是 “GC tuning(性能調優)” 存在的起因——這不是因爲運行時工程師是愚蠢的。它反映了我們計算機科學能力的基本限制。
代際假說(The Generational Hypothesis)
自 1984 年以來,人們就知道大多數分配內存 “早夭”[8],即內存分配後很快就會變成垃圾。這種現象被稱爲代際假說(The Generational Hypothesis),是整個 PL 工程領域中最強大的經驗之一。在不同類型的編程語言和軟件行業數十年的變革中,無論對於函數式語言,命令式語言,以及動態語言、靜態語言,它都如此。
這個發現很有用,因爲它意味着可以利用它設計 GC 算法。這些新一代 GC 在舊 STW 風格上有很多改進:
-
GC 吞吐量:他們可以更快地收集更多垃圾。
-
分配性能:分配新內存不再需要在堆中搜索以尋找空閒時隙,因此分配實際上是免費的。
-
程序吞吐量:分配整齊地排布在彼此相鄰的空間中,從而顯着提高了緩存利用率。分代收集器確實要求程序在運行時執行一些額外的工作,但從經驗上看,他被提升的緩存效果所擊敗
-
暫停時間:大多數(但不是全部)暫停時間變得更短。
他們還介紹了一些缺點:
-
兼容性:實現分代收集器需要能夠在內存中移動內容,並在程序在某些情況下寫入指針時執行額外的工作。這意味着 GC 必須與編譯器緊密集成。C ++ 沒有分代 GC 。
-
堆開銷:這些收集器通過在各種 “空間” 之間來回複製分配來工作。因爲必須有空間可以複製到,所以這些收集器會產生一些堆開銷。此外,它們需要維護各種指針映射(記住的集合),這進一步增加了開銷。
-
暫停分佈:雖然許多 GC 暫停現在非常快,但仍有一些需要在整個堆上進行完整的 標記 / 清除。
-
調優(tuning):分代 GC 引入了 “年輕一代” 或“伊甸園空間”的概念,程序性能變得對這個空間的大小非常敏感。
-
預熱時間:爲了響應調優問題,一些收集器通過觀察程序在實踐中如何運行來動態調整年輕代的大小,但現在暫停時間取決於程序運行的時間長短。實際上,這在基準測試之外很少有用。
然而,好處是如此巨大,基本上所有現代 GC 算法都是 分代收集 的。如果你(可)能負擔得起,你就會想要。分代收集器增強各種其他特性,典型的現代 GC 將支持 併發,並行,壓縮等所有特性。
Go 併發收集器
由於 Go 是一種具有值類型的相對普通的命令式語言,其內存訪問模式可能與 C# 相當,其中分代假設當然成立,因此 .NET 使用分代收集器。
事實上,Go 程序通常是 請求 / 響應 處理,如 HTTP 服務器,這意味着 Go 程序表現出強烈的分代收集行爲,而 Go 團隊正在探索未來可能使用的——稱爲 “面向請求的收集器”[9] 的東西。現在看來,這實際上是一個重新命名的分代 GC,具有調整任期策略功能 [10]。這個 GC 可以在 請求 / 響應 處理的其他運行時中進行模擬,方法是確保年輕的一代足夠大,能夠容納處理請求生成的所有垃圾。
儘管如此,Go 目前的 GC 還不是分代收集的。它只是在後臺運行一個普通的古老的 標記 / 清除。
這樣做有一個好處——你可以獲得非常短的暫停時間——但幾乎所有其他方面都變得更糟。難道就像這樣嗎?那麼,從上面的基本理論我們可以看到:
-
GC 吞吐量:清除堆所需的時間與堆的大小成比例。簡單來說,程序使用的內存越多,釋放內存的速度就越慢,相對處理有效工作時間,計算機用於收集垃圾時間也就越多。如果您的程序根本不併行化,但是您可以無限制地繼續向 GC 添加核心數,是唯一不好的地方。
-
緊湊:由於沒有壓縮,程序最終會將堆碎片化。我將在下面詳細討論堆碎片。在緩存中整齊排列也不會帶來收益。
-
程序吞吐量:由於在每一輪循環中 gc 都需要做大量的工作,其將從 應用程序本身偷取 CPU 時間,並使其變慢
-
暫停分佈:任何與程序併發運行的垃圾收集器都可能遇到 Java 世界所稱的併發模式故障: 程序創建垃圾的速度比 GC 線程清理垃圾的速度還快。在這種情況下,運行時別無選擇,只能完全停止程序,等待 GC 循環完成。因此,當 Go 聲明 GC 暫停時間很短時,這種聲明只適用於 GC 有足夠的 CPU 時間和內存空間超過主程序(分配內存的速度快過主程序)時。另外,go 編譯器不支持穩定停下線程的特性,也就是說,暫停時間的長短很大程度上取決於你的程序什麼時候運行,例如:在單一線程上解碼一個巨大的 base64 會使暫停時間升高。
-
堆開銷:因爲通過 標記 / 清除 收集堆非常慢,所以需要大量空閒空間來確保不會出現 “併發模式故障”。Go 默認的堆開銷爲 100%,它將使程序所需的內存增加一倍。
我們可以在 golang-dev 的帖子 [11] 中看到這些平衡,如下所示:
服務 1 分配的數量超過服務 2,因此 服務 1 STW 暫停值更高。但是 STW 暫停持續時間在兩種服務上都下降了一個數量級。在切換兩種服務後,我們看到在 GC 中花費的 CPU 使用率增加了約 20%。
所以在這個特定的例子中,Go 在暫停時間上獲得了一個數量級的下降,但代價是一個更慢的收集器。這是一個很好的平衡嗎?還是暫停時間已經足夠低了? 海報上沒有說。
但有一點,需要更多硬件才能獲得更低的暫停時間不再有意義。如果您的服務器暫停時間從 10 毫秒到 1 毫秒,您的用戶是否真的會注意到這一點?如果你不得不使你的機器數量加倍來實現它?
go 以吞吐量爲代價來優化暫停時間。以至於它似乎願意將程序的速度降低幾乎任何程度,以獲得稍微短一點的暫停時間。
與 Java 相比
HotSpot JVM 有幾種 GC 算法,您可以在命令行中選擇。Java 沒有像 Go 這樣短的暫停時間,因爲它們更看重其他因素。有必要對比一下,可以瞭解他們是怎麼平衡這些因素的。
因爲在程序運行時進行編譯,只需重新啓動程序就可以在 GC 之間切換,可以根據需要和問題編譯和優化不同算法。
所有現代計算機上的默認都是吞吐量收集器。這是爲批處理作業設計的,默認情況下沒有任何暫停時間目標(可以在命令行上給出一個)。
人們傾向於認爲,這種默認選擇是 java gc 必然有些糟糕的原因:開箱即用,Java 試圖讓你的應用程序儘可能快地運行,儘可能減少內存開銷,但卻有該死的暫停時間。
如果暫停時間對您更重要,那麼您可能會切換到併發標記 / 清除收集器(CMS)。這是最接近 Go 的算法。但它也是分代的,這就是爲什麼它的暫停時間比 Go 長:年輕一代被壓縮而應用暫停,因爲它涉及移動對象。CMS 中有兩種類型的暫停:第一種速度更快,可能持續約 25 毫秒。第二種可能更接近 20 毫秒。CMS 是自適應的: 因爲它是併發的,所以它必須預測什麼時候開始運行 (就像 Go 一樣)。Go 要求您配置堆開銷來優化它,而 CMS 將在運行時自我調整,以嘗試並避免發模式失敗。由於堆的大部分是普通的標記 / 清除,因此可能會出現堆碎片導致的問題和速度下降。
最新一代 Java GC 被稱爲 “垃圾優先”(“G1”)。Java 8 中沒有預置,而是在 Java 9 中。它旨在成爲通用的“一刀切” 算法,或者儘可能接近現在的樣子。它主要是整個堆的併發,生成和壓縮。它也在很大程度上是自我調整,但是因爲(像所有的 GC 算法一樣)它無法知道你真正想要的東西,它允許你指定你喜歡的平衡:只需告訴它你將允許它使用的最大 RAM 量和暫停時間目標(以毫秒爲單位),當應用程序運行以嘗試滿足暫停時間目標時,它將調整其他所有內容。默認暫停時間目標大約爲 100 毫秒,因此除非您指定不同的結果,否則您不應該期望看到更好的結果:G1 會偏向於應用運行得比暫停時更快。暫停並不完全一致——大多數都非常快(< 1 ms),而一些在壓縮堆時會更慢(更像是 50ms)。G1 非常好。有報道稱人們使用 TB 級大小的堆。它還有一些簡潔的功能,比如對堆中的字符串去重。
最後,開發了一種名爲 Shenandoah 的新 GC 算法。它用於 OpenJDK ,但僅能在使用 Red Hat(贊助項目)的特殊 Java 時構建,而不能 Java 9 中使用。無論堆大小如何,它都可以提供非常低的暫停時間,同時也可以進行壓縮。成本是額外的堆開銷和更多障礙:在應用程序仍在運行時移動對象需要指針讀寫,以便與 GC 交互。在這個意義上,它類似於 Azul 的 “無暫停(pauseless)” GC 。
結論
本文的重點不是說服您使用不同的編程語言或工具。
你從這篇文章中可以學到的一點:垃圾收集是一個非常非常難的問題,幾十年來,計算機科學家一直在研究。所以要經常質疑別人是否做出了所謂的突破。別人宣稱的 “突破” 更有可能只是僞裝下的奇怪的、不同尋常的平衡,而其他人(Java)只是出於某些原因而避免這樣做,這些原因可能要到後來纔會顯現出來。
但是,如果您希望以犧牲其他所有特性爲代價來最小化暫停時間,那麼請務必查看 Go GC。
via: https://blog.plan99.net/modern-garbage-collection-911ef4f8bd8e?gi=90cab303c568
作者:Mike Hearn[12] 譯者:TomatoAres[13] 校對:DingdingZhou[14]
本文由 GCTT[15] 原創編譯,Go 中文網 [16] 榮譽推出,發佈在 Go 語言中文網公衆號,轉載請聯繫我們授權。
參考資料
[1]
Hacker News: https://news.ycombinator.com/item?id=13218550
[2]
Reddit: https://www.reddit.com/r/golang/comments/5j7phw/modern_garbage_collection/
[3]
這是新版本(Go 1.5)垃圾收集器的首次公告: https://blog.golang.org/go15gc
[4]
技術講座: https://talks.golang.org/2015/go-gc.pdf
[5]
Dijkstra: http://dl.acm.org/citation.cfm?id=359655
[6]
Boehm GC: http://www.hboehm.info/gc/
[7]
3A 視頻遊戲: https://wiki.unrealengine.com/Garbage_Collection_Overview
[8]
自 1984 年以來,人們就知道大多數分配內存 “早夭”: http://citeseerx.ist.psu.edu/viewdoc/download?doi=10.1.1.122.4295&rep=rep1&type=pdf
[9]
“面向請求的收集器”: https://docs.google.com/document/d/1gCsFxXamW8RRvOe5hECz98Ftk-tcRRJcDFANj2VwCB0/edit
[10]
現在看來,這實際上是一個重新命名的分代 GC,具有調整任期策略功能: https://news.ycombinator.com/item?id=11969740
[11]
golang-dev 的帖子: https://groups.google.com/d/msg/golang-dev/Ab1sFeoZg_8/pv0Yg7tkAwAJ
[12]
Mike Hearn: https://blog.plan99.net/@octskyward
[13]
TomatoAres: https://github.com/TomatoAres
[14]
DingdingZhou: https://github.com/DingdingZhou
[15]
GCTT: https://github.com/studygolang/GCTT
[16]
Go 中文網: https://studygolang.com/
福利
我爲大家整理了一份從入門到進階的 Go 學習資料禮包,包含學習建議:入門看什麼,進階看什麼。關注公衆號 「polarisxu」,回覆 ebook 獲取;還可以回覆「進羣」,和數萬 Gopher 交流學習。
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/Xg0--enw8MS-DSHPTctlJA