Go 部落格
Go 的演進:Go 垃圾收集器的旅程
這是我在 2018 年 6 月 18 日國際記憶體管理專題討論會 (ISMM) 上發表的主題演講的文字記錄。在過去的 25 年裡,ISMM 一直是發表記憶體管理和垃圾收集論文的首要場所,我很榮幸能被邀請發表主題演講。
摘要
Go 語言的特性、目標和用例迫使我們重新思考整個垃圾收集器棧,並引領我們走向一個令人驚訝的地方。這段旅程令人興奮。本次演講將描述我們的旅程。這是一段由開源和 Google 的生產需求驅動的旅程。其中包括我們因資料引導而進入死衚衕般的峽谷的插曲。本次演講將深入探討我們旅程的方式和原因,我們在 2018 年的位置,以及 Go 為下一段旅程所做的準備。
個人簡介
Richard L. Hudson(Rick)因其在記憶體管理方面的工作而聞名,包括髮明瞭 Train、Sapphire 和 Mississippi Delta 演算法,以及使得 Modula-3、Java、C# 和 Go 等靜態型別語言能夠進行垃圾收集的 GC 棧圖。Rick 目前是 Google Go 團隊的一員,負責 Go 的垃圾收集和執行時相關問題。
聯絡方式:rlh@golang.org
評論:請參閱 golang-dev 上的討論。
文字記錄

我是 Rick Hudson。
本次演講是關於 Go 執行時,特別是垃圾收集器的。我準備了大約 45 或 50 分鐘的材料,之後我們將有時間進行討論,我也會在附近,所以之後歡迎大家過來交流。

在開始之前,我想感謝一些人。
演講中很多精彩的部分是由 Austin Clements 完成的。劍橋 Go 團隊的其他成員,Russ、Than、Cherry 和 David,都是一群引人入勝、令人興奮且有趣的合作伙伴。
我們還要感謝全球 160 萬 Go 使用者,他們為我們帶來了許多有趣的問題需要解決。沒有他們,很多問題可能永遠不會被發現。
最後,我要感謝 Renee French 多年來創作了所有這些可愛的 Go 地鼠。在整個演講中,您會看到其中的幾個。

在我們深入討論這些內容之前,我們必須先展示 GC 眼中的 Go 是什麼樣子。

首先,Go 程式擁有成千上萬個棧。它們由 Go 排程器管理,並且總是在 GC 安全點被搶佔。Go 排程器將 Go 例程多路複用到 OS 執行緒上,希望每個硬體執行緒執行一個 OS 執行緒。我們透過複製棧並更新棧中的指標來管理棧及其大小。這是一個本地操作,因此擴充套件性相當好。

接下來重要的一點是,Go 是一種遵循 C 語言風格的系統語言的傳統,以值為主導的語言,而不是像大多數託管執行時語言那樣以引用為主導。例如,這展示了 tar 包中的一個型別在記憶體中的佈局方式。所有欄位都直接嵌入到 Reader 值中。這讓程式設計師在需要時能夠更好地控制記憶體佈局。可以將具有相關值關係的欄位放在一起,這有助於提高快取區域性性。
值為主導的方式也有助於外部函式介面(FFI)。我們與 C 和 C++ 之間有一個快速的 FFI。顯然,Google 有大量的可用設施,但它們都是用 C++ 編寫的。Go 不可能等待將所有這些功能用 Go 重寫,所以 Go 必須透過外部函式介面訪問這些系統。
這個設計決策導致了執行時中一些更令人驚歎的事情。這可能是 Go 與其他支援 GC 的語言最重要的區別。

當然,Go 可以有指標,事實上它們可以有內部指標。這些指標會保持整個值存活,而且它們相當常見。

我們還採用了提前編譯系統,因此二進位制檔案包含完整的執行時。
沒有 JIT 重編譯。這有優點也有缺點。首先,程式執行的可復現性大大提高了,這使得編譯器改進的推進速度更快。
不足之處在於,我們沒有機會像 JIT 系統那樣進行反饋最佳化。
所以有利有弊。

