轉(zhuǎn)自:https://zhuanlan.zhihu.com/p/76812714
請(qǐng)問(wèn)sync.Pool有什么缺點(diǎn)?
1.12及之前版本的sync.Pool有三個(gè)問(wèn)題:
- 每次GC都回收所有對(duì)象,如果緩存對(duì)象數(shù)量太大,會(huì)導(dǎo)致STW1階段的耗時(shí)增加。
- 每次GC都回收所有對(duì)象,導(dǎo)致緩存對(duì)象命中率下降,New方法的執(zhí)行造成額外的內(nèi)存分配消耗。
- Pool.Get方法底層有鎖,極端情況下,要嘗試最多P次搶鎖,也獲取不到緩存對(duì)象,最后得執(zhí)行New方法返回對(duì)象。
這些問(wèn)題就對(duì)sync.Pool的室使用提出了要求,不滿足時(shí),性能并不會(huì)有大幅提升:
- 最好是高并發(fā)場(chǎng)景。(對(duì)應(yīng)問(wèn)題3)
- 最好兩次GC之間的間隔足夠長(zhǎng)。(對(duì)應(yīng)問(wèn)題1,2)
先簡(jiǎn)單介紹下原理,看哪塊源碼造成這三個(gè)問(wèn)題。
如果對(duì)sync.Pool的基本原理一點(diǎn)都不了解,可以移步先閱讀《golang標(biāo)準(zhǔn)庫(kù)sync.Pool原理及源碼簡(jiǎn)析》
sync.Pool對(duì)象內(nèi)部為每個(gè)P都分配了一個(gè)private區(qū)和shared區(qū)。
private區(qū)只能存放一個(gè)可復(fù)用對(duì)象,因?yàn)槊總€(gè)P在任意時(shí)刻只運(yùn)行一個(gè)G,所以在private區(qū)上寫入和取出對(duì)象是不用加鎖的。
shared區(qū)可以放多個(gè)可復(fù)用對(duì)象,它本身是slice。進(jìn)shared區(qū)就append,出shared區(qū)就slice[:last-1]。但shared區(qū)上寫入和取出對(duì)象要加鎖,因?yàn)閯e的G可能過(guò)來(lái)偷對(duì)象。
type poolLocalInternal struct {
// 私有對(duì)象,每個(gè)P都有,用于不同G執(zhí)行g(shù)et和put可以無(wú)鎖操作
private interface{}
// 共享對(duì)象數(shù)組,每個(gè)P都有一個(gè),同一個(gè)P上不同G可以多次執(zhí)行put方法,需要有地方能存儲(chǔ)。并且別的P上的G可能過(guò)來(lái)偷,所以要加鎖
shared []interface{}
// 對(duì)shared進(jìn)行加鎖,private不用加鎖
Mutex
}
問(wèn)題3 就是由于shared區(qū)是一個(gè)帶鎖的后進(jìn)先出隊(duì)列造成的。每次Pool.Get方法在調(diào)用時(shí),執(zhí)行順序是:
- 先看當(dāng)前P的private區(qū)是否為空。
- 加鎖,看當(dāng)前P的shared區(qū)是否為空。
- 加鎖,循環(huán)遍歷看其他P的shared區(qū)是否為空。
- 只要上面三步任意一步就不為空,就可以把緩存對(duì)象返回了。但若都為空,最后就得調(diào)用New方法返回對(duì)象。
// 遍歷一次其他P的共享區(qū),偷一個(gè),每次嘗試偷都得上鎖
for i := 0; i < int(size); i++ {
// 定位到某個(gè)P上的shared區(qū)
l := indexLocal(local, (pid+i+1)%int(size))
l.Lock()
last := len(l.shared) - 1
if last >= 0 {
// 如果有緩存對(duì)象,就返回,并解鎖
x = l.shared[last]
l.shared = l.shared[:last]
l.Unlock()
break
}
// 沒(méi)有緩存對(duì)象,解鎖,繼續(xù)遍歷下一個(gè)P
l.Unlock()
}
這一頓的加鎖操作和Mutex鎖自帶的阻塞喚醒開(kāi)銷,Get方法在極端情況下就會(huì)有性能問(wèn)題。
Mutex鎖分析參考《一份詳細(xì)注釋的go Mutex源碼》
問(wèn)題1和2 都是由于每次GC時(shí),遍歷清空所有緩存對(duì)象造成的。
sync.Pool在init()中向runtime注冊(cè)了一個(gè)cleanup方法,它在STW1階段被調(diào)用的。如果它執(zhí)行過(guò)久,就會(huì)硬生生延長(zhǎng)STW1階段耗時(shí)。
func init() {
runtime_registerPoolCleanup(poolCleanup)
}
這個(gè)cleanup方法干的事情是遍歷所有的sync.Pool對(duì)象,再遍歷每個(gè)sync.Pool對(duì)象中的每個(gè)P的shared區(qū),把shared區(qū)每個(gè)緩存對(duì)象設(shè)置為nil。代碼中就是三層for循環(huán),簡(jiǎn)單粗暴時(shí)間復(fù)雜度高。
func poolCleanup() {
// ...
for i, p := range allPools {
// 有多少個(gè)Sync.Pool對(duì)象,遍歷多少次
allPools[i] = nil
for i := 0; i < int(p.localSize); i++ {
// 有多少個(gè)P,遍歷多少次
l := indexLocal(p.local, i)
l.private = nil
for j := range l.shared {
// 清空shared區(qū)中每個(gè)緩存對(duì)象
l.shared[j] = nil
}
l.shared = nil
}
// ...
}
// ...
}
好消息是1.13beta1已經(jīng)解決了這三個(gè)問(wèn)題。注意是beta版本,而不是stable版本。
接下來(lái)主要看1.13通過(guò)什么思路解決這些問(wèn)題的。
不排除未來(lái)的stable版本或1.13的小版本會(huì)對(duì)這塊實(shí)現(xiàn)做小改動(dòng)。
取消每次GC默認(rèn)對(duì)全部對(duì)象進(jìn)行回收
解決問(wèn)題1和2的思路就是不能每次全部回收。但該回收多少呢?
@aclements 提出了一種思路,這輪在sync.Pool中的對(duì)象。最快也在下輪GC才被回收。
https://github.com/golang/go/issues/22950#issuecomment-352935997
還記得上面說(shuō)過(guò)每個(gè)P都有private區(qū)和shared區(qū)嗎?現(xiàn)在每個(gè)P里兩個(gè)區(qū)合在一起構(gòu)成數(shù)組,給個(gè)名字叫local(其實(shí)也是源碼中的實(shí)現(xiàn))。1.13版本的實(shí)現(xiàn)中再引入一個(gè)victim,它結(jié)構(gòu)與local一致。
// 1.13版本源碼
type Pool struct {
local unsafe.Pointer // 實(shí)際指向[P]poolLocal
localSize uintptr // P的個(gè)數(shù)
victim unsafe.Pointer // 指向上輪的local
victimSize uintptr // 指向上輪的localSize
New func() interface{}
}
type poolLocal struct {
poolLocalInternal
pad [128 - unsafe.Sizeof(poolLocalInternal{})%128]byte
}
type poolLocalInternal struct {
private interface{}
shared poolChain
}
有了victim,Get和Put方法的步驟就有所變化:
- Get時(shí),先從local里嘗試取出緩存對(duì)象(包括所有的P)。如果失敗,就嘗試從victim里取。
- victim里也取對(duì)象失敗,就調(diào)用New方法。
- Put時(shí),只放local里。
新的數(shù)據(jù)結(jié)構(gòu)下,cleanup方法策略也有所變化,改為每次只把victim里的對(duì)象回收掉。然后victim再指向當(dāng)前的local。
var (
// 所有sync.Pool對(duì)象
allPools []*Pool
// 待回收的所有sync.Pool對(duì)象
oldPools []*Pool
)
func poolCleanup() {
for _, p := range oldPools {
// 每次只回收victim
p.victim = nil
p.victimSize = 0
}
for _, p := range allPools {
// victim指向當(dāng)前的local
p.victim = p.local
p.victimSize = p.localSize
p.local = nil
p.localSize = 0
}
oldPools, allPools = allPools, nil
}
顯然這樣好處就是這輪的緩存對(duì)象在GC時(shí)不會(huì)立馬回收,而是存放起來(lái),滯后一輪。這樣下一輪能得到復(fù)用機(jī)會(huì),提高了緩存對(duì)象的命中率。并且回收對(duì)象時(shí),由對(duì)shared區(qū)O(n)的遍歷操作,變成O(1)。
從benchmark感受這個(gè)優(yōu)化帶來(lái)的性能提升:
# 1.9.7
BenchmarkPoolSTW-8 p96-ns/STW 285485 p50-ns/STW 190467
# 1.13beta1
BenchmarkPoolSTW-8 p96-ns/STW 7720 p50-ns/STW 4979
1.9.7版本的STW1階段耗時(shí)TP96線是285485ns,而1.13beta1是7720ns。
Benchmark代碼參考1.13beat1源碼src/sync/pool_test.go.BenchmarkPoolSTW方法
使用無(wú)鎖隊(duì)列替換shared區(qū)
問(wèn)題3 是因?yàn)樵趕hared的訪問(wèn)加了一把Mutex鎖造成的。如果不消除這把鎖,引入victim區(qū)也是徒勞。因?yàn)榇藭r(shí)victim的訪問(wèn)也得加鎖。
舊實(shí)現(xiàn)中shared區(qū)是單純的帶鎖后進(jìn)先出隊(duì)列,1.13beta版本改成了單生產(chǎn)者,多消費(fèi)者的雙端無(wú)鎖環(huán)形隊(duì)列。
單生產(chǎn)者是指,每個(gè)P上運(yùn)行的G,執(zhí)行Put方法時(shí),就往隊(duì)列里存放緩存對(duì)象(別的P上運(yùn)行的G不能往里放),并且只能放在隊(duì)列頭部。由于每個(gè)P任意時(shí)刻只有一個(gè)G被運(yùn)行,所以存放緩存對(duì)象不需要加鎖。
多消費(fèi)者分兩種角色,一是在P上運(yùn)行的G,執(zhí)行Get方法時(shí),從隊(duì)列頭部取出緩存對(duì)象。同上,取對(duì)象不用加鎖;二是在其他P上運(yùn)行的G,執(zhí)行Get方法時(shí),本地沒(méi)有緩存對(duì)象,就到別的P上偷。此時(shí)盜竊者G只能從隊(duì)列尾部取出對(duì)象,因?yàn)楸I竊者可能有多個(gè),所以尾部取數(shù)據(jù)用CAS來(lái)實(shí)現(xiàn)無(wú)鎖。
注意,每個(gè)P都持有自己無(wú)鎖隊(duì)列,下圖只畫出了P0的。
并且隊(duì)列也可能有多個(gè),下圖只畫出單隊(duì)列的情況。
如何正確地實(shí)現(xiàn)無(wú)鎖隊(duì)列超出本文意圖,不展開(kāi)介紹。感興趣可以自行找資料學(xué)習(xí)或看源碼。
每個(gè)P持有的循環(huán)隊(duì)列初始化多大呢?增長(zhǎng)和收縮策略呢?
下圖用一張圖做宏觀介紹。
陳述要點(diǎn):
- shared區(qū)改用雙向鏈表,每個(gè)鏈表節(jié)點(diǎn)指向一個(gè)無(wú)鎖環(huán)形隊(duì)列。
- 鏈表節(jié)點(diǎn)必須在頭部插入。
- 當(dāng)前P上的G取緩存對(duì)象時(shí),只從頭部鏈表節(jié)點(diǎn)指向的無(wú)鎖隊(duì)列里取。取不到,沿著prev指針到下一個(gè)無(wú)鎖隊(duì)列上重復(fù)操作,也沒(méi)有的話。就到別的P上偷。
- 盜竊者G在偷緩存對(duì)象時(shí),只從尾部鏈表節(jié)點(diǎn)指向的無(wú)鎖隊(duì)列里取。取不到,沿著next指針到下一個(gè)無(wú)鎖隊(duì)列上重復(fù)操作,也沒(méi)有的話。就到別的P上繼續(xù)偷,直到都偷不著,就調(diào)用New方法。
- 鏈表首次插入節(jié)點(diǎn)時(shí),指向無(wú)鎖隊(duì)列初始化大小為8,增長(zhǎng)策略為在頭部插入新節(jié)點(diǎn),指向的無(wú)鎖隊(duì)列大小為舊頭部節(jié)點(diǎn)指向無(wú)鎖隊(duì)列大小的兩倍,始終保持2的n次方大小。
- 假如在鏈表長(zhǎng)度為3的情況下。尾部節(jié)點(diǎn)指向的無(wú)鎖隊(duì)列里緩存對(duì)象被偷光了。那么尾部節(jié)點(diǎn)會(huì)沿著next指針前移,把舊的無(wú)鎖隊(duì)列內(nèi)存釋放掉。此時(shí)鏈表長(zhǎng)度變?yōu)?,這就是鏈表的收縮策略。最小時(shí)剩下一個(gè)節(jié)點(diǎn),不會(huì)收縮成空鏈表。
- 無(wú)鎖隊(duì)列的自身最大的大小是2**30。達(dá)到上限時(shí),再執(zhí)行Put操作就放不進(jìn)去,也不報(bào)錯(cuò)。
總體就是這樣,讓我們期待1.13的stable版本吧。