1. 程式人生 > >Golang GC 垃圾回收機制詳解

Golang GC 垃圾回收機制詳解

摘要

在實際使用 go 語言的過程中,碰到了一些看似奇怪的記憶體佔用現象,於是決定對go語言的垃圾回收模型進行一些研究。本文對研究的結果進行一下總結。

什麼是垃圾回收?

曾幾何時,記憶體管理是程式設計師開發應用的一大難題。傳統的系統級程式語言(主要指C/C++)中,程式設計師必須對記憶體小心的進行管理操作,控制記憶體的申請及釋放。稍有不慎,就可能產生記憶體洩露問題,這種問題不易發現並且難以定位,一直成為困擾開發者的噩夢。如何解決這個頭疼的問題呢?過去一般採用兩種辦法:

  • 記憶體洩露檢測工具。這種工具的原理一般是靜態程式碼掃描,通過掃描程式檢測可能出現記憶體洩露的程式碼段。然而檢測工具難免有疏漏和不足,只能起到輔助作用。
  • 智慧指標。這是 c++ 中引入的自動記憶體管理方法,通過擁有自動記憶體管理功能的指標物件來引用物件,是程式設計師不用太關注記憶體的釋放,而達到記憶體自動釋放的目的。這種方法是採用最廣泛的做法,但是對程式設計師有一定的學習成本(並非語言層面的原生支援),而且一旦有忘記使用的場景依然無法避免記憶體洩露。

為了解決這個問題,後來開發出來的幾乎所有新語言(java,python,php等等)都引入了語言層面的自動記憶體管理 – 也就是語言的使用者只用關注記憶體的申請而不必關心記憶體的釋放,記憶體釋放由虛擬機器(virtual machine)或執行時(runtime)來自動進行管理。而這種對不再使用的記憶體資源進行自動回收的行為就被稱為垃圾回收。

常見的垃圾回收方法

引用計數(reference counting)

這是最簡單的一種垃圾回收演算法,和之前提到的智慧指標異曲同工。對每個物件維護一個引用計數,當引用該物件的物件被銷燬或更新時被引用物件的引用計數自動減一,當被引用物件被建立或被賦值給其他物件時引用計數自動加一。當引用計數為0時則立即回收物件。

這種方法的優點是實現簡單,並且記憶體的回收很及時。這種演算法在記憶體比較緊張和實時性比較高的系統中使用的比較廣泛,如ios cocoa框架,php,python等。簡單引用計數演算法也有明顯的缺點:

  • 頻繁更新引用計數降低了效能。一種簡單的解決方法就是編譯器將相鄰的引用計數更新操作合併到一次更新;還有一種方法是針對頻繁發生的臨時變數引用不進行計數,而是在引用達到0時通過掃描堆疊確認是否還有臨時物件引用而決定是否釋放。等等還有很多其他方法,具體可以參考這裡。
  • 迴圈引用問題。當物件間發生迴圈引用時引用鏈中的物件都無法得到釋放。最明顯的解決辦法是避免產生迴圈引用,如cocoa引入了strong指標和weak指標兩種指標型別。或者系統檢測迴圈引用並主動打破迴圈鏈。當然這也增加了垃圾回收的複雜度。

標記-清除(mark and sweep)

該方法分為兩步,標記從根變數開始迭代得遍歷所有被引用的物件,對能夠通過應用遍歷訪問到的物件都進行標記為“被引用”;標記完成後進行清除操作,對沒有標記過的記憶體進行回收(回收同時可能伴有碎片整理操作)。這種方法解決了引用計數的不足,但是也有比較明顯的問題:每次啟動垃圾回收都會暫停當前所有的正常程式碼執行,回收是系統響應能力大大降低!當然後續也出現了很多mark&sweep演算法的變種(如三色標記法)優化了這個問題。

分代收集(generation)

經過大量實際觀察得知,在面向物件程式語言中,絕大多數物件的生命週期都非常短。分代收集的基本思想是,將堆劃分為兩個或多個稱為 代(generation)的空間。新建立的物件存放在稱為 新生代(young generation)中(一般來說,新生代的大小會比 老年代小很多),隨著垃圾回收的重複執行,生命週期較長的物件會被 提升(promotion)到老年代中。因此,新生代垃圾回收和老年代垃圾回收兩種不同的垃圾回收方式應運而生,分別用於對各自空間中的物件執行垃圾回收。新生代垃圾回收的速度非常快,比老年代快幾個數量級,即使新生代垃圾回收的頻率更高,執行效率也仍然比老年代垃圾回收強,這是因為大多數物件的生命週期都很短,根本無需提升到老年代。

