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