GoでのContext: timeout、cancel、値の送信

1. 概要

複数のgoroutineを処理するGoサービスを書く場合、子goroutineに「停止してください、もう結果は不要です」または「2秒だけ実行してください」と統一された方法で伝える必要があります。context.Contextはまさこの問題を解決するために生まれました。

この記事ではcontextで行う3つの主な操作を説明します:手動キャンセル、timeout/deadlineの設定、request-scopedな値の関数呼び出しのレイヤー間での送信です。読み終わったあと、HTTP handler、DB呼び出し関数、子goroutine呼び出し関数を適切なタイミングで自動停止させるgoroutineリークのないコードを書けるようになります。

2. Contextとは何か、なぜ必要か

context.Contextは処理フロー(通常はリクエスト)の「コンテキスト」を説明するinterfaceです。以下を保持します:

  • キャンセルシグナル
  • Deadline/timeout
  • リクエストに紐付いた小さなkey-valueペア
type Context interface {
	Deadline() (deadline time.Time, ok bool)
	Done() <-chan struct{}
	Err() error
	Value(key any) any
}
  • Done()はchannelを返します。contextがキャンセルされたか期限切れになると閉じられます。
  • Err()は理由を示します:context.Canceledまたはcontext.DeadlineExceeded
  • Value()はcontextに紐付けられた値を取得します。

注意: Contextは任意のoptionalパラメータを送信するために使用されるべきではありません。制御シグナルと、request IDや現在のユーザーのようなrequest-scopedなデータのみを含めるべきです — configや重要なbusiness objectではなく。

3. WithCancelで手動キャンセル

キャンセルするタイミングを自分で決めたい場合に使用します。例えば、複数のgoroutineの1つが結果を得たら、残りのgoroutineをキャンセルする場合です。

func fetchFromMultipleSources(ctx context.Context) (string, error) {
	ctx, cancel := context.WithCancel(ctx)
	defer cancel() // リソースを解放するため常にcancelを呼び出す

	resultCh := make(chan string, 1)

	for _, src := range []string{"cache", "db", "api"} {
		go func(source string) {
			select {
			case <-time.After(100 * time.Millisecond):
				resultCh <- source
				cancel() // 結果が得られました、他のsourceをキャンセル
			case <-ctx.Done():
				return // キャンセルされました、早く終了
			}
		}(src)
	}

	select {
	case res := <-resultCh:
		return res, nil
	case <-ctx.Done():
		return "", ctx.Err()
	}
}
  • context.WithCancel(ctx)は子contextとそれをキャンセルするためのcancel関数を作成します。
  • defer cancel()は必須です:呼び出さないと、子contextは親がキャンセルされるまで「ハング」し、ctx.Done()でselectしているgoroutineをリークします。
  • 最初に終わるgoroutineがcancel()を呼び出し、残りのgoroutineはctx.Done()を通じてシグナルを受け取り停止します。

よくあるエラー:defer cancel()を忘れること。関数が早期に返却される場合(例えばerrorで)、contextをキャンセルしないと、子goroutineは間接的にガベージコレクションされるまで生存し続けます — 実質的にはリークです。

4. WithTimeout、WithDeadlineでのTimeoutとDeadline

4.1. 相対的な時間制限のためのWithTimeout

func callPaymentService(ctx context.Context, orderID string) error {
	ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
	defer cancel()

	req, err := http.NewRequestWithContext(ctx, http.MethodGet,
		"https://payment.local/orders/"+orderID, nil)
	if err != nil {
		return err
	}

	resp, err := http.DefaultClient.Do(req)
	if err != nil {
		return err // context.DeadlineExceededである可能性があります
	}
	defer resp.Body.Close()
	return nil
}
  • context.WithTimeout(ctx, 2*time.Second)は2秒後に自動的にcontextをキャンセルします。
  • http.NewRequestWithContextはcontextをリクエストに紐付けます。contextの期限が切れると、http.Clientは実行中のリクエストを自動的にキャンセルします。
  • 2秒を超えた場合、resp, err := http.DefaultClient.Do(req)はcontext.DeadlineExceededでラップされたerrを返します。

timeoutエラーの確認:

if err != nil {
	if errors.Is(err, context.DeadlineExceeded) {
		log.Println("payment service timeout")
	}
	return err
}

4.2. 絶対的な時刻のためのWithDeadline

func processBeforeMidnight(ctx context.Context) error {
	deadline := time.Date(2026, 10, 6, 23, 59, 59, 0, time.Local)
	ctx, cancel := context.WithDeadline(ctx, deadline)
	defer cancel()

	select {
	case <-time.After(5 * time.Hour):
		return errors.New("処理が完了しました")
	case <-ctx.Done():
		return ctx.Err()
	}
}
  • WithDeadlineは特定の時刻(例えば日の終わり)がある場合に使用します。これはWithTimeout(現在からの相対的な時間間隔)と異なります。
  • 本質的には、WithTimeout(ctx, d)はWithDeadline(ctx, time.Now().Add(d))を呼び出す薄いラッパーに過ぎません。

5. WithValueでcontextを通じた値の送信

リクエストに紐付いたデータに使用します。ビジネスパラメータではなく。

type requestIDKey struct{}

func WithRequestID(ctx context.Context, id string) context.Context {
	return context.WithValue(ctx, requestIDKey{}, id)
}

func RequestIDFromContext(ctx context.Context) (string, bool) {
	id, ok := ctx.Value(requestIDKey{}).(string)
	return id, ok
}
func loggingMiddleware(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		id := r.Header.Get("X-Request-ID")
		ctx := WithRequestID(r.Context(), id)
		next.ServeHTTP(w, r.WithContext(ctx))
	})
}

