幾個Go系統可能遇到的鎖問題

以前統一特徵系統在 QA 同窗的幫助下進行了一些壓測,發現了一些問題,這些問題是較爲通用的問題,發出來給其餘同窗參考一下,避免踩一樣的坑。git

几个Go系统可能遇到的锁问题

底層依賴 sync.Pool 的場景github

有一些開源庫,爲了優化性能,使用了官方提供的 sync.Pool,好比咱們使用的 https://github.com/valyala/fasttemplate 這個庫,每當你執行下面這樣的代碼的時候:網絡

  1. template := "http://{{host}}/?q={{query}}&foo={{bar}}{{bar}}" 
  2.     t := fasttemplate.New(template, "{{", "}}") 
  3.     s := t.ExecuteString(map[string]interface{}{ 
  4.         "host":  "google.com", 
  5.         "query": url.QueryEscape("hello=world"), 
  6.         "bar":   "foobar", 
  7.     }) 
  8.     fmt.Printf("%s", s) 

內部都會生成一個 fasttemplate.Template 對象,並帶有一個 byteBufferPool 字段:架構

  1. type Template struct { 
  2.     template string 
  3.     startTag string 
  4.     endTag   string 
  5.  
  6.     texts          [][]byte 
  7.     tags           []string 
  8.     byteBufferPool bytebufferpool.Pool   ==== 就是這個字段 

byteBufferPool 底層就是通過封裝的 sync.Pool:併發

  1. type Pool struct { 
  2.     calls       [steps]uint64 
  3.     calibrating uint64 
  4.  
  5.     defaultSize uint64 
  6.     maxSize     uint64 
  7.  
  8.     pool sync.Pool 

這種設計會帶來一個問題,若是使用方每次請求都 New 一個 Template 對象。並進行求值,好比咱們最初的用法,在每次拿到了用戶的請求以後,都會用參數填入到模板:app

  1. func fromTplToStr(tpl string, params map[string]interface{}) string { 
  2.   tplVar := fasttemplate.New(tpl, `{{`, `}}`) 
  3.   res := tplVar.ExecuteString(params) 
  4.   return res 

在模板求值的時候:高併發

  1. func (t *Template) ExecuteFuncString(f TagFunc) string { 
  2.     bb := t.byteBufferPool.Get() 
  3.     if _, err := t.ExecuteFunc(bb, f); err != nil { 
  4.         panic(fmt.Sprintf("unexpected error: %s", err)) 
  5.     } 
  6.     s := string(bb.Bytes()) 
  7.     bb.Reset() 
  8.     t.byteBufferPool.Put(bb) 
  9.     return s 

會對該 Template 對象的 byteBufferPool 進行 Get,在使用完以後,把 ByteBuffer Reset 再放回到對象池中。但問題在於,咱們的 Template 對象自己並無進行復用,因此這裏的 byteBufferPool 自己的做用其實並無發揮出來。oop

相反的,由於每個請求都須要新生成一個 sync.Pool,在高併發場景下,執行時會卡在 bb := t.byteBufferPool.Get() 這一句上,經過壓測能夠比較快地發現問題,達到必定 QPS 壓力時,會有大量的 Goroutine 堆積,好比下面有 18910 個 G 堆積在搶鎖代碼上:性能

  1. goroutine profile: total 18910 
  2. 18903 @ 0x102f20b 0x102f2b3 0x103fa4c 0x103f77d 0x10714df 0x1071d8f 0x1071d26 0x1071a5f 0x12feeb8 0x13005f0 0x13007c3 0x130107b 0x105c931 
  3. #   0x103f77c   sync.runtime_SemacquireMutex+0x3c                               /usr/local/go/src/runtime/sema.go:71 
  4. #   0x10714de   sync.(*Mutex).Lock+0xfe                                     /usr/local/go/src/sync/mutex.go:134 
  5. #   0x1071d8e   sync.(*Pool).pinSlow+0x3e                                   /usr/local/go/src/sync/pool.go:198 
  6. #   0x1071d25   sync.(*Pool).pin+0x55                                       /usr/local/go/src/sync/pool.go:191 
  7. #   0x1071a5e   sync.(*Pool).Get+0x2e                                       /usr/local/go/src/sync/pool.go:128 
  8. #   0x12feeb7   github.com/valyala/fasttemplate/vendor/github.com/valyala/bytebufferpool.(*Pool).Get+0x37   /Users/xargin/go/src/github.com/valyala/fasttemplate/vendor/github.com/valyala/bytebufferpool/pool.go:49 
  9. #   0x13005ef   github.com/valyala/fasttemplate.(*Template).ExecuteFuncString+0x3f              /Users/xargin/go/src/github.com/valyala/fasttemplate/template.go:278 
  10. #   0x13007c2   github.com/valyala/fasttemplate.(*Template).ExecuteString+0x52                  /Users/xargin/go/src/github.com/valyala/fasttemplate/template.go:299 
  11. #   0x130107a   main.loop.func1+0x3a                                        /Users/xargin/test/go/http/httptest.go:22 

有大量的 Goroutine 會阻塞在獲取鎖上,爲何呢?繼續看看 sync.Pool 的 Get 流程:學習

  1. func (p *Pool) Get() interface{} { 
  2.     if race.Enabled { 
  3.         race.Disable() 
  4.     } 
  5.     l := p.pin() 
  6.     x := l.private 
  7.     l.private = nil 
  8.     runtime_procUnpin() 

而後是 pin:

  1. func (p *Pool) pin() *poolLocal { 
  2.     pid := runtime_procPin() 
  3.      
  4.     s := atomic.LoadUintptr(&p.localSize) // load-acquire 
  5.     l := p.local                          // load-consume 
  6.     if uintptr(pid) < s { 
  7.         return indexLocal(l, pid) 
  8.     } 
  9.     return p.pinSlow() 

由於每個對象的 sync.Pool 都是空的,因此 pin 的流程必定會走到 p.pinSlow:

  1. func (p *Pool) pinSlow() *poolLocal { 
  2.     runtime_procUnpin() 
  3.     allPoolsMu.Lock() 
  4.     defer allPoolsMu.Unlock() 
  5.     pid := runtime_procPin() 

而 pinSlow 中會用 allPoolsMu 來加鎖,這個 allPoolsMu 主要是爲了保護 allPools 變量:

  1. var ( 
  2.     allPoolsMu Mutex 
  3.     allPools   []*Pool 

在加了鎖的狀況下,會把用戶新生成的 sync.Pool 對象 append 到 allPools 中:

  1. if p.local == nil { 
  2.         allPools = append(allPools, p) 
  3.     } 

標準庫的 sync.Pool 之因此要維護這麼一個 allPools 意圖也比較容易推測,主要是爲了 GC 的時候對 pool 進行清理,這也就是爲何說使用 sync.Pool 作對象池時,其中的對象活不過一個 GC 週期的緣由。sync.Pool 自己也是爲了解決大量生成臨時對象對 GC 形成的壓力問題。

說完了流程,問題也就比較明顯了,每個用戶請求最終都須要去搶一把全局鎖,高併發場景下全局鎖是大忌。可是這個全局鎖是由於開源庫間接帶來的全局鎖問題,經過看本身的代碼並非那麼容易發現。

知道了問題,改進方案其實也還好實現,第一是能夠修改開源庫,將 template 的 sync.Pool 做爲全局對象來引用,這樣大部分 pool.Get 不會走到 pinSlow 流程。第二是對 fasttemplate.Template 對象進行復用,道理也是同樣的,就不會有那麼多的 sync.Pool 對象生成了。但前面也提到了,這個是個間接問題,若是開發工做繁忙,不太可能全部的依賴庫把代碼全看完以後再使用,這種狀況下怎麼避免線上的故障呢?

壓測儘可能早作唄。

metrics 上報和 log 鎖

這兩個本質都是同樣的問題,就放在一塊兒了。

公司以前 metrics 上報 client 都是基於 udp 的,大多數作的簡單粗暴,就是一個 client,用戶傳什麼就寫什麼,最終必定會走到:

  1. func (c *UDPConn) WriteToUDP(b []byte, addr *UDPAddr) (int, error) { 
  2.     ---------- 刨去無用細節 
  3.     n, err := c.writeTo(b, addr) 
  4.     ---------- 刨去無用細節 
  5.     return n, err 

或者是:

  1. func (c *UDPConn) WriteTo(b []byte, addr Addr) (int, error) { 
  2.  
  3.     ---------- 刨去無用細節 
  4.     n, err := c.writeTo(b, a) 
  5.     ---------- 刨去無用細節 
  6.     return n, err 

調用的是:

  1. func (c *UDPConn) writeTo(b []byte, addr *UDPAddr) (int, error) { 
  2.     ---------- 刨去無用細節 
  3.     return c.fd.writeTo(b, sa) 

而後:

  1. func (fd *netFD) writeTo(p []byte, sa syscall.Sockaddr) (n int, err error) { 
  2.     n, err = fd.pfd.WriteTo(p, sa) 
  3.     runtime.KeepAlive(fd) 
  4.     return n, wrapSyscallError("sendto", err) 

而後是:

  1. func (fd *FD) WriteTo(p []byte, sa syscall.Sockaddr) (int, error) { 
  2.     if err := fd.writeLock(); err != nil {  =========> 重點在這裏 
  3.         return 0, err 
  4.     } 
  5.     defer fd.writeUnlock() 
  6.  
  7.     for { 
  8.         err := syscall.Sendto(fd.Sysfd, p, 0, sa) 
  9.         if err == syscall.EAGAIN && fd.pd.pollable() { 
  10.             if err = fd.pd.waitWrite(fd.isFile); err == nil { 
  11.                 continue 
  12.             } 
  13.         } 
  14.         if err != nil { 
  15.             return 0, err 
  16.         } 
  17.         return len(p), nil 
  18.     } 

本質上,就是在高成本的網絡操做上套了一把大的寫鎖,一樣在高併發場景下會致使大量的鎖衝突,進而致使大量的 Goroutine 堆積和接口延遲。

一樣的,知道了問題,解決辦法也很簡單。再看看日誌相關的。由於公司目前大部分日誌都是直接向文件系統寫,本質上同一個時刻操做的是同一個文件,最終都會走到:

  1. func (f *File) Write(b []byte) (n int, err error) { 
  2.     n, e := f.write(b) 
  3.     return n, err 
  4.  
  5. func (f *File) write(b []byte) (n int, err error) { 
  6.     n, err = f.pfd.Write(b) 
  7.     runtime.KeepAlive(f) 
  8.     return n, err 

而後:

  1. func (fd *FD) Write(p []byte) (int, error) { 
  2.     if err := fd.writeLock(); err != nil { =========> 又是 writeLock 
  3.         return 0, err 
  4.     } 
  5.     defer fd.writeUnlock() 
  6.     if err := fd.pd.prepareWrite(fd.isFile); err != nil { 
  7.         return 0, err 
  8.     } 
  9.     var nn int 
  10.     for { 
  11.         ----- 略去不相關內容 
  12.         n, err := syscall.Write(fd.Sysfd, p[nn:max]) 
  13.         ----- 略去無用內容 
  14.     } 

和 UDP 網絡 FD 同樣有 writeLock,在系統打日誌打得不少的狀況下,這個 writeLock 會致使和 metrics 上報同樣的問題。

總結

上面說的幾個問題實際上本質都是併發場景下的 lock contention 問題,全局寫鎖是高併發場景下的性能殺手,一旦大量的 Goroutine 阻塞在寫鎖上,會致使系統的延遲飈升,直至接口超時。在開發系統時,涉及到 sync.Pool、單個 FD 的信息上報、以及寫日誌的場景時,應該多加註意。早作壓測保平安。

感興趣的能夠本身來個人Java架構羣,能夠獲取免費的學習資料,羣號:855801563 對Java技術,架構技術感興趣的同窗,歡迎加羣,一塊兒學習,相互討論。

相關文章
相關標籤/搜索