Go 併發之性能提高殺器 Pool

Go 併發系列是根據我對晁嶽攀老師的《Go 併發編程實戰課》的吸取和理解整理而成,若有誤差,歡迎指正~

爲何須要池化 Pool

Go 是一個支持自動垃圾回收的語言,對程序員而言,咱們想建立對象就建立,不用關心資源的回收,大大提升了開發的效率。程序員

可是方便的背後,卻有也有不小的代價。Go 的垃圾回收機制仍是有一個 STW (stop-the-world,程序暫停)的時間,大量建立的對象,都會影響垃圾回收標記的時間。數據庫

除此以外,像數據庫的鏈接,tcp 鏈接,這些鏈接的建立自己就十分耗時,若是能將這些鏈接複用,也能減小業務耗時。編程

因此,高併發場景下,採用池化(Pool)手段,對某些對象集中管理,重複利用,減小建立和垃圾回收的成本,不只能夠大大提升業務的響應速度,也能提升程序的總體性能。緩存

什麼是池化 Pool

池化就是對某些對象進行集中管理,重複利用,減小對象建立和垃圾回收的成本。安全

Go 標準庫 sync 提供了一個通用的 Pool,經過這個 Pool 能夠建立池化對象,實現通常對象的管理。數據結構

下面咱們主要看一下 sync.Pool 的實現。併發

sync.Pool 使用

sync.Pool 的使用很簡單,它有1個對外的成員變量 New 和2個對外的成員方法 Get 和 Put。tcp

下面是一個使用示例(見 fUsePool 方法):函數

type AFreeCoder struct {    officialAccount string    article         string    content         \[\]string    placeHolder     string}// 爲了真實模擬,這裏禁止編譯器使用內聯優化//go:noinlinefunc NewAFreeCoder() \*AFreeCoder {    return &AFreeCoder{        officialAccount: "碼農的自由之路",        content:         make(\[\]string, 10000, 10000),        placeHolder:     "若是以爲有用,歡迎關注哦~",    }}func (a \*AFreeCoder) Write() {    a.article = "Go 併發之性能提高殺器 Pool"}func f(concurrentNum int) {    var w sync.WaitGroup    w.Add(concurrentNum)    for i := 0; i < concurrentNum; i++ {        go func() {            defer w.Done()            a := NewAFreeCoder()            a.Write()        }()    }    w.Wait()}func fUsePool(concurrentNum int) {    var w sync.WaitGroup    p := sync.Pool{        New: func() interface{} {            return NewAFreeCoder()        },    }    w.Add(concurrentNum)    for i := 0; i < concurrentNum; i++ {        go func() {            defer w.Done()            a := p.Get().(\*AFreeCoder)            defer p.Put(a)            a.Write()        }()    }    w.Wait()}

AFreeCoder 是自定義的結構體,用來模擬初始化比較耗時類型。高併發

New 是函數類型變量,傳入的函數須要實現 AFreeCoder 的初始化。

Get 方法返回的是 interface{} 類型,須要斷言成 New 返回的類型。

Put 方法也比較好理解,變量用完了再放回去。

使用 sync.Pool 真的能提高性能嗎?

上面的示例中,f 和 fUsePool 分別實現了不使用 Pool 和使用 Pool 狀況下,併發執行 Write 函數的功能。

那麼這兩個函數的性能對好比何呢?咱們能夠用 go test 的 benchmark 測試一下(併發數 concurrentNum=100),測試結果以下:

goos: darwingoarch: amd64pkg: go\_practice/pool\_exampleBenchmark\_f-8                853           1355041 ns/op        16392237 B/op        203 allocs/opBenchmark\_fUsePool-8       12460             98046 ns/op          565066 B/op          9 allocs/opPASSok      go\_practice/pool\_example        4.663s

測試結果顯示,使用了 Pool 以後,內存分配的次數相比不使用 Pool 的方式少不少,總體的耗時也會小不少。

若是把上面示例中初始化函數 NewAFreeCoder 中 content 的初始化操做去掉,再測試一次呢?測試結果以下:

goos: darwingoarch: amd64pkg: go\_practice/pool\_exampleBenchmark\_f-8                853           1355041 ns/op        16392237 B/op        203 allocs/opBenchmark\_fUsePool-8       12460             98046 ns/op          565066 B/op          9 allocs/opPASSok      go\_practice/pool\_example        4.663s

上面數據粘錯了)使用了 Pool 以後,內存分配次數和每次操做消耗的內存仍然不多,可是總體的耗時相對不使用 Pool 的狀況並沒減小。

這是由於此時建立 AFreeCoder 對象的成本較低,而 Pool 相關操做也會有性能的消耗,因此才致使二者總體耗時差很少。

使用 sync.pool 的注意點

sync.Pool 自己是線程安全的,能夠多個 goroutine 併發調用,使用起來很方便,可是有兩個注意點:

  1. 禁止拷貝
  2. 不能存放須要保持長鏈接的對象

第1點,禁止拷貝很好理解,畢竟 New 很容易修改。

第2點,不能存放須要保持長鏈接的對象。這是由於 sync.Pool 註冊了本身的 Pool 清理函數,Pool 中的變量可能會被垃圾回收掉。若是需求保存長鏈接,有不少其它的 Pool 實現了這種功能。

sync.pool 的實現

sync.pool 的定義

看一下 Pool 的定義:

type Pool struct {
    noCopy noCopy

    local     unsafe.Pointer // local fixed-size per-P pool, actual type is \[P\]poolLocal
    localSize uintptr        // size of the local array

    victim     unsafe.Pointer // local from previous cycle
    victimSize uintptr        // size of victims array

    // New optionally specifies a function to generate
    // a value when Get would otherwise return nil.
    // It may not be changed concurrently with calls to Get.
    New func() interface{}
}