Go 提供了兩個旋鈕來控制 GC。第一個是 GCPercent。這基本上是一個調整您想要使用多少 CPU 和多少記憶體的旋鈕。預設值是 100,這意味著堆的一半用於存活記憶體,一半用於分配。您可以向任一方向修改它。
MaxHeap 尚未釋出,但正在內部使用和評估,它允許程式設計師設定最大堆大小。記憶體不足 (OOM) 對 Go 來說是個棘手的問題;記憶體使用的臨時峰值應該透過增加 CPU 成本來處理,而不是透過中止程式。基本上,如果 GC 檢測到記憶體壓力,它會通知應用程式應該減輕負載。一旦恢復正常,GC 會通知應用程式可以恢復正常負載。MaxHeap 也提供了更大的排程靈活性。執行時不再需要總是擔心可用記憶體,它可以將堆大小調整到 MaxHeap 的大小。
這總結了我們關於對垃圾收集器很重要的 Go 部分的討論。

現在我們來談談 Go 執行時以及我們如何走到今天,我們是如何走到現在的。

時間來到了 2014 年。如果 Go 不能以某種方式解決 GC 延遲問題,那麼 Go 就不會成功。這一點很清楚。
其他新語言也面臨同樣的問題。像 Rust 這樣的語言走了另一條路,但我們將討論 Go 所選擇的道路。
為什麼延遲如此重要?

數學原理對此毫不留情。
99% 分位的獨立 GC 延遲服務水平目標 (SLO),例如 99% 的時間 GC 週期耗時 < 10ms,根本無法擴充套件。重要的是在整個會話期間或一天多次使用應用程式過程中的延遲。假設一個會話瀏覽多個網頁,最終在一個會話中發出 100 個伺服器請求,或者發出 20 個請求,而您一天有 5 個這樣的會話。在這種情況下,只有 37% 的使用者能在整個會話中獲得一致的低於 10ms 的體驗。
如果您希望 99% 的使用者擁有低於 10ms 的體驗,正如我們所建議的,數學告訴您,您實際上需要達到 4 個 9,即 99.99% 分位。
因此,時間到了 2014 年,Jeff Dean 剛剛發表了他的論文《大規模系統的長尾問題》(The Tail at Scale),本文對此進行了進一步探討。這篇論文在 Google 內部被廣泛閱讀,因為它對 Google 未來的發展和試圖在 Google 規模下進行擴充套件具有嚴重的影響。
我們將這個問題稱為“9的暴政”。

那麼如何對抗“9的暴政”呢?
2014 年,人們正在做很多事情。
如果您想要 10 個答案,就多請求幾個,然後取前 10 個,這些就是您放在搜尋頁面上的答案。如果請求超過 50% 分位,就重新發出或轉發請求到另一臺伺服器。如果 GC 即將執行,就拒絕新的請求或將請求轉發到另一臺伺服器,直到 GC 完成。等等等等。
所有這些變通方案都來自非常聰明的人,他們面臨非常現實的問題,但這些方案並沒有解決 GC 延遲的根本問題。在 Google 的規模下,我們必須解決根本問題。為什麼?

冗餘無法擴充套件,冗餘成本很高。它需要新的伺服器叢集。
我們希望能夠解決這個問題,並將其視為改善伺服器生態系統的機會,在這個過程中拯救一些瀕危的玉米田,讓一些玉米粒在七月四日前長到齊膝高,併發揮其全部潛力。

所以這是 2014 年的 SLO。是的,確實是我在保守(或“放水”),我是團隊的新成員,對我來說這是一個新的流程,而且我不想過度承諾。
此外,關於其他語言中 GC 延遲的簡報簡直令人害怕。

最初的計劃是實現一個無讀屏障的併發複製 GC。那是長期計劃。關於讀屏障的開銷有很多不確定性,所以 Go 想避免它們。
但在 2014 年短期內,我們必須振作起來。我們必須將所有的執行時和編譯器轉換為 Go。當時它們是用 C 編寫的。不再使用 C,不再因 C 程式設計師不理解 GC 但對如何複製字串有酷炫想法而導致一長串 bug。我們還需要快速完成某些工作,並且專注於延遲,但效能損失必須小於編譯器帶來的加速。因此我們受到了限制。我們基本上有一年的編譯器效能改進可以被併發 GC 消耗掉。但也僅限於此。我們不能減慢 Go 程式的執行速度。這在 2014 年是站不住腳的。

