Goでsliceをpoolするときの罠

# sync.Poolのすすめ

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

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

```go
// 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を貯めています.

```go

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にコピーする必要があります.

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

```go: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を貯めています.

```go

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に行ってしまっていることがわかります.

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

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

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

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

```go
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)をコピーします.

```go
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