GO的垃圾回收器

go語言垃圾回收總體採用的是經典的mark and sweep演算法。

  • 1.3版本以前,golang的垃圾回收演算法都非常簡陋,然後其效能也廣被詬病:go runtime在一定條件下(記憶體超過閾值或定期如2min),暫停所有任務的執行,進行mark&sweep操作,操作完成後啟動所有任務的執行。在記憶體使用較多的場景下,go程式在進行垃圾回收時會發生非常明顯的卡頓現象(Stop The World)。在對響應速度要求較高的後臺服務程序中,這種延遲簡直是不能忍受的!這個時期國內外很多在生產環境實踐go語言的團隊都或多或少踩過gc的坑。當時解決這個問題比較常用的方法是儘快控制自動分配記憶體的記憶體數量以減少gc負荷,同時採用手動管理記憶體的方法處理需要大量及高頻分配記憶體的場景。
  • 1.3版本開始go team開始對gc效能進行持續的改進和優化,每個新版本的go釋出時gc改進都成為大家備受關注的要點。1.3版本中,go runtime分離了mark和sweep操作,和以前一樣,也是先暫停所有任務執行並啟動mark,mark完成後馬上就重新啟動被暫停的任務了,而是讓sweep任務和普通協程任務一樣並行的和其他任務一起執行。如果執行在多核處理器上,go會試圖將gc任務放到單獨的核心上執行而儘量不影響業務程式碼的執行。go team自己的說法是減少了50%-70%的暫停時間。
  • 1.4版本(當前最新穩定版本)對gc的效能改動並不多。1.4版本中runtime很多程式碼取代了原生c語言實現而採用了go語言實現,對gc帶來的一大改變是可以是實現精確的gc。c語言實現在gc時無法獲取到記憶體的物件資訊,因此無法準確區分普通變數和指標,只能將普通變數當做指標,如果碰巧這個普通變數指向的空間有其他物件,那這個物件就不會被回收。而go語言實現是完全知道物件的型別資訊,在標記時只會遍歷指標指向的物件,這樣就避免了C實現時的堆記憶體浪費(解決約10-30%)。
  • 1.5版本go team對gc又進行了比較大的改進(1.4中已經埋下伏筆如write barrier的引入),官方的主要目標是減少延遲。go 1.5正在實現的垃圾回收器是“非分代的、非移動的、併發的、三色的標記清除垃圾收集器”。分代演算法上文已經提及,是一種比較好的垃圾回收管理策略,然1.5版本中並未考慮實現;我猜測的原因是步子不能邁太大,得逐步改進,go官方也表示會在1.6版本的gc優化中考慮。同時引入了上文介紹的三色標記法,這種方法的mark操作是可以漸進執行的而不需每次都掃描整個記憶體空間,可以減少stop the world的時間。 由此可以看到,一路走來直到1.5版本,go的垃圾回收效能也是一直在提升,但是相對成熟的垃圾回收系統(如java jvm和javascript v8),go需要優化的路徑還很長(但是相信未來一定是美好的~)。

實踐經驗

團隊在實踐go語言時同樣碰到最多和最棘手的問題也是記憶體問題(其中gc為主),這裡把遇到的問題和經驗總結下,歡迎大家一起交流探討。

go程式記憶體佔用大的問題

這個問題在我們對後臺服務進行壓力測試時發現,我們模擬大量的使用者請求訪問後臺服務,這時各服務模組能觀察到明顯的記憶體佔用上升。但是當停止壓測時,記憶體佔用並未發生明顯的下降。花了很長時間定位問題,使用gprof等各種方法,依然沒有發現原因。最後發現原來這時正常的…主要的原因有兩個,

一是go的垃圾回收有個觸發閾值,這個閾值會隨著每次記憶體使用變大而逐漸增大(如初始閾值是10MB則下一次就是20MB,再下一次就成為了40MB…),如果長時間沒有觸發gc go會主動觸發一次(2min)。高峰時記憶體使用量上去後,除非持續申請記憶體,靠閾值觸發gc已經基本不可能,而是要等最多2min主動gc開始才能觸發gc。

第二個原因是go語言在向系統交還記憶體時只是告訴系統這些記憶體不需要使用了,可以回收;同時作業系統會採取“拖延症”策略,並不是立即回收,而是等到系統記憶體緊張時才會開始回收這樣該程式又重新申請記憶體時就可以獲得極快的分配速度。

gc時間長的問題