所以我們稍作退讓。我們不打算做複製部分。
決定是採用三色併發演算法。在我職業生涯的早期,Eliot Moss 和我曾做過期刊證明,表明 Dijkstra 演算法適用於多個應用程式執行緒。我們還證明了可以消除 STW(Stop-The-World)問題,並且我們有可以實現的證明。
我們也擔心編譯器的速度,也就是編譯器生成的程式碼。如果我們在大部分時間關閉寫屏障,編譯器最佳化受到的影響將最小,編譯器團隊可以快速推進。Go 在 2015 年也迫切需要短期成功。

那麼讓我們看看我們做的一些事情。
我們採用了按大小分割的 Span(記憶體塊)。內部指標是個問題。
垃圾收集器需要高效地找到物件的起始位置。如果它知道 Span 中物件的大小,它只需向下取整到那個大小,那就是物件的起始位置。
當然,按大小分割的 Span 還有其他一些優點。
低碎片:除了 Google 的 TCMalloc 和 Hoard,我在 C 語言方面的經驗,以及我深度參與的 Intel Scalable Malloc 的工作,都讓我們相信對於不可移動的分配器來說,碎片不會成為問題。
內部結構:我們完全理解並擁有相關經驗。我們懂得如何按大小分割 Span,我們懂得如何實現低或零競爭的分配路徑。
速度:非複製方式我們並不擔心,分配速度誠然可能較慢,但仍在 C 語言的量級。可能不如 Bump Pointer 快,但這沒關係。
我們還有外部函式介面的問題。如果我們不移動物件,那麼我們就不必處理在使用移動收集器時可能遇到的冗長的 bug 尾部,這些 bug 會在您嘗試固定物件並在 C 和您正在使用的 Go 物件之間引入間接層時出現。

下一個設計選擇是把物件的元資料放在哪裡。由於我們沒有物件頭,我們需要一些關於物件的資訊。標記位儲存在旁邊,用於標記和分配。每個字都有 2 個與之關聯的位,用於告訴您該字是標量還是指標。它還編碼了物件中是否還有更多的指標,以便我們可以儘快停止掃描物件。我們還有一個額外的位編碼,可以用作額外的標記位或進行其他除錯。這對於讓這些東西執行起來和查詢 bug 非常有價值。

那麼寫屏障呢?寫屏障只在 GC 期間開啟。在其他時間,編譯後的程式碼會載入一個全域性變數並檢視它。由於 GC 通常是關閉的,硬體會正確地推測並繞過寫屏障的分支。當我們在 GC 內部時,該變數是不同的,寫屏障負責確保在三色操作期間不會丟失任何可到達的物件。

程式碼的另一部分是 GC Pacer(GC 調節器)。這是 Austin 做的一些很棒的工作。它基本上基於一個反饋迴路,用於確定何時最好地開始一個 GC 週期。如果系統處於穩定狀態且沒有發生階段變化,標記將在記憶體耗盡時結束。
情況可能並非如此,所以 Pacer 還必須監控標記進度,並確保分配不會超過併發標記的速度。
如需,Pacer 會在加快標記速度的同時減慢分配速度。從宏觀上看,Pacer 會暫停執行大量分配的 Goroutine,並讓它執行標記工作。工作量與該 Goroutine 的分配量成正比。這既能加速垃圾收集器,又能減慢修改器(mutator)的速度。
當所有這些都完成後,Pacer 會結合從本次 GC 週期和之前週期中學到的東西,預測何時開始下一個 GC。
它做的遠不止這些,但這是基本的方法。
其中的數學原理非常引人入勝,請聯絡我獲取設計文件。如果您正在進行併發 GC,您真的應該看看這些數學原理,看看是否與您的計算方法相同。如果您有任何建議,請告訴我們。
*Go 1.5 併發垃圾收集器調節 和 提案:分離軟硬堆大小目標

