[譯] Go: 理解 Sync.Pool 的設計

ℹ️本文基於 Go 1.12 和 1.13 版本,並解釋了這兩個版本之間 sync/pool.go 的演變。git

sync 包提供了一個強大且可複用的實例池,以減小 GC 壓力。在使用該包以前,咱們須要在使用池以前和以後對應用程序進行基準測試。這很是重要,由於若是不瞭解它內部的工做原理,可能會影響性能。github

池的限制

咱們來看一個例子以瞭解它如何在一個很是簡單的上下文中分配 10k 次:golang

type Small struct {
   a int
}

var pool = sync.Pool{
   New: func() interface{} { return new(Small) },
}

//go:noinline
func inc(s *Small) { s.a++ }

func BenchmarkWithoutPool(b *testing.B) {
   var s *Small
   for i := 0; i < b.N; i++ {
      for j := 0; j < 10000; j++ {
         s = &Small{ a: 1, }
         b.StopTimer(); inc(s); b.StartTimer()
      }
   }
}

func BenchmarkWithPool(b *testing.B) {
   var s *Small
   for i := 0; i < b.N; i++ {
      for j := 0; j < 10000; j++ {
         s = pool.Get().(*Small)
         s.a = 1
         b.StopTimer(); inc(s); b.StartTimer()
         pool.Put(s)
      }
   }
}
複製代碼

上面有兩個基準測試,一個沒有使用 sync.Pool,另外一個使用了:緩存

name           time/op        alloc/op        allocs/op
WithoutPool-8  3.02ms ± 1%    160kB ± 0%      1.05kB ± 1%
WithPool-8     1.36ms ± 6%   1.05kB ± 0%        3.00 ± 0%
複製代碼

因爲循環有 10k 次迭代,所以不使用池的基準測試在堆上須要 10k 次內存分配,而使用了池的基準測試僅進行了 3 次分配。 這 3 次分配由池產生的,但卻只分配了一個結構實例。目前看起來還不錯;使用 sync.Pool 更快,消耗更少的內存。安全

可是,在一個真實的應用程序中,你的實例可能會被用於處理繁重的任務,並會作不少頭部內存分配。在這種狀況下,當內存增長時,將會觸發 GC。咱們還可使用命令 runtime.GC() 來強制執行基準測試中的 GC 來模擬此行爲:(譯者注:在 Benchmark 的每次迭代中添加runtime.GC()併發

name           time/op        alloc/op        allocs/op
WithoutPool-8  993ms ± 1%    249kB ± 2%      10.9k ± 0%
WithPool-8     1.03s ± 4%    10.6MB ± 0%     31.0k ± 0%
複製代碼

咱們如今能夠看到,在 GC 的狀況下池的性能較低,分配數和內存使用也更高。咱們繼續更深刻地瞭解緣由。性能

池的內部工做流程

深刻了解 sync/pool.go 包的初始化,能夠幫助咱們以前的問題的答案:測試

func init() {
   runtime_registerPoolCleanup(poolCleanup)
}
複製代碼

他將註冊一個在運行時清理 pool 對象的方法。GC 在文件 runtime/mgc.go 中將觸發這個方法:ui

func gcStart(trigger gcTrigger) {
   [...]
   // 在開始 GC 前調用 clearpools
   clearpools()
複製代碼

這就解釋了爲何在調用 GC 時性能較低。由於每次 GC 運行時都會清理 pool 對象(譯者注:pool 對象的生存時間介於兩次 GC 之間)。文檔也告知咱們:spa

存儲在池中的任何內容均可以在不被通知的狀況下隨時自動刪除

如今,讓咱們建立一個流程圖以瞭解池的管理方式:

sync.Pool workflow in Go 1.12

對於咱們建立的每一個 sync.Pool,go 生成一個鏈接到每一個處理器(譯者注:處理器即 Go 中調度模型 GMP 的 P,pool 裏實際存儲形式是 [P]poolLocal)的內部池 poolLocal。該結構由兩個屬性組成:privateshared。第一個只能由其全部者訪問(push 和 pop 不須要任何鎖),而 shared 屬性可由任何其餘處理器讀取,而且須要併發安全。實際上,池不是簡單的本地緩存,它能夠被咱們的應用程序中的任何 線程/goroutines 使用。

Go 的 1.13 版本將改進 shared 的訪問,而且還將帶來一個新的緩存,以解決 GC 和池清理相關的問題。

新的無鎖池和 victim 緩存

Go 1.13 版將 shared 用一個雙向鏈表poolChain做爲儲存結構,此次改動刪除了鎖並改善了 shared 的訪問。如下是 shared 訪問的新流程:

new shared pools in Go 1.13

使用這個新的鏈式結構池,每一個處理器能夠在其 shared 隊列的頭部 push 和 pop,而其餘處理器訪問 shared 只能從尾部 pop。因爲 next/prev 屬性,shared 隊列的頭部能夠經過分配一個兩倍大的新結構來擴容,該結構將連接到前一個結構。初始結構的默認大小爲 8。這意味着第二個結構將是 16,第三個結構 32,依此類推。

此外,如今 poolLocal 結構不須要鎖了,代碼能夠依賴於原子操做。

關於新加的 victim 緩存(譯者注:關於引入 victim 緩存的 commit,引入該緩存就是爲了解決以前 Benchmark 那個問題),新策略很是簡單。如今有兩組池:活動池和存檔池(譯者注:allPoolsoldPools)。當 GC 運行時,它會將每一個池的引用保存到池中的新屬性(victim),而後在清理當前池以前將該組池變成存檔池:

// 從全部 pool 中刪除 victim 緩存
for _, p := range oldPools {
   p.victim = nil
   p.victimSize = 0
}

// 把主緩存移到 victim 緩存
for _, p := range allPools {
   p.victim = p.local
   p.victimSize = p.localSize
   p.local = nil
   p.localSize = 0
}

// 非空主緩存的池如今具備非空的 victim 緩存,而且池的主緩存被清除
oldPools, allPools = allPools, nil
複製代碼

有了這個策略,應用程序如今將有一個循環的 GC 來 建立/收集 具備備份的新元素,這要歸功於 victim 緩存。在以前的流程圖中,將在請求"shared" pool 的流程以後請求 victim 緩存。

相關文章
相關標籤/搜索