图文结合,白话 Go 的垃圾回收原理
前面兩篇文章介紹了Go語言的內存分配策略以及Go協程動態擴充和收縮棧內存的原理,今天這篇文章我們主要來聊一下內存管理的另外一大塊內容:垃圾回收。
下面首先我們會聊一下什么是GC (垃圾回收),GC的作用是什么,然后再結合圖示用每個人都能聽懂的大白話解釋Go的GC原理。
什么是GC?
現代高級編程語言管理內存的方式分為兩種:自動和手動,像C、C++ 等編程語言使用手動管理內存的方式,工程師編寫代碼過程中需要主動申請或者釋放內存;而 PHP、Java 和 Go 等語言使用自動的內存管理系統,有內存分配器和垃圾收集器來代為分配和回收內存,其中垃圾收集器就是我們常說的GC。
GC回收的是什么?
在應用程序中會使用到兩種內存,分別為堆(Heap)和棧(Stack),GC負責回收堆內存,而不負責回收棧中的內存。那么這是為什么呢?
主要原因是棧是一塊專用內存,專門為了函數執行而準備的,存儲著函數中的局部變量以及調用棧。除此以外,棧中的數據都有一個特點——簡單。比如局部變量不能被函數外訪問,所以這塊內存用完就可以直接釋放。正是因為這個特點,棧中的數據可以通過簡單的編譯器指令自動清理,并不需要通過 GC 來回收。
GC算法的種類
主流的垃圾回收算法有兩大類,分別是追蹤式垃圾回收算法和引用計數法( Reference counting )。而Go語言現在用的三色標記法就屬于追蹤式垃圾回收算法的一種。
追蹤式算法的核心思想是判斷一個對象是否可達,一旦這個對象不可達就可以在垃圾回收的控制循環里被 GC 回收了。那么我們怎么判斷一個對象是否可達呢?很簡單,第一步找出所有的全局變量和當前函數棧里的變量,標記為可達。第二步,從已經標記的數據開始,進一步標記它們可訪問的變量,以此類推。
Go的垃圾回收算法
Go的垃圾收集器從一開始到現在一直在演進,在v1.5版本開始三色標記法作為垃圾回收算法前使用Mark-And-Sweep(標記清除)算法。從v1.5版本Go實現了基于三色標記清除的并發垃圾收集器,大幅度降低垃圾收集的延遲從幾百 ms 降低至 10ms 以下。在v1.8又使用混合寫屏障將垃圾收集的時間縮短至 0.5ms 以內。
標記清除算法的缺點
Mark-And-Sweep,這個算法就是嚴格按照追蹤式算法的思路來實現的。這個垃圾回收算法的執行流程可以用下面這張圖來表示。
垃圾回收--標記清除此算法主要有兩個步驟:
暫停應用程序的執行, 從根對象出發標記出可達對象。
清除未標記的對象,恢復應用程序的執行。
這個算法最大的問題是 GC 執行期間需要把整個程序完全暫停,不能異步地進行垃圾回收,對實時性要求高的系統來說,這種需要長時間掛起的標記清掃法是不可接受的。所以就需要一個算法來解決 GC 運行時程序長時間掛起的問題。
三色標記清除法
從v1.5版本Go實現了基于三色標記清除的并發垃圾收集器,注意三色標記這個算法不是Go的垃圾收集器獨有的。這個算法背后的核心思想是由Edsger W. Dijkstra,Leslie Lamport,A.J.Martin,C.S.Scholten和E.F.M.Steffens提出的,算法首先于1978年發表在論文*On-the-fly Garbage Collection:An Exercise in Cooperation*[1]上面。三色標記清除算法背后的首要原則就是它把堆中的對象根據它們的顏色分到不同集合里面。
三色標記算法將程序中的對象分成白色、黑色和灰色三類:
白色對象 — 潛在的垃圾,其內存可能會被垃圾收集器回收;
黑色對象 — 活躍的對象,包括不存在任何引用外部指針的對象以及從根對象可達的對象,垃圾回收器不會掃描這些對象的子對象;
灰色對象 — 活躍的對象,因為存在指向白色對象的外部指針,垃圾收集器會掃描這些對象的子對象;
文字解釋起來不太好理解,我用下面幾張圖演示一下三色標記清除的整個過程:
第一步:在進入GC的三色標記階段的一開始,所有對象都是白色的。
第二步, 遍歷根節點集合里的所有根對象,把根對象引用的對象標記為灰色,從白色集合放入灰色集合。
第三步, 遍歷灰色集合,將灰色對象引用的對象從白色集合放入灰色集合,之后將此灰色對象放入黑色集合第四步:重復第三步, 直到灰色集合中無任何對象。第五步:回收白色集合里的所有對象,本次垃圾回收結束。
這里所說的根節點集合里的根對象就是棧上的對象或者堆上的全局變量。
寫屏障
Go 在GC階段執行三色標記前,還需要先做一個準備工作——打開寫屏障(Write Barrier)。那么寫屏障是什么呢?我們知道三色標記法是一種可以并發執行的算法。所以在GC運行過程中程序的函數棧內可能會有新分配的對象,那么這些對象該怎么通知到 GC,怎么給他們著色呢?如果還是按照之前新建的對象標記為白色就有可能出現下圖中的問題:
在GC進行的過程中,應用程序新建了對象I,此時如果已經標記成黑的對象F引用了對象I,那么在本次GC執行過程中因為黑色對象不會再次掃描,所以如果I著色成白色的話,會被回收掉,這顯然是不允許的。
這個時候就需要我們的寫屏障出馬了。寫屏障主要做一件事情,修改原先的寫邏輯,然后在對象新增的同時給它著色,并且著色為灰色。因此打開了寫屏障可以保證了三色標記法在并發下安全正確地運行。那么有人就會問這些寫屏障標記成灰色的對象什么時候回收呢?答案是后續的GC過程中回收,在新的GC過程中所有已存對象就又從白色開始逐步被標記啦。
三色不變性
想要在并發或者增量的標記算法中保證正確性,我們需要達成以下兩種三色不變性(Tri-color invariant)中的任意一種:
強三色不變性 — 黑色對象不會指向白色對象,只會指向灰色對象或者黑色對象;
弱三色不變性 — 黑色對象指向的白色對象必須包含一條從灰色對象經由多個白色對象的可達路徑
屏障技術
垃圾收集中的屏障技術更像是一個鉤子方法,它是在用戶程序讀取對象、創建新對象以及更新對象指針時執行的一段代碼,根據操作類型的不同,我們可以將它們分成讀屏障(Read barrier)和寫屏障(Write barrier)兩種,因為讀屏障需要在讀操作中加入代碼片段,對用戶程序的性能影響很大,所以編程語言往往都會采用寫屏障保證三色不變性。
Go的混合寫屏障
在Go 語言 v1.7 版本之前,使用的是Dijkstra插入寫屏障保證強三色不變性,但是運行時并沒有在所有的垃圾收集根對象上開啟插入寫屏障。因為 Go 語言的應用程序可能包含成百上千的 goroutine,而垃圾收集的根對象一般包括全局變量和棧對象,如果運行時需要在幾百個 goroutine 的棧上都開啟寫屏障,會帶來巨大的額外開銷,所以 Go 團隊在實現上選擇了在標記階段完成時暫停程序、將所有棧對象標記為灰色并重新掃描,在活躍 goroutine 非常多的程序中,重新掃描的過程需要占用 10 ~ 100ms 的時間。
Go 語言在 v1.8 組合 Dijkstra 插入寫屏障和 Yuasa 刪除寫屏障構成了如下所示的混合寫屏障,該寫屏障會將被覆蓋的對象標記成灰色并在當前棧沒有掃描時將新對象也標記成灰色:
writePointer(slot,?ptr):shade(*slot)if?current?stack?is?grey:shade(ptr)*slot?=?ptr為了移除棧的重掃描過程,除了引入混合寫屏障之外,在垃圾收集的標記階段,我們還需要將創建的所有新對象都標記成黑色,防止新分配的棧內存和堆內存中的對象被錯誤地回收,因為棧內存在標記階段最終都會變為黑色,所以不再需要重新掃描棧空間。
一次完整的GC過程
Go的垃圾回收器在使用了三色標記清除算法和混合寫屏障后大大減少了暫停程序(STW)的時間,主要是在開啟寫屏障前和移除寫屏障前暫停應用程序。
Go的垃圾收集的整個過程可以分成標記準備、標記、標記終止和清除四個不同階段,每個階段完成的工作如下:
標記準備階段
暫停程序,所有的處理器在這時會進入安全點(Safe point);
標記階段
將狀態切換至 _GCmark、開啟寫屏障、用戶程序協助(Mutator Assiste)并將根對象入隊;
恢復執行程序,標記進程和用于協助的用戶程序會開始并發標記內存中的對象,標記用的算法就是上面介紹的三色標記清除法。寫屏障會將被覆蓋的指針和新指針都標記成灰色,而所有新創建的對象都會被直接標記成黑色;
開始掃描根對象,包括所有 goroutine 的棧、全局對象以及不在堆中的運行時數據結構,掃描 goroutine 棧期間會暫停當前處理器;
依次處理灰色隊列中的對象,將對象標記成黑色并將它們指向的對象標記成灰色;
使用分布式的終止算法檢查剩余的工作,發現標記階段完成后進入標記終止階段;
在標記開始的時候,收集器會默認搶占 25% 的 CPU 性能,剩下的75%會分配給程序執行。但是一旦收集器認為來不及進行標記任務了,就會改變這個 25% 的性能分配。這個時候收集器會搶占程序額外的 CPU,這部分被搶占 goroutine 有個名字叫 Mark Assist。而且因為搶占 CPU的目的主要是 GC 來不及標記新增的內存,那么搶占正在分配內存的 goroutine 效果會更加好,所以分配內存速度越快的 goroutine 就會被搶占越多的資源。
除此以外 GC 還有一個額外的優化,一旦某次 GC 中用到了 Mark Assist,下次 GC 就會提前開始,目的是盡量減少 Mark Assist 的使用,從而避免影響正常的程序執行。
標記終止階段
暫停程序、將狀態切換至 _GCmarktermination 并關閉輔助標記的用戶程序;
清理處理器上的線程緩存;
清理階段
將狀態切換至 _GCoff 開始清理階段,初始化清理狀態并關閉寫屏障;
恢復用戶程序,所有新創建的對象會標記成白色;
后臺并發清理所有的內存管理單元,當 goroutine 申請新的內存管理單元時就會觸發清理;
清理這個過程是并發進行的。清掃的開銷會增加到分配堆內存的過程中,所以這個時間也是無感知的,不會與垃圾回收的延遲相關聯。
總結
Go語言的垃圾收集的實現非常復雜,難懂的技術概念和原理也比較多,這篇文章意在用每個人都能看懂的白話文字結合圖示把Go的垃圾回收原理解釋清楚,讓讀者能對垃圾回收的大體流程有個概念。
下面用一句話總結概況Go的垃圾回收原理:
Go的GC最早期使用的回收算法是標記-清除算法,該算法需要在執行期間需要暫停應用程序(STW),無法滿足并發程序的實時性。后面Go的GC轉為使用三色標記清除算法,并通過混合寫屏障技術保證了Go并發執行GC時內存中對象的三色一致性(這里的并發指的是GC和應用程序的goroutine能同時執行)。
一次完整的垃圾回收會分為四個階段,分別是標記準備、標記、結束標記以及清理。在標記準備和標記結束階段會需要 STW,標記階段會減少程序的性能,而清理階段是不會對程序有影響的。
這句話有點長,哈哈哈。看了今天的文章你對Go的垃圾回收有大體的理解了嗎?能用自己的話總結一下嗎?歡迎把自己的總結發在留言里,也歡迎把文章分享給朋友閱讀。關注公眾號「網管叨bi叨」,獲取我整理的所有關于Go語言的技術文章。
參考資料
垃圾收集器[2]
Go 垃圾回收——三色標記法[3]
[1]
On-the-fly Garbage Collection:An Exercise in Cooperation: https://www.microsoft.com/en-us/research/publication/fly-garbage-collection-exercise-cooperation/
[2]垃圾收集器: https://draveness.me/golang/docs/part3-runtime/ch07-memory/golang-garbage-collector/
[3]Go 垃圾回收——三色標記法: https://zhuanlan.zhihu.com/p/105495961
總結
以上是生活随笔為你收集整理的图文结合,白话 Go 的垃圾回收原理的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: iptables redirect 劫持
- 下一篇: 如何学习 Go 语言的核心知识