sync.Pool原理

轉(zhuǎn)自:https://zhuanlan.zhihu.com/p/76812714

請(qǐng)問(wèn)sync.Pool有什么缺點(diǎn)?

1.12及之前版本的sync.Pool有三個(gè)問(wèn)題:

  1. 每次GC都回收所有對(duì)象,如果緩存對(duì)象數(shù)量太大,會(huì)導(dǎo)致STW1階段的耗時(shí)增加。
  2. 每次GC都回收所有對(duì)象,導(dǎo)致緩存對(duì)象命中率下降,New方法的執(zhí)行造成額外的內(nèi)存分配消耗。
  3. Pool.Get方法底層有鎖,極端情況下,要嘗試最多P次搶鎖,也獲取不到緩存對(duì)象,最后得執(zhí)行New方法返回對(duì)象。

這些問(wèn)題就對(duì)sync.Pool的室使用提出了要求,不滿足時(shí),性能并不會(huì)有大幅提升:

  1. 最好是高并發(fā)場(chǎng)景。(對(duì)應(yīng)問(wèn)題3)
  2. 最好兩次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í)行順序是:

  1. 先看當(dāng)前P的private區(qū)是否為空。
  2. 加鎖,看當(dāng)前P的shared區(qū)是否為空。
  3. 加鎖,循環(huán)遍歷看其他P的shared區(qū)是否為空。
  4. 只要上面三步任意一步就不為空,就可以把緩存對(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方法的步驟就有所變化:

  1. Get時(shí),先從local里嘗試取出緩存對(duì)象(包括所有的P)。如果失敗,就嘗試從victim里取。
  2. victim里也取對(duì)象失敗,就調(diào)用New方法。
  3. 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ì)列的情況。

image

如何正確地實(shí)現(xiàn)無(wú)鎖隊(duì)列超出本文意圖,不展開(kāi)介紹。感興趣可以自行找資料學(xué)習(xí)或看源碼。

每個(gè)P持有的循環(huán)隊(duì)列初始化多大呢?增長(zhǎng)和收縮策略呢?

下圖用一張圖做宏觀介紹。

image

陳述要點(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版本吧。

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

推薦閱讀更多精彩內(nèi)容

  • 前言 在 golang 中有一個(gè)池,它特別神奇,你只要和它有個(gè)約定,你要什么它就給什么,你用完了還可以還回去,但是...
    LinkinStar閱讀 19,704評(píng)論 3 8
  • 如果能夠?qū)⑺袃?nèi)存都分配到棧上無(wú)疑性能是最佳的,但不幸的是我們不可避免需要使用堆上分配的內(nèi)存。我們可以優(yōu)化使用堆內(nèi)...
    光華路程序猿閱讀 469評(píng)論 0 1
  • 目的 Many Go programs and packages try to reuse memory eith...
    7贏月閱讀 381評(píng)論 0 0
  • golang是一個(gè)自動(dòng)垃圾回收的語(yǔ)言,創(chuàng)建對(duì)象的時(shí)候無(wú)需關(guān)心他的回收,但是由于垃圾回收機(jī)制有一個(gè)STW(stop-...
    陳陳陳_6150閱讀 2,007評(píng)論 0 1
  • 夜鶯2517閱讀 127,752評(píng)論 1 9