Go中級 #5 context.Context 深掘り

読了 6分

#4 selectとタイムアウト で見たように — 並行コードには キャンセルタイムアウト がほぼ必然的に付いてきます。Go の標準ツールが context.Context です。今回の記事は context のすべての役割を整理します。

なぜ context が標準になったのか #

リクエスト一つが処理される間 — 複数のゴルーチン、複数のサービス呼び出しが起きます。そのすべてに:

  1. キャンセル信号 を伝播
  2. デッドライン を流す
  3. リクエスト範囲のデータ (ユーザー ID、trace ID など) を伝達

直接 done チャネルや deadline 変数を毎回書くのは面倒で一貫性も崩れます。Go 標準ライブラリに context が入って — この三つを一つのインターフェースで標準化しました。

Context インターフェース #

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) — 保存された値を取り出す

基本コンテキスト — BackgroundTODO #

基本コンテキスト
ctx := context.Background()    // 空の root コンテキスト — 絶対にキャンセルされない
ctx := context.TODO()           // 何を使うべきかまだ分からない — 一時的
  • Background — main 関数、初期化、テストなど root コンテキストの出発点
  • TODO — 後でどのコンテキストを使うか決めなければならない場面(意図が見えるプレースホルダ)

二つは動作は同じですが意図が違います。普段は Background() を使います。

子コンテキストを作る #

既存のコンテキストに新しい動作(キャンセル、タイムアウトなど)を加えて — 子コンテキスト を作ります。

WithCancel — 明示的なキャンセル #

WithCancel
ctx, cancel := context.WithCancel(context.Background())
defer cancel()    // リソースリーク防止

go work(ctx)

// 任意のタイミングで
cancel()    // ctx.Done() が close される

cancel() 関数を呼ぶと — ctx.Done() チャネルが close されて、それを聞いているすべてのゴルーチンが気づきます。

defer cancel() が肝心です — 関数が終わるときに子コンテキストが整理されるように。呼ばないとコンテキストとそれが追跡するリソースが GC されない可能性があります。

WithTimeout — 一定時間後に自動キャンセル #

WithTimeout
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()

result, err := slowOperation(ctx)

5秒以内に終わらなければ — ctx が自動キャンセルされて slowOperation が早期に終了する可能性があります。

WithDeadline — 絶対時刻でキャンセル #

WithDeadline
deadline := time.Now().Add(5 * time.Second)
ctx, cancel := context.WithDeadline(context.Background(), deadline)
defer cancel()

相対時間ではなく絶対時刻。timeout と意味は同じですが表現が違います。時計上の特定時刻(例: 真夜中まで)のような場面に合います。

キャンセルを聞く関数 #

select でキャンセルを聞く
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 を受け取る #

ctx を IO に伝播
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 など)を伝達するツール。

WithValue
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 ハンドラ #

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) 複数同時リクエスト + 最初の失敗ですべてキャンセル #

early cancel
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) データベースクエリ #

DB クエリも ctx
rows, err := db.QueryContext(ctx, "SELECT * FROM users WHERE active = $1", true)

標準 database/sql API が ctx を受け取る関数群を持っています — QueryContextExecContextBeginTx。ctx を通じてクエリもキャンセルされ得ます。

4) Kubernetes / cloud SDK #

ほとんどの Go SDK がすべての RPC 呼び出しで ctx を受け取ります。クラウド API 呼び出しも — ユーザーがリクエストをキャンセルすればサーバに送った呼び出しも自動的に中断されます。

errgroup — コンテキストとペア #

よく一緒に使われるパターンなので #1 並行性パターン で詳しく扱いますが、プレビュー。

errgroup プレビュー
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 テストパターンを扱います。

X