是的,所以我們取得了成功,而且很多。一個更年輕、更瘋狂的 Rick 會把這些圖表中的一些紋在肩膀上,我為此感到非常自豪。

這是一系列為 Twitter 生產伺服器製作的圖表。我們當然與那臺生產伺服器沒有任何關係。Brian Hatfield 做了這些測量,而且奇怪的是,他在 Twitter 上釋出了這些資訊。
在 Y 軸上我們有 GC 延遲,單位是毫秒。在 X 軸上我們有時間。每個點代表該次 GC 期間的 Stop-The-World 暫停時間。
在我們的第一個版本(2015 年 8 月釋出)中,我們看到延遲從大約 300 - 400 毫秒下降到 30 或 40 毫秒。這很不錯,是數量級的改進。
我們在這裡將 Y 軸從 0 到 400 毫秒大幅更改為 0 到 50 毫秒。

這是 6 個月後的情況。改進很大程度上歸功於系統性地消除了我們在 Stop-The-World 期間執行的所有與堆大小成比例(O(heap))的操作。這是我們的第二個數量級改進,延遲從 40 毫秒下降到 4 或 5 毫秒。

其中存在一些我們需要清理的 bug,我們在 1.6.3 小版本釋出時完成了這項工作。這使得延遲降到了遠低於 10 毫秒,這是我們的 SLO。
我們即將再次更改 Y 軸,這次是降到 0 到 5 毫秒。

所以我們到了這裡,這是 2016 年 8 月,距離第一個版本釋出一年。我們再次繼續消除這些與堆大小相關的 Stop-The-World 過程。我們這裡討論的是一個 18GB 的堆。我們有更大的堆,當我們消除了這些與堆大小相關的 Stop-The-World 暫停時,堆的大小顯然可以顯著增長而不會影響延遲。所以這在 1.7 版本中有所幫助。

下一個版本是在 2017 年 3 月。我們經歷了最後一次大幅延遲下降,這是因為我們找到了如何避免在 GC 週期結束時進行 Stop-The-World 的棧掃描。這使我們進入了亞毫秒級別。同樣,Y 軸即將更改為 1.5 毫秒,我們看到了第三個數量級的改進。

2017 年 8 月的版本改進不大。我們知道剩餘的暫停是由什麼引起的。這裡的 SLO 目標大約在 100-200 微秒,我們將朝著這個目標努力。如果您看到任何超過幾百微秒的情況,那麼我們非常想與您交流,找出這是否屬於我們已知的問題,或者是否是新的、我們尚未研究過的問題。無論如何,似乎對更低延遲的需求不大。重要的是要注意,這些延遲水平可能是由各種非 GC 原因引起的,俗話說“你不必跑得比熊快,你只需要跑得比你旁邊那個人快。”
2018 年 2 月的 1.10 版本沒有實質性變化,只是一些清理和處理邊界情況。

新的一年,新的 SLO。這是我們 2018 年的 SLO。
我們將總 CPU 消耗降至 GC 週期中使用的 CPU 消耗。
堆大小仍保持在 2 倍。
我們現在的目標是每個 GC 週期 Stop-The-World 暫停時間為 500 微秒。這裡可能有點保守。
分配將繼續與 GC 協助成比例。
Pacer 已經改進了很多,所以我們期望在穩態下看到最小的 GC 協助。
我們對此非常滿意。同樣,這不是一個 SLA(服務級別協議),而是一個 SLO(服務級別目標),因此它是一個目標,而不是一個協議,因為我們無法控制作業系統等因素。

這些是成功的部分。現在我們來談談我們的失敗。這些是我們的傷疤;它們就像紋身一樣,每個人都會有。不過,它們伴隨著更好的故事,所以我們來聊聊其中的一些。

我們的第一次嘗試是實現一種叫做請求導向收集器(Request Oriented Collector,或簡稱 ROC)的東西。其假設可以在這裡看到。

