幾個(gè)Go系統(tǒng)可能遇到的鎖問(wèn)題
之前統(tǒng)一特征系統(tǒng)在 QA 同學(xué)的幫助下進(jìn)行了一些壓測(cè),發(fā)現(xiàn)了一些問(wèn)題,這些問(wèn)題是較為通用的問(wèn)題,發(fā)出來(lái)給其他同學(xué)參考一下,避免踩同樣的坑。
底層依賴 sync.Pool 的場(chǎng)景
有一些開(kāi)源庫(kù),為了優(yōu)化性能,使用了官方提供的 sync.Pool,比如我們使用的 https://github.com/valyala/fasttemplate 這個(gè)庫(kù),每當(dāng)你執(zhí)行下面這樣的代碼的時(shí)候:
- template := "http://{{host}}/?q={{query}}&foo={{bar}}{{bar}}"
- t := fasttemplate.New(template, "{{", "}}")
- s := t.ExecuteString(map[string]interface{}{
- "host": "google.com",
- "query": url.QueryEscape("hello=world"),
- "bar": "foobar",
- })
- fmt.Printf("%s", s)
內(nèi)部都會(huì)生成一個(gè) fasttemplate.Template 對(duì)象,并帶有一個(gè) byteBufferPool 字段:
- type Template struct {
- template string
- startTag string
- endTag string
- texts [][]byte
- tags []string
- byteBufferPool bytebufferpool.Pool ==== 就是這個(gè)字段
- }
byteBufferPool 底層就是經(jīng)過(guò)封裝的 sync.Pool:
- type Pool struct {
- calls [steps]uint64
- calibrating uint64
- defaultSize uint64
- maxSize uint64
- pool sync.Pool
- }
這種設(shè)計(jì)會(huì)帶來(lái)一個(gè)問(wèn)題,如果使用方每次請(qǐng)求都 New 一個(gè) Template 對(duì)象。并進(jìn)行求值,比如我們最初的用法,在每次拿到了用戶的請(qǐng)求之后,都會(huì)用參數(shù)填入到模板:
- func fromTplToStr(tpl string, params map[string]interface{}) string {
- tplVar := fasttemplate.New(tpl, `{{`, `}}`)
- res := tplVar.ExecuteString(params)
- return res
- }
在模板求值的時(shí)候:
- func (t *Template) ExecuteFuncString(f TagFunc) string {
- bb := t.byteBufferPool.Get()
- if _, err := t.ExecuteFunc(bb, f); err != nil {
- panic(fmt.Sprintf("unexpected error: %s", err))
- }
- s := string(bb.Bytes())
- bb.Reset()
- t.byteBufferPool.Put(bb)
- return s
- }
會(huì)對(duì)該 Template 對(duì)象的 byteBufferPool 進(jìn)行 Get,在使用完之后,把 ByteBuffer Reset 再放回到對(duì)象池中。但問(wèn)題在于,我們的 Template 對(duì)象本身并沒(méi)有進(jìn)行復(fù)用,所以這里的 byteBufferPool 本身的作用其實(shí)并沒(méi)有發(fā)揮出來(lái)。
相反的,因?yàn)槊恳粋€(gè)請(qǐng)求都需要新生成一個(gè) sync.Pool,在高并發(fā)場(chǎng)景下,執(zhí)行時(shí)會(huì)卡在 bb := t.byteBufferPool.Get() 這一句上,通過(guò)壓測(cè)可以比較快地發(fā)現(xiàn)問(wèn)題,達(dá)到一定 QPS 壓力時(shí),會(huì)有大量的 Goroutine 堆積,比如下面有 18910 個(gè) G 堆積在搶鎖代碼上:
- goroutine profile: total 18910
- 18903 @ 0x102f20b 0x102f2b3 0x103fa4c 0x103f77d 0x10714df 0x1071d8f 0x1071d26 0x1071a5f 0x12feeb8 0x13005f0 0x13007c3 0x130107b 0x105c931
- # 0x103f77c sync.runtime_SemacquireMutex+0x3c /usr/local/go/src/runtime/sema.go:71
- # 0x10714de sync.(*Mutex).Lock+0xfe /usr/local/go/src/sync/mutex.go:134
- # 0x1071d8e sync.(*Pool).pinSlow+0x3e /usr/local/go/src/sync/pool.go:198
- # 0x1071d25 sync.(*Pool).pin+0x55 /usr/local/go/src/sync/pool.go:191
- # 0x1071a5e sync.(*Pool).Get+0x2e /usr/local/go/src/sync/pool.go:128
- # 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
- # 0x13005ef github.com/valyala/fasttemplate.(*Template).ExecuteFuncString+0x3f /Users/xargin/go/src/github.com/valyala/fasttemplate/template.go:278
- # 0x13007c2 github.com/valyala/fasttemplate.(*Template).ExecuteString+0x52 /Users/xargin/go/src/github.com/valyala/fasttemplate/template.go:299
- # 0x130107a main.loop.func1+0x3a /Users/xargin/test/go/http/httptest.go:22
有大量的 Goroutine 會(huì)阻塞在獲取鎖上,為什么呢?繼續(xù)看看 sync.Pool 的 Get 流程:
- func (p *Pool) Get() interface{} {
- if race.Enabled {
- race.Disable()
- }
- l := p.pin()
- x := l.private
- l.private = nil
- runtime_procUnpin()
然后是 pin:
- func (p *Pool) pin() *poolLocal {
- pid := runtime_procPin()
- s := atomic.LoadUintptr(&p.localSize) // load-acquire
- l := p.local // load-consume
- if uintptr(pid) < s {
- return indexLocal(l, pid)
- }
- return p.pinSlow()
- }
因?yàn)槊恳粋€(gè)對(duì)象的 sync.Pool 都是空的,所以 pin 的流程一定會(huì)走到 p.pinSlow:
- func (p *Pool) pinSlow() *poolLocal {
- runtime_procUnpin()
- allPoolsMu.Lock()
- defer allPoolsMu.Unlock()
- pid := runtime_procPin()
而 pinSlow 中會(huì)用 allPoolsMu 來(lái)加鎖,這個(gè) allPoolsMu 主要是為了保護(hù) allPools 變量:
- var (
- allPoolsMu Mutex
- allPools []*Pool
- )
在加了鎖的情況下,會(huì)把用戶新生成的 sync.Pool 對(duì)象 append 到 allPools 中:
- if p.local == nil {
- allPools = append(allPools, p)
- }
標(biāo)準(zhǔn)庫(kù)的 sync.Pool 之所以要維護(hù)這么一個(gè) allPools 意圖也比較容易推測(cè),主要是為了 GC 的時(shí)候?qū)?pool 進(jìn)行清理,這也就是為什么說(shuō)使用 sync.Pool 做對(duì)象池時(shí),其中的對(duì)象活不過(guò)一個(gè) GC 周期的原因。sync.Pool 本身也是為了解決大量生成臨時(shí)對(duì)象對(duì) GC 造成的壓力問(wèn)題。
說(shuō)完了流程,問(wèn)題也就比較明顯了,每一個(gè)用戶請(qǐng)求最終都需要去搶一把全局鎖,高并發(fā)場(chǎng)景下全局鎖是大忌。但是這個(gè)全局鎖是因?yàn)殚_(kāi)源庫(kù)間接帶來(lái)的全局鎖問(wèn)題,通過(guò)看自己的代碼并不是那么容易發(fā)現(xiàn)。
知道了問(wèn)題,改進(jìn)方案其實(shí)也還好實(shí)現(xiàn),***是可以修改開(kāi)源庫(kù),將 template 的 sync.Pool 作為全局對(duì)象來(lái)引用,這樣大部分 pool.Get 不會(huì)走到 pinSlow 流程。第二是對(duì) fasttemplate.Template 對(duì)象進(jìn)行復(fù)用,道理也是一樣的,就不會(huì)有那么多的 sync.Pool 對(duì)象生成了。但前面也提到了,這個(gè)是個(gè)間接問(wèn)題,如果開(kāi)發(fā)工作繁忙,不太可能所有的依賴庫(kù)把代碼全看完之后再使用,這種情況下怎么避免線上的故障呢?
壓測(cè)盡量早做唄。
metrics 上報(bào)和 log 鎖
這兩個(gè)本質(zhì)都是一樣的問(wèn)題,就放在一起了。
公司之前 metrics 上報(bào) client 都是基于 udp 的,大多數(shù)做的簡(jiǎn)單粗暴,就是一個(gè) client,用戶傳什么就寫(xiě)什么,最終一定會(huì)走到:
- func (c *UDPConn) WriteToUDP(b []byte, addr *UDPAddr) (int, error) {
- ---------- 刨去無(wú)用細(xì)節(jié)
- n, err := c.writeTo(b, addr)
- ---------- 刨去無(wú)用細(xì)節(jié)
- return n, err
- }
或者是:
- func (c *UDPConn) WriteTo(b []byte, addr Addr) (int, error) {
- ---------- 刨去無(wú)用細(xì)節(jié)
- n, err := c.writeTo(b, a)
- ---------- 刨去無(wú)用細(xì)節(jié)
- return n, err
- }
調(diào)用的是:
- func (c *UDPConn) writeTo(b []byte, addr *UDPAddr) (int, error) {
- ---------- 刨去無(wú)用細(xì)節(jié)
- return c.fd.writeTo(b, sa)
- }
然后:
- func (fd *netFD) writeTo(p []byte, sa syscall.Sockaddr) (n int, err error) {
- n, err = fd.pfd.WriteTo(p, sa)
- runtime.KeepAlive(fd)
- return n, wrapSyscallError("sendto", err)
- }
然后是:
- func (fd *FD) WriteTo(p []byte, sa syscall.Sockaddr) (int, error) {
- if err := fd.writeLock(); err != nil { =========> 重點(diǎn)在這里
- return 0, err
- }
- defer fd.writeUnlock()
- for {
- err := syscall.Sendto(fd.Sysfd, p, 0, sa)
- if err == syscall.EAGAIN && fd.pd.pollable() {
- if err = fd.pd.waitWrite(fd.isFile); err == nil {
- continue
- }
- }
- if err != nil {
- return 0, err
- }
- return len(p), nil
- }
- }
本質(zhì)上,就是在高成本的網(wǎng)絡(luò)操作上套了一把大的寫(xiě)鎖,同樣在高并發(fā)場(chǎng)景下會(huì)導(dǎo)致大量的鎖沖突,進(jìn)而導(dǎo)致大量的 Goroutine 堆積和接口延遲。
同樣的,知道了問(wèn)題,解決辦法也很簡(jiǎn)單。再看看日志相關(guān)的。因?yàn)楣灸壳按蟛糠秩罩径际侵苯酉蛭募到y(tǒng)寫(xiě),本質(zhì)上同一個(gè)時(shí)刻操作的是同一個(gè)文件,最終都會(huì)走到:
- func (f *File) Write(b []byte) (n int, err error) {
- n, e := f.write(b)
- return n, err
- }
- func (f *File) write(b []byte) (n int, err error) {
- n, err = f.pfd.Write(b)
- runtime.KeepAlive(f)
- return n, err
- }
然后:
- func (fd *FD) Write(p []byte) (int, error) {
- if err := fd.writeLock(); err != nil { =========> 又是 writeLock
- return 0, err
- }
- defer fd.writeUnlock()
- if err := fd.pd.prepareWrite(fd.isFile); err != nil {
- return 0, err
- }
- var nn int
- for {
- ----- 略去不相關(guān)內(nèi)容
- n, err := syscall.Write(fd.Sysfd, p[nn:max])
- ----- 略去無(wú)用內(nèi)容
- }
- }
和 UDP 網(wǎng)絡(luò) FD 一樣有 writeLock,在系統(tǒng)打日志打得很多的情況下,這個(gè) writeLock 會(huì)導(dǎo)致和 metrics 上報(bào)一樣的問(wèn)題。
總結(jié)
上面說(shuō)的幾個(gè)問(wèn)題實(shí)際上本質(zhì)都是并發(fā)場(chǎng)景下的 lock contention 問(wèn)題,全局寫(xiě)鎖是高并發(fā)場(chǎng)景下的性能殺手,一旦大量的 Goroutine 阻塞在寫(xiě)鎖上,會(huì)導(dǎo)致系統(tǒng)的延遲飚升,直至接口超時(shí)。在開(kāi)發(fā)系統(tǒng)時(shí),涉及到 sync.Pool、單個(gè) FD 的信息上報(bào)、以及寫(xiě)日志的場(chǎng)景時(shí),應(yīng)該多加注意。早做壓測(cè)保平安。