對於對使用者響應事件有要求的後端程式,golang gc時的stop the world兼職是噩夢。根據上文的介紹,1.5版本的go再完成上述改進後應該gc效能會提升不少,但是所有的垃圾回收型語言都難免在gc時面臨效能下降,對此我們對於應該儘量避免頻繁建立臨時堆物件(如&abc{}, new, make等)以減少垃圾收集時的掃描時間,對於需要頻繁使用的臨時物件考慮直接通過陣列快取進行重用;很多人採用cgo的方法自己管理記憶體而繞開垃圾收集,這種方法除非迫不得已個人是不推薦的(容易造成不可預知的問題),當然迫不得已的情況下還是可以考慮的,這招帶來的效果還是很明顯的~

goroutine洩露的問題

我們的一個服務需要處理很多長連線請求,實現時,對於每個長連線請求各開了一個讀取和寫入協程,全部採用endless for loop不停地處理收發資料。當連線被遠端關閉後,如果不對這兩個協程做處理,他們依然會一直執行,並且佔用的channel也不會被釋放…這裡就必須十分注意,在不使用協程後一定要把他依賴的channel close並通過再協程中判斷channel是否關閉以保證其退出。

Golang-gc基本知識

APR 30TH, 2016 8:02 PM | COMMENTS

這一部分主要介紹golang gc的一些入門的相關知識,由於gc內容涉及比較多,一點一點慢慢整理。

Golang GC的背景

  • golang是基於garbage collection的語言,這是它的設計原則。
  • 作為一個有垃圾回收器的語言,gc與程式互動時候的效率會影響到整個程式的執行效率。
  • 通常程式本身的記憶體管理會影響gc和程式之間的效率,甚至造成效能瓶頸。

Golang GC的相關問題

主要參的這個:

是14年寫的,估計那個時候的gc機制還比較simple,新版本的golang對gc的改動應該會比較大

還有那個go語言讀書筆記中關於golang gc 的相關部分

關於記憶體洩露

“記憶體洩露”(Memory Leak)這個詞看似自己很熟悉,可實際上卻也從沒有看過它的準確含義。

記憶體洩露,是從作業系統的角度上來闡述的,形象的比喻就是“作業系統可提供給所有程序的儲存空間(虛擬記憶體空間)正在被某個程序榨乾”,導致的原因就是程式在執行的時候,會不斷地動態開闢的儲存空間,這些儲存空間在在執行結束之後後並沒有被及時釋放掉。應用程式在分配了某段記憶體之後,由於設計的錯誤,會導致程式失去了對該段記憶體的控制,造成了記憶體空間的浪費。

如果程式在記憶體空間內申請了一塊記憶體,之後程式執行結束之後,沒有把這塊記憶體空間釋放掉,而且對應的程式又沒有很好的gc機制去對程式申請的空間進行回收,這樣就會導致記憶體洩露。

從使用者的角度來說,記憶體洩露本身不會有什麼危害,因為這不是對使用者功能的影響,但是“記憶體洩露”如果進

對於 C 和 C++ 這種沒有 Garbage Collection 的語言來講,我們主要關注兩種型別的記憶體洩漏:

  • 堆記憶體洩漏(Heap leak)。對記憶體指的是程式執行中根據需要分配通過 malloc,realloc new 等從堆中分配的一塊記憶體,再是完成後必須通過呼叫對應的 free 或者 delete 刪掉。如果程式的設計的錯誤導致這部分記憶體沒有被釋放,那麼此後這塊記憶體將不會被使用,就會產生 Heap Leak.
  • 系統資源洩露(Resource Leak).主要指程式使用系統分配的資源比如 Bitmap,handle ,SOCKET 等沒有使用相應的函式釋放掉,導致系統資源的浪費,嚴重可導致系統效能降低,系統執行不穩定。

記憶體洩露涉及到的相關問題還有很多,這裡暫不展開討論。

常見的 GC 模式

具體的優缺點可以參考這個,這裡只是進行大致介紹。

  • 引用計數(reference counting)每個物件維護一個引用計數器,當引用該物件的物件被銷燬或者更新的時候,被引用物件的引用計數器自動減 1,當被應用的物件被建立,或者賦值給其他物件時,引用 +1,引用為 0 的時候回收,思路簡單,但是頻繁更新引用計數器降低效能,存在迴圈以引用(php,Python所使用的)
  • 標記清除(mark and sweep)就是 golang 所使用的,從根變數來時遍歷所有被引用物件,標記之後進行清除操作,對未標記物件進行回收,缺點:每次垃圾回收的時候都會暫停所有的正常執行的程式碼,系統的響應能力會大大降低,各種 mark&swamp 變種(三色標記法),緩解效能問題。
  • 分代蒐集(generation)jvm 就使用的分代回收的思路。在面向物件程式語言中,絕大多數物件的生命週期都非常短。分代收集的基本思想是,將堆劃分為兩個或多個稱為代(generation)的空間。新建立的物件存放在稱為新生代(young generation)中(一般來說,新生代的大小會比 老年代小很多),隨著垃圾回收的重複執行,生命週期較長的物件會被提升(promotion)到老年代中(這裡用到了一個分類的思路,這個是也是科學思考的一個基本思路)。

