一個調度系統的開發與性能優化

原文作者:fiisio 來源:知乎

背景:隨着Go的不斷髮展,流行度越來越高,業界對Go的認可度也越來越高,所以很多團隊或者公司在遇到性能問題時都會嘗試使用Go來重構系統,尤其是雲計算領域,大家期望能夠通過語言棧來解決遇到的性能問題,該系統即是此類典型的案例。你可以把該系統認爲就是kubernetes中的scheduler模塊,不過更加複雜一些,增加了更多的預選複雜屬性以及一些我們自己的優化。

毫無疑問的是,語言棧只可以解決基本的編碼問題,例如開發週期與質量的平衡,語言本身的自帶的高併發屬性等等,但是毋庸置疑的是語言的選擇只是解決性能問題的第一步,對此我是比較贊同的;重構和優化系統的絕大部分是對於系統邏輯設計的重新思考和編碼細節的細緻優化,一般看來,系統的架構和邏輯設計決定了系統性能的基本走勢,編碼以及算法的優化只是在此基礎上的性能提升,猶如站在巨人的肩上,巨人的高低基本決定了我們系統最終的性能高度。

OK,回到我們的話題,這個類kube-scheduler的調度系統經過近一個月的開發,包括代碼重構,代碼規範,開發新的功能等等不斷的Coding和修改,總算基本完成了,接着便面臨系統優化相關的一系列問題,例如CPU和內存佔用情況,初始化速度,響應速度,批量處理的性能以及Go的GC時間和goroutine執行情況等等很多指標。更少的資源,達到更高的性能將是優化的主題,所以一方面關注資源,另一方面關注性能,兩者既矛盾又相輔相成。這裏不會對調度系統本身和雲計算的東西過多介紹。

一、初始化優化

在開始的時候,我發現啓動該系統,然後直接去請求服務要等36s左右才返回結果,我得到的初始化火焰圖如下:

圖一

熟悉火焰圖的同學可以一眼看到,中間很長的candidate.(*HostBuilder).init是我們需要關注的,它佔據了近65%的CPU時間,我們等待系統初始化的時間應該大部分都耗在這裏,說明這個地方是我們的一個瓶頸,給我們的優化指明瞭方向。

我看到了下列的代碼(示例代碼):

setFuncs := []func(){
    func() { b.set1(dbCache, errMessageChannel) },
    func() { b.set2(syncCache, ids, errMessageChannel) },
    func() { b.set3(syncCache, ids, errMessageChannel) },
    func() { b.set4(ids, errMessageChannel) },
    func() { b.set5(ids, errMessageChannel) },
    func() { b.set6(ids, errMessageChannel) },
    func() { b.set7(ids, errMessageChannel) },
    func() { b.set8(errMessageChannel) },
    func() { b.set9(ids, errMessageChannel) },
    func() { b.set10(errMessageChannel) },
    func() { b.set11(ids, errMessageChannel) },
    func() { b.set12(errMessageChannel) },}

可以看到在setFuncs中有12個函數分別是給初始化數據set屬性的值,每一個函數都有一定的計算量,而且我們是安裝上面的順序依次執行的。ok,所以我們可不可以併發執行呢?這樣寫本是因爲要獲取錯誤信息,要依次將錯誤信息append,所以首先我們如何解決這個問題呢?當然是channel,所以,我們完全可以使用sync.WaitGroup將該部分併發執行直到全部都結束。

你可以自己封裝它:

type WaitGroupWrapper struct {
  gosync.WaitGroup}func (w *WaitGroupWrapper) Wrap(cb func()) {
  w.Add(1)
  go func() {
    cb()
    w.Done()
  }()}

這樣你就可以方面的使用,不用擔心是不是少Add或者Done

for _, f := range setFuncs {
    wg.Wrap(f)
  }
  wg.Wait()

效果很明顯,初始化速度降至10s左右,和之前Python版相比也算低了一個數量級。基本可接受(後面該性能還會進一步優化)

圖2

二、減少無效計算

熟悉kubernetes的同學應該知道在scheduler中有預選和優選兩個階段,在預選階段有一系列的Filter,一臺宿主機如果有不滿足的指標那麼這臺宿主機將會被排除,在優選階段不會在列。

從火焰圖我們可以看到filter佔據的CPU時間較多

圖3

