sync.Pool が返す値はポインタにするべき?

Goの標準パッケージには sync.Pool というものが用意されている。 これは、一度生成したものを使い回すような最適化を可能にするもので、 Pool.Newに生成処理を書くとGet() でプールから取得、Put() でプールへ返却ができる。

sync.Poolを使うことで700%(!)の高速化ができたという記事もあったりして、シンプルながら強力!

sync.Pool で生成する値の作り方

たとえば、[]byte のスライスをPoolで使いまわしたいといった場合、素直に書くとこうなる。

pool := sync.Pool{New: func() interface{} {
    return make([]byte, 1024)
}}


buf := pool.Get().([]byte)
...
pool.Put(buf)

しかし、Go公式リポジトリ内の sync.Pool の例を見てみると次のようなコメントが書かれていた。

var bufPool = sync.Pool{
    New: func() interface{} {
        // The Pool's New function should generally only return pointer
        // types, since a pointer can be put into the return interface
        // value without an allocation:
        return new(bytes.Buffer)
    },
}

どうやら、interface{} 型を返す場合に、実体の型がポインタでないとアロケーションが発生してしまうため、 Pool.New で生成する値は基本的にポインタを返すべきらしい。ええーーそうなのか!

つまり、[]byte を生成する場合は次のように変えたほうが良いということになる。

pool := sync.Pool{New: func() interface{} {
    buf := make([]byte, 1024)
    return &buf
}}

buf := pool.Get().(*[]byte)
...
pool.Put(buf)

実際にベンチマークを取ってみる

本当にそうなのか気になったのでベンチマークを取ってみた。

  • そもそもPoolを使わない場合
  • 非ポインタを返すPoolを使った場合
  • ポインタを返すPoolを場合
// スタックではなくヒープに乗せるためにここに宣言
var buf []byte

func BenchmarkBufferWithoutPool(b *testing.B) {
    for i := 0; i < b.N; i++ {
        buf = make([]byte, 1024)
    }
}

func BenchmarkBytesWithValuePool(b *testing.B) {
    pool := sync.Pool{New: func() interface{} {
        return make([]byte, 1024)
    }}
    for i := 0; i < b.N; i++ {
        buf := pool.Get().([]byte)
        pool.Put(buf)
    }
}

func BenchmarkBytesWithPointerPool(b *testing.B) {
    pool := sync.Pool{New: func() interface{} {
        buf := make([]byte, 1024)
        return &buf
    }}
    for i := 0; i < b.N; i++ {
        buf := pool.Get().(*[]byte)
        pool.Put(buf)
    }
}
BenchmarkBufferWithoutPool-8          7208229           139 ns/op        1024 B/op          1 allocs/op
BenchmarkBytesWithValuePool-8       24608931            48.4 ns/op        32 B/op          1 allocs/op
BenchmarkBytesWithPointerPool-8     69337044            16.9 ns/op         0 B/op          0 allocs/op

確かにポインタを使わない場合は、1 allocs/op と出て、ポインタを使った方は 0 allocs/op になった。なるほど。

感想

Goはあまりポインタとか値とか気にしなくても書けるな〜って思ってたら、こういった場所で差が出たりして難しさを感じた。

Go公式リポジトリのissueで「ポインタを返すべきとドキュメントに書くべきでは?」という提案がされたこともあったようだ。

これに対して「たとえ毎回allocationが発生してもpoolを使わない実装に比べるとパフォーマンスが良くなることもあるし、こういったものは細かい最適化テクニックだからアプリケーション実装側で各自ベンチマークを取ってやってくれ、ドキュメントには書かない」(雑訳)という形でスルーされていた。