那麼這意味著什麼呢?
Goroutines 是輕量級執行緒,看起來像 Go 地鼠,所以這裡我們有兩個 Goroutine。它們共享一些東西,比如中間的兩個藍色物件。它們有自己的私有棧和各自的私有物件集合。假設左邊的 Goroutine 想要共享綠色物件。

這個 goroutine 將它放入共享區域,這樣另一個 Goroutine 就可以訪問它。它們可以將其連線到共享堆中的某個東西,或者將其賦值給一個全域性變數,然後另一個 Goroutine 就可以看到它。

最後,左邊的 Goroutine 即將走向死亡,它快要死去了,真傷心。

如您所知,當您“死去”時,您無法帶走您的物件。您的棧也不能帶走。此時棧實際上是空的,並且物件是不可達的,因此您可以簡單地回收它們。

這裡重要的一點是,所有操作都是本地的,不需要任何全域性同步。這與分代 GC 等方法根本不同,我們希望透過避免全域性同步獲得的擴充套件性足以讓我們取得成功。

這個系統存在的另一個問題是寫屏障始終開啟。每當發生寫入時,我們都必須檢查是否正在將指向私有物件的指標寫入公共物件。如果是這樣,我們必須將被引用物件設定為公共的,然後對可到達的物件進行傳遞性遍歷,確保它們也是公共的。這是一個相當昂貴的寫屏障,可能導致許多快取未命中。

話雖如此,哇,我們還是取得了一些不錯的成功。
這是一個端到端 RPC 基準測試。標記錯誤的 Y 軸範圍是 0 到 5 毫秒(越低越好),不管怎樣,就是這樣。X 軸基本上是壓載物,或者說是記憶體資料庫的大小。
正如您所見,如果開啟了 ROC 並且沒有太多共享,效能實際上擴充套件得相當不錯。如果未開啟 ROC,則效果遠不如開啟 ROC。

但那還不夠好,我們還必須確保 ROC 不會減慢系統的其他部分。當時我們對編譯器非常關注,不能減慢編譯器的速度。不幸的是,編譯器恰恰是 ROC 表現不佳的程式。我們看到了 30%、40%、50% 甚至更多的減速,這是不可接受的。Go 以其快速的編譯器為傲,所以我們不能減慢編譯器,當然更不能減慢這麼多。

然後我們去查看了一些其他程式。這些是我們的效能基準測試。我們有 200 或 300 個基準測試集,這些是編譯器團隊認為對他們工作和改進很重要的。這些測試完全不是由 GC 團隊選擇的。結果資料普遍都很差,ROC 不可能成為贏家。

確實我們實現了擴充套件,但我們只有 4 到 12 個硬體執行緒系統,所以我們無法克服寫屏障的開銷。也許將來當我們擁有 128 核系統並且 Go 充分利用它們時,ROC 的擴充套件特性可能會帶來勝利。當那發生時,我們可能會回來重新審視它,但目前 ROC 是一個失敗的方案。

那麼接下來我們要做什麼呢?我們來試試分代 GC 吧。這是一個經典而有效的方法。ROC 行不通,所以我們回到我們更有經驗的東西上來。

我們不會放棄我們的低延遲,也不會放棄我們是不可移動的這一事實。所以我們需要一個不可移動的分代 GC。

那麼我們能做到嗎?是的,但對於分代 GC,寫屏障是始終開啟的。當 GC 週期執行時,我們使用與現在相同的寫屏障,但在 GC 關閉時,我們使用一個快速的 GC 寫屏障,它會緩衝指標,然後在緩衝區溢位時將緩衝區重新整理到卡片標記表。

那麼在不可移動的情況下這會如何工作呢?這裡是標記/分配對映。基本上,您維護一個當前指標。當您進行分配時,您會尋找下一個零,當找到那個零時,您就在那個空間中分配一個物件。

然後您將當前指標更新到下一個零。

您繼續,直到某個時刻該進行分代 GC 了。您會注意到,如果在標記/分配向量中有一個一,那麼該物件在上次 GC 時是存活的,因此它是成熟的。如果它是零並且您到達了它,那麼您就知道它是年輕的。