最開始的時候我通過併發的執行所有的filter來提高處理速度,但是收效甚微。後來想能不能在出現不符合的Filter時後面的Filter不再執行?由於我們有好幾萬臺宿主機,可以想象應該可以節省不少的計算量,但這會帶來一個問題,即在失敗時只能知道哪一個Filter不符合,不能知道後面的是否符合。這是一個取捨的問題。經過和同事商量,決定還是捨去。經過測試發現處理速度有了顯著的提高。一次調度從260ms降低到150ms左右。

我們通過火焰圖可以看出效果:

圖4

無獨有偶,在Kubernetes社區上你也可以看到有人提出類似的優化Issue,這一點說明不僅僅我們是這樣想的,在針對此類問題業界也在尋求更加優秀的調度算法,在和scheduler的owner溝通過程中,他也意識到這樣的優化是很多場景會用到的,同時覺得可以通過一個配置項開啓這個功能,所以我針對此問題給Kubernetes的原生scheduler提了一個PR,爲Kubernetes也增加該項優化,據我們初步測試性能可提升40%左右,後期還會有對此的優化,這裏不做詳細介紹了。

三、關於Go的棧擴張問題

雖然一直對Go很是推崇,自己也在一直研究它,但是很客觀地說Go本身還是有很多地方不足,最直接的問題是你會碰到一些坑。細心的同學應該已經看到圖4的火焰圖最右邊的morestack

這裏要說下Go的棧,我們都知道Go語言中的goroutine初始棧大小隻有2K,但如果運行過程中調用鏈比較長(例如在使用遞歸算法時或者項目很龐大導致調用鏈很長),超過的這個大小的時候,棧會自動地擴張,這個時候就會調用到函數runtime.morestack,也就是我們上面看到的。當然開一個goroutine本身開銷非常小,但是調用morestack進行擴棧的開銷是比較大的。因爲牽涉到棧上對象以及引用的問題,所以在morestack的時候,裏面的對象都是需要調整位置的,指針都要重定位到新的棧。棧越大,調用鏈越長,涉及到需要調整的對象也就越多,所以可以看到調用morestack的時候開銷也越大。

我們的項目裏就發現這個問題,morestack的CPU時間幾乎佔到了12%,這是我們無法忍受的。針對該問題看了好久源碼,解決方案一般有兩種,一種是初始化時就將棧擴展,在社區中看到有人提出將這個大小默認擴展到32K,但我不是很看好這個方案,這會導致所有的goroutine都變大,反而喪失了goroutine本身輕便的特性。

結合業界對該問題的其他解決方案,這裏我們採用協程池的方案,其實goroutine這麼輕量的東西,其實本身做池意義並不大,隨用隨開,用完就扔,完全沒有什麼負擔。採用goroutine pool之後,如果goroutine被擴棧了,再還到pool裏面,下次拿出來時是一個已擴棧過的goroutine,因此可以減少morestack。TiDB中針對此問題有個很不錯的PR。

四、ioutil.ReadAll的坑

在pprof觀察heap增長的時候發現一個處理http response.Body體的邏輯導致heap不斷增長,棧如下:

1: 67108864 [2: 134217728] @ 0x49b4a7 0x49b3e1 0x57887c 0x57895e 0x137dbbb 0x138035d 0x138029f 0x138cc6a 0x138e1c0 0x1341c8b 0x139073c 0x1341526 0x1340e2c 0x1341f7a 0x7fb3fe 0x7fb0cd 0x7faffd 0x45c291
# 0x49b4a6  bytes.makeSlice+0x76                      /usr/local/Cellar/go/1.9.2/libexec/src/bytes/buffer.go:231
# 0x49b3e0  bytes.(*Buffer).ReadFrom+0x290                    /usr/local/Cellar/go/1.9.2/libexec/src/bytes/buffer.go:203
# 0x57887b  io/ioutil.readAll+0x12b                     /usr/local/Cellar/go/1.9.2/libexec/src/io/ioutil/ioutil.go:33
# 0x57895d  io/ioutil.ReadAll+0x3d                      /usr/local/Cellar/go/1.9.2/libexec/src/io/ioutil/ioutil.go:42

這是抓取的pprof中的heap信息,在程序運行過程中一直在增加,可以看出大部分內存開銷集中在 bytes.makeSlice 和 ioutil.ReadAll上面,我們的程序也確實有調用 ioutil.ReadAll().