noCopy 不用多解釋,用於靜態檢查,防拷貝的。New 前面也說過,用來存對象初始化函數的。

重點是 local 和 victim 這兩個字段。解釋這兩個字段前,先上一張《Go 併發實戰課》原文的 sync.Pool 數據結構的示意圖:

圖片

sync.Pool 中,緩存的對象並非存儲在一個隊列中,而是根據處理器 P 的核數 n 存了 n 份,這樣能最大程度的保證併發的時候 n 個 goroutine 能夠同時獲取對象。

local 和 victim 結構都同樣,都是 poolLocal 類型,有 private 和 shared 成員。private 存儲單個對象,shared 是 poolChain 類型,相似隊列,存了一堆對象。由於 local 和 victim 都是和處理器綁定的,當某個 goroutine 獨佔一個處理器時,直接經過 private 取值不須要加鎖,速度就會很快。

爲何有了 local,還須要 victim 呢?這是爲了下降池子中對象被回收的可能性

因爲 sync.Pool 中存儲對象的個數不定,大小不定,因此它須要在系統閒暇的時候將變量回收掉。其實現方式以下:

func poolCleanup() {
    for \_, p := range oldPools {
        p.victim = nil
        p.victimSize = 0
    }

    for \_, p := range allPools {
        p.victim = p.local
        p.victimSize = p.localSize
        p.local = nil
        p.localSize = 0
    }
    
    oldPools, allPools = allPools, nil
}

poolClean() 函數被註冊到了 runtime 中,會在每一次 GC 調用以前被調用。這樣 GC 第一次調用的時候,local 雖然被清空,可是還能經過 victim 拿到池子中的對象。

Put 方法

Put 方法實現的功能是將用完的對象從新放回池子裏。由於 Put 比較簡單,因此先介紹 Put 方法。

Put 方法實現以下:

func (p \*Pool) Put(x interface{}) {    if x == nil {        return    }    // 把當前goroutine固定在當前的P上    // l 就是 local    l, \_ := p.pin()     if l.private == nil {        l.private = x        x = nil    }    if x != nil {        l.shared.pushHead(x)    }    runtime\_procUnpin()}

先說一下 p.pin() 和 runtime\_procUnpin(), 這兩個函數分別實現了某 goroutine 搶佔當前 P(處理器)和解除搶佔的功能。因此這裏 private 的複製和以後 Get 方法中的讀取都不須要加鎖。

整個邏輯比較簡單,優先存到本地 private,若是 private 已經有值了,就放到本地隊列中。

Get 方法

Get 方法實現以下:

func (p \*Pool) Get() interface{} {    // 把當前goroutine固定在當前的P上    l, pid := p.pin()    x := l.private // 優先從local的private字段取,快速    l.private = nil    if x == nil {        // 從當前的local.shared彈出一個,注意是從head讀取並移除        x, \_ = l.shared.popHead()        if x == nil { // 若是沒有,則去偷一個            x = p.getSlow(pid)         }    }    runtime\_procUnpin()    // 若是沒有獲取到,嘗試使用New函數生成一個新的    if x == nil && p.New != nil {        x = p.New()    }    return x}

Get 方法總體歸納就是從池子中取出一個對象,若是沒有對象了,就 New 一個,再返回。

細節上,先從當前 P 對應的 local 的 private 獲取,獲取不到,就從當前 P 的 local 的隊列 shared 中獲取,還獲取不到就從其它 P 的 shared 中獲取 (getSlow 方法)。

若是最終仍然獲取不到,才 New 一個對象。

sync.Pool 的坑

雖然 sync.Pool 也作了不少優化,性能有了很大的提高,可是使用的時候仍是有兩個坑:

內存泄露

若是池子中對象的類型是 slice,它的 cap 可能不斷的變大,而 sync.Pool 的回收機制(第二次回收)可能致使這些過大的對象愈來愈多,且一直沒法回收,最終形成內存泄露。

因此有一些特定 Pool 的使用中,會對池子中的變量的大小作一個限制,超過一個閾值直接丟棄。

內存浪費

除了內存泄露外,還有一種浪費的狀況,就是池子中的變量變得很大,可是不少時候只須要一個很小的變量,就會形成內存浪費的狀況。

長鏈接對象如何池化?

由於 sync.Pool 保存的對象可能會被無通知的釋放掉,並不適合用來保存鏈接對象。鏈接對象的保存通常都經過其它方法完成。

好比 Go 中 http 鏈接的鏈接池的實如今 Transport 中,它用一個 idleConn 對象(map)來保存鏈接,因爲沒有相似 sync.Pool 的垃圾回收方法 PoolClean(),因此能保持長鏈接。Transport 對鏈接數量的控制經過 LRU 實現。

像第三方包 faith/pool,它是經過 channel + Mutex 的方式實現的 Pool,空閒的鏈接放到 channel 中。這也是 channel 的一個應用場景。

最後

Pool 是一個通用的概念,也是解決對象重用和預先分配的一個經常使用的優化手段。

可是項目一開始其實不必考慮考慮這種優化,只有到了中後期階段,出現性能瓶頸,須要優化的時候,能夠考慮經過 Pool 的方式來優化。

圖片

碼農的自由之路

碼農的自由之路

996的碼農,也能自由~

47篇原創內容

公衆號


都看到這裏了,不如點個 贊/在看,加個關注唄~~

相關文章
相關標籤/搜索