那麼您如何進行晉升呢?如果您發現標記為 1 的東西指向標記為 0 的東西,那麼您只需將該零設定為一即可晉升被引用物件。

您必須進行傳遞性遍歷,以確保所有可到達的物件都被晉升。

當所有可到達的物件都被晉升後,次要 GC 就終止了。

最後,為了完成您的分代 GC 週期,您只需將當前指標設定迴向量的起始位置,然後就可以繼續了。在那個 GC 週期中沒有被訪問到的零都變成了空閒空間,可以被重用。許多人可能知道,這被稱為“粘性位”(sticky bits),是由 Hans Boehm 和他的同事發明的。

那麼效能如何呢?對於大堆來說還不錯。這些是 GC 應該表現良好的基準測試。一切都很好。

然後我們在我們的效能基準測試上執行它,結果就不太好了。那麼發生了什麼呢?

寫屏障雖然快,但還不夠快。此外,它很難最佳化。例如,如果在物件分配和下一個安全點之間有初始化寫入,可以省略寫屏障。但我們不得不轉向一個系統,其中每個指令都有一個 GC 安全點,所以將來真的沒有任何可以省略的寫屏障了。

我們還有逃逸分析,而且它變得越來越好了。還記得我們之前談論的值導向的東西嗎?我們不是將指標傳遞給函式,而是傳遞實際的值。因為我們傳遞的是值,逃逸分析只需要進行過程內(intraprocedural)逃逸分析,而不需要進行過程間(interprocedural)分析。
當然,如果區域性物件的指標逃逸了,那麼物件就會在堆上分配。
並不是說分代假設對 Go 不成立,只是年輕物件在棧上生成並快速消亡。結果是,分代收集的效果遠不如其他託管執行時語言中那麼明顯。

因此,這些反對寫屏障的力量開始集結。如今,我們的編譯器比 2014 年好得多。逃逸分析能夠捕獲很多物件並將它們放在棧上——這些物件本可以由分代收集器處理。我們開始建立工具來幫助使用者找到逃逸的物件,如果問題不大,他們可以修改程式碼,幫助編譯器在棧上分配。
使用者越來越聰明地採用值導向的方法,並且指標的數量正在減少。陣列和對映儲存的是值,而不是指向結構體的指標。一切都很好。
但這並不是 Go 的寫屏障未來發展舉步維艱的主要令人信服的原因。

讓我們看看這張圖。這只是一個標記成本的分析圖。每條線代表一個可能具有標記成本的不同應用程式。假設您的標記成本是 20%,這相當高,但有可能。紅線是 10%,仍然很高。下面的線是 5%,這大約是如今寫屏障的成本。那麼如果您將堆大小加倍會發生什麼呢?那就是右邊的點。標記階段的累計成本顯著下降,因為 GC 週期不再那麼頻繁了。寫屏障的成本是固定的,所以增加堆大小的成本會使得標記成本降到寫屏障成本之下。

這裡是寫屏障更常見的成本,即 4%,我們看到即使如此,透過簡單地增加堆大小,我們也可以將標記屏障的成本降到寫屏障的成本之下。
分代 GC 的真正價值在於,在檢視 GC 時間時,寫屏障的成本被忽略了,因為它們分散在修改器(mutator)中。這是分代 GC 的巨大優勢,它大大減少了完整 GC 週期的長時間 STW,但它並不一定能提高吞吐量。Go 沒有 Stop-The-World 的問題,所以它必須更仔細地研究吞吐量問題,而這正是我們所做的。

這是很多失敗,伴隨失敗而來的是食物和午餐。我照常抱怨道:“天哪,要是沒有寫屏障就好了。”
與此同時,Austin 剛花了一個小時與 Google 的一些硬體 GC 專家交流,他說我們應該和他們談談,試著弄清楚如何獲得可能有所幫助的硬體 GC 支援。然後我開始講一些我曾在一家大型硬體公司工作時,關於零填充快取行、可重啟原子序列以及其他未能實現的東西的“戰爭故事”。當然,我們確實把一些東西放進了名為 Itanium 的晶片裡,但沒能把它們放到如今更受歡迎的晶片中。所以故事的寓意就是:只使用我們現有的硬體。
總之,這讓我們開始討論一些瘋狂的想法。

