Go程式GC優化經驗分享
作者:達達
最近一段時間對《仙俠道》的服務端進行了一系列針對GC的調優,這裡跟各位分享一下調優的經驗。
遊戲第一次上線的時候,大部分精力都投入在做cpuprof和memprof找效能瓶頸和記憶體洩漏上,沒有關注過Go的GC執行情況。
有一次cpuprof裡的scanblock呼叫所佔的比例讓我注意到Go的GC所帶來的效能消耗,記得那份cpuprof裡,scanblock呼叫佔到49%。也就是說有一半的CPU時間浪費在了GC上。
於是我開始研究如何進行優化,過程中免不了要分析資料,經過一番搜尋,我好到了GOGCTRACE這個環境變數。
用法類似這樣:
GOGCTRACE=1 ./my_go_program 2 > log_file
通過這個環境變數可以讓Go程式在每次GC時都輸出資訊,資訊是輸出到標準錯誤的,所以需要用 2> 把輸出重定向到檔案裡。
輸出的內容像這樣:
gc16(8): 34+6+5 ms, 367 -> 365 MB 817253 -> 782045 (18216892-17434847) objects, 64(2182) handoff, 72(22022) steal, 553/244/51 yields
其中gc16表示第16次進行GC,後面的(8)表示由8個執行緒執行,這個執行緒數對應GOMAXPROCS環境變數,34+6+5 ms分別代表一系列GC動作消耗的時間,這三個時間加起來45ms,就是這個程式在這次GC過程中暫停的時間。
後面接著的是記憶體、物件數量等,在GC前後的變化,其中最關鍵的是物件數量,這邊可以看到GC後還有782045個物件存在。
我在實際遊戲服和內網開發測試服都開啟了GOGCTRACE,發現GC暫停時間相差甚大,當時(還未做第一次優化前)外網GC暫停達到400多ms,而內網才20ms。
顯然跟記憶體中資料多少有關係,於是我推測跟記憶體中物件數量關係最大,原因很簡單,假設我是GC開發者,不可能讓一個物件佔用100M記憶體跟一萬個物件佔用100M記憶體同樣消耗效能,顯然那一個佔用100M記憶體的物件,當我發現它不需要回收的話,我就不需要做什麼事情了,而那一萬個物件,我需要逐個檢查是否還有被引用,所以記憶體大小不是關鍵,物件數量才是關鍵。
於是我按這個推測進行了第一次效能優化,我把儲存遊戲記憶體資料的連結串列結構改為slice,當初設計成連結串列是因為資料有插入和刪除,slice可以擴容但是要收縮就比較麻煩了,於是想到了連結串列,連結串列要刪除單個節點的時候,只需要把節點從連結串列上斷開,不需要複製資料,效率高於陣列結構。這裡直觀的表示一下兩種資料結構的區別:
type MyData1 struct {
next *MyData
Id int
Name string
}
var mydata1 *MyData1
type MyData2 struct {
Id int
Name string
}
var mydata2 []MyData2
上面示例程式碼的mydata1用的是連結串列結構,每個節點都有一個指向下一個節點的指標,想像下儲存1萬個物件到mydata1,是不是需要建立1萬個MyData1型別的物件。
示例中的mydata2用的是slice結構,一個slice就是一個物件,其中的元素都是這一塊記憶體中的值,而不是物件,需要注意 []MyData2 和 []*MyData2 是不一樣的,如果換用第二種寫法,那麼每個元素一樣都是一個物件,因為這時候slice存的不是值而是指向物件的指標,而這些指標每一個都分別指到一個物件。
我做了一組不同資料結構跟物件數量關係的實驗,可以直觀的感受區別:github連結
經過這番改造,物件數量少了一個數量級,具體對少物件我已經記不得了,但是可以自己估計一下,一個mydata1這樣的記憶體表,假設平均20條記錄,假設有50個這樣的表,就是1000個物件,換成mydata2這樣的記憶體表,就只要50個物件。
當然這樣一換,記憶體佔用肯定就上去了,但是實際觀測下來,記憶體佔用在可接收範圍,甚至還是遠小於之前我用erlang開發的遊戲,而GC掃描時間從300多ms降到幾十ms,降了一個數量級。
本來優化到此我就打算告一段落了,但是隨著遊戲的持續執行,資料的持續增加,我發現slice自身佔用的物件數量也還是值得動動腦筋消除掉的,線上GC暫停時間最高的伺服器,達到了100ms,如果再漲上去,一樣還是可能達到200ms設定300ms。
所以又繼續懂了一些腦筋,比如把玩家資料壓縮起來,等需要用的時候再解開來用,嘗試過json序列化等等,目的都是把多個物件歸併成一個。
但是這些方案都是犧牲資料訪問的效率為代價的,需要訪問資料時就要反序列化展開資料。
其實在第一次優化時,我大部分時間花在嘗試cgo上面,而不是嘗試slice上,我第一個思路是用cgo申請記憶體,偽造成go的物件,這些物件就不受Go的GC管理裡,也就不會對GC有負擔。但是嘗試下來,總是遇到各種指標異常,我可以確信不是我的指標運算問題,但是為什麼自己申請的記憶體會影響到Go的執行,我一直弄不明白,時間不等人,不可能一直研究下去,所以我才想了slice的這個方案,不是最優解但至少暫時解決問題。
而這一次,因為使用了slice,原先的記憶體資料庫的資料結構就變得很單一,而優化的目的也明顯,減少slice的記憶體消耗。正好那陣子我在嘗試將SpiderMonkey嵌入到Go,接觸到了cgo操作slice的一些技巧,比如將C的陣列對映成Go的slice,或者利用reflect.SliceHeader取得slice所指向的記憶體塊地址,然後用cgo複製資料。
於是我就想到用C來申請slice所需記憶體塊,然後自己構造SliceHeader的辦法。
這裡需要說明下SliceHader和slice之間的關係。
Go提供了一個很有用的資料結構slice,slice比起C時代的數值有很明顯的優勢,有邊界判斷、可以反覆切割、沒有犧牲執行效率,如何做到的呢?官方這片文章有很清楚的說明:點選檢視
簡單說來,Go的slice其實是一個三個欄位的結構體,三個欄位分別存放著slice的當前長度、記憶體塊的大小和實際記憶體塊的地址,每次len(slice)的時候是不需要迴圈計算長度的,只是到結構體裡去一下長度,而重新切割的過程,只是重新構造一個指向同一個記憶體塊或塊中某一位置的過程,所以不會有記憶體拷貝和迴圈等消耗效能的操作。
這個三個欄位的結構體,在Go的反射包裡面使用SliceHeader型別表示,這讓我們的程式有機會構造自己的SliceHeader。
cgo的wiki文件裡有這樣一段示例程式碼,演示如何把C的陣列包裝成Go的slice:
import "C"
import "unsafe"
...
var theCArray *TheCType := C.getTheArray()
length := C.getTheArrayLength()
var theGoSlice []TheCType
sliceHeader := (*reflect.SliceHeader)((unsafe.Pointer(&theGoSlice)))
sliceHeader.Cap = length
sliceHeader.Len = length
sliceHeader.Data = uintptr(unsafe.Pointer(&theCArray[0]))
// now theGoSlice is a normal Go slice backed by the C array
這邊用到了unsafe.Pointer,通過Pointer型別,我們可以在Go的程式裡實現指標運算,之前我有寫過相關文章,這裡就不重複介紹了:點選檢視
於是我將記憶體資料庫用到的slice型別全部換成自己用C偽造的slice,還好當初記憶體資料庫用的是程式碼生成器,否則程式碼就要改死掉了 :)
全部替換完後,我拿外網同樣資料對比,優化前的程式GC掃描時間100多ms,物件數量140萬,優化後的程式GC掃描時間18ms,物件數量16萬。
本來可以就這樣打完收功了,但是生活總是充滿戲劇性,內網測試的時候發現好友列表裡面的名字全亂碼了,肯定跟優化有關係,但為什麼會亂碼呢?
我的推測是go構造的字串物件被C構造的物件引用,這樣的引用導致go把字串物件當成沒人使用,於是就被回收利用了。
我只好把所有字串欄位也全部改為C偽造的物件,原理給偽造slice是一樣的,不同的是字串用StringHeader表示。
經過改造,字串再也不會亂碼了,不過需要很小心的釋放記憶體。
優化過程中Go提供的pprof模組起到了很重要的作用,所有的優化都是以資料為依據的,如果不能看到資料就沒有辦法定位問題。
程式中可以用 pprof.Lookup("heap") 來獲得堆資訊,其中包含了物件數量和GC執行時間等有用的資料。
上次群裡有人問 map[int]XXX 這樣的資料結構是否會有GC問題,正好這個資料結構我之前也考慮過,也在上面的資料結構實驗裡體現了,map[int]XXX 和 map[int]XXX是一樣的,一條資料就是一個物件,對GC是否有影響取決於物件的數量。
從上面的觀測數值來看百來萬的物件數量所造成的暫停應該還不足以影響程式,除非應用場景對實時性要求非常高。
但是對於遊戲這樣的常駐記憶體程式來說,物件的增長速度和物件數量上限也需要留意,比如剛開始物件數量只有幾萬,隨著日子增長,玩家資料增多,物件數量達到百萬千萬,那時候可能就會有影響了。
之前第一次優化過後正好有人在知乎問Go的GC情況,我回了一帖,裡面有比較詳細的第一次優化的資料,大家可以參考一下:點選檢視
不想看長篇大論的,這裡先給個結論,go的gc還不完善但也不算不靠譜,關鍵看怎麼用,儘量不要建立大量物件,也儘量不要頻繁建立物件,這個道理其實在所有帶gc的程式語言也都通用。
想知道如何提前預防和解決問題的,請耐心看下去。
先介紹下我的情況,我們團隊的專案《仙俠道》在7月15號第一次接受玩家測試,這個專案的服務端完全用Go語言開發的,遊戲資料都放在記憶體中由go 管理。
在上線測試後我對程式做了很多調優工作,最初是穩定性優先,所以先解決的是記憶體洩漏問題,主要靠memprof來定位問題,接著是進一步提高效能,主要靠cpuprof和自己做的一些統計資訊來定位問題。
調優效能的過程中我從cpuprof的結果發現發現gc的scanblock呼叫佔用的cpu竟然有40%多,於是我開始搞各種物件重用和儘量避免不必要的物件建立,效果顯著,CPU佔用降到了10%多。
但我還是挺不甘心的,想繼續優化看看。網上找資料時看到GOGCTRACE這個環境變數可以開啟gc除錯資訊的列印,於是我就在內網測試服開啟了,每當go執行gc時就會列印一行資訊,內容是gc執行時間和回收前後的物件數量變化。
我驚奇的發現一次gc要20多毫秒,我們伺服器請求處理時間平均才33微秒,差了一個量級別呢。
於是我開始關心起gc執行時間這個數值,它到底是一個恆定值呢?還是更資料多少有關呢?
我帶著疑問在外網玩家測試的伺服器也開啟了gc追蹤,結果更讓我冒冷汗了,gc執行時間竟然達到300多毫秒。go的gc是固定每兩分鐘執行一次,每次執行都是暫停整個程式的,300多毫秒應該足以導致可感受到的響應延遲。
所以縮短gc執行時間就變得非常必要。從哪裡入手呢?首先,可以推斷gc執行時間跟資料量是相關的,內網資料少外網資料多。其次,gc追蹤資訊把物件數量當成重點資料來輸出,估計掃描是按物件掃描的,所以物件多掃描時間長,物件少掃描時間短。
於是我便開始著手降低物件數量,一開始我嘗試用cgo來解決問題,由c申請和釋放記憶體,這部分c建立的物件就不會被gc掃描了。
但是實踐下來發現cgo會導致原有的記憶體資料操作出些詭異問題,例如一個物件明明初始化了,但還是讀到非預期的資料。另外還會引起go執行時報申請記憶體死鎖的錯誤,我反覆讀了go申請記憶體的程式碼,跟我直接用c的malloc完全都沒關聯,實在是很詭異。
我只好暫時放棄cgo的方案,另外想了個法子。一個玩家有很多資料,如果把非活躍玩家的資料序列化成一個位元組陣列,就等於把多個物件壓縮成了一個,這樣就可以大量減少物件數量。
我按這個思路用快速改了一版程式碼,放到外網實際測試,物件數量從幾百萬降至幾十萬,gc掃描時間降至二十幾微秒。
效果不錯,但是要用玩家資料時要反序列化,這個消耗太大,還需要再想辦法。
於是我索性把記憶體資料都改為結構體和切片存放,之前用的是物件和單向連結串列,所以一條資料就會有一個物件對應,改為結構體和結構體切片,就等於把多個物件資料縮減下來。
結果如預期的一樣,記憶體多消耗了一些,但是物件數量少了一個量級。
其實專案之初我就擔心過這樣的情況,那時候到處問人,物件多了會不會增加gc負擔,導致gc時間過長,結果沒得到答案。
現在我填過這個坑了,可以確定的說,會。大家就不要再往這個坑跳了。
如果go的gc聰明一點,把老物件和新物件區別處理,至少在我這個應用場景可以減少不必要的掃描,如果gc可以非同步進行不暫停程式,我才不在乎那幾百毫秒的執行時間呢。
但是也不能完全怪go不完善,如果一開始我早點知道用GOGCTRACE來觀測,就可以比較早點發現問題從而比較根本的解決問題。但是既然用了,專案也上了,沒辦法大改,只能見招拆招了。
總結以下幾點給打算用go開發專案或已經在用go開發專案的朋友:
1、儘早的用memprof、cpuprof、GCTRACE來觀察程式。
2、關注請求處理時間,特別是開發新功能的時候,有助於發現設計上的問題。
3、儘量避免頻繁建立物件(&abc{}、new(abc{})、make()),在頻繁呼叫的地方可以做物件重用。
4、儘量不要用go管理大量物件,記憶體資料庫可以完全用c實現好通過cgo來呼叫。
手機回覆打字好累,先寫到這裡,後面再來補充案例的資料。
資料補充:
圖1,7月22日的一次cpuprof觀測,取樣3000多次呼叫,資料顯示scanblock吃了43.3%的cpu。
圖2,7月23日,對修改後的程式做cpuprof,取樣1萬多次呼叫,資料顯示cpu佔用降至9.8%
資料1,外網伺服器的第一次gc trace結果,資料顯示gc執行時間有400多ms,回收後物件數量1659922個:
gc13(1): 308+92+1 ms , 156 -> 107 MB 3339834 -> 1659922 (12850245-11190323) objects, 0(0) handoff, 0(0) steal, 0/0/0 yields
資料2,程式做了優化後的外網伺服器gc trace結果,資料顯示gc執行時間30多ms,回收後物件數量126097個:
gc14(6): 16+15+1 ms, 75 -> 37 MB 1409074 -> 126097 (10335326-10209229) objects, 45(1913) handoff, 34(4823) steal, 455/283/52 yields
示例1,資料結構的重構過程:
最初的資料結構類似這樣
// 玩家資料表的集合 type tables struct { tableA *tableA tableB *tableB tableC *tableC // ...... 此處省略一大堆表 } // 每個玩家只會有一條tableA記錄 type tableA struct { fieldA int fieldB string } // 每個玩家有多條tableB記錄 type tableB struct { xxoo int ooxx int next *tableB // 指向下一條記錄 } // 每個玩家只有一條tableC記錄 type tableC struct { id int value int64 }
最初的設計會導致每個玩家有一個tables物件,每個tables物件裡面有一堆類似tableA和tableC這樣的一對一的資料,也有一堆類似tableB這樣的一對多的資料。
假設有1萬個玩家,每個玩家都有一條tableA和一條tableC的資料,又各有10條tableB的資料,那麼將總的產生1w (tables) + 1w (tableA) + 1w (tableC) + 10w (tableB)的物件。
而實際專案中,表數量會有大幾十,一對多和一對一的表參半,物件數量隨玩家數量的增長倍數顯而易見。
為什麼一開始這樣設計?
1、因為有的表可能沒有記錄,用物件的形式可以用 == nil 來判斷是否有記錄
2、一對多的表可以動態增加和刪除記錄,所以設計成連結串列
3、省記憶體,沒資料就是沒資料,有資料才有物件
改造後的設計:
// 玩家資料表的集合 type tables struct { tableA tableA tableB []tableB tableC tableC // ...... 此處省略一大堆表 } // 每個玩家只會有一條tableA記錄 type tableA struct { _is_nil bool fieldA int fieldB string } // 每個玩家有多條tableB記錄 type tableB struct { _is_nil bool xxoo int ooxx int } // 每個玩家只有一條tableC記錄 type tableC struct { _is_nil bool id int value int64 }
一對一表用結構體,一對多表用slice,每個表都加一個_is_nil的欄位,用來表示當前的資料是否是有用的資料。
這樣修改的結果就是,一萬個玩家,產生的物件總量是 1w (tables) + 1w ([]tablesB),跟之前的設計差別很明顯。
但是slice不會收縮,而結構體則是一開始就佔了記憶體,所以修改後會導致記憶體消耗增大。
參考連結:
go的gc程式碼,scanblock等函式都在裡面:
http://golang.org/src/pkg/runtime/mgc0.c
go的runtime包文件有對GOGCTRACE等關鍵的幾個環境變數做說明:
http://golang.org/pkg/runtime/
如何使用cpuprof和memprof,請看《Profiling Go Programs》:
http://blog.golang.org/profiling-go-programs
我做的一些小試驗程式碼,優化都是基於這些試驗的資料的,可以參考下:
https://github.com/realint/labs/tree/master/src