Go 每日一庫(kù)之 bytebufferpool

簡(jiǎn)介

在編程開(kāi)發(fā)中菩鲜,我們經(jīng)常會(huì)需要頻繁創(chuàng)建和銷毀同類對(duì)象的情形。這樣的操作很可能會(huì)對(duì)性能造成影響惦积。這時(shí)接校,常用的優(yōu)化手段就是使用對(duì)象池(object pool)。需要?jiǎng)?chuàng)建對(duì)象時(shí)狮崩,我們先從對(duì)象池中查找蛛勉。如果有空閑對(duì)象,則從池中移除這個(gè)對(duì)象并將其返回給調(diào)用者使用睦柴。只有在池中無(wú)空閑對(duì)象時(shí)诽凌,才會(huì)真正創(chuàng)建一個(gè)新對(duì)象。另一方面爱只,對(duì)象使用完之后皿淋,我們并不進(jìn)行銷毀。而是將它放回到對(duì)象池以供后續(xù)使用恬试。使用對(duì)象池在頻繁創(chuàng)建和銷毀對(duì)象的情形下,能大幅度提升性能疯暑。同時(shí)训柴,為了避免對(duì)象池中的對(duì)象占用過(guò)多的內(nèi)存。對(duì)象池一般還配有特定的清理策略妇拯。Go 標(biāo)準(zhǔn)庫(kù)sync.Pool就是這樣一個(gè)例子幻馁。sync.Pool中的對(duì)象會(huì)被垃圾回收清理掉洗鸵。

在這類對(duì)象中,比較特殊的一類是字節(jié)緩沖(底層一般是字節(jié)切片)仗嗦。在做字符串拼接時(shí)膘滨,為了拼接的高效,我們通常將中間結(jié)果存放在一個(gè)字節(jié)緩沖稀拐。在拼接完成之后火邓,再?gòu)淖止?jié)緩沖中生成結(jié)果字符串。在收發(fā)網(wǎng)絡(luò)包時(shí)德撬,也需要將不完整的包暫時(shí)存放在字節(jié)緩沖中铲咨。

Go 標(biāo)準(zhǔn)庫(kù)中的類型bytes.Buffer封裝字節(jié)切片,提供一些使用接口蜓洪。我們知道切片的容量是有限的纤勒,容量不足時(shí)需要進(jìn)行擴(kuò)容。而頻繁的擴(kuò)容容易造成性能抖動(dòng)隆檀。bytebufferpool實(shí)現(xiàn)了自己的Buffer類型摇天,并使用一個(gè)簡(jiǎn)單的算法降低擴(kuò)容帶來(lái)的性能損失。bytebufferpool已經(jīng)在大名鼎鼎的 Web 框架fasthttp和靈活的 Go 模塊庫(kù)quicktemplate得到了應(yīng)用恐仑。實(shí)際上闸翅,這 3 個(gè)庫(kù)是同一個(gè)作者:valyala??。

快速使用

本文代碼使用 Go Modules菊霜。

創(chuàng)建目錄并初始化:

$ mkdir bytebufferpool && cd bytebufferpool
$ go mod init github.com/darjun/go-daily-lib/bytebufferpool

安裝bytebufferpool庫(kù):

$ go get -u github.com/PuerkitoBio/bytebufferpool

典型的使用方式先通過(guò)bytebufferpool提供的Get()方法獲取一個(gè)bytebufferpool.Buffer對(duì)象坚冀,然后調(diào)用這個(gè)對(duì)象的方法寫入數(shù)據(jù),使用完成之后再調(diào)用bytebufferpool.Put()將對(duì)象放回對(duì)象池中鉴逞。例:

package main

import (
  "fmt"

  "github.com/valyala/bytebufferpool"
)

func main() {
  b := bytebufferpool.Get()
  b.WriteString("hello")
  b.WriteByte(',')
  b.WriteString(" world!")

  fmt.Println(b.String())

  bytebufferpool.Put(b)
}

