Go
のcontext
パッケージの使い方がイマイチよく分からない。このように思っている方は、多いのではないでしょうか。
「なんかリクエスト情報を持っている」、今までなんとなくそう思いながら使っていました。そして、そのcontext
の使い方を間違えて本番にリリースした結果・・・ 見事に事故りました。
事故の経緯
今回使用したのは、graphql
というライブラリです。GraphQLクエリ実行に便利なクライアントですね。
https://github.com/machinebox/graphql
社内のAPIからコールしていたのは、このライブラリのgraphql.Run()
メソッドです。今回の修正がこちら。
もともとcontext.Backgrount()
で空のcontext
を生成して使用していたのですが、同じリクエストスコープだからといって、パラメータのcontext
を使用することにしたら、
・
・
・
事故りました−−−
client.Run()
でエラーが連発していたんです(泣)
事故の原因
client.Run()
の中身を覗いてみると、先頭でこのようなチェックをしています。
func (c *Client) Run(ctx context.Context, req *Request, resp interface{}) error {
select {
case <-ctx.Done():
return ctx.Err()
default:
}
if len(req.files) > 0 && !c.useMultipartForm {
パラメータのcontext
が終了しているか否かを、ctx.Done()
でチェックしているわけです。「先程まで使っていたcontext
だから、終了しているわけないじゃん」と思ったあなた。実はここに大きな落とし穴がありました。誰も気づかない大きな大きな落とし穴が・・・
このgraphql
処理を呼んでいる関数doSomething()
ですが、呼び出し元ではこんな呼び方をしていました。
func do(ctx context.Context) {
go doSomething(ctx)
}
Goroutine
で呼び出していたわけです。
つまり、先程までcontext
が生きていたからといって、違うGoroutine
でも生きている保証はないんです。なぜかというと、waitGroup
も何も使ってなく、ただ別のGoroutine
で呼び出しているだけなので、呼び出し元のGoroutine
が待ってくれる保証がないからです。
呼び出し元の親Goroutine
が先に終了した場合、子Goroutine
内ではctx<-Done()
になってしまい、エラーを返してくるわけです。
ここまでのまとめ
親子間で異なるcontext
を使った場合は、何の心配も不要です。ご自由にご利用ください。
以下、親子間で同じcontext
を使いまわした場合の挙動をまとめます。
- 同期処理の場合
- 親
Goroutine
が子Goroutine
処理を待ってくれるので、context
を使いまわしても問題ない
- 親
- 非同期処理の場合
- 親
Goroutine
が子Goroutine
処理を待ってくれる保証はないので、親Goroutine
が先に終了すると、子Goroutine
内のctx<-Done()
になる。もちろんその次の挙動は実装によります。上記の例では、ctx.Err()
を返してくれていたので、エラーになったわけです。
- 親
同じcontext
を使い回す意味
「じゃ、毎回違うcontext
を使ったらいいじゃん」と思うかもしれませんが、一度原点に戻って考えてみます。そもそもcontext
は、プロセス間を横断する必要のあるリクエストスコープな値を伝達させるためのものです。
Package context defines the Context type, which carries deadlines, cancellation signals, and other request-scoped values across API boundaries and between processes.
(訳)パッケージコンテキストはコンテキストタイプを定義します。コンテキストタイプは、APIの境界を越えて、プロセス間で、期限、キャンセルシグナル、およびその他のリクエストスコープの値を伝達します。
出典:pkg.go.dev - context pkg
今回はトレースログを収集するために、同じcontext
を使い回すことが必要でした。親子間で異なるcontext
を使ったら、途中でトレースが追えなくなってしまいます。
考えられる対応策
元のcontext
の値を引き継いだ、新しいcontext
を生成することが求められます。実際context
パッケージ内には、次のようなメソッドが提供されています。いずれも元のcontext
の値を引き継ぎながら、オプション的な機能が追加された新しいcontext
が生成されます。
func WithCancel(parent Context) (ctx Context, cancel CancelFunc)
func WithDeadline(parent Context, d time.Time) (Context, CancelFunc)
func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc)
func WithValue(parent Context, key, val any) Context
しかし、今回は元のcontext
とまったく同じcontext
を生成するだけで物足ります。オプション的な機能は必要ありません。既存のメソッドを利用すると、使用しない機能が追加された(ゴミデータが混ざっている)context
が生成されるので、使用を躊躇しちゃいました。
ということでたどり着いたのが、次に示す方法です。
採用した対応策
元のcontext
の値のみをコピーした、新しいcontext
を生成して使用することにしました。公式のcontext
パッケージを眺めてみると、Context
の定義は次のようになっています。
type Context interface {
Deadline() (deadline time.Time, ok bool)
Done() <-chan struct{}
Err() error
Value(key any) any
}
公式のcontext
を内包した構造体を作り、上記interface
を実装すれば行けそうな気がします。そして、実際実装したcustomcontext
の中身がこちらです。
package customcontext
import (
"context"
"time"
)
type customContext struct { ctx context.Context }
func (customContext) Deadline() (time.Time, bool) { return time.Time{}, false }
func (customContext) Done() <-chan struct{} { return nil }
func (customContext) Err() error { return nil }
func (x customContext) Value(key any) any { return x.ctx.Value(key) }
func Clone(ctx context.Context) context.Context { return customContext{ctx} }
使用する箇所ではcustomcontext.Clone()
メソッドで、値がコピーされた新しいcontext
を作って使用するだけです。これで、元のcontext
の値を引き継いでおり、且つ元のcontext
とは生存関係を持たない新しいcontext
が作れました。
めでたし❣ めでたし❣
func do(ctx context.Context) {
newCtx := customcontext.Clone(ctx)
go doSomething(newCtx)
}