這是一個建立於 的文章,其中的資訊可能已經有所發展或是發生改變。
http://ruizeng.net/golang-gc-internals/
摘要
在實際使用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是否關閉以保證其退出。
http://wangzhezhe.github.io/blog/2016/04/30/golang-gc/
Golang-gc基本知識
APR 30TH, 2016 8:02 PM | COMMENTS
這一部分主要介紹golang gc的一些入門的相關知識,由於gc內容涉及比較多,一點一點慢慢整理。
Golang GC的背景
- golang是基於garbage collection的語言,這是它的設計原則。
- 作為一個有記憶體回收行程的語言,gc與程式互動時候的效率會影響到整個程式的運行效率。
- 通常程式本身的記憶體管理會影響gc和程式之間的效率,甚至造成效能瓶頸。
Golang GC的相關問題
主要參的這個:
http://morsmachine.dk/machine-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 會在某一個時間點上停止原本正在啟動並執行程式,之後它會掃描runtime已經知道的的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使用的入門技巧。
減少對象分配 所謂減少對象的分配,實際上是盡量做到,對象的重用。 比如像如下的兩個函數定義:
12 |
func(r*Reader)Read()([]byte,error)func(r*Reader)Read(buf[]byte)(int,error)
|
第一個函數沒有形參,每次調用的時候返回一個[]byte,第二個函數在每次調用的時候,形參是一個buf []byte 類型的對象,之後返回讀入的byte的數目。
第一個函數在每次調用的時候都會分配一段空間,這會給gc造成額外的壓力。第二個函數在每次迪調用的時候,會重用形參聲明。
老生常談 string與[]byte轉化 在stirng與[]byte之間進行轉換,會給gc造成壓力 通過gdb,可以先對比下兩者的資料結構:
123 |
type = struct []uint8 { uint8 *array; int len; int cap;}type = struct string { uint8 *str; int len;}
|
兩者發生轉換的時候,底層資料結結構會進行複製,因此導致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函數來進行。
但是還有一個弊端,比如參考如下代碼:
1 |
b := make([]int, 1024) b = append(b, 99) fmt.Println("len:", len(b), "cap:", cap(b))
|
在使用了append操作之後,數組的空間由1024增長到了1312,所以如果能提前知道數組的長度的話,最好在最初分配空間的時候就做好空間規劃操作,會增加一些代碼管理的成本,同時也會降低gc的壓力,提升代碼的效率。
參考資料
https://talks.golang.org/2015/go-gc.pdf
https://www.zhihu.com/question/21615032
https://blog.golang.org/go15gc
golang gc 中文入門(總結比較全面 包括golang gc 在不同版本的比較 贊) http://www.open-open.com/lib/view/open1435846881544.html(原文)
其他記憶體回收相關文章
這個介紹的gc較為系統: http://newhtml.net/v8-garbage-collection/
1.5版本的記憶體回收行程 http://ruizeng.net/go-15-release-notes/
記憶體泄露參考 http://blog.csdn.net/na_he/article/details/7429171
Go1.5源碼剖析 https://github.com/qyuhen/book
手動管理golang gc的一個例子(比較深層次的內容) http://my.oschina.net/lubia/blog/175154