respData, err := ioutil.ReadAll(resp.Body)  
    err = json.NewDecoder(resp.Body).Decode(&reqData.RespValue)
    if err != nil {
        err = errors.Newf(err, "failed reading the response body")  
        return    
    }
    
    if len(respData) > 0 {    
      if reqData.RespValue != nil {   
          err = json.Unmarshal(respData, &reqData.RespValue)    
          if err != nil {   
              err = errors.Newf(err, "failed unmarshaling the response body: %s", respData)   
          }   
      }
    }
// readAll reads from r until an error or EOF and returns the data it read// from the internal buffer allocated with a specified capacity.func readAll(r io.Reader, capacity int64) (b []byte, err error) {
  var buf bytes.Buffer
  // If the buffer overflows, we will get bytes.ErrTooLarge.
  // Return that as an error. Any other panic remains.
  defer func() {
    e := recover()
    if e == nil {
      return
    }
    if panicErr, ok := e.(error); ok && panicErr == bytes.ErrTooLarge {
      err = panicErr
    } else {
      panic(e)
    }
  }()
  if int64(int(capacity)) == capacity {
    buf.Grow(int(capacity))
  }
  _, err = buf.ReadFrom(r)
  return buf.Bytes(), err}// ReadAll reads from r until an error or EOF and returns the data it read.// A successful call returns err == nil, not err == EOF. Because ReadAll is// defined to read from src until EOF, it does not treat an EOF from Read// as an error to be reported.func ReadAll(r io.Reader) ([]byte, error) {
  return readAll(r, bytes.MinRead)}

從以上可以看到,ioutil.ReadAll 每次都會分配初始化一個大小爲 bytes.MinRead 的 buffer ,bytes.MinRead 在 Golang 裏是一個常量,值爲 512 。就是說每次調用 ioutil.ReadAll 都會分配一塊大小爲 512 字節的內存。makeSlice的原因是ioutil.ReadAll() 裏會調用 bytes.Buffer.ReadFrom, 而 bytes.Buffer.ReadFrom 會進行 makeSlice

// ReadFrom reads data from r until EOF and appends it to the buffer, growing// the buffer as needed. The return value n is the number of bytes read. Any// error except io.EOF encountered during the read is also returned. If the// buffer becomes too large, ReadFrom will panic with ErrTooLarge.func (b *Buffer) ReadFrom(r io.Reader) (n int64, err error) {
  b.lastRead = opInvalid
  for {
    i := b.grow(MinRead)
    m, e := r.Read(b.buf[i:cap(b.buf)])
    if m < 0 {
      panic(errNegativeRead)
    }

    b.buf = b.buf[:i+m]
    n += int64(m)
    if e == io.EOF {
      return n, nil // e is EOF, so return nil explicitly
    }
    if e != nil {
      return n, e
    }
  }}// grow grows the buffer to guarantee space for n more bytes.// It returns the index where bytes should be written.// If the buffer can't grow it will panic with ErrTooLarge.func (b *Buffer) grow(n int) int {
  m := b.Len()
  // If buffer is empty, reset to recover space.
  if m == 0 && b.off != 0 {
    b.Reset()
  }
  // Try to grow by means of a reslice.
  if i, ok := b.tryGrowByReslice(n); ok {
    return i
  }
  // Check if we can make use of bootstrap array.
  if b.buf == nil && n <= len(b.bootstrap) {
    b.buf = b.bootstrap[:n]
    return 0
  }
  c := cap(b.buf)
  if n <= c/2-m {
    // We can slide things down instead of allocating a new
    // slice. We only need m+n <= c to slide, but
    // we instead let capacity get twice as large so we
    // don't spend all our time copying.
    copy(b.buf, b.buf[b.off:])
  } else if c > maxInt-c-n {
    panic(ErrTooLarge)
  } else {
    // Not enough space anywhere, we need to allocate.
    buf := makeSlice(2*c + n)
    copy(buf, b.buf[b.off:])
    b.buf = buf
  }
  // Restore b.off and len(b.buf).
  b.off = 0
  b.buf = b.buf[:m+n]
  return m}
  • 這個函數主要作用就是從 io.Reader 裏讀取的數據放入 buffer 中,如果 buffer 空間不夠,就按照每次 2x + MinRead 的算法遞增,這裏 MinRead 的大小也是 512 Bytes ,由於我們每次調用 ioutil.ReadAll 讀取的數據遠小於 512 Bytes ,照理說不會觸發 buffer grow 的算法,但是仔細看下實現發現不是這麼回事,buffer grow 的算法大概是是這樣子的:
  • 1.計算 buffer 剩餘大小 free;
  • 2.如果 free < MinRead, 分配一個大小爲 2 * cap + MinRead 的 newBuf, 把老 buffer 的數據拷貝到 newBuf;
  • 3.如果 free >= MinRead, 讀取數據到 buffer, 遇到錯誤就返回,否則跳轉到第 1 步. 因爲我們用了 io.LimitReader , 第一趟循環只會讀取固定字節的數據,不會觸發任何錯誤。但是第二次循環的時候,由於 buffer 的剩餘空間不足,就會觸發一次 buffer grow 的算法,再分配一個大小爲 1536 Bytes 的 Buffer , 然後再次 Read() 的時候會返回 io.EOF 的錯誤。這就是爲什麼會有那麼多次 makeSlice 調用的原因

