sync.Pool と unsafe.Pointer は混ぜるな危険

Go で書いた API サーバーでなかなか不思議なバグに遭遇したのでメモ。

バグの発生状況をできるだけ簡単化して記述すると以下の通り。

func handleFoo(res http.ResponseWriter, req *http.Request) {
    var bytes []byte = fetchBytes() // ライブラリ使用

    foo := *(*string)(unsafe.Pointer(&bytes))

    callExternalAPI(foo) // 外部 API 呼び出し

    renderJSON(res, foo) // foo を JSON に整形して返す
}

この API レスポンスに含まれる foo の内容が確率的に壊れる。

バグの起きたレスポンスを見るに、何かメモリが壊されているような雰囲気は察したので、unsafe.Pointer 周りが怪しそうな予想を立てつつも、元となる []byte はリクエストの goroutine 毎に独立なはずだしなあ、と悩む。

当然ながら、外部 API を呼ぶ際に foo が壊されているのかもしれないと疑うも、

  1. 外部 API を呼ぶ前にレスポンスを返すとバグは起きない
  2. 外部 API を呼ぶとバグが起きる
  3. foo の代わりに任意の string を与えてもバグが起きる

となり、さながら ハイゼンバグ かとさらに悩む。

さらに調査を進めると、ライブラリを使用している fetchBytes() の中身もさらに怪しくなってきた。これも説明のために簡単化して書くと、

var pool sync.Pool

func fetchBytes() []byte {
    parser := pool.Get().(*Parser) // 何らかのパーサー
    defer pool.Put(parser)

    parser.Parse() // 何らかのデータをパースする

    return parser.GetBytes() // []byte を返す
}

事実、この fetchBytes() は他の API でも使われており、sync.Pool ということは一度アロケートしたメモリ領域を可能な限り使い回す意図だから、ここに何かヒントがあるのでは……? と思い、Parser の中身も調べると、

type Parser {
    cache []byte
}

func (p *Parser) Parse() {
    var data string = someString()

    p.cache = append(p.cache[:0], data...)
}

func (p *Parser) GetBytes() []byte {
    // 説明のため簡略化
    return p.cache
}

これで今回のバグの原因が明らかに。まとめると次の通り。

  1. 問題の API が叩かれ、Parser の保持したメモリ領域上(cache)にデータが読み込まれる
  2. unsafe.Pointer を使うため、foo の string は Parser の保持するメモリ領域上を指す
  3. 外部 API を呼んでレスポンスが返ってくるまでに、一定の待ち時間が発生する
  4. この待ち時間に、同じく fetchBytes() を呼ぶ他の API が叩かれる(別スレッド・別 goroutine)
  5. sync.Pool から取り出した Parser が 1. と同じものだった(既に pool.Put() 済みだった)場合
  6. Parsercachep.cache[:0] でクリアされるだけなので、同じメモリ領域上に別のデータが展開される
  7. こうして最初のスレッドで処理されていた foo は意図しないデータを指してしまう

もちろん、実際のコードはさらに複雑で、fetchBytes() とお茶を濁していたライブラリは valyala/fastjson だったりする。

今回の場合、そこまで大きいデータを扱っているわけでもなく、[]bytestring 変換が何回も走るわけでもないので、単純に string(bytes) で明示的なコピーをするよう修正して事なきを得た。

過度なパフォーマンスチューニングは YAGNI だと再認識。

PR

私が Lead Engineer を務める Qufooit では、Go・k8s を中心にサーバサイドエンジニアを募集しています。私たちと一緒に世界へ通用するサービスを開発しませんか?

www.wantedly.com