這是一個建立於 的文章,其中的資訊可能已經有所發展或是發生改變。
不想看長篇大論的,這裡先給個結論,go的gc還不完善但也不算不靠譜,關鍵看怎麼用,盡量不要建立大量對象,也盡量不要頻繁建立對象,這個道理其實在所有帶gc的程式設計語言也都通用。
想知道如何提前預防和解決問題的,請耐心看下去。
我們項目的服務端完全用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