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
が壊されているのかもしれないと疑うも、
- 外部 API を呼ぶ前にレスポンスを返すとバグは起きない
- 外部 API を呼ぶとバグが起きる
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 }
これで今回のバグの原因が明らかに。まとめると次の通り。
- 問題の API が叩かれ、
Parser
の保持したメモリ領域上(cache
)にデータが読み込まれる unsafe.Pointer
を使うため、foo
の string はParser
の保持するメモリ領域上を指す- 外部 API を呼んでレスポンスが返ってくるまでに、一定の待ち時間が発生する
- この待ち時間に、同じく
fetchBytes()
を呼ぶ他の API が叩かれる(別スレッド・別 goroutine) - sync.Pool から取り出した Parser が 1. と同じものだった(既に
pool.Put()
済みだった)場合 Parser
のcache
はp.cache[:0]
でクリアされるだけなので、同じメモリ領域上に別のデータが展開される- こうして最初のスレッドで処理されていた
foo
は意図しないデータを指してしまう
もちろん、実際のコードはさらに複雑で、fetchBytes()
とお茶を濁していたライブラリは valyala/fastjson だったりする。
今回の場合、そこまで大きいデータを扱っているわけでもなく、[]byte
→ string
変換が何回も走るわけでもないので、単純に string(bytes)
で明示的なコピーをするよう修正して事なきを得た。
過度なパフォーマンスチューニングは YAGNI だと再認識。
TIL: Go の sync.Pool と unsafe.Pointer 混ぜるな危険
— Hash / Ryo Kato (@hashedhyphen) 2020年5月12日
PR
私が Lead Engineer を務める Qufooit では、Go・k8s を中心にサーバサイドエンジニアを募集しています。私たちと一緒に世界へ通用するサービスを開発しませんか?