直接調(diào)用bytebufferpool包的Get()Put()方法记某,底層操作的是包中默認(rèn)的對(duì)象池:

// bytebufferpool/pool.go
var defaultPool Pool

func Get() *ByteBuffer { return defaultPool.Get() }
func Put(b *ByteBuffer) { defaultPool.Put(b) }

我們當(dāng)然可以根據(jù)實(shí)際需要?jiǎng)?chuàng)建新的對(duì)象池,將相同用處的對(duì)象放在一起(比如我們可以創(chuàng)建一個(gè)對(duì)象池用于輔助接收網(wǎng)絡(luò)包构捡,一個(gè)用于輔助拼接字符串):

func main() {
  joinPool := new(bytebufferpool.Pool)
  b := joinPool.Get()
  b.WriteString("hello")
  b.WriteByte(',')
  b.WriteString(" world!")

  fmt.Println(b.String())

  joinPool.Put(b)
}

bytebufferpool沒(méi)有提供具體的創(chuàng)建函數(shù)液南,不過(guò)可以使用new創(chuàng)建。

優(yōu)化細(xì)節(jié)

在將對(duì)象放回池中時(shí)勾徽,會(huì)根據(jù)當(dāng)前切片的容量進(jìn)行相應(yīng)的處理滑凉。bytebufferpool將大小分為 20 個(gè)區(qū)間:

| < 2^6 | 2^6 ~ 2^7-1 | ... | > 2^25 |

如果容量小于 2^6,則屬于第一個(gè)區(qū)間喘帚。如果處于 2^6 和 2^7-1 之間畅姊,則落在第二個(gè)區(qū)間。依次類推吹由。執(zhí)行足夠多的放回次數(shù)后若未,bytebufferpool會(huì)重新校準(zhǔn),計(jì)算處于哪個(gè)區(qū)間容量的對(duì)象最多倾鲫。將defaultSize設(shè)置為該區(qū)間的上限容量粗合,第一個(gè)區(qū)間的上限容量為 2^6萍嬉,第二區(qū)間為 2^7,最后一個(gè)區(qū)間為 2^26隙疚。后續(xù)通過(guò)Get()請(qǐng)求對(duì)象時(shí)壤追,若池中無(wú)空閑對(duì)象,創(chuàng)建一個(gè)新對(duì)象時(shí)供屉,直接將容量設(shè)置為defaultSize行冰。這樣基本可以避免在使用過(guò)程中的切片擴(kuò)容,從而提升性能贯卦。下面結(jié)合代碼來(lái)理解:

// bytebufferpool/pool.go
const (
  minBitSize = 6 // 2**6=64 is a CPU cache line size
  steps      = 20

  minSize = 1 << minBitSize
  maxSize = 1 << (minBitSize + steps - 1)

  calibrateCallsThreshold = 42000
  maxPercentile           = 0.95
)

type Pool struct {
  calls       [steps]uint64
  calibrating uint64

  defaultSize uint64
  maxSize     uint64

  pool sync.Pool
}

我們可以看到资柔,bytebufferpool內(nèi)部使用了標(biāo)準(zhǔn)庫(kù)中的對(duì)象sync.Pool

這里的steps就是上面所說(shuō)的區(qū)間撵割,一共 20 份贿堰。calls數(shù)組記錄放回對(duì)象容量落在各個(gè)區(qū)間的次數(shù)。

調(diào)用Pool.Get()將對(duì)象放回時(shí)啡彬,首先計(jì)算切片容量落在哪個(gè)區(qū)間羹与,增加calls數(shù)組中相應(yīng)元素的值:

// bytebufferpool/pool.go
func (p *Pool) Put(b *ByteBuffer) {
  idx := index(len(b.B))

  if atomic.AddUint64(&p.calls[idx], 1) > calibrateCallsThreshold {
    p.calibrate()
  }

  maxSize := int(atomic.LoadUint64(&p.maxSize))
  if maxSize == 0 || cap(b.B) <= maxSize {
    b.Reset()
    p.pool.Put(b)
  }
}

