天天看點

go 的垃圾回收

兩個goroutine,一個不停地執行加法運算,大約會執行 30 秒。另一個 goroutine 會不停地執行記憶體配置設定,最多會配置設定 50MB 的記憶體,大約50秒

開啟goroutine并發後,預計50秒左右完成程式,略大于50M記憶體,但是記憶體占用100M左右,同時整個程式運作80秒

原因:由于GC-stop the world 機制,即所有的goroutine必須停下來

為友善區分兩個goroutine,暫且分别叫它們為,申請記憶體goroutine,加法計算goroutine。開始并發後,剛開始兩個goroutine并發運作,中期GC時,申請記憶體的goroutine for循環内有函數調用,被停止,而計算加法goroutine,for循環内隻是單純的計算,并沒有其他函數,無法進行搶占式排程,是以加法goroutine仍在運作。

是以GC隻能等加法goroutine運作結束,此時此刻,實質隻有計算加法的goroutine在運作,并發已經不存在。

搶占式排程

這個搶占式排程的原理則是在每個函數或方法的入口,加上一段額外的代碼,讓runtime有機會檢查是否需要執行搶占排程。這種解決方案隻能說局部解決了“餓死”問題,對于沒有函數調用,純算法循環計算的G,scheduler依然無法搶占。

Go語言的垃圾回收器是stoptheworld的。如果垃圾回收器想要運作了,那麼它必須先通知其它的goroutine合作停下來。這會造成較長時間的垃圾回收等待時間。我們考慮一種很極端的情況,其它的goroutine都停下來了,除了有一個沒有停,那麼垃圾回收就會一直等待。搶占式排程可以解決這種問題,在搶占式情況下,不停goroutine是否合作,它都會被yield。

優化:

1.doIdleAdd 的 for 循環中增加一行代碼time.Sleep(0)

2.調整GOGC值,改變回收速度,該值即是,新配置設定的資料與上一個收集之後剩餘的實時資料的比率達到該百分比時,觸發垃圾收集

為了解釋垃圾回收是什麼,我們先來說說 GC 不回收什麼。在我們程式中會使用到兩種記憶體,分别為堆(Heap)和棧(Stack),而 GC 不負責回收棧中的記憶體。那麼這是為什麼呢?

主要原因是棧是一塊專用記憶體,專門為了函數執行而準備的,存儲着函數中的局部變量以及調用棧。除此以外,棧中的資料都有一個特點——簡單。比如局部變量就不能被函數外通路,是以這塊記憶體用完就可以直接釋放。正是因為這個特點,棧中的資料可以通過簡單的編譯器指令自動清理,也就不需要通過 GC 來回收了。

現在我們知道了垃圾回收隻負責回收堆中的資料,那麼為什麼堆中的資料需要自動垃圾回收呢?

其實早期的語言是沒有自動垃圾回收的。比如在 C 語言中就需要使用 malloc/free 來人為地申請或者釋放堆記憶體。這種做法除了增加工作量以外,還容易出現其他問題[1]。

一種可能是并發問題,并發執行的程式容易錯誤地釋放掉還在使用的記憶體。一種可能是重複釋放記憶體,還有可能是直接忘記釋放記憶體,進而導緻記憶體洩露等問題。而這類問題不管是發現還是排查往往會花費很多時間和精力。是以現代的語言都有了這樣的需求——一個自動記憶體管理工具。

看到這裡,垃圾回收的定義也就十厘清楚了。當我們說垃圾回收(GC garbage collection)的時候,我們其實說的是自動垃圾回收(Automatic Garbage Collection),一個自動回收堆記憶體的工具。是以垃圾回收一點也不神奇,它隻是一種工具,可以更便捷更高效地幫助程式員管理記憶體。

主流的兩類垃圾回收算法有兩種,分别是追蹤式垃圾回收算法[1]和引用計數法( Reference counting )。而三色标記法是屬于追蹤式垃圾回收算法的一種。

追蹤式算法的核心思想是判斷一個對象是否可達,因為一旦這個對象不可達就可以立刻被 GC 回收了。那麼我們怎麼判斷一個對象是否可達呢?很簡單,第一步找出所有的全局變量和目前函數棧裡的變量,标記為可達。第二步,從已經标記的資料開始,進一步标記它們可通路的變量,以此類推。

在三色标記法之前有一個算法叫 Mark-And-Sweep(标記清掃),這個算法就是嚴格按照追蹤式算法的思路來實作的。這個算法會設定一個标志位來記錄對象是否被使用。最開始所有的标記位都是 0,如果發現對象是可達的就會置為 1,一步步下去就會呈現一個類似樹狀的結果。等标記的步驟完成後,會将未被标記的對象統一清理,再次把所有的标記位設定成 0 友善下次清理。

這個算法最大的問題是 GC 執行期間需要把整個程式完全暫停,不能異步進行 GC 操作。因為在不同階段标記清掃法的标志位 0 和 1 有不同的含義,那麼新增的對象無論标記為什麼都有可能意外删除這個對象。對實時性要求高的系統來說,這種需要長時間挂起的标記清掃法是不可接受的。是以就需要一個算法來解決 GC 運作時程式長時間挂起的問題,那就三色标記法。

相比傳統的标記清掃算法,三色标記最大的好處是可以異步執行,進而可以以中斷時間極少的代價或者完全沒有中斷來進行整個 GC。

