sync.PoolでGolangのメモリアロケーションをチューニングする

メモリアロケーションはコストが大きい。 例えば、スライスを固定長でmakeしてアロケーションを一度で済ます場合と、appendで都度拡張する場合を比較すると、下記の差が出る。

// make([]int, 0)で作成したスライスに、100回append
BenchmarkMake0-4         	100000000	        48.8 ns/op	      49 B/op	       0 allocs/op

// make([]int, 100)で作成したスライスに、100回append
BenchmarkMake100-4       	100000000	        14.6 ns/op	      49 B/op	       0 allocs/op

また、固定長で領域を確保した場合でも、当然ながらmakeでアロケーションの時間が発生する。 大容量のアロケーションが必要な場合や、頻繁に実行される処理の場合、アロケーションにかかる時間も極力短くしたい。

そういった速度面の問題を解決するため、fmtio/ioutilの内部では、sync.Poolを使用している。

sync.Poolを使用すると、一度使用した領域を破棄せずプールしておき、後で領域が必要となった際にアロケーション無しで再利用することができる。

使い方

まずはプールを管理するsync.Pool型の変数を用意する。 そして、プールの在庫が無かった場合に領域を確保するための関数をNewに定義する。

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

プールの在庫を取得するには、Getを使用する。 在庫が無かった場合には、Newで定義した関数で領域が新しく確保される。

処理が終わった段階で、Putで領域をプールに戻すことができる。

p := pool.Get().([]int)
// do something
pool.Put(p)

これらの仕組みはgoroutine safeなので、並行処理でも競合しない。

ただし、Getでプールから取得した場合、過去にPutした時点の値が格納されている点には注意が必要(サンプル)。 また、プール中の領域は、通知なしで削除されることがある。

計測

[]intcounts要素分、確保した際のベンチマークを取得する。

検証コードは下記の通り。

// go version go1.9.1 darwin/amd64

var counts = []int{0, 1, 10, 100, 1000, 10000, 100000, 1000000}

func BenchmarkWithoutPool(b *testing.B) {
	for _, count := range counts {
		b.Run(fmt.Sprintf("%d", count), func(b *testing.B) {
			for i := 0; i < b.N; i++ {
				_ = make([]int, count)
			}
		})
	}
}

func BenchmarkWithPool(b *testing.B) {
	for _, count := range counts {
		b.Run(fmt.Sprintf("%d", count), func(b *testing.B) {
			pool := sync.Pool{
				New: func() interface{} {
					return make([]int, count)
				},
			}
			b.ResetTimer()
			for i := 0; i < b.N; i++ {
				p := pool.Get().([]int)
				pool.Put(p)
			}
		})
	}
}

結果(sync.Pool無効)

BenchmarkWithoutPool/0-4      	200000000	  8.79 ns/op	      0 B/op	0 allocs/op
BenchmarkWithoutPool/1-4      	 50000000	  25.2 ns/op	      8 B/op	1 allocs/op
BenchmarkWithoutPool/10-4     	 30000000	  44.8 ns/op	     80 B/op	1 allocs/op
BenchmarkWithoutPool/100-4    	 10000000	   209 ns/op	    896 B/op	1 allocs/op
BenchmarkWithoutPool/1000-4   	  1000000	  1687 ns/op	   8192 B/op	1 allocs/op
BenchmarkWithoutPool/10000-4  	   100000	 10979 ns/op	  81920 B/op	1 allocs/op
BenchmarkWithoutPool/100000-4 	    10000	104683 ns/op	 802816 B/op	1 allocs/op
BenchmarkWithoutPool/1000000-4	     2000	814199 ns/op	8003584 B/op	1 allocs/op

結果(sync.Pool有効)

BenchmarkWithPool/0-4         	 20000000	  80.7 ns/op	     32 B/op	1 allocs/op
BenchmarkWithPool/1-4         	 20000000	  78.0 ns/op	     32 B/op	1 allocs/op
BenchmarkWithPool/10-4        	 20000000	  77.8 ns/op	     32 B/op	1 allocs/op
BenchmarkWithPool/100-4       	 20000000	  78.9 ns/op	     32 B/op	1 allocs/op
BenchmarkWithPool/1000-4      	 20000000	  78.3 ns/op	     32 B/op	1 allocs/op
BenchmarkWithPool/10000-4     	 20000000	  78.1 ns/op	     32 B/op	1 allocs/op
BenchmarkWithPool/100000-4    	 20000000	  80.5 ns/op	     32 B/op	1 allocs/op
BenchmarkWithPool/1000000-4   	 20000000	  81.9 ns/op	     39 B/op	1 allocs/op

sync.Poolが無効の場合、要素数の増加に伴い処理時間が長くなっている。 sync.Poolが有効の場合、ほぼ一定の処理時間となり、プールが効いていることがわかる。

また、要素数が少ない場合は無効にした方が高速で、今回の場合では要素数10〜100の間で優劣が逆転している。

まとめ