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

Bolt 製 Slack Bot で app_mention イベントに反応させる

Bolt のチュートリアル には app.message() しか取り上げられていないが、app.message() だと app_mention イベントに反応させることができない。

Bot 宛のメンションにだけ反応させたいとか、スコープを app_mentions:read だけに絞りたいときに、わざわざ message イベントまで subscribe する必要があって不便。

f:id:hashedhyphen:20200505011721p:plain
Slack Bot が subscribe するイベントの設定画面

実際にはこんな感じに書く必要がある。

giste25c64094a3738d2efb42459f26fcd06

解説

app.message() やその前後のソースを眺めてみると、実は message イベントしか subscribe されていないこと、本質的には app.event("message", matchMessage(pattern)) の alias だということが分かる。

https://github.com/slackapi/bolt/blob/455bf5849708c8cea4f0683ca45d900c29f97535/src/App.ts#L312-L327

matchMessage(pattern) もソースを追いかけてみると、pattern に合致した post のみ通過させるフィルタを作るヘルパー関数だと分かる。ただ実際に import して使ってみると分かるが、何故か app_mention イベントに転用できない(微妙に型が違う)。

なので、上記のように matchMessage() と似たようなフィルタを自前で書きつつ、app.event("app_mention") でリスナーを登録してあげればちゃんとメンションを聞ける。

めでたい。

余談

とはいえ書き味が少しだるいので、もうちょっといい感じに書ける API 欲しいなーと思って Issue だけ立ててみた。同意得られたら PR 送ろうと思う。

github.com

2020-05-28 追記:PR が master にマージされた。

github.com