怎麼解決?仔細看下代碼會發現,我們的目的不就是Umarshal其中的數據嗎?交給json自己做吧,沒必要多那一步。

err = json.NewDecoder(resp.Body).Decode(&reqData.RespValue)if err != nil {
  err = errors.Newf(err, "failed unmarshaling the response body: %v", resp.Body)}

再繼續看下heap發現減輕很多了。

五、關於並行度的大小

這個是讓我比較苦惱的地方。剛開始我是在一個4核8G的虛擬機中測試,發現不斷調整workqueue.Parallelize的並行度出現以下的特點。

圖5

爲什麼會是14呢?

難道是我的機器原因,我換了一臺32核的機器,32核的機器CPU負載只有20%左右,並且擁有256個虛擬核數。這裏有上萬個任務需要跑,我在測試之前一直覺得要麼是32最合適,要麼是256最合適,可結果出乎我所料,仍然是14。CPU上不去我推測的可能性是開啓的goroutine運行時間過短,CPU沒有採集到,但我覺得這個理由說服不了我自己。

進而想想會不會是GC的原因,開啓GC我發現GC頻率很低的,大概幾十秒左右。所以排除了GC的影響,但是可以肯定的是並不是goroutine越多越好,對於每一個系統具體應該是多少的數量應該根據自己系統和機器調整到最佳狀態,這個只有方法可循沒有公式可用。

(PS:這個問題至今我沒有找到這個數字關係,苦惱中,有興趣的同學可以給提提思路!)

六、一些小的CASE

  • 使用高性能json序列化

從火焰圖發現encoding/json佔據了不少的CPU時間,從目前的開源json序列化package老看,最好的是http://github.com/json-iterator/go,比標準庫快數十倍。

  • 儘量提前分配slice和map的長度

這個應該都清楚,一次性分配空間可以減少自動擴張引起的複製過程。

  • 儘量減少對象的分配

雖然我已經儘量的減少對象,但是考慮到成本和目前GC並沒有帶來嚴重的影響,還可以忍受,後期應該會專門優化這部分也說不定。

  • 減少無效的計算和優化算法一樣重要

我發現大部分情況下,不是說使用更加高效的算法來解決的,更多的是對於流程的優化和無效計算的減少。

總結

  • 1.使用pprof,火焰圖等工具可以對優化工作帶來很大的便利,善於使用工具非常重要,也是一個必備的技能。掌握了使用的套路,就是收穫了經驗
  • 2.拋棄業務只去想優化很可能是在做無用功,深入業務才能更好的開展開發和優化工作
  • 3.Go系統優化的過程,深入瞭解Go的特性是基礎,不然會不知思路在哪裏,這也是初級Go學習者想要進階的一個途徑:深入Go的特性
  • 4.深入業務又要給自己產生正向反饋,你解決的問題可能就是業界也存在的問題,積極尋找外界的信息會幫助自己更好的構建系統也可能爲業界貢獻自己的才智

說的比較亂,不對和不足還請大家指教!

兩個月的努力?終於也在2018年前上線了。。。。。看到性能監控的曲線真漂亮!

更一下曲線:


版權申明:內容來源網絡,版權歸原創者所有。除非無法確認,我們都會標明作者及出處,如有侵權煩請告知,我們會立即刪除並表示歉意。謝謝。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章