因此,新生代垃圾回收和老年代垃圾回收兩種不同的垃圾回收方式應運而生(先分類,之後再對症下藥),分別用於對各自空間中的物件執行垃圾回收。新生代垃圾回收的速度非常快,比老年代快幾個數量級,即使新生代垃圾回收的頻率更高,執行效率也仍然比老年代垃圾回收強,這是因為大多數物件的生命週期都很短,根本無需提升到老年代。

golang 中的 gc 通常是如何工作的

golang 中的 gc 基本上是標記清除的思路:

在記憶體堆中(由於有的時候管理記憶體頁的時候要用到堆的資料結構,所以稱為堆記憶體)儲存著有一系列的物件,這些物件可能會與其他物件有關聯(references between these objects) a tracing garbage collector 會在某一個時間點上停止原本正在執行的程式,之後它會掃描 runtim e已經知道的的 object 集合(already known set of objects),通常它們是存在於 stack 中的全域性變數以及各種物件。gc 會對這些物件進行標記,將這些物件的狀態標記為可達,從中找出所有的,從當前的這些物件可以達到其他地方的物件的 reference,並且將這些物件也標記為可達的物件,這個步驟被稱為 mark phase,即標記階段,這一步的主要目的是用於獲取這些物件的狀態資訊。

一旦將所有的這些物件都掃描完,gc 就會獲取到所有的無法 reach 的物件(狀態為 unreachable 的物件),並且將它們回收,這一步稱為 sweep phase,即是清掃階段

gc 僅僅蒐集那些未被標記為可達(reachable)的物件。如果 gc 沒有識別出一個 reference,最後有可能會將一個仍然在使用的物件給回收掉,就引起了程式執行錯誤。

可以看到主要的三個步驟:掃描,回收,清掃。

感覺比起其他的語言,golang 中的垃圾回收模型還是相對簡單的。

gc中的問題

gc 的引入可以說就是為了解決記憶體回收的問題。新開發的語言(java,python,php等等),在使用的時候,可以使使用者不必關心記憶體物件的釋放,只需要關心物件的申請即可,通過在 runtime 或者在 vm 中進行相關的操作,達到自動管理記憶體空間的效果,這種對不再使用的記憶體資源進行自動回收的行為就被稱為垃圾回收。

根據前面的表述,能否正常識別一個 reference 是 gc 能夠正常工作的基礎,因此第一個問題就是 gc 應該如何識別一個 reference?

最大的問題:對於 reference 的識別比較難,machine code 很難知道,怎樣才算是一個reference。如果錯漏掉了一個 reference,就會使得,原本沒有準備好要被 free 掉的記憶體現在被錯誤地 free 掉,所以策略就是寧多勿少。

一種策略是把所有的 memory 空間都看做是有可能的 references(指標值)。這種被稱為保守型垃圾回收器(conservative garbage collector)。C 中的 Boehm garbage collector 就是這樣工作的。就是說把記憶體中的普通變數也當做指標一樣去處理,儘量 cover 到所有的指標的情況,如果碰巧這個普通的變數值所指向的空間有其他的物件,那麼這個物件是不會被回收的。而 go 語言實現是完全知道物件的型別資訊,在標記時只會遍歷指標指向的物件,這樣就避免了 C 實現時的堆記憶體浪費(解決約 10-30% )。

三色標記

2014/6 1.3 引入併發清理(垃圾回收和使用者邏輯併發執行?)

2015/8 1.5 引入三色標記法

關於併發清理的引入,參照的是這裡在 1.3 版本中,go runtime 分離了 mark 和 sweep 的操作,和以前一樣,也是先暫停所有任務執行並啟動 mark( mark 這部分還是要把原程式停下來的),mark 完成後就馬上就重新啟動被暫停的任務了,並且讓 sweep 任務和普通協程任務一樣並行,和其他任務一起執行。如果執行在多核處理器上,go 會試圖將 gc 任務放到單獨的核心上執行而儘量不影響業務程式碼的執行,go team 自己的說法是減少了 50%-70% 的暫停時間。

基本演算法就是之前提到的清掃+回收,Golang gc 優化的核心就是儘量使得 STW(Stop The World) 的時間越來越短。

如何測量 GC