如果calls數(shù)組該元素超過(guò)指定值calibrateCallsThreshold=42000(說(shuō)明距離上次校準(zhǔn),放回對(duì)象到該區(qū)間的次數(shù)已經(jīng)達(dá)到閾值了庶灿,42000 應(yīng)該就是個(gè)經(jīng)驗(yàn)數(shù)字)纵搁,則調(diào)用Pool.calibrate()執(zhí)行校準(zhǔn)操作:

// bytebufferpool/pool.go
func (p *Pool) calibrate() {
  // 避免并發(fā)放回對(duì)象觸發(fā) `calibrate`
  if !atomic.CompareAndSwapUint64(&p.calibrating, 0, 1) {
    return
  }

  // step 1.統(tǒng)計(jì)并排序
  a := make(callSizes, 0, steps)
  var callsSum uint64
  for i := uint64(0); i < steps; i++ {
    calls := atomic.SwapUint64(&p.calls[i], 0)
    callsSum += calls
    a = append(a, callSize{
      calls: calls,
      size:  minSize << i,
    })
  }
  sort.Sort(a)

  // step 2.計(jì)算 defaultSize 和 maxSize
  defaultSize := a[0].size
  maxSize := defaultSize

  maxSum := uint64(float64(callsSum) * maxPercentile)
  callsSum = 0
  for i := 0; i < steps; i++ {
    if callsSum > maxSum {
      break
    }
    callsSum += a[i].calls
    size := a[i].size
    if size > maxSize {
      maxSize = size
    }
  }

  // step 3.保存對(duì)應(yīng)值
  atomic.StoreUint64(&p.defaultSize, defaultSize)
  atomic.StoreUint64(&p.maxSize, maxSize)

  atomic.StoreUint64(&p.calibrating, 0)
}

step 1.統(tǒng)計(jì)并排序

calls數(shù)組記錄了放回對(duì)象到對(duì)應(yīng)區(qū)間的次數(shù)。按照這個(gè)次數(shù)從大到小排序往踢。注意:minSize << i表示區(qū)間i的上限容量腾誉。

step 2.計(jì)算defaultSizemaxSize

defaultSize很好理解,取排序后的第一個(gè)size即可峻呕。maxSize值記錄放回次數(shù)超過(guò) 95% 的多個(gè)對(duì)象容量的最大值利职。它的作用是防止將使用較少的大容量對(duì)象放回對(duì)象池,從而占用太多內(nèi)存瘦癌。這里就可以理解Pool.Put()方法后半部分的邏輯了:

// 如果要放回的對(duì)象容量大于 maxSize猪贪,則不放回
maxSize := int(atomic.LoadUint64(&p.maxSize))
if maxSize == 0 || cap(b.B) <= maxSize {
  b.Reset()
  p.pool.Put(b)
}

step 3.保存對(duì)應(yīng)值

后續(xù)通過(guò)Pool.Get()獲取對(duì)象時(shí),若池中無(wú)空閑對(duì)象讯私,新創(chuàng)建的對(duì)象默認(rèn)容量為defaultSize热押。這樣的容量能滿足絕大多數(shù)情況下的使用,避免使用過(guò)程中的切片擴(kuò)容斤寇。

// bytebufferpool/pool.go
func (p *Pool) Get() *ByteBuffer {
  v := p.pool.Get()
  if v != nil {
    return v.(*ByteBuffer)
  }
  return &ByteBuffer{
    B: make([]byte, 0, atomic.LoadUint64(&p.defaultSize)),
  }
}

