体はドクペで出来ている

インフラ、Goの割合が多い技術ブログ

sync.Poolの使い方

はじめに

Golangには平行処理でよく使う機能がまとめられたパッケージ sync があります。この中には Pool という構造体がありその名の通り何らかの使うものを貯めておけ、必要なタイミングでそれを取り出し不要になったらまたしまう、という仕組みを簡単に作ることができます。

少し触ってみて面白かったので使い方を書き留めておきます。

考え方

一般的にITの文脈でいうところの「プール」と考えてOKです。プールには待機状態になっている何らかのリソースがあり、それを取得して処理が終わったらまた戻してやることで再利用できるようになります。

Golangのプール機能においては取得時にリソースが不足している場合は自動的に新規作成され、それが使用後にプールに戻されるので需要の逼迫度合いに応じてリソースが増えていきます。

このような特性から「調達コストが重いオブジェクトを使い回したい」「調達コストが重いので初期化時点である程度用意しておくが、不足した場合には随時追加して欲しい」という性質のものが使いどころになります(例えばメモリーアロケーションやデータベースとの接続等)。

基本的な使い方

まず下記のように sync.Pool 型の構造体を作りリソース新規作成用の関数を定義します。ここでは単にint型の1を返却しているだけですが、実用する場合は先に挙げたような重いリソース確保の処理を書くことになるでしょう。

pool := &sync.Pool{
    New: func() interface{} {
        return 1
    },
}

リソースを取得する場合は Get() を、返却する場合は Put(interface{}) を使います。ちなみに初期化用の仕組みは無いのでプログラム開始時に予めリソースを用意する場合にもPutを使います。

また sync.Pool とのやりとりは全て interface{} 型で行うためリソース取得時にはアサーションが必要になります。

r, ok := pool.Get().(int) // リソースの取得
if !ok {
    panic("なんか変")
}
// なんらかの処理
pool.Put(r) // リソースの返却

注意点

返却を忘れるとプールの意味を成さない

当たり前ですがPutを忘れていると全く再利用が行われなくなり、全てのリソースを作成して使い捨てる場合と何ら変わりなくなってしまいます。

Put忘れのような初歩的なミスはさておき、負荷がバーストした場合も似たような事象が発生します(後でサンプルコードあり)。この場合は使い回しによる効率化を狙うことは難しくなるので初期化時に用意するリソースの数で上手く吸収してあげる必要があります。

取得・返却時の排他処理は不要

sync.Pool は並行処理の中で使うことを前提に作られているので複数のgoroutineが同時にPoolをやりとりした場合でも特に考慮する必要はありません。但しそれはGetやPutに関する話で sync.Pool.New に定義する初期化の関数はスレッドセーフでなければなりません(サンプルコードのところでちょっとした実例があります)。

型が混ざると死ぬ

上記の通りリソースのやり取りは interface{} 型で行うためやろうと思えば何でもPutできてしまいます。これはビルドエラーにならず実行してみるまで気付けないので注意しましょう。もちろん想定する限りの範囲で複数の型を入れて使うことはできますが、複雑さを避けるという意味で可能な限り避けた方がいいかと思います。

func main() {
    pool := &sync.Pool{
        New: func() interface{} {
            return 1
        },
    }

    pool.Put("変なもの") // intを扱うことが前提のプールにstringを突っ込んでおく

    r, ok := pool.Get().(int)
    if !ok { // intアサーションに失敗する
        panic("なんか変")
    }
    fmt.Println(r)
    pool.Put(r)
}
$ go run main.go 
panic: なんか変

goroutine 1 [running]:
main.main()
    /home/ca/go/src/local/main.go:18 +0x184
exit status 2

サンプルコード

プールを使ってリソースを再利用する

以下のコードではまずプールを作成し初期リソースを100個追加します。リソース作成用の関数は内部で起動された回数をインクリメントし空の struct を返却しますが、カウンターでデータ競合が起きないようMutexで排他制御しています。

その後goroutineを10,000個生成し各goroutineの中でリソースを取得、1ミリ秒待機したらそのリソースをプールに戻し、最後に何回Newが呼ばれたかを表示するという動作をします。

pool.go

package main

import (
    "fmt"
    "runtime/debug"
    "sync"
    "time"
)

const (
    numberOfExecute int = 10000
)

func main() {
    debug.SetGCPercent(-1) // GCが悪さをしてカウントが若干増えることがあるので無効化する
    var numberOfCreated int
    var m sync.Mutex

    // プール作成
    pool := &sync.Pool{
        New: func() interface{} {
            m.Lock()
            numberOfCreated++ // 実験のためリソースの新規作成回数をカウント
            m.Unlock()
            return &struct{}{}
        },
    }

    // 初期リソースの作成
    for i := 0; i < 100; i++ {
        pool.Put(pool.New())
    }

    var wg sync.WaitGroup
    wg.Add(numberOfExecute)
    for i := numberOfExecute; i > 0; i-- { // goroutineを10,000個生成
        go func() {
            defer wg.Done()
            s, ok := pool.Get().(*struct{}) // リソースの取得(枯渇時は内部でリソースが新規作成される)
            if !ok {
                panic("なんか変")
            }
            time.Sleep(1 * time.Millisecond) // 何らかの処理を模すため1ms待機
            pool.Put(s)                      // リソースの返却
        }()
    }
    wg.Wait()

    fmt.Printf("Number of created: %d\n", numberOfCreated)
}

$ go run pool.go 
Number of created: 2058
$ go run pool.go 
Number of created: 2562
$ go run pool.go 
Number of created: 3190
$ go run pool.go 
Number of created: 3115
$ go run pool.go 
Number of created: 3094

さて実行してみて下さい。どのようになりましたか?環境によって結果は異なりますが私の場合では凡そ2,000〜3,000回しかNewが呼ばれず再利用による効率的なリソースの使用を行えていることが確認できました。

仮にプールを使わないように作った場合は10,000個リソースを作成する必要がありますが、このように実装することでgoroutineがプールを通してリソースを使いまわすので10,000回の生成は必要無いことがわかります。

再利用を阻害して生成数を比較する

最後に1ミリ秒の待機 time.Sleep(1 * time.Millisecond) を1秒に変更してみる time.Sleep(1 * time.Second) とどうなるでしょうか?

$ go run pool.go 
Number of created: 10000
$ go run pool.go 
Number of created: 10000
$ go run pool.go 
Number of created: 10000
$ go run pool.go 
Number of created: 10000
$ go run pool.go 
Number of created: 10000

あまりにも遅すぎるPCで全てのgoroutineが起動完了するまでに1秒以上かかるようなものでない限り(そんなPCでGolangは多分動かない)上記のような結果になるはずです。これはGet後の処理が重すぎて中々リソースが返却されず再利用が上手く行えなかったことになります。処理の内容によってはこのようにバースト的な負荷を捌ききれないので初期リソースの数はよく検討する必要があるでしょう。

まとめ

  • sync.Pool を使うとリソースプールを手軽に作れる
  • 重い処理を予めストックしておき使い回したい場合に有効
  • プールに想定しない型を入れてしまわないよう注意
  • Get, Putは暗黙的にスレッドセーフだがNewは自分で考慮する必要がある

参考文献