概要
Goにはsync.PoolというFreeListの仕組みがあります。
役割としては既に割り当てられたメモリが不要になった時、解放する代わりにListにとして保持しておいて、メモリが必要になったらそこから取るというものです。なのでGCコストやメモリのallocationコストを省くことが可能になります。
またgoroutine-safeであるので並列処理でも問題ありません。
環境
- go 1.7.4
どんなところで使われている?
標準パッケージのfmtやloggerライブラリなどでよく見ます。
一定の文字列を保持してから書き込む、という処理を何度も繰り返すので、毎回メモリを確保するよりFreeListを持って再利用することでGCコストやメモリのallocationコストを省いているのが分かります。
使い方
以下のような流れです。
- sync.PoolのNewに、FreeListを新規作成するための関数を渡す
- GetでFreeListを取得する
- ↑でプールに空きがなければNewを呼んで新規で生成する
- 使い終わったらリストの中身をリセットし、Putしてプールに戻す
コードとしては以下の感じです。
import "sync" var cap = 10000 var globalPool = sync.Pool{ New: func() interface{} { return make([]int, 0, cap) }, } func someFunc() []int { list := globalPool.Get().([]int) for i := 0; i < 2*cap; i++ { list = append(list, i) } out := list // reset free list list = list[:0] // return free list to pool globalPool.Put(list) return out }
比較①
以下の3パターンで比較してみます。
- 毎回リストを生成
- グローバル変数にリストを保持
- sync.Poolにリストを保持
1. 毎回リストを生成
var cap = 10000 func someFunc1() { list := make([]int, 0, cap) for i := 0; i < 2*cap; i++ { list = append(list, i) } return }
毎回sliceを生成するので無駄がある感じです。
2. グローバル変数にリストを保持
var cap = 10000 var globalList = make([]int, 0, cap) var globalMutex = sync.Mutex{} func someFunc2() { globalMutex.Lock() defer globalMutex.Unlock() for i := 0; i < 2*cap; i++ { globalList = append(globalList, i) } // reset free list globalList = globalList[:0] return }
1だと毎回生成する無駄があるので自前でFreeListを用意した形です。
グローバルなのでそのままだとgoroutine-safeではないのでmutexを入れていますが、このリストを並行して利用することはできないのでスケールしません。
3. sync.Poolにリストを保持
var cap = 10000 var globalPool = sync.Pool{ New: func() interface{} { return make([]int, 0, cap) }, } func someFunc3() { list := globalPool.Get().([]int) for i := 0; i < 2*cap; i++ { list = append(list, i) } // reset free list list = list[:0] // return free list to pool globalPool.Put(list) return }
sync.Poolで再利用する仕組みにしています。
ポイントは
// reset free list list = list[:0]
で、これによってcapacityを保ったまま(=再アロケーションをせず)、リセットできる(lenが0のsliceに)ようになります。
func main() { a := []int{1, 2, 3} fmt.Printf("len: %d, cap: %d\n", len(a), cap(a)) a = a[:0] fmt.Printf("len: %d, cap: %d\n", len(a), cap(a)) } // len: 3, cap: 3 // len: 0, cap: 3
ベンチマーク結果
$ go test -bench . -benchmem testing: warning: no tests to run BenchmarkSomeFunc1-8 20000 74012 ns/op 507904 B/op 4 allocs/op BenchmarkSomeFunc2-8 30000 44044 ns/op 0 B/op 0 allocs/op BenchmarkSomeFunc3-8 50000 32996 ns/op 42 B/op 1 allocs/op
速さはsync.Poolが、メモリ使用量・allocsはグローバル変数が少ないです。
グローバルに持ってるので、sync.Poolと違ってメモリアロケーションがカウントされないのだと思います。
しかしながら速さの差は並行で実行するとロックがないsync.Poolの方が何倍も早くすることができます。また毎回生成するケースと比べても非常にメモリ使用量が少ないです。
このようにsync.PoolによってFreeListを利用することで省コスト&高速化することが可能になります。
比較②
今度はsliceでなく、普通のstructで検証してみます。
コード
特定のJSONを何度も同じstructにunmarshalするといったケースを想定しています。
type Album struct { ID string `json:"id"` Name string `json:"name"` } var jsonPool = sync.Pool{ New: func() interface{} { return Album{} }, } func run(n int) { albums := make([]Album, n) for i := 0; i < n; i++ { b := Album{} json.Unmarshal([]byte(fmt.Sprintf("{\"id\":\"%d\",\"name\":\"album%d\"}", i, i)), &b) albums[i] = b } } func runWithPool(n int) { albums := make([]Album, n) for i := 0; i < n; i++ { b := jsonPool.Get().(Album) json.Unmarshal([]byte(fmt.Sprintf("{\"id\":\"%d\",\"name\":\"album%d\"}", i, i)), &b) albums[i] = b jsonPool.Put(b) } }
ベンチマークコード
var size = 1000 func BenchmarkRun(b *testing.B) { for i := 0; i < b.N; i++ { run(size) } } func BenchmarkRunWithPool(b *testing.B) { for i := 0; i < b.N; i++ { runWithPool(size) } }
結果
$ go test -bench . -benchmem goos: darwin goarch: amd64 BenchmarkRun-8 1000 1310701 ns/op 448914 B/op 8999 allocs/op BenchmarkRunWithPool-8 1000 1370557 ns/op 481046 B/op 10000 allocs/op PASS
今度は逆に遅くなって&アロケーションも増えてしまいました。
つまりなんでもかんでもPoolすべきではなく、大きなsliceやstructで効果を発揮することが分かります。