其他一些細(xì)節(jié):

  • 容量最小值取 2^6 = 64桶癣,因?yàn)檫@就是 64 位計(jì)算機(jī)上 CPU 緩存行的大小。這個(gè)大小的數(shù)據(jù)可以一次性被加載到 CPU 緩存行中抡驼,再小就無(wú)意義了鬼廓。
  • 代碼中多次使用atomic原子操作,避免加鎖導(dǎo)致性能損失致盟。

當(dāng)然這個(gè)庫(kù)缺點(diǎn)也很明顯碎税,由于大部分使用的容量都小于defaultSize,會(huì)有部分內(nèi)存浪費(fèi)馏锡。

總結(jié)

去掉注釋雷蹂,空行,bytebufferpool只用了 150 行左右的代碼就實(shí)現(xiàn)了一個(gè)高性能的Buffer對(duì)象池杯道。其中細(xì)節(jié)值得細(xì)細(xì)品味匪煌。閱讀高質(zhì)量的代碼有助于提升自己的編碼能力,學(xué)習(xí)編碼細(xì)節(jié)党巾。強(qiáng)烈建議抽空細(xì)讀Nァ!齿拂!

大家如果發(fā)現(xiàn)好玩驳规、好用的 Go 語(yǔ)言庫(kù),歡迎到 Go 每日一庫(kù) GitHub 上提交 issue??

參考

  1. bytebufferpool GitHub:https://github.com/valyala/bytebufferpool
  2. Go 每日一庫(kù) GitHub:https://github.com/darjun/go-daily-lib

我的博客:https://darjun.github.io

