sync.Pool 一定會提升性能嗎?建議你先學習一下它的設計

2019-12-02   Go語言中文網


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

sync 包提供了一個強大且可復用的實例池,以減少 GC 壓力。在使用該包之前,我們需要在使用池之前和之後對應用程式進行基準測試。這非常重要,因為如果不了解它內部的工作原理,可能會影響性能。

池的限制

我們來看一個例子以了解它如何在一個非常簡單的上下文中分配 10k 次:

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)
}

他將註冊到 runtime 作為一個方法去清理池。GC 在文件 runtime/mgc.go 中將觸發這個方法:

func gcStart(trigger gcTrigger) {
[...]
// 在 GC 之前調用 clearpools
clearpools()

這就解釋了為什麼在調用 GC 時性能較低。因為每次 GC 運行時都會清理池對象(譯者註:池對象的生存時間介於兩次 GC 之間)。文檔[1] 也告知我們:

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

現在,讓我們創建一個流程圖以了解池的管理方式:

sync.Pool workflow in Go 1.12

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

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

新的無鎖池和 victim 緩存

Go 1.13 版將 shared 用一個雙向鍊表[2]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[3],所謂受害者緩存 Victim Cache,是一個與直接匹配或低相聯緩存並用的、容量很小的全相聯緩存。當一個數據塊被逐出緩存時,並不直接丟棄,而是暫先進入受害者緩存。如果受害者緩存已滿,就替換掉其中一項。當進行緩存標籤匹配時,在與索引指向標籤匹配的同時,並行查看受害者緩存,如果在受害者緩存發現匹配,就將其此數據塊與緩存中的不匹配數據塊做交換,同時返回給處理器。),新策略非常簡單。現在有兩組池:活動池和存檔池(譯者註:allPools 和 oldPools)。當 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 緩存。


via https://medium.com/@blanchon.vincent/go-understand-the-design-of-sync-pool-2dde3024e277

作者:Vincent Blanchon[4]譯者:咔嘰咔嘰[5]校對:polaris1119[6]

本文由 GCTT[7] 原創編譯,Go 中文網[8] 榮譽推出


文中連結

[1]文檔: https://golang.org/pkg/sync/#Pool

[2]雙向鍊表: https://github.com/golang/go/commit/d5fd2dd6a17a816b7dfd99d4df70a85f1bf0de31#diff-491b0013c82345bf6cfa937bd78b690d

[3]commit: https://github.com/golang/go/commit/2dcbf8b3691e72d1b04e9376488cef3b6f93b286

[4]Vincent Blanchon: https://medium.com/@blanchon.vincent

[5]

咔嘰咔嘰: https://github.com/watermelo

[6]

polaris1119: https://github.com/polaris1119

[7]

GCTT: https://github.com/studygolang/GCTT

[8]

Go 中文網: https://studygolang.com/


推薦閱讀

  • Go 號稱幾行代碼開啟一個 HTTP Server,底層都做了什麼?
  • Go 語言中 GOROOT 一定需要設置嗎?




喜歡本文的朋友,歡迎關注「Go語言中文網」:

Go語言中文網啟用微信學習交流群,歡迎加微信:274768166