goroutineが間違った道具になるとき

Goのコンテキストキャンセルに関する本番ポストモーテム:fire-and-forgetのキャッシュ書き込みがリクエストのcontextを継承し、リクエストと一緒に死んだ——診断、差し戻された最初の修正、そして退屈な最終修正。

私は賢いコードを書くよりも、賢いコードを消すほうが好きです。このバグがその理由です。外部APIの前面にキャッシュ層を持つゲートウェイサービス——Cloud Run上のGo、より大きなマイクロサービスプラットフォームの一部——で、読み取りパスは上流APIからリストを取得し、それをキャッシュに保存していました。そこに、もっともらしく聞こえるアイデアが登場します:呼び出し元をキャッシュ書き込みで待たせる必要はない、goroutineで発火して即座にreturnしよう。結果は、いつまで経っても温まらないキャッシュでした。

症状

断続的で、低強度の奇妙さ——最悪の種類です:

  • トラフィックパターンからすればあり得ないほど低いキャッシュヒット率。「書かれたはず」のエントリが、次のリクエストではそこにない。
  • ログに現れるコンテキストキャンセルのエラー。しかもタイムスタンプは、対応するリクエストが正常完了した後。
  • 呼び出し元に見える失敗はゼロ。レスポンスはすべて正しく、システムはただ静かに、余計な上流呼び出しを永遠に払い続けていました。

メカニズム

バグの形を煮詰めると:

func (o *Operation) GetListWithCache(ctx context.Context, key string) (*List, error) {
    list, err := o.upstream.GetList(ctx, key)
    if err != nil {
        return nil, err
    }
    go func() {
        // バグ:ctx はリクエストのもの——ハンドラーが戻れば死ぬ
        _ = o.cache.Set(ctx, key, list)
    }()
    return list, nil // ハンドラーが戻る → ctx キャンセル → 上の書き込みは大抵負けるレースを走っている
}

GoのHTTPスタックでは、ハンドラーが戻った瞬間にリクエストのコンテキストはキャンセルされます。goroutineはそのコンテキストを継承していたので、レスポンスが出て行った瞬間——それこそが「待たない」ことの目的だったのに——実行中のキャッシュ書き込みが足元からキャンセルされる。書き込みが生き残るかどうかは、ネットワークの往復と関数のreturn文の競走で決まりました。そしてreturn文は大抵勝ちます。

これが陰湿だったのは、コードが一見コンテキスト規律に従って見えることです——周囲のコードと同じように ctx をきちんと渡している。リクエストパス上では正しい規約(呼び出し元のコンテキストを伝播せよ)が、リクエストから切り離されるべき仕事にとっては、正確に間違いなのです。

切り離し方の間違い、2種類

このバグには鏡像の双子がいて、両方に名前を付けておく価値があります。片方の修正がもう片方を生むからです:

  • リクエストのコンテキストを継承する(今回のバグ):切り離されたはずの仕事がリクエストと一緒に死ぬ。fire-and-forgetが「fire-and-大抵-完走し忘れる」になる。
  • context.Background() に差し替える:仕事は今度はリクエストより長生きします——何にも縛られず、誰にも所有されず、とっくにタイムアウトし、リトライし、諦めた呼び出し元のために、嬉々として副作用を実行します。

どちらにせよ、goroutineの本当の問題は同じです:この仕事を誰が所有し、いつ終わるのかを、誰も言えないこと。

修正——空振りを1回はさんで

最初の試みは賢さを温存しました:キャッシュ書き込みを管理された非同期ハンドラーグループに包む。ただしやはりリクエストパスから発火する。ほぼ即座に差し戻されました——同じカテゴリーのバグに、機械が増えただけです。

最終修正は、コミットログの言葉をほぼそのまま借りれば:APIコールが終わればコンテキストはキャンセルされる、そしてgoroutineはこういうケースに向いていない。だから——goroutineなし:

func (o *Operation) GetListWithCache(ctx context.Context, key string) (*List, error) {
    list, err := o.upstream.GetList(ctx, key)
    if err != nil {
        return nil, err
    }
    _ = o.cache.Set(ctx, key, list) // 同期。キャッシュ書き込み1回分のレイテンシ
    return list, nil
}

goroutineが「節約」していたレイテンシはキャッシュ書き込み1回——上流への往復をまるごと払ったばかりのエンドポイントにとっての、ミリ秒です。非同期版は誤差を節約し、引き換えにキャッシュ全体を支払っていました。

ここから生まれたルール

ポストモーテムは説教ではなく、レビューのチェックリスト行になりました:

  1. goroutineには、明言されたオーナーと明言された終わりが要る。 何がそれを止め、誰がそれを待つのかを言えないなら、それは工程の多いリークです。
  2. リクエストから仕事を切り離すのは、声に出して行うコンテキストの意思決定である。 ctx を継承すれば仕事はリクエストと死ぬ。context.Background() ならオーナーより長生きする。どちらの答えもしっくり来ないなら、その仕事はgoroutineの場所ではありません——リクエストパスの上か、独自のライフサイクルとタイムアウトを持つ本物のキューに属します。
  3. 並行性は居場所を稼がなくてはならない。 レビューでの問いは「この並行性は正しいか?」ではなく「この並行性は何を買っているか?」。ここでの正直な答えはミリ秒でした——そして逐次版は構成からして正しかった。
  4. 結果だけでなく、キャンセルをテストする。 リクエストを完了させてから副作用が実際に起きたことをアサートする——あるいは途中でキャンセルして起きなかったことをアサートする——テストなら、これを捕まえていました。戻り値のテストは永遠に捕まえません。

その後のすべてのGoコードベースに、この教訓は付いてきました:私が出会った本番の並行性バグの多くは、賢い機械の奥深くの微妙なレースではありません——理由なく並行にされ、誰も考えていないコンテキストを握った、普通のコードでした。contextパッケージはすでに規律をコード化しています。仕事は、そこから安易に離脱しないことです。