沒有寫屏障的卡片標記怎麼樣?結果發現 Austin 有些檔案,他把所有瘋狂的想法都寫在這些檔案裡,出於某種原因他沒有告訴我。我想這是一種治療方式吧。我以前和 Eliot 也做過類似的事情。新的想法很容易被扼殺,在將它們公之於眾之前,需要保護它們,讓它們變得更強大。總之,他拿出了這個想法。
這個想法是,你在每張卡片中維護一個成熟指標的雜湊值。如果指標被寫入卡片,雜湊值會改變,這張卡片就會被視為已標記。這會用雜湊的成本來替代寫屏障的成本。

但更重要的是,它是硬體對齊的。
現代體系結構擁有 AES(高階加密標準)指令。其中一個指令可以進行加密級別的雜湊計算,並且有了加密級別的雜湊,如果我們遵循標準的加密策略,就不必擔心衝突。所以雜湊計算不會花費我們太多,但我們必須載入要進行雜湊計算的資料。幸運的是,我們是按順序遍歷記憶體的,所以記憶體和快取效能非常好。如果您有一個 DIMM,並且您訪問的是連續地址,那麼這是一個優勢,因為它們會比訪問隨機地址快。硬體預取器也會啟動,這也會有所幫助。總之,我們有 50 年、60 年設計硬體來執行 Fortran、執行 C 和執行 SPECint 基準測試的經驗。因此,硬體能快速執行這類操作也就不足為奇了。

我們進行了測量。結果相當不錯。這是針對大堆的基準測試套件,應該效果不錯。

接著我們討論了效能基準測試的情況如何?不太好,有一些異常值。但現在我們將寫屏障從總是在 mutator 中開啟改為了作為 GC 週期的一部分執行。現在,關於是否執行分代 GC 的決定被推遲到了 GC 週期開始時。我們在那方面有更多控制,因為我們已經將卡片工作本地化了。現在我們有了工具,可以將其交給 Pacer,它能夠很好地動態中止那些表現不佳且無法從分代 GC 中受益的程式。但這樣做在未來會成功嗎?我們必須瞭解或者至少思考未來的硬體會是什麼樣子。

未來的記憶體是什麼樣子的?

我們來看看這張圖。這是典型的摩爾定律圖。Y 軸是對數刻度,顯示單個晶片中的電晶體數量。X 軸是 1971 年到 2016 年的年份。我要指出,這些年份是某人在某處預測摩爾定律已死的年份。
丹納德定標(Dennard scaling)在大約十年前結束了頻率的改進。新的工藝需要更長的爬坡時間。所以現在不是 2 年,而是 4 年或更長。因此很明顯,我們正在進入摩爾定律放緩的時代。
我們只看紅色圓圈中的晶片。這些是維持摩爾定律表現最好的晶片。
這些晶片的邏輯越來越簡單,並且多次複製。大量相同的核心、多個記憶體控制器和快取、GPU、TPU 等等。
隨著我們不斷簡化和增加複製,我們最終會漸近地得到幾根導線、一個電晶體和一個電容器。換句話說,就是一個 DRAM 儲存單元。
換句話說,我們認為將記憶體加倍比將核心加倍更有價值。
原始圖表位於 www.kurzweilai.net/ask-ray-the-future-of-moores-law。