三色标記法過程。

首先将對象用三種顔色表示,分别是白色、灰色和黑色。

最開始所有對象都是白色的,然後把其中全局變量和函數棧裡的對象置為灰色。

第二步把灰色的對象全部置為黑色,然後把原先灰色對象指向的變量都置為灰色,

以此類推。等發現沒有對象可以被置為灰色時,所有的白色變量就一定是需要被清理的垃圾了。

三色标記法因為多了一個白色的狀态來存放不确定的對象,是以可以異步地執行。當然異步執行的代價是可能會造成一些遺漏,因為那些早先被标記為黑色的對象可能目前已經是不可達的了。是以三色标記法是一個 false negative(假陰性)的算法。

1)Go 執行三色标記前,需要先做一個準備工作——打開 Write Barrier。

那麼 Write Barrier[1]是什麼呢?我們知道三色标記法是一種可以并發執行的算法。是以在運作過程中程式的函數棧内可能會有新配置設定的對象,那麼這些對象該怎麼通知到 GC,怎麼給他們着色呢?這個時候就需要我們的 Write Barrier 出馬了。Write Barrier 主要做這樣一件事情,修改原先的寫邏輯,然後在對象新增的同時給它着色,并且着色為”灰色“。是以打開了 Write Barrier 可以保證了三色标記法在并發下安全正确地運作。

不過在打開 Write Barrier 前有一個依賴,我們需要先停止所有的 goroutine,也就是所說的 STW(Stop The World)操作。那麼接下來問題來了,GC 該怎麼通知所有的 goroutine 停止呢 ?

我們知道,在停止 goroutine 的方案中,Go 語言采取的是合作式搶占模式(目前 1.13 及之前版本)。這種模式的做法是在程式編譯階段注入額外的代碼,更精确的說法是在每個函數的序言中增加一個合作式搶占點。因為一個 goroutine 中通常有無數調用函數的操作,選擇在函數序言中增加搶占點可以較好地平衡性能和實時性之間的利弊。在通常情況下,一次 Mark Setup 操作會在 10-30 微秒[3]之間。

但是,因為 Go 語言選擇了合作式搶占模式,是以總會有一些意外發生,比如我在第一篇文章中舉得那個例子。在這個例子中,程式運作後記憶體會一直在增長,是以GC 判斷需要執行一次垃圾回收。但是其中一個 goroutine 執行的 for 循環是一個存粹的加法的操作——整整運作 30 秒都沒有函數調用。是以為了執行 GC 标記,就需要先 STW 并且打開 Write Barrier。但是因為沒有函數調用,整個程式就隻能等着那個 goroutine 運作完。也就出現了我們看到的現象同一時刻隻有一個 goroutine 在運作着。

通常這個問題在我們的程式中是不會發生的,但是一旦發生了就會産生很大的影響。事實上 github 上确實有一些 issue 提到了這個問題,Go 官方也在嘗試修複這個問題。一個正在 coding 的解決方案是采用非合作的 goroutine 搶占模式,關心具體進展的同學可以關注一下這個 issue[4]。

在第一階段打開 Write Barrier 後,就進入第二階段的标記了。Marking 使用的算法就是我們之前提到的三色标記法,這裡不再贅述。不過我們可以簡單了解一下标記階段的資源配置設定情況。

在标記開始的時候,收集器會預設搶占 25% 的 CPU 性能,剩下的75%會配置設定給程式執行。但是一旦收集器認為來不及進行标記任務了,就會改變這個 25% 的性能配置設定。這個時候收集器會搶占程式額外的 CPU,這部分被搶占 goroutine 有個名字叫 Mark Assist。而且因為搶占 CPU的目的主要是 GC 來不及标記新增的記憶體,那麼搶占正在配置設定記憶體的 goroutine 效果會更加好,是以配置設定記憶體速度越快的 goroutine 就會被搶占越多的資源。

除此以外 GC 還有一個額外的優化,一旦某次 GC 中用到了 Mark Assist,下次 GC 就會提前開始,目的是盡量減少 Mark Assist 的使用,進而避免影響正常的程式執行。

最重要的 Marking 階段結束後就會進入 Mark Termination 階段。這個階段會關閉掉已經打開了的 Write Barrier,和 Mark Setup 階段一樣這個階段也需要 STW。

标記結束階段還需要做的事情是計算下一次清理的目标和計劃,比如第二階段使用了 Mark Assist 就會促使下次 GC 提早進行。如果想人為地減少或者增加 GC 的頻率,那麼我們可以用 GOGC 這個環境變量設定。一個小細節是在 Go 的文檔[5]中有提及, Go 的 GC 有且隻會有一個參數進行調優,也就是我們所說的 GOGC,目的是為了防止大家在一大堆調優參數中摸不着頭腦。

通常情況下,标記結束階段會耗時 60-90 微秒。

最後一個階段就是垃圾清理階段,這個過程是并發進行的。清掃的開銷會增加到配置設定堆記憶體的過程中,是以這個時間也是無感覺不會與垃圾回收的延遲相關聯。

一次完整的垃圾回收會分為四個階段,分别是标記準備、标記、結束标記以及清理。在标記準備和标記結束階段會需要 STW,标記階段會減少程式的性能,而清理階段是不會對程式有影響的。目前已經講了這麼多理論了,是以在下一篇文章中,我們會介紹一些實戰案例。