歡迎關(guān)注我的微信公眾號(hào)【GoUpUp】署海,共同學(xué)習(xí)吗购,一起進(jìn)步~

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
  • 序言:七十年代末趴俘,一起剝皮案震驚了整個(gè)濱河市蚤吹,隨后出現(xiàn)的幾起案子狡赐,更是在濱河造成了極大的恐慌裹刮,老刑警劉巖埃脏,帶你破解...
    沈念sama閱讀 219,490評(píng)論 6 508
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件纳击,死亡現(xiàn)場(chǎng)離奇詭異俏讹,居然都是意外死亡蝴罪,警方通過(guò)查閱死者的電腦和手機(jī)研底,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 93,581評(píng)論 3 395
  • 文/潘曉璐 我一進(jìn)店門埠偿,熙熙樓的掌柜王于貴愁眉苦臉地迎上來(lái),“玉大人飘哨,你說(shuō)我怎么就攤上這事胚想。” “怎么了芽隆?”我有些...
    開(kāi)封第一講書人閱讀 165,830評(píng)論 0 356
  • 文/不壞的土叔 我叫張陵浊服,是天一觀的道長(zhǎng)。 經(jīng)常有香客問(wèn)我胚吁,道長(zhǎng)牙躺,這世上最難降的妖魔是什么? 我笑而不...
    開(kāi)封第一講書人閱讀 58,957評(píng)論 1 295
  • 正文 為了忘掉前任腕扶,我火速辦了婚禮孽拷,結(jié)果婚禮上,老公的妹妹穿的比我還像新娘半抱。我一直安慰自己脓恕,他們只是感情好膜宋,可當(dāng)我...
    茶點(diǎn)故事閱讀 67,974評(píng)論 6 393
  • 文/花漫 我一把揭開(kāi)白布。 她就那樣靜靜地躺著炼幔,像睡著了一般秋茫。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上乃秀,一...
    開(kāi)封第一講書人閱讀 51,754評(píng)論 1 307
  • 那天肛著,我揣著相機(jī)與錄音,去河邊找鬼跺讯。 笑死枢贿,一個(gè)胖子當(dāng)著我的面吹牛,可吹牛的內(nèi)容都是我干的刀脏。 我是一名探鬼主播局荚,決...
    沈念sama閱讀 40,464評(píng)論 3 420
  • 文/蒼蘭香墨 我猛地睜開(kāi)眼,長(zhǎng)吁一口氣:“原來(lái)是場(chǎng)噩夢(mèng)啊……” “哼火本!你這毒婦竟也來(lái)了危队?” 一聲冷哼從身側(cè)響起,我...
    開(kāi)封第一講書人閱讀 39,357評(píng)論 0 276
  • 序言:老撾萬(wàn)榮一對(duì)情侶失蹤钙畔,失蹤者是張志新(化名)和其女友劉穎茫陆,沒(méi)想到半個(gè)月后,有當(dāng)?shù)厝嗽跇?shù)林里發(fā)現(xiàn)了一具尸體擎析,經(jīng)...
    沈念sama閱讀 45,847評(píng)論 1 317
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡簿盅,尸身上長(zhǎng)有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 37,995評(píng)論 3 338
  • 正文 我和宋清朗相戀三年,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了揍魂。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片桨醋。...
    茶點(diǎn)故事閱讀 40,137評(píng)論 1 351
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡,死狀恐怖现斋,靈堂內(nèi)的尸體忽然破棺而出喜最,到底是詐尸還是另有隱情,我是刑警寧澤庄蹋,帶...
    沈念sama閱讀 35,819評(píng)論 5 346
  • 正文 年R本政府宣布瞬内,位于F島的核電站,受9級(jí)特大地震影響限书,放射性物質(zhì)發(fā)生泄漏虫蝶。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 41,482評(píng)論 3 331
  • 文/蒙蒙 一倦西、第九天 我趴在偏房一處隱蔽的房頂上張望能真。 院中可真熱鬧,春花似錦、人聲如沸粉铐。這莊子的主人今日做“春日...
    開(kāi)封第一講書人閱讀 32,023評(píng)論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽(yáng)秦躯。三九已至忆谓,卻和暖如春裆装,著一層夾襖步出監(jiān)牢的瞬間踱承,已是汗流浹背。 一陣腳步聲響...
    開(kāi)封第一講書人閱讀 33,149評(píng)論 1 272
  • 我被黑心中介騙來(lái)泰國(guó)打工哨免, 沒(méi)想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留茎活,地道東北人。 一個(gè)月前我還...
    沈念sama閱讀 48,409評(píng)論 3 373
  • 正文 我出身青樓琢唾,卻偏偏與公主長(zhǎng)得像载荔,于是被迫代替她去往敵國(guó)和親。 傳聞我的和親對(duì)象是個(gè)殘疾皇子采桃,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 45,086評(píng)論 2 355

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

  • 簡(jiǎn)介 在編程開(kāi)發(fā)中懒熙,我們經(jīng)常會(huì)需要頻繁創(chuàng)建和銷毀同類對(duì)象的情形。這樣的操作很可能會(huì)對(duì)性能造成影響普办。這時(shí)工扎,常用的優(yōu)化...
    Go語(yǔ)言由淺入深閱讀 719評(píng)論 2 0
  • 簡(jiǎn)介 處理大量并發(fā)是 Go 語(yǔ)言的一大優(yōu)勢(shì)。語(yǔ)言內(nèi)置了方便的并發(fā)語(yǔ)法衔蹲,可以非常方便的創(chuàng)建很多個(gè)輕量級(jí)的 gorou...
    darjun閱讀 2,268評(píng)論 0 3
  • 最新版本:https://blog.haohtml.com/archives/30211[https://blog...
    cfanbo閱讀 554評(píng)論 0 0
  • hello World 應(yīng)用程序入口[注意] 必須是main包:package main 必須是main方法:fu...
    茶還是咖啡閱讀 403評(píng)論 0 2
  • 轉(zhuǎn)載自:超詳細(xì)的講解Go中如何實(shí)現(xiàn)一個(gè)協(xié)程池 并發(fā)(并行)肢娘,一直以來(lái)都是一個(gè)編程語(yǔ)言里的核心主題之一,也是被開(kāi)發(fā)者...
    紫云02閱讀 1,041評(píng)論 0 1