我們看看另一張專注於 DRAM 的圖。這些數字來自 CMU 最近的一篇博士論文。如果我們看這張圖,會發現摩爾定律是藍線。紅線是容量,它似乎也在遵循摩爾定律。很奇怪的是,我看到過一張追溯到 1939 年使用磁鼓記憶體的圖,容量和摩爾定律當時也在一同前進,所以這張圖已經存在很長時間了,肯定比這個房間裡的任何人都活得久。
如果我們將這張圖與 CPU 頻率或各種“摩爾定律已死”的圖表進行比較,我們會得出結論,記憶體,或者至少是晶片容量,將比 CPU 更長時間地遵循摩爾定律。頻寬(黃線)不僅與記憶體頻率有關,還與晶片引腳的數量有關,所以它沒有跟上得那麼好,但表現也不差。
延遲(綠線)的表現非常差,儘管我要指出的是,順序訪問的延遲比隨機訪問的延遲要好。
(資料來自“Understanding and Improving the Latency of DRAM-Based Memory Systems Submitted in partial fulfillment of the requirements for the degree of Doctor of Philosophy in Electrical and Computer Engineering Kevin K. Chang M.S., Electrical & Computer Engineering, Carnegie Mellon University B.S., Electrical & Computer Engineering, Carnegie Mellon University Carnegie Mellon University Pittsburgh, PA May, 2017”。參見 Kevin K. Chang 的論文。引言中的原始圖表格式不易於我繪製摩爾定律線,因此我更改了 X 軸使其更均勻。)

我們來看看實際情況。這是實際的 DRAM 定價,從 2005 年到 2016 年總體呈下降趨勢。我之所以選擇 2005 年,是因為大約在那個時候,丹納德定標結束了,隨之結束的還有頻率的改進。
如果你看紅色圓圈部分,這基本上是我們為降低 Go 的 GC 延遲而努力的時間段,我們會看到頭幾年價格表現良好。最近就不太好了,因為需求超過了供應,導致過去兩年價格上漲。當然,電晶體並沒有變大,在某些情況下晶片容量反而增加了,所以這是市場力量驅動的。RAMBUS 和其他晶片製造商表示,未來我們將在 2019-2020 年左右看到下一代工藝的縮小。
我不會對記憶體行業的全球市場力量進行過多猜測,只想指出定價是週期性的,並且從長遠來看,供應趨向於滿足需求。
從長遠來看,我們相信記憶體價格的下降速度將遠快於 CPU 價格。
(來源 https://hblok.net/blog/ 和 https://hblok.net/storage_data/storage_memory_prices_2005-2017-12.png)

我們看看另一條線。哎呀,要是我們在這條線上就好了。這是 SSD 的線。它在保持低價方面做得更好。這些晶片的材料物理比 DRAM 要複雜得多。邏輯更復雜,每個單元不是一個電晶體,而是大約六個左右。
展望未來,在 DRAM 和 SSD 之間有一條線,NVRAM(例如英特爾的 3D XPoint 和相變儲存器 (PCM))將佔據一席之地。在未來十年內,這種型別記憶體的可用性很可能會變得更加主流,這隻會加強“增加記憶體是為伺服器增加價值的廉價方式”這一觀點。
更重要的是,我們可以期待看到 DRAM 的其他競爭性替代品。我不會假裝知道五年或十年後哪種會更受歡迎,但競爭將非常激烈,堆記憶體將更接近此處突出顯示的藍色 SSD 線。
所有這些都強化了我們避免使用常開屏障而傾向於增加記憶體的決定。

那麼這一切對 Go 未來發展意味著什麼?

我們打算讓執行時更靈活、更健壯,因為我們正在審視使用者反饋的邊緣案例。希望收緊排程器,獲得更好的確定性和公平性,但我們不想犧牲任何效能。
我們也不打算增加 GC API 表面。我們已經快十年了,我們有兩個旋鈕(引數),感覺差不多夠用了。目前還沒有一個應用程式重要到需要我們為此新增新的標誌(flag)。
我們還將研究如何改進我們已經相當不錯的逃逸分析,並最佳化 Go 的值導向程式設計。不僅在程式設計中,還在我們提供給使用者的工具中。
在演算法層面,我們將專注於設計空間中那些最小化屏障使用的部分,尤其是那些總是開啟的屏障。
最後,也是最重要的一點,我們希望能夠順應摩爾定律偏向記憶體而非 CPU 的趨勢,未來五年肯定如此,未來十年也有望如此。
就是這樣。謝謝大家。

附:Go 團隊正在招聘工程師,協助開發和維護 Go 執行時和編譯器工具鏈。
感興趣?來看看我們的 招聘職位。
下一篇文章:使用 Go Cloud 進行可移植的雲程式設計
上一篇文章:更新 Go 行為準則
部落格索引