[譯]Go: 理解Sync.Pool的設計思想
這篇文章基於Go1.12和1.13,我們來看看這兩個版本間sync/pool.go的革命性變化。
Sync包提供了強大的可被重複利用例項池,為了降低垃圾回收的壓力。在使用這個包之前,需要將你的應用跑出使用pool之前與之後的benchmark資料,因為在一些情況下使用如果你不清楚pool內部原理的話,反而會讓應用的效能下降。
pool的侷限性
我們先來看看一些基礎的例子,來看看他在一個相當簡單情況下(分配1K記憶體)是如何工作的:
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)
}
}
}
複製程式碼
下面是兩個benchmarks,一個是使用了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的迭代,那個沒有使用pool的benchmark顯示在堆上建立了10k的記憶體分配,而使用了pool的只使用了3. 3個分配由pool進行的,但只有一個結構體的例項被分配到記憶體。到目前為止可以看到使用pool對於記憶體的處理以及記憶體消耗上面更加友善。
但是,在實際例子裡面,當你使用pool,你的應用將會有很多新在堆上的記憶體分配。這種情況下,當記憶體升高了,就會觸發垃圾回收。
我們可以強制垃圾回收的發生通過使用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%
複製程式碼
我們現在可以看到使用了pool的情況反而記憶體分配比不使用pool的時候高了。我們來深入地看一下這個包的原始碼來理解為什麼會這樣。
內部工作流
看一下sync/pool.go
檔案會給我們展示一個初始化函式,這個函式裡面的內容能解釋我們剛剛的情景:
func init() {
runtime_registerPoolCleanup(poolCleanup)
}
複製程式碼
這裡在執行時註冊成了一個方法去清理pools。並且同樣的方法在垃圾回收裡面也會觸發,在檔案runtime/mgc.go
裡面
func gcStart(trigger gcTrigger) {
[...]
// clearpools before we start the GC
clearpools()
複製程式碼
這就解釋了為什麼當呼叫垃圾回收時,效能會下降。pools在每次垃圾回收啟動時都會被清理。這個檔案其實已經有警告我們
Any item stored in the Pool may be removed automatically at any time without notification
複製程式碼
接下來讓我們建立一個工作流來理解一下這裡面是如何管理的
sync.Pool workflow in Go 1.12我們建立的每一個sync.Pool
,go都會生成一個內部池poolLocal
連線著各個processer(GMP中的P)。這些內部的池由兩個屬性組成private
和shared
。前者只是他的所有者可以訪問(push以及pop操作,也因此不需要鎖),而`shared可以被任何processer讀取並且是需要自己維持併發安全。而實際上,pool不是一個簡單的本地快取,他有可能在我們的程式中被用於任何的協程或者goroutines
Go的1.13版將改善對shared
的訪問,還將帶來一個新的快取,該快取解決與垃圾回收器和清除池有關的問題。
新的無需鎖pool和victim cache
Go 1.13版本使用了一個新的雙向連結串列作為shared pool
,去除了鎖,提高了shared
的訪問效率。這個改造主要是為了提高快取效能。這裡是一個訪問shared
的流程
在這個新的鏈式pool裡面,每一個processpr都可以在連結串列的頭進行push與pop,然後訪問shared
可以從連結串列的尾pop出子塊。結構體的大小在擴容的時候會變成原來的兩倍,然後結構體之間使用next/prev
指標進行連線。結構體預設大小是可以放下8個子項。這意味著第二個結構體可以容納16個子項,第三個是32個子項以此類推。同樣地,我們現在不再需要鎖,程式碼執行具有原子性。
關於新快取,新策略非常簡單。 現在有2組池:活動池和已歸檔池。 當垃圾收集器執行時,它將保留每個池對該池內新屬性的引用,然後在清理當前池之前將池的集合複製到歸檔池中:
// Drop victim caches from all pools.
for _,p := range oldPools {
p.victim = nil
p.victimSize = 0
}
// Move primary cache to victim cache.
for _,p := range allPools {
p.victim = p.local
p.victimSize = p.localSize
p.local = nil
p.localSize = 0
}
// The pools with non-empty primary caches now have non-empty
// victim caches and no pools have primary caches.
oldPools,allPools = allPools,nil
複製程式碼
通過這種策略,由於受害者快取,該應用程式現在將有一個更多的垃圾收集器週期來建立/收集帶有備份的新專案。 在工作流中,將在共享池之後在過程結束時請求犧牲者快取。