func orderHandler(w http.ResponseWriter, r *http.Request) {
	if id, ok := RequestIDFromContext(r.Context()); ok {
		log.Printf("[%s] 注文を処理中", id)
	}
}
  • type requestIDKey struct{}を専用のkeyとして使用します — 直接stringを使用することは避けます。他のpackageのkeyと衝突しやすいからです。
  • WithRequestIDとRequestIDFromContextはset/getをカプセル化します。他のpackageは実際のkeyが何であるかを知る必要はありません。
  • r.WithContext(ctx)は値が紐付いたcontextを持つ新しいリクエストを作成し、middlewareチェーンを通じて渡します。

注意: context.WithValueを*sql.DB、config、または関数が正しく動作するために必要な任意のものを送信するために使用しないでください。それらは明示的なパラメータまたはstructのフィールドであるべきです。Context valueはrequest ID、trace ID、ログインユーザーのような「あるといいな」データにのみ適切です。

6. Context.TODOとcontext.Background

func main() {
	ctx := context.Background() // ルートcontext、決してキャンセルされません
	run(ctx)
}

func legacyFunctionBeingRefactored() {
	ctx := context.TODO() // プレースホルダ、後で実際のcontextに置き換えます
	_ = ctx
}
  • context.Background()はルートcontextです。main、test、またはトップレベルの初期化に使用します。
  • context.TODO()はrefactor中で、どのcontextを渡すべきか不確実な場合に使用します。機能的にはBackground()と同じですが、コード読者とlinterツールに意図をはっきり示します。

7. 複数の関数呼び出しレイヤーを通じたcontextの伝播

慣例:ctx context.Contextは常に最初のパラメータです。

func (s *OrderService) CreateOrder(ctx context.Context, order Order) error {
	if err := s.validate(ctx, order); err != nil {
		return err
	}
	return s.repo.Save(ctx, order)
}

func (r *OrderRepository) Save(ctx context.Context, order Order) error {
	_, err := r.db.ExecContext(ctx,
		"INSERT INTO orders (id, total) VALUES (?, ?)", order.ID, order.Total)
	return err
}
  • ExecContextはcontextを受け取ります。上のHTTPリクエストがキャンセルされた場合(clientが接続を閉じた場合)、DBドライバは実行中のクエリを直ちにキャンセルします — 無駄な接続保持を避けます。
  • 規則:I/O(HTTP、DB、gRPC)を呼び出す任意の関数はcontextを受け取り、contextをサポートするAPI(*Contextサフィックス)に渡すべきです。

8. テストで動作を確認

func TestFetchFromMultipleSources_ReturnsFastestSource(t *testing.T) {
	ctx := context.Background()

	result, err := fetchFromMultipleSources(ctx)

	require.NoError(t, err)
	assert.Contains(t, []string{"cache", "db", "api"}, result)
}

func TestCallPaymentService_TimesOutWhenSlow(t *testing.T) {
	slowServer := httptest.NewServer(http.HandlerFunc(
		func(w http.ResponseWriter, r *http.Request) {
			time.Sleep(3 * time.Second)
		}))
	defer slowServer.Close()

	ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
	defer cancel()

	req, _ := http.NewRequestWithContext(ctx, http.MethodGet, slowServer.URL, nil)
	_, err := http.DefaultClient.Do(req)

	assert.ErrorIs(t, err, context.DeadlineExceeded)
}
<!-- go.mod: assertionライブラリを追加します(XMLではなく、注釈のみです) -->
go get github.com/stretchr/testify
  • 最初のテストはcontext.Background()を使用します。通常のunit testではtimeoutを制御する必要がないためです。
  • 後のテストは意図的にtimeoutを500ms(サーバ処理時間の3秒より短い)に設定して、返されるエラーが正しくcontext.DeadlineExceededであることを確認します。
  • httptest.NewServerはダミーサーバを構築し、複雑なmockは不要です。

9. よくあるエラー

  • cancel()を呼び出さない: contextが自動的に期限切れになる場合でも、WithCancel/WithTimeout/WithDeadlineの直後に必ずdefer cancel()を使用してください — Go vetはこれを見落とすと警告します。
  • structフィールドにcontextを保存する: contextは関数パラメータを通じて渡すべきです。structのフィールドとして保存してはいけません(http.Requestのような特例を除く)。フィールドに保存するとstaleなcontextが原因となりやすいです。
  • 必須パラメータを送信するためにcontextを使用する: その値がない場合、関数が正しく実行されない場合は、通常のパラメータとして渡してください。contextに隠さないでください。
  • 親contextの代わりに新しいcontextを作成する: callerからctxを受け取る関数内でcontext.Background()を呼び出すと、上からのキャンセル/timeout機能が失われます — 常に受け取ったcontextから派生させてください:context.WithTimeout(ctx, ...)。context.WithTimeout(context.Background(), ...)ではなく。

contextが不要な場合: 純粋な計算関数(pure function)で、I/Oがなく、goroutineがない場合、contextはパラメータとして不要です — 追加するだけでノイズになります。

10. 結論

Contextはgo concurrencyの3つの核となる機能を解決します:キャンセルシグナル、時間制限の適用、関数呼び出しレイヤーを通じた小さな値の送信です。最も安全なルールは、常にctxを最初のパラメータとして受け取り、常にdefer cancel()を使用し、WithValueは本当にrequest-scopedなデータにのみ使用することです。これらの原則をマスターすれば、goroutineのリークがなく、clientがtimeoutまたはリクエストをキャンセルしたときに適切に応答するGoサービスを書くことができます。

golangcontextconcurrencygoroutinetimeout