Carpe Diem

備忘録

Goでsync.Poolを使って省コスト&高速化

概要

Goにはsync.PoolというFreeListの仕組みがあります。
役割としては既に割り当てられたメモリが不要になった時、解放する代わりにListにとして保持しておいて、メモリが必要になったらそこから取るというものです。なのでGCコストやメモリのallocationコストを省くことが可能になります。
またgoroutine-safeであるので並列処理でも問題ありません。

環境

  • go 1.7.4

どんなところで使われている?

標準パッケージのfmtloggerライブラリなどでよく見ます。
一定の文字列を保持してから書き込む、という処理を何度も繰り返すので、毎回メモリを確保するより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パターンで比較してみます。

  1. 毎回リストを生成
  2. グローバル変数にリストを保持
  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

The Go Playground

ベンチマーク結果

$  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で効果を発揮することが分かります。

ソース