如何缓解Golang大型游戏服务器的GC压力
背景
Golang的垃圾回收器使用的是并行三色標記回收算法。該算法對比分代算法的最大問題就是,無法區分年輕代和老年代對象,如果老年代對象非常多的話,新生代對象的回收效率就會下降。如果程序沒有減慢對象分配速度的話,golang為了避免出現內存不足,會強制降低相關協程的執行速度,擠出cpu幫助垃圾回收。結果就是程序出現延遲和卡頓,尤其業務頻繁的時候。這對游戲玩家來說是非常糟糕的體驗。
有沒有辦法解決這個問題呢?當然有,思路很顯然,對象太多就減少對象分配,回收慢就提高回收效率。但是具體怎么操作,又要保證安全,又要保證有可操作性,就很難了。
分析
減少對象分配,一個是少創建對象,邏輯層面可以合并一些對象,用值代替指針,都是需要調整邏輯結構的微觀層面的操作方法,有效但是杯水車薪。
重用對象也能減少分配,首當其沖的辦法就是對象池。對象池簡單實用,但是缺點就是太簡陋了。分配的對象類型固定,還要使用者手動放回,而且保證不再操作已放回的對象。這對對象類型非常繁多,關系非常復雜的程序來說,可操作性不高。而且如果沒有gc兜底,對象泄露了也很難捕捉。對于某些大型對象,關系簡單的情況,效果很好。
提高回收效率的話,golang本身做了很多底層回收優化,留給應用層的空間不大。針對老生代對象問題,如果能夠讓GC跳過這些對象的掃描,必然節約很大的開銷。可惜golang沒有直接提供對應的支持,唯一發現的就是go:notinheap標記,可惜主要作用是優化寫屏障,卡的很死。
還有一個大殺器CGO,CGO調用C運行庫跳過GC分配內存,也跳過了GC掃描,非常好的思路。但是C內存對象在go里面使用限制太多,像map這種常用數據接口根本沒有對應辦法實現,因為不能操作符重載和泛型,結果就是使用起來太復雜了。所有的數據操作方式都要走C的風格,像是回到了遠古時代,簡直是對高級語言的侮辱。
因為實際情況是,對象數量確實是減少不了多少的,剩下唯一的辦法只有減少GC或者不GC。其實CGO如果可行的話,對象被搬運到C,既能減少go這邊的大量對象分配,也就減少了GC的壓力,非常完美??上Р钜稽c就達成。如果哪天CGO可以像微軟的C++/CLI一樣流暢的跨語言操作.NET,就牛逼了。這點還是不得不佩服微軟的技術實力。當然.NET設計之初就是為了跨語言而生的,情況有點不一樣。扯遠了。
回頭來說,有沒有辦法用go實現一個GC透明的分配器呢。
答案是肯定的。
但是,需要考慮幾個問題,是池子加強版嗎?那本質還是池子啊!肯定不能是池子,我們要的是一個真正的分配器。他能自己管理內存,繞過GC的掃描,能從內存中任意分配對象,這才能滿足我們的需求。
思路
終于知道我們需要什么了!為了不重復造輪子,先看看有沒有現成的成熟方案吧。
- https://github.com/shoenig/offheap 太簡陋了,基本等于啥都沒有
- offheap package - github.com/glycerine/offheap - pkg.go.dev 這個好像功能很多,但是好像主要是優化hash表,而且還是要手動釋放,接口使用起來有點繁瑣,對老代碼改動太大了。
- https://github.com/cachelot/cachelot好像主要是像cache一樣使用呢,也是要大改代碼
沒有一個符合我們需求的輪子呢,自己擼一個可行嗎?那就試試吧。
純CGO的實現還是算了,用起來麻煩,調試也麻煩,編譯還賊慢,直接pass。既然選擇golang就是為了方便啊。那就直接用golang實現吧,還能利用golang的高級功能呢!
內存不就是byte數組嗎?這個簡單。怎么把一個地址轉成對象呢?unsafe指針強轉貌似可以,reflect.NewAt也可以,這個NewAt不就是簡化版C++的placement new嗎?愛了。
分配的對象怎么回收呢?一個個手動放回嗎?那還是洗洗睡吧。干嘛要放回呢?我們是老生代對象,可以永遠不回收的。配置數據就是全局數據,加載好了永遠都不用釋放。那就好處理了。
還有一堆新生代對象呢?好多臨時對象,比如發到網絡上的包,發完了就廢棄了。簡單,我直接把整個分配器一起扔了不就可以了,nice。
所以大概的思路就出來了,我就一直分配對象不回收,最后整個分配器一起丟掉。中間只有分配器占用了一個byte數組對象需要GC掃描,但是因為Go認為數組里面沒有指針,所以就只標記數組頭為可達,掃描就完成了。目標達成!
注:為什么byte數組化分出來的對象也是被指針引用,GC就不掃描呢,因為GO的GC是根據GO分配對象當時的對象信息來確定要不要掃描的,細節請看go的垃圾回收文檔。雖然有指針引用我們分配的對象,但是從這個指針指向的地址去找heap中對應的內存塊的時候,找到的是byte數組,他的對象信息里面是沒有子對象的,因此此處的掃描結束。
實現
這一直分配的方式,熟悉的人一下就發現是線性分配器。搞C++的游戲開發不會不知道這些。在C++里面定制分配器簡直就是家常便飯。終于發現C++搞多了還是有很多好處的。
文章有點長了,長話短說不賣關子了。要注意的幾點:
- 線性分配器分配的對象對GC是透明的,那就不能把原生分配器分配的對象掛到上面,會被GC認為是無人引用的對象回收掉,所以需要能自動識別這種情況。方法就是分配器分配的對象保存下來,通過反射挨個遞歸地從每個對象遍歷子對象,檢查是否有不是分配器分配的內存。(用golang來實現的一個好處就是能用golang的反射,COG的方案就沒有這個好處)
- 不能在分配器銷毀后,繼續使用分配器分配的對象。C++的內存診斷工具的做法是把對應的內存頁設為不可讀,讀和寫會觸發硬件中斷。不過這個實現起來有點復雜,而且內存開銷非常大。簡單點的方法,分配器返回id,使用的時候再去取對象,這個需要改變對象的使用方式,對老代碼很不友好。其他簡單實用的方法目前沒有找到,如果在宏觀層面能保證的話,問題也不大。
- 分配器不能分配失敗。簡單,buffer不夠了再加就行了,然后用新buffer繼續分配。絕對不能使用原生的分配器分配對象再掛到分配器上,因為掛載的過程不一定是gc原子性的(我發明的詞匯,參考GC safe-point),坑跟data race一樣隱蔽(不過data race有detector而已)。
- 分配器也可以重用,用完reset丟到池子里面,取出來又是一條好漢。免得頻繁創建分配器,雖然大部分時候go的原生分配器很快,但是高峰的時候就不一定了。(而且有golang的gc兜底,忘了放回去也不會有大問題。COG的方案就沒有這個好處)
大的問題都解決了,其他就是小細節了,可以參看具體實現:
GitHub - crazybie/linear_ac: Speed up the memory allocation and improve the garbage collection performance.
倉庫還在測試階段不一定穩定。等上線了再反饋優化一波。但是請放心,上線的項目完全夠壓力。
好了,該睡覺了。仿佛夢見CPU再也不飚了,游戲再也不延遲了,完美。
參考:
- Go內存分配
- CGO優化內存分配
- notinheap介紹
- The GC Pacer
- GitHub - crazybie/linear_ac: Speed up the memory allocation and improve the garbage collection performance.
總結
以上是生活随笔為你收集整理的如何缓解Golang大型游戏服务器的GC压力的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 点云数据集开源大汇总
- 下一篇: go post 参数_用 Go 编写能存