go 的垃圾回收


案例分析

package main
import (
	"math"
	"sync"
	"time"
)
func doAllocate(nKB int, wg *sync.WaitGroup) {
	var slice []byte
	for i := 0; i < nKB; i++ {
		t := make([]byte, 1024) // 1KB
		slice = append(slice, t...)
		// 大約會執行 50 秒,方便觀察內存增長
		time.Sleep(time.Millisecond)
	}
	wg.Done()
	println("doAllocate done")
}
func doIdleAdd(n int64, wg *sync.WaitGroup) {
	var res int64
	for i := int64(0); i < n; i++ {
		res += i
	}
	wg.Done()
	println("doIdleAdd done")
}
func main() {
        // runtime.GOMAXPROCS(runtime.NumCPU()) // needed before go 1.5
	wg := new(sync.WaitGroup)
	wg.Add(2)
	go doAllocate(50*1024, wg) // 申請內存空間 50MB, 約50秒
	t := int64(math.Pow(10, 11))
	go doIdleAdd(t, wg) // 執行加法,大約會執行 30 秒,方便觀察運行情況
	wg.Wait()
}
  • 兩個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值,改變回收速度,該值即是,新分配的數據與上一個收集之后剩余的實時數據的比率達到該百分比時,觸發垃圾收集

1.垃圾回收是什么

GC 不回收什么?

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

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

為什么需要垃圾回收?

現在我們知道了垃圾回收只負責回收堆中的數據,那么為什么堆中的數據需要自動垃圾回收呢?

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

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

什么是垃圾回收?

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

2.三色標記法

追蹤式垃圾回收(Tracing garbage collection)

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

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

為什么需要三色標記法?

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

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

三色標記法好在哪里?

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

三色標記法過程。

  • 首先將對象用三種顏色表示,分別是白色、灰色和黑色。
  • 最開始所有對象都是白色的,然后把其中全局變量和函數棧里的對象置為灰色。
  • 第二步把灰色的對象全部置為黑色,然后把原先灰色對象指向的變量都置為灰色,
  • 以此類推。等發現沒有對象可以被置為灰色時,所有的白色變量就一定是需要被清理的垃圾了。

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

3.一次完整回收過程

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

Write Barrier

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

Stop The World

不過在打開 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]。

2)Marking 標記(Concurrent)

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

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

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

3)Mark Termination 標記結束(STW)

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

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

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

4)weeping 清理(Concurrent)

最后一個階段就是垃圾清理階段,這個過程是並發進行的。清掃的開銷會增加到分配堆內存的過程中,所以這個時間也是無感知不會與垃圾回收的延遲相關聯。

5)總結

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


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM