1. 程式人生 > 程式設計 >[譯]Go: 理解Sync.Pool的設計思想

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

原文:medium.com/a-journey-w…

這篇文章基於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)。這些內部的池由兩個屬性組成privateshared。前者只是他的所有者可以訪問(push以及pop操作,也因此不需要鎖),而`shared可以被任何processer讀取並且是需要自己維持併發安全。而實際上,pool不是一個簡單的本地快取,他有可能在我們的程式中被用於任何的協程或者goroutines

Go的1.13版將改善對shared的訪問,還將帶來一個新的快取,該快取解決與垃圾回收器和清除池有關的問題。

新的無需鎖pool和victim cache

Go 1.13版本使用了一個新的雙向連結串列作為shared pool,去除了鎖,提高了shared的訪問效率。這個改造主要是為了提高快取效能。這裡是一個訪問shared的流程

new shared pools in Go 1.13

在這個新的鏈式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
複製程式碼

通過這種策略,由於受害者快取,該應用程式現在將有一個更多的垃圾收集器週期來建立/收集帶有備份的新專案。 在工作流中,將在共享池之後在過程結束時請求犧牲者快取。