原因
目前 Go 的 GC 雖然高效,但是也是有代價的。
對於一些會有大量堆對象生成的場景,GC 相關的內存和CPU資源佔用,會導致服務吞吐量和相應速度受到影響。
因此需要一個效率更高且安全的內存管理機制,應對內存(GC)密集型的需求場景。
這也是個人長期以來對於 Go 的一個特別關注點。之前見過一些基於 mmap 系統內存自己管理的方案,但是很遺憾,這些方案看起來都很難真正的在項目中使用(接口複雜,拋棄了 Go 原生數據結構,有併發問題,容易導致panic等)。
22年初開始,一個叫 Go arena 的提議 引起了社區裏的關注。
此提議下面的討論豐富且精彩,寫一篇博文來記錄一下從中學習到的內容。
本文編寫時間
2023年1月末,目前狀態:
- Go 1.20(尚未發佈) 裏面已經有一個比較完善的 arena 實現。
- 可以通過
GOEXPERIMENT="arenas"環境變量開啓此項功能。 - (不知道是否和激烈的討論有關)在 Go 1.20 的 release notes 裏面不會提及此項 EXPERIMENT。
什麼是 arena
Go values can be manually allocated and freed in bulk.
Go arena: Go 1.20 新增的試驗型的特性,用於手工批量申請和釋放值(values)的內置庫。
它有幾個基本特點:
- 使用 Go 1.18 增加的泛型接口,分配基礎原生類型和 slice。
- 不支持併發安全。
- 分配的值參加 GC。
為何 arena 會高效
- 一次申請,連續分配,一次釋放。
- 更快的回收(重用)內存。
-
可能降低 GC 頻率。
- 如果在 GC 週期之前 free,可能會推遲 GC 週期的執行
- 可以通過 GOGC 參數的原理了解。
-
減少 mark-sweep 開銷 (錯誤:實際上也參與 GC)。
堆內存管理的本質
(棧上的值由編譯器管理,略)
我個人的理解:
程序從堆上獲取了內存,確沒有在使用完畢,或者説生命週期結束的時候歸還給系統。
和別家 Go 分配器相比的優勢
- 原生類別支持更好
-
完整的GC支持:
- 一個堆上的對象,如果唯一指向它的指針是在 arena 中的話,它也==不會==被 GC 掉。
- 外部指向 arena 分配的對象不會變為錯誤數據。(但是會 panic,且並不保證)
什麼是內存安全(memory safe)
比較好解釋的是,什麼是內存不安全:
- memory corruption
個人理解:一個對象指向或者引用的數據,被另一個處程序篡改寫入(並非故意)。 - fault address crash
個人理解:某一個指針指向的值的內存地址實際上已經無效。指針地址運算和缺乏越界檢查的語言會比較容易出現此問題。
關於內存泄露:我的理解是,程序失去或者遺忘了系統申請的內存的控制權,導致這塊無法被控制的內存得不到應有的釋放。
在 Go 程序裏,不太會出現因為作用域而導致失去控制權的情況(因為會被GC處理)。
如果不出動 unsafe 包裏的代碼,Go 基本上來説是內存安全的。
Go 的內存安全
根據arena討論,我學到一種新穎的説法:
Go 的指針,幾乎只有兩種狀態:
- 指向一塊合法的內存
nil
因此我們判斷指針的時候,只要不是 nil 就能夠很有信心的使用它!
if ptr != nil { v := *ptr }
一定不會 panic (但是data-race會導致錯誤的數據)。
但是,sync.Pool 會帶來 memory corruption。
Arena 會帶來 panic。
GC 能夠帶來什麼
- 從繁瑣的對象生命週期管理中解放出來
- 減少一些人工寫的生命週期管理代碼的錯誤
不能帶來什麼:
- 併發導致的 data race
- memory leak
- memory corruption (因為
unsafe和sync.Pool)
GC 是無可替代的麼?
內存管理技術至少有這些:
- 手工管理
- GC
- 類似 borrow checker
(我的理解:通過借用所有權,讓動態分配對象和生命週期和代碼中的一處對象綁定,通過對這個對象的自動的來回收內存?)
實際使用起來的爭論
在我看來,下面的幾項可能是 arena 面臨的最大問題,而且大部分問題目前看起來沒有太好的方案。
##### 1. 是否是侵入性的接口,為何不能用庫實現
首先確認無法用第三方庫實現(因為和 GC 相關)。
其次,基於目前的實現,接口一定是侵入性且具有傳染性的。
比如,我需要構建一個較為複雜的結構體,在構建的過程中會調用其他函數(甚至是第三方庫的接口)來構建某些成員變量。那麼相關的接口會被 arena 污染。
func newTObj(params...) *T {
t := T{a:1}
t.b = newObjB(params[2])
xx.UpdateC(t, params[3])
return &t
}
func newObjB(int) int
// package xx
func NewC(*T, int) int
如果在 newTObj 中使用了 arena,那麼它調用的接口可能就變成了:
func newObjB(*arena, int) int
// package xx
func NewC(*arena, *T, int) int
即便引入 option 可選參數,帶來的痛苦也會是巨大的。
##### 2. 是否會帶來使用者的心智負擔:arena 分配出的對象被其他地方保存(persist)。
我個人認為會有很大的負擔。但是,這裏有一個爭論點:即在 Go 裏面保留其他函數分配出的對象是否是正確的。這個説法估計可以吵幾天(特別是引入 sync.Pool 之後),這裏我保留觀點。
##### 3. 是否會導致大量panic,如何防止上下游的老代碼不會panic。
我認為會帶來較多panic,因為沒有人會在意一個對象是否被保存(或者逃逸)到其他地方去。
##### 4. 使用的第三發庫怎樣利用這個分配器(考慮到大量的內存實際上是類似 protobuf/json 庫分配出的)
沒有看到較好的解決方案
##### 5. panic的問題(見下文)
一個大問題: use-after-free 的行為一致性
func use_after_free() []byte {
a := arena.NewArena()
v := arena.MakeSlice[byte](a, 8, 8)
a.Free()
return v
}
請問,使用 use_after_free 返回的 slice 時:
- 究竟會不會 panic?
- 什麼時候 panic?
- 是否讓確保立刻 panic 會更好?
- (妥協方案)是否應當增加一種類似
-race的模式,在此模式下所有的「不當的使用」能夠被更加快速和直觀的偵查到?
其他 arena 參考信息
- C++ 裏面類似的技術常被使用,Java 20 也加了 arenas。
- 相比於
sync.Pool,arena 能夠:(1). 分配不同類型 (2). 無GC (3). 快速分配和回收 -
從系統獲取一大塊內存自己管理處理。
- 問題:原生數據兼容性
- 作用域問題
- 和GC的衝突
- 併發問題
實現相關的一些細節
- arena 地址空間和堆內存地址空間是完全分開的。意味着在做GC掃描的時候能夠很輕易的區分堆上的對象和 arena 對象。
- Free 之後的 chunk 會立刻回收其物理內存,但是不會立刻被重用,當 GC 掃描到所有其分配對象都不被引用的時候,才會被重用。
- 因為有 GC 的存在,所以 arena 可能在用户顯式調用 Free 之前就已經被 runtime Free 了。
其他
此 proposal 是 Google 內部人員發起的。意味着他們會遇到 GC 的問題麼,讓我們來無責任猜想一下它的前因後果:
- 他們內部有大量的提供服務的程序,主要使用是 gRPC。
- 對於每一個獨立的請求處理,gRPC 框架的對於客户端發來的負責protobuf struct 的解包操作產生了大量的堆內存分配。
- 繁忙服務裏的堆內存導致較高的 GC 開銷,影響了業務吞吐量。
很自然的想法:既然每個 gRPC Call 都是獨立的,從最外層的請求開始,整個處理鏈路使用到的堆上的值,會在請求結束時候統一的結束生命週期。那麼,如果我們讓這個處理鏈路所有的值都從自己的一個 slab 分配器上產生,並且在請求結束的時候一次性回收,豈不是在分配效率,回收效率和存活堆內存三個方面都有極大的改善?
於是有了 Go arena。