Go中級 #5 context.Context 深掘り
#4 selectとタイムアウト で見たように — 並行コードには キャンセル と タイムアウト がほぼ必然的に付いてきます。Go の標準ツールが context.Context です。今回の記事は context のすべての役割を整理します。
なぜ context が標準になったのか
#
リクエスト一つが処理される間 — 複数のゴルーチン、複数のサービス呼び出しが起きます。そのすべてに:
- キャンセル信号 を伝播
- デッドライン を流す
- リクエスト範囲のデータ (ユーザー ID、trace ID など) を伝達
直接 done チャネルや deadline 変数を毎回書くのは面倒で一貫性も崩れます。Go 標準ライブラリに context が入って — この三つを一つのインターフェースで標準化しました。
Context インターフェース
#
type Context interface {
Deadline() (deadline time.Time, ok bool)
Done() <-chan struct{}
Err() error
Value(key any) any
}四つのメソッドだけを持つ非常に小さなインターフェース。私たちはほぼ次の三つだけを気にします。
Done()— キャンセルされるかタイムアウトすると close されるチャネルErr()— Done 後の理由(context.Canceledまたはcontext.DeadlineExceeded)Value(key)— 保存された値を取り出す
基本コンテキスト — Background と TODO
#
ctx := context.Background() // 空の root コンテキスト — 絶対にキャンセルされない
ctx := context.TODO() // 何を使うべきかまだ分からない — 一時的- Background — main 関数、初期化、テストなど root コンテキストの出発点
- TODO — 後でどのコンテキストを使うか決めなければならない場面(意図が見えるプレースホルダ)
二つは動作は同じですが意図が違います。普段は Background() を使います。
子コンテキストを作る #
既存のコンテキストに新しい動作(キャンセル、タイムアウトなど)を加えて — 子コンテキスト を作ります。
WithCancel — 明示的なキャンセル
#
ctx, cancel := context.WithCancel(context.Background())
defer cancel() // リソースリーク防止
go work(ctx)
// 任意のタイミングで
cancel() // ctx.Done() が close されるcancel() 関数を呼ぶと — ctx.Done() チャネルが close されて、それを聞いているすべてのゴルーチンが気づきます。
defer cancel() が肝心です — 関数が終わるときに子コンテキストが整理されるように。呼ばないとコンテキストとそれが追跡するリソースが GC されない可能性があります。
WithTimeout — 一定時間後に自動キャンセル
#
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
result, err := slowOperation(ctx)5秒以内に終わらなければ — ctx が自動キャンセルされて slowOperation が早期に終了する可能性があります。
WithDeadline — 絶対時刻でキャンセル
#
deadline := time.Now().Add(5 * time.Second)
ctx, cancel := context.WithDeadline(context.Background(), deadline)
defer cancel()相対時間ではなく絶対時刻。timeout と意味は同じですが表現が違います。時計上の特定時刻(例: 真夜中まで)のような場面に合います。
キャンセルを聞く関数 #
func work(ctx context.Context) error {
for {
select {
case <-ctx.Done():
return ctx.Err() // context.Canceled または DeadlineExceeded
default:
// 1単位の作業
doOneStep()
}
}
}<-ctx.Done() が case に入るのが標準です。キャンセルされれば即座に抜け出します。
時間がかかる IO も ctx を受け取る #
func fetch(ctx context.Context, url string) ([]byte, error) {
req, _ := http.NewRequestWithContext(ctx, "GET", url, nil)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
return io.ReadAll(resp.Body)
}http.NewRequestWithContext が ctx を受け取って — リクエストが進行中に ctx がキャンセルされれば自動的にリクエストも中断します。標準ライブラリのほぼすべての IO API が ctx を受け取るように発展してきました。
Context 伝播ルール #
第一引数として ctx を受け取り、すべての下位呼び出しにそのまま渡す
func handleRequest(ctx context.Context, req Request) error {
user, err := loadUser(ctx, req.UserID) // 伝播
if err != nil {
return err
}
posts, err := loadPosts(ctx, user.ID) // 伝播
if err != nil {
return err
}
return saveAudit(ctx, user, posts) // 伝播
}各関数が第一引数として ctx context.Context を受け取るのが標準コンベンションです。呼び出し側がキャンセルすれば — 下位のすべての呼び出しが自動的に中断されます。
絶対に ctx を struct に保存しないでください #
type Service struct {
ctx context.Context // ✗ ダメ
}ctx は 関数の引数としてのみ 伝播すべきです。オブジェクトに保存すると — どの ctx がどこで使われるかの追跡が難しくなり、ライフサイクルがこじれます。
このルールを破る場面は本当にまれです。ほぼ常に第一引数パターンです。
コンテキストに値を持たせる — WithValue
#
リクエスト範囲のデータ(ユーザー、trace ID など)を伝達するツール。
type userKey struct{}
ctx := context.WithValue(parent, userKey{}, currentUser)
// 別の場所で取り出す
user, ok := ctx.Value(userKey{}).(*User)
if !ok {
// なし
}キーは普通 struct{} 型の空の値。string キーは衝突の危険があるので推奨されません。自分のパッケージの unexported な型をキーとして使うのが標準です。
ヘルパーで包む #
type contextKey int
const (
userKey contextKey = iota
requestIDKey
)
func WithUser(ctx context.Context, u *User) context.Context {
return context.WithValue(ctx, userKey, u)
}
func UserFrom(ctx context.Context) (*User, bool) {
u, ok := ctx.Value(userKey).(*User)
return u, ok
}
// 使用
ctx = WithUser(ctx, user)
if u, ok := UserFrom(ctx); ok {
// ...
}キーと型を一箇所にまとめて — 他の場所ではヘルパー関数だけを使う。安全性と可読性が一緒に良くなります。
WithValue が合う場面 vs 合わない場面
#
合う場面 #
- リクエスト ID、trace ID
- 認証されたユーザー情報
- ロギングコンテキスト(logger インスタンス)
リクエスト処理中にほぼすべての関数が見たいメタデータ。
合わない場面 #
- 関数の引数として明示されるべきドメインデータ
ctx := context.WithValue(parent, "amount", 1000)
ctx := context.WithValue(parent, "userID", "u1")
// 誤った呼び出し
processPayment(ctx)processPayment(ctx, userID, amount)引数として明示されるべきデータを ctx に隠すと — 関数シグネチャだけ見てもどのデータが必要か分からず、デバッグが難しくなります。
ルール: ctx の中には「horizontal」データ、引数には「vertical」データ。horizontal はすべての階層が共通で見るもの、vertical はその関数の役割に直接関連するもの。
よく出会うパターン #
1) HTTP ハンドラ #
func handler(w http.ResponseWriter, r *http.Request) {
ctx := r.Context() // リクエストに紐づいた ctx (クライアントが切れば cancel)
user, err := loadUser(ctx, getUserID(r))
if err != nil {
http.Error(w, err.Error(), 500)
return
}
// ...
}r.Context() は — HTTP リクエストが切れると自動キャンセル されるコンテキスト。クライアントが接続を切ると(ブラウザタブを閉じるなど)サーバ側の作業も自動中断されます。
2) 複数同時リクエスト + 最初の失敗ですべてキャンセル #
ctx, cancel := context.WithCancel(parent)
defer cancel()
results := make(chan Result, n)
for _, url := range urls {
go func(u string) {
r, err := fetch(ctx, u)
results <- Result{r, err}
}(url)
}
for i := 0; i < n; i++ {
r := <-results
if r.err != nil {
cancel() // 1つが失敗すれば他はすべて中断
return r.err
}
}このパターンは #1 並行性パターン でより精巧な形(errgroup)で扱います。
3) データベースクエリ #
rows, err := db.QueryContext(ctx, "SELECT * FROM users WHERE active = $1", true)標準 database/sql API が ctx を受け取る関数群を持っています — QueryContext、ExecContext、BeginTx。ctx を通じてクエリもキャンセルされ得ます。
4) Kubernetes / cloud SDK #
ほとんどの Go SDK がすべての RPC 呼び出しで ctx を受け取ります。クラウド API 呼び出しも — ユーザーがリクエストをキャンセルすればサーバに送った呼び出しも自動的に中断されます。
errgroup — コンテキストとペア
#
よく一緒に使われるパターンなので #1 並行性パターン で詳しく扱いますが、プレビュー。
import "golang.org/x/sync/errgroup"
g, ctx := errgroup.WithContext(parent)
g.Go(func() error {
return fetch1(ctx)
})
g.Go(func() error {
return fetch2(ctx)
})
if err := g.Wait(); err != nil {
return err
}複数のゴルーチン + 最初のエラーで全キャンセル + 結果集計 — このパターンが errgroup 一行で整理されます。Go 並行コードの最も有用なライブラリの一つです。
まとめ #
今回の記事で整理した内容:
context.Context— キャンセル/タイムアウト/リクエスト範囲値の標準ツールBackground()が root、TODO()はプレースホルダWithCancel/WithTimeout/WithDeadlineで子コンテキスト- 常に
defer cancel()— リソースリーク防止 <-ctx.Done()で select の中でキャンセルを聞く- 関数の第一引数として ctx を渡すのが標準 — struct に保存しない
WithValueは horizontal メタデータにのみ(ドメインデータは引数)- HTTP リクエスト、DB クエリ、外部 API すべて ctx 統合
- 子コンテキストは親がキャンセルされれば一緒にキャンセル
次の記事(#6 テスティング)では標準 testing パッケージでテストとベンチマークを書く方法、table-driven テストパターンを扱います。