morestack 與 goroutine pool
Go 語言的 goroutine 初始棧大小隻有 2K,如果運行過程中調用鏈比較長,超過的這個大小的時候,棧會自動地擴張。這個時候會調用到一個函數runtime.morestack
。開一個 goroutine 本身開銷非常小,但是調用 morestack 進行擴棧的開銷是比較大的。想想,如果函數的棧擴張了,有人引用原棧上的對象怎麼辦?所以 morestack 的時候,裏面的對象都是需要調整位置的,指針都要重定位到新的棧。棧越大,涉及到需要調整的對象越多,調用 morestack 的時候開銷也越大。
我們可以寫一個簡單的 bench,這個函數遞歸調用是比較消耗棧空間的:
func f(n int) {
var useStack [100]byte
if n == 0 {
return
}
_ = useStack[3]
f(n - 1)
}
下面是對比測試:
func bench1() {
var wg sync.WaitGroup
for i := 0; i < benchCount; i++ {
wg.Add(1)
go func() {
for j := 0; j < 15; j++ {
f(2)
}
wg.Done()
}()
wg.Wait()
}
}
func bench2() {
var wg sync.WaitGroup
for i := 0; i < benchCount; i++ {
wg.Add(1)
go func() {
f(30)
wg.Done()
}()
wg.Wait()
}
}
兩者工作量是一樣的,但 bench1 不會觸發runtime.morestack
,bench2 會觸發,可以看到結果相差了一個數量級:
bench1 used: 52480486 ns
bench2 used: 559074503 ns
我們的項目裏有發現這個問題,morestack 的 CPU 時間幾乎佔到了 10%。隔壁友商 [cockroach 也發現這個問題]https://github.com/golang/go/issues/18138。怎麼解決呢?
有兩個方向,一個是初始分配更大的初始棧,比如起 goroutine 後,先調用下面這個函數,將棧擴到 8K:
// reserveStack reserves 8KB memory on the stack to avoid runtime.morestack.
func reserveStack(dummy bool) {
var buf [8 << 10]byte
// avoid compiler optimize the buf out.
if dummy {
for i := range buf {
buf[i] = byte(i)
}
}
}
提前預留棧空間的方式,相比程序跑到後面棧不夠了擴棧,開銷低一些。cockroach 是這個做法。實測時我發現 morestak 的開銷並沒有被消除,而是轉移了。
所以我要說的是另一個方向,goroutine pool。
其實 goroutine 這麼輕量的東西,其實本身做池意義並不大,隨用隨開,用完就扔,挺好的。然而在觸發 morestack 的情況下,這個開銷就有點高,在火焰圖上是可以抓到的 (go pprof 不那麼敏感)。採用 pool 之後,如果 goroutine 被擴棧了,再還到 pool 裏面,下次拿出來時是一個已擴棧過的 goroutine,因此可以避免 morestack。
接下來說說這個 goroutine pool 該怎麼寫。
我希望接口是這樣的:
pool = New() // 創建pool
pool.Go(func() {
// do something
})
跟調用
go func() {
}()
效果是一模一樣的,只不過pool.Go
執行閉包函數以後,goroutine 不是退出,而是放回到池子中,供下次再調用。
爲此,我們要將 goroutine 抽象成一種資源,
func (pool *Pool) Go(f func()) {
res := pool.get()
res.run(f)
// pool.put(res) 這裏還不能歸還,後面講爲什麼
}
這個資源比較特殊,它是由一個 channel 和一個後臺 goroutine 組成:
type res struct {
ch chan
pool *Pool
}
go func(r *res) {
for work := r.ch {
work()
r.pool.put(res)
}
}
run 只需要往 channel 裏投餵,後臺的 goroutine 拿到 work 後就會執行它。
func (r *res) run(f) {
r.ch <- f
}
這裏有個細節,執行完以後歸還到 goroutine pool,要留下 work 執行完以後做,因爲res.run(f)
是非阻塞的。
池子的實現是比較容易的,用鏈表就可以了,把 res 串起來,首尾結點,尾進頭出。不過要注意線程安全,想優化可以把首尾結點的訪問分開加鎖,甚至用無鎖隊形來實現。
注意到,我沒有爲 pool 設計 Close 接口,爲什麼?這是有意爲之的。那麼,池子裏的 goroutine 什麼時候釋放呢?設計成過一段時間不用自動回收。
從經驗上看,只要涉及重用 goroutine 的代碼,都有很大概率發生泄漏問題,尤其是調用 close 以及 close 的實現。分配出去的資源,它是屬於池子呢,還是不屬於池子呢?關閉的池子時候是等資源還回來呢?還是不等呢?如果歸還資源的時候,池子已經關閉了呢?關閉的瞬間,跟正在讀寫池子的請求,如何處理好加鎖呢?所以這是一個設計上的問題。
每次使用都打上最後使用的時間,多起一個回收的 goroutine 定期的掃描池子內的 goroutine,如果很久沒用過,就回收掉。這個回收的 goroutine 如果發現池子裏一個 goroutine 都沒了,那它自己也退出,非常乾淨,完全不會有泄漏。退出前要加個標記,如果池子又被使用了,重新創建回收 goroutine 起來工作。
完整的代碼,可以看看 [這個 PR] https://github.com/pingcap/tidb/pull/3752/files 裏面。
轉自:tiancaiamao
鏈接:https://www.zenlife.tk/goroutine-pool.md
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/Z8fo3g4HVEmQf3KlR6K7hw