之前說了那麼多,那如何測量 gc 的之星效率,判斷它到底是否對程式的執行造成了影響呢? 第一種方式是設定 godebug 的環境變數,具體可以參考這一篇,真的是講的很好的文章:連結,比如執行GODEBUG=gctrace=1 ./myserver,如果要想對於輸出結果瞭解,還需要對於 gc 的原理進行更進一步的深入分析,這篇文章的好處在於,清晰的之處了 golang 的 gc 時間是由哪些因素決定的,因此也可以針對性的採取不同的方式提升 gc 的時間:

根據之前的分析也可以知道,golang 中的 gc 是使用標記清楚法,所以 gc 的總時間為:

Tgc = Tseq + Tmark + Tsweep( T 表示 time)

  • Tseq 表示是停止使用者的 goroutine 和做一些準備活動(通常很小)需要的時間
  • Tmark 是堆標記時間,標記發生在所有使用者 goroutine 停止時,因此可以顯著地影響處理的延遲
  • Tsweep 是堆清除時間,清除通常與正常的程式運行同時發生,所以對延遲來說是不太關鍵的

之後粒度進一步細分,具體的概念還是有些不太懂:

  • 與 Tmark 相關的:1 垃圾回收過程中,堆中活動物件的數量,2 帶有指標的活動物件佔據的記憶體總量 3 活動物件中的指標數量。
  • 與 Tsweep 相關的:1 堆記憶體的總量 2 堆中的垃圾總量

如何進行 gc 調優( gopher 大會 Danny )

硬性引數

涉及演算法的問題,總是會有些引數。GOGC 引數主要控制的是下一次 gc 開始的時候的記憶體使用量

比如當前的程式使用了 4M 的對記憶體(這裡說的是堆記憶體),即是說程式當前 reachable 的記憶體為 4m,當程式佔用的記憶體達到 reachable*(1+GOGC/100)=8M 的時候,gc 就會被觸發,開始進行相關的 gc 操作。

如何對 GOGC 的引數進行設定,要根據生產情況中的實際場景來定,比如 GOGC 引數提升,來減少 GC 的頻率。

小tips

想要有深入的 insights,使用 gdb 時必不可少的了,這篇文章裡面整理了一些 gdb 使用的入門技巧。

減少物件分配 所謂減少物件的分配,實際上是儘量做到,物件的重用。 比如像如下的兩個函式定義:

第一個函式沒有形參,每次呼叫的時候返回一個 []byte,第二個函式在每次呼叫的時候,形參是一個 buf []byte 型別的物件,之後返回讀入的 byte 的數目。

第一個函式在每次呼叫的時候都會分配一段空間,這會給 gc 造成額外的壓力。第二個函式在每次迪呼叫的時候,會重用形參宣告。

老生常談 string 與 []byte 轉化 在 stirng 與 []byte 之間進行轉換,會給 gc 造成壓力 通過 gdb,可以先對比下兩者的資料結構:

兩者發生轉換的時候,底層資料結結構會進行復制,因此導致 gc 效率會變低。解決策略上,一種方式是一直使用 []byte,特別是在資料傳輸方面,[]byte 中也包含著許多 string 會常用到的有效的操作。另一種是使用更為底層的操作直接進行轉化,避免複製行為的發生。可以參考微信“雨痕學堂”中效能優化的第一部分,主要是使用 unsafe.Pointer 直接進行轉化。

對於 unsafe 的使用,感覺可以單獨整理一出一篇文章來了,先把相關資料列在這裡 http://studygolang.com/articles/685 直觀上,可以把 unsafe.Pointer 理解成 c++ 中的 void*,在 golang 中,相當於是各種型別的指標進行轉化的橋樑。

關於 uintptr 的底層型別是 int,它可以裝下指標所指的地址的值。它可以和 unsafe.Pointer 進行相互轉化,主要的區別是,uintptr 可以參與指標運算,而 unsafe.Pointer 只能進行指標轉化,不能進行指標運算。想要用 golang 進行指標運算,可以參考這個。具體指標運算的時候,要先轉成 uintptr 的型別,才能進一步計算,比如偏移多少之類的。

少量使用+連線 string 由於採用 + 來進行 string 的連線會生成新的物件,降低 gc 的效率,好的方式是通過 append 函式來進行。

但是還有一個弊端,比如參考如下程式碼:

在使用了append操作之後,陣列的空間由1024增長到了1312,所以如果能提前知道陣列的長度的話,最好在最初分配空間的時候就做好空間規劃操作,會增加一些程式碼管理的成本,同時也會降低gc的壓力,提升程式碼的效率。

參考資料

其他垃圾回收相關文章