前言
在benchmarkgame(世界上最火的性能對比網站)上,Go語言一直有一個槽點,就是極其慢的binary tree性能,執行用時40秒 (我的機器上,16秒),與此對比,Java版本是6秒,那麼問題來了:爲什麼慢得令人髮指?我們來深入研究下慢的原因,然後看看能否對其進行改進。
對於binary tree算法中,最耗性能的地方就是海量的node分配和bottomUpTree()遞歸函數的調用,與這兩項對應的go的特性就是gc的goroutine的堆棧分配。
GC
這個世界沒有完美的GC,任何選擇都有代價,一般來說就是低延遲和高吞吐的權衡。
問題描述
Java採用的是分代GC,優點是node的分配非常輕量,缺點就是分代gc需要使用更多的內存空間,同時當對象被移動到tenured堆時,會發生大量的內存拷貝。
Go的gc不是分代的,因此在node的分配上需要消耗更多的資源。Go的gc選擇了超低的延遲,同時犧牲了部分吞吐,對於絕大多數應用來說,Go的選擇是非常正確的,但是對於binary tree這種算法來說,就不是很適合了。
解決方案
針對GC的優化,有兩個通用的解決方案就是提前分配合適的堆棧空間和對象複用。對於binary tree算法,就是Nodes的預先分配和複用。
Goroutine的堆棧
輕量的Goroutine是Go語言的靈魂所在
問題描述
爲了讓goroutine儘可能輕量,go僅僅爲每個goroutine分配了2KB的初始堆棧大小,在之後Go會根據需要動態的擴展堆棧大小。同樣,對於絕大多數場景,這種選擇都是非常正確的,但是針對binary tree算法,這種選擇就有了一些問題。
Go是在每次函數調用之前檢查goroutine的堆棧大小,如果發現當前堆棧不夠用,就會重新分配一個新的堆棧空間,然後將舊的堆棧拷貝到新的裏。這種操作開銷是很小的,但是在binary tree中,bottomUpTree()基本上不做什麼工作,調用卻是極其頻繁,這樣一來再小的開銷累積起來也會非常可觀。而且這個函數的調用是深遞歸,當堆棧需要增長時,可能會拷貝幾次,不僅僅是一次!
解決方案
將bottomUpTree()改爲非遞歸的函數,雖然不易實現,但是還是可以做到的。
新舊Binary tree實現對比
沒有對比,就沒有傷害!
運行用時:
> time go run old.go 20
stretch tree of depth 21 check: -1
2097152 trees of depth 4 check: -2097152
524288 trees of depth 6 check: -524288
131072 trees of depth 8 check: -131072
32768 trees of depth 10 check: -32768
8192 trees of depth 12 check: -8192
2048 trees of depth 14 check: -2048
512 trees of depth 16 check: -512
128 trees of depth 18 check: -128
32 trees of depth 20 check: -32
long lived tree of depth 20 check: -1
real 0m16.279s
user 1m47.569s
sys 0m2.663s
運行用時
time go run new.go 20
stretch tree of depth 21 check: -1
2097152 trees of depth 4 check: -2097152
524288 trees of depth 6 check: -524288
131072 trees of depth 8 check: -131072
32768 trees of depth 10 check: -32768
8192 trees of depth 12 check: -8192
2048 trees of depth 14 check: -2048
512 trees of depth 16 check: -512
128 trees of depth 18 check: -128
32 trees of depth 20 check: -32
long lived tree of depth 20 check: -1
dur: 1.71074946s
real 0m1.914s
user 0m10.149s
sys 0m0.157s
結論
性能從16.28秒提升到了1.91秒,提升巨大!
這裏提出的解決方案看似是針對binary tree,其實對於任何GC語言和使用場景來說都是通用的。
牢記這兩種解決方案吧:
- 內存空間預分配
- 對象複用