LoginSignup
10
5

More than 3 years have passed since last update.

Goでsliceをpoolするときの罠

Last updated at Posted at 2020-09-09

sync.Poolのすすめ

Goの高速化テクニックの一つにsync.Poolを使うというものがあります. bufferとして使う領域を毎回allocせずに使いまわすことで, allocとGCにかかる時間を省くのが狙いです.

poolが効果を発揮するのは可変長データをgo routineで並列処理する場合です.
予め適切なbuffer sizeがわからない場合でも, 足りなくなったら都度交換することで, 十分な時間が経てばpool内は大きなbufferだけが残る事になり, 償却的にzero allocにできます.

// pool初期化
pool := sync.Pool {
        New: func() interface{} {
            return &Buffer{}
        },
}

//...

// 使うとき
buf := pool.Get().(*Buffer)
buf.Reset()
if !buf.EnoughSize() {
    buf = NewLargerBuffer() //bufferが足りないので大きいbufferに取り替える
}
pool.Put(buf)

ここで古い小さなbufferをpoolに戻さないところがポイントです.

例えばhttp handlerでzero allocを達成できれば, webサーバーの高速化にもかなり貢献するはずです.

sliceをpoolする

bufferとしてsliceを使うユースケースは多いと思いますが, sliceをpoolに突っ込んだ場合, zero allocを達成するまでにいくつか罠を踏んだので書いておきます.

slice実体をpoolする(罠1)

sliceをpoolしようとして, まず私はこう書きました. poolは[]intを貯めています.


func BenchmarkSlicePool(b *testing.B) {
    pool := sync.Pool{
        New: func() interface{} {
            return make([]int, 0, 0) // 適切なsizeがわからない場合を想定してあえてcap = 0とした. 
        },
    }
    b.RunParallel(func(pb *testing.PB) {
        for pb.Next() {
            arr := pool.Get().([]int)[0:0] // キャストしてlenリセット
            arr = append(arr, 42)
            pool.Put(arr)
        }
    })
}

slice = ポインタという感覚からすると正しいように思えますが, しかしこれはzero allocにはなりません.

bench name cores parallelism ns / op alloc bytes / op alloc times / op
BenchmarkSlicePool 12 100 28.2 ns/op 32 B/op 1 allocs/op

このallocは pool.Put(arr) から来ています.
Putの引数はinterface{}型なので, sliceをinterface{}にキャストしますがそのときこの中でslice headerをheapにコピーする必要があります.

実際にキャストの内部を見てみると...

src/runtime/iface.go
   380  func convTslice(val []byte) (x unsafe.Pointer) {
   381      // Note: this must work for any element type, not just byte.
   382      if (*slice)(unsafe.Pointer(&val)).array == nil {
   383          x = unsafe.Pointer(&zeroVal[0])
   384      } else {
   385          x = mallocgc(unsafe.Sizeof(val), sliceType, true)
   386          *(*[]byte)(x) = val
   387      }
   388  }   

なお, slice headerはsliceの内部表現で, 下記の通りptr, len, capを持つstructです.

type SliceHeader struct {
        Data uintptr
        Len  int
        Cap  int
}

sliceのポインタをpoolする(罠2)

OK. じゃあポインタにすっかという事で, return &make([]int)がsyntax errorになる事に若干イライラしつつ, こうします. 今度は*[]intを貯めています.


func BenchmarkSlicePtrPool_1(b *testing.B) {
    pool := sync.Pool{
        New: func() interface{} {
            s := make([]int, 0, 0)
            return &s
        },
    }
    b.RunParallel(func(pb *testing.PB) {
        for pb.Next() {
            arr := (*pool.Get().(*[]int))[0:0] // キャストしてderefしてlenリセット
            arr = append(arr, 42)
            pool.Put(&arr)
        }
    })
}

しかしこれもzero allocならず.

bench name cores parallelism ns / op alloc bytes / op alloc times / op
BenchmarkSlicePtrPool_1 12 100 27.0 ns/op 32 B/op 1 allocs/op

escape解析をしてみると, Getの行でarrがheapに行ってしまっていることがわかります.

$ go test . -gcflags="-m"
...
moved to heap: arr

Putでheapに行くことがわかっているので, arrをstackに確保しなかったということですね.

sliceのポインタをpoolし, ポインタを残す(罠3)

arrをstackに確保するべく, 次の手は

func BenchmarkSlicePtrPool_2(b *testing.B) {
    pool := sync.Pool{
        New: func() interface{} {
            s := make([]int, 0, 0)
            return &s
        },
    }
    b.RunParallel(func(pb *testing.PB) {
        for pb.Next() {
            ptr := pool.Get().(*[]int) // キャスト
            arr := *ptr                // deref
            arr = arr[0:0]             // lenリセット
            arr = append(arr, 42)
            pool.Put(ptr)
        }
    })
}

これもzero allocならず.

bench name cores parallelism ns / op alloc bytes / op alloc times / op
BenchmarkSlicePtrPool_2 12 100 12.4 ns/op 8 B/op 1 allocs/op

今度は, appendでせっかくgrowしたarrがPutできていません. Putの時点でptr != &arrであり, ptrの指すsliceは依然としてcap == 0のため, 毎回growしてしまいます.

sliceのポインタをpoolし, ポインタを残し, Put前にheaderをコピーする (多分これが一番速いと思います)

Putする前に, ptrが指すheap上のslice headerにstack上のslice header (arr)をコピーします.

func BenchmarkSlicePtrPool_3(b *testing.B) {
    pool := sync.Pool{
        New: func() interface{} {
            s := make([]int, 0, 0)
            return &s
        },
    }
    b.RunParallel(func(pb *testing.PB) {
        for pb.Next() {
            ptr := pool.Get().(*[]int)
            arr := *ptr
            arr = arr[0:0]
            arr = append(arr, 42)
            *ptr = arr                   //slice headerの書き戻し
            pool.Put(ptr)
        }
    })
}

これでようやく zero alloc達成できました.

bench name cores parallelism ns / op alloc bytes / op alloc times / op
BenchmarkSlicePtrPool_3 12 100 3.39 ns/op 0 B/op 0 allocs/op
BenchmarkSlicePool (再掲) 12 100 28.2 ns/op 32 B/op 1 allocs/op

slice実体のpoolから比べると8.3x速くなっています. 配列の領域自体は使い回しできていても, 高々32Bのallocでも馬鹿にできないことがわかります.

検証環境

go version go1.15.1 darwin/amd64
Mac Pro (Late 2013)
3.5 GHz 6-Core Intel Xeon E5

10
5
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
10
5