Goのコンテキスト

コンテキストは、Goにおける並行処理制御の要です。これにより、タイムアウト、キャンセル、および値が、goroutineの呼び出しチェーンを通じて円滑に渡されるようになります。

サービスが数十もの外部依存関係を同時に処理する必要がある場合(それぞれに独自のタイムアウトやキャンセルロジックがある)、それらを一貫性を持って管理するにはどうすればよいでしょうか?このレッスンでは、Goのcontextパッケージを包括的に活用する方法を学びます。

1. 学習内容


2. バックエンドエンジニアの実話

(1) 課題:上流のコンポーネントの1つでタイムアウトが発生すると、システム全体がクラッシュしてしまう。

Xiaoliは順序システムのエンジニアです。彼女は、3つの下流サービスに依存するREST APIを担当しています:

「順序配置APIは、在庫サービス、決済サービス、通知サービスの3つのサービスを呼び出していました。ある日、在庫サービスの応答に20秒かかってしまい、すべてのgoroutineがその応答を待っていたため、私のサービスのメモリ使用量が急増しました。その結果、他のユーザーからのリクエストも処理できなくなってしまいました。上司から『なぜ順序ページが完全にダウンしているんだ?』と尋ねられました。」

問題の分析:

GO
// 不良コード:タイムアウト制御なし
func PlaceOrder(ctx context.Context, order Order) error {
    // もし InventoryCheck 詰まる 30 秒,goroutine ただ待っているだけ 30 秒
    ok, err := InventoryCheck(ctx, order.Items)
    if err != nil {
        return err
    }
    // 決済サービスがタイムアウトした場合,すでに前の方で無駄にしてしまった 30 秒,ユーザーはとっくに諦めている
    err = Charge(ctx, order.Total)
    if err != nil {
        return err
    }
    // 通知サービスもフリーズしている……goroutine 漏れが上限に達した → OOM
    return Notify(ctx, order.UserID)
}

(2) Goの解決策:背景

GO
// context_demo.go
パッケージ main

import (
    "context"
    "fmt"
    "time"
)

func main() {
    // 根 Context
    root := context.Background()

    // で WithTimeout 小包:2 秒のタイムアウト
    ctx, cancel := context.WithTimeout(root, 2*time.Second)
    defer cancel()  // リソースの解放を確実に行う

    result := PlaceOrder(ctx, "順序-123")
    fmt.Println(result)
}

func PlaceOrder(ctx context.Context, orderID 文字列) 文字列 {
    // 確認 Context キャンセルされましたか
    選択 {
    case <-ctx.Done():
        return fmt.Sprintf("キャンセル: %v", ctx.Err())
    default:
    }

    // 各下流呼び出しに、より短いタイムアウトを割り当てる
    checkCtx, cancel := context.WithTimeout(ctx, 1*time.Second)
    defer cancel()

    ok := InventoryCheck(checkCtx, orderID)
    if !ok {
        return "在庫不足"
    }
    return "注文が完了しました"
}

func InventoryCheck(ctx context.Context, orderID 文字列) bool {
    // 時間がかかる呼び出しのシミュレーション
    選択 {
    case <-time.After(500 * time.Millisecond):
        return true
    case <-ctx.Done():
        fmt.Printf("InventoryCheck 取り消された: %v\n", ctx.Err())
        return false
    }
}

出力(通常):

TEXT
注文が完了しました

(3) メリット:文脈がある場合とない場合

状況 結果
タイムアウト制御なし ゴルーチンリーク、システムのメモリ不足 (OOM)
手動による時間設定。チェック後 コードが散在しており、各関数に独自のタイムアウトが設定されている
統合コンテキスト制御 親コンテキストがキャンセルされると、すべての子コンテキストがカスケード方式でキャンセルされます

3. ルートコンテキスト

GO
package main

import (
    "context"
    "fmt"
)

func main() {
    // Background():根 Context,決してキャンセルされることはありません
    // 用途: main 関数、初期化、最上位のリクエスト
    ctx := context.Background()
    fmt.Printf("Background: %v\n", ctx)

    // TODO():何を使えばいいか分からないときは Context 時,まずは TODO プレースホルダー
    // タグはまだ接続されていません Context このコードはリファクタリングが必要だ
    todo := context.TODO()
    fmt.Printf("TODO: %v\n", todo)
}

(1) 背景 vs やるべきこと

背景 目的 中止されるのか?
Background() ルートノード、すべてのコンテキストの起点 なし
TODO() コードがContextにまだ統合されていないことを示すプレースホルダー なし
💡 ヒント: context.Background() はすべてのコンテキストツリーのルートノードであり、削除されることはありません。context.TODO() は、まだコンテキストチェーンに統合されていないコードを示すために使用されます。できるだけ早く、適切なコンテキストに置き換えてください。


4. context.WithCancel: 手動によるキャンセル

▶ サンプル:ゴルーチンを手動でキャンセルする

GO
パッケージ main

import (
    "context"
    "fmt"
    "time"
)

func Worker(ctx context.Context, id int) {
    for {
        選択 {
        case <-ctx.Done():
            fmt.Printf("Worker %d 停止: %v\n", id, ctx.Err())
            return
        default:
            fmt.Printf("Worker %d 仕事中...\n", id)
            time.Sleep(500 * time.Millisecond)
        }
    }
}

func main() {
    ctx, cancel := context.WithCancel(context.Background())

    go Worker(ctx, 1)
    go Worker(ctx, 2)

    time.Sleep(2 * time.Second)
    fmt.Println("主 goroutine 開始・キャンセル...")
    cancel()  // みんなに伝える Worker 停止

    // 待つ goroutine 終了
    time.Sleep(500 * time.Millisecond)
}
▶ 試してみよう

▶ サンプル:カスケードキャンセル

GO
package main

import (
    "context"
    "fmt"
    "time"
)

func handler(ctx context.Context) {
    // 子 Context 父の遺志を継いで Context
    childCtx, cancel := context.WithCancel(ctx)
    defer cancel()

    go subTask(childCtx, "task-1")
    go subTask(childCtx, "task-2")

    // 父 Context キャンセル → 子 Context 自動キャンセル
    select {
    case <-time.After(1 * time.Second):
        fmt.Println("Handler 完了")
    case <-ctx.Done():
        fmt.Println("Handler 取り消された")
    }
}

func subTask(ctx context.Context, name string) {
    select {
    case <-time.After(3 * time.Second):
        fmt.Printf("%s 完了\n", name)
    case <-ctx.Done():
        fmt.Printf("%s 取り消された: %v\n", name, ctx.Err())
    }
}

func main() {
    ctx, cancel := context.WithCancel(context.Background())
    go handler(ctx)

    time.Sleep(500 * time.Millisecond)
    cancel()  // キャンセル → handler → subTask すべてをカスケードで解除

    time.Sleep(1 * time.Second)
}
▶ 試してみよう
100%
sequenceDiagram
    participant Main as main()
    participant H as handler
    participant ST1 as subTask-1
    participant ST2 as subTask-2

    Main->>H: WithCancel
    H->>ST1: WithCancel
    H->>ST2: WithCancel
    Note over Main,ST2: 正常に実行される
    Main->>Main: cancel()
    Main-->>H: ctx.Done()
    H-->>ST1: ctx.Done()
    H-->>ST2: ctx.Done()
    Note over Main,ST2: すべてをカスケードで解除
🔥 よくある間違い: cancel() を呼び出す必要があります。WithTimeout を使用する場合でも、cancel() を遅延実行する必要があります。そうしないと、コンテキストのリソース(タイマー、ゴルーチン)が解放されません。ルール:WithCancelWithTimeout、またはWithDeadlineを作成した場合は、直ちにdefer cancel()を実行すること。


5. context.WithTimeout: タイムアウト時の自動キャンセル

▶ サンプル:タイムアウト制御

GO
package main

import (
    "context"
    "fmt"
    "time"
)

func callExternalAPI(ctx context.Context, name string, delay time.Duration) (string, error) {
    select {
    case <-time.After(delay):
        return fmt.Sprintf("%s 応答", name), nil
    case <-ctx.Done():
        return "", ctx.Err()
    }
}

func main() {
    // 1 秒のタイムアウト
    ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
    defer cancel()

    // 2つの下流サービスを呼び出す
    result1 := make(chan string, 1)
    result2 := make(chan string, 1)

    go func() {
        r, err := callExternalAPI(ctx, "サービスA", 500*time.Millisecond)
        if err != nil {
            result1 <- fmt.Sprintf("サービスA 失敗: %v", err)
            return
        }
        result1 <- r
    }()

    go func() {
        r, err := callExternalAPI(ctx, "サービスB", 1500*time.Millisecond)
        if err != nil {
            result2 <- fmt.Sprintf("サービスB 失敗: %v", err)
            return
        }
        result2 <- r
    }()

    fmt.Println(<-result1)  // サービスA 応答(500ms < 1s タイムアウト)
    fmt.Println(<-result2)  // サービスB 失敗: context deadline exceeded(1500ms > 1s)
}
▶ 試してみよう

▶ サンプル:WithTimeout 対 WithDeadline

GO
package main

import (
    "context"
    "fmt"
    "time"
)

func main() {
    // WithTimeout:今から 2 秒後にタイムアウト
    timeoutCtx, cancel1 := context.WithTimeout(context.Background(), 2*time.Second)
    defer cancel1()

    // WithDeadline:絶対時間を指定する
    deadline := time.Now().Add(2 * time.Second)
    deadlineCtx, cancel2 := context.WithDeadline(context.Background(), deadline)
    defer cancel2()

    // 両者の効果は同じである
    fmt.Printf("timeoutCtx deadline: %v\n", timeoutCtx.Deadline())
    fmt.Printf("deadlineCtx deadline: %v\n", deadlineCtx.Deadline())
}
▶ 試してみよう

(2) WithTimeout 対 WithDeadline

メソッド パラメータ 目的
WithTimeout(parent, 2*time.Second) 相対時間 最もよく使われる表現:「最大2秒待つ」
WithDeadline(parent, time.Time) 絶対時間 締切時刻を指定します。「15:30までに完了」

6. context.WithValue を使用したリクエストレベルでの値の渡し方

▶ サンプル:WithValue

GO
package main

import (
    "context"
    "fmt"
)

// カスタマイズ key 種類(対立を避ける)
type contextKey string

const (
    UserIDKey    contextKey = "user_id"
    TraceIDKey   contextKey = "trace_id"
    RequestIDKey contextKey = "request_id"
)

func middleware(ctx context.Context) context.Context {
    // リクエストヘッダーから取得する trace ID
    ctx = context.WithValue(ctx, TraceIDKey, "trace-123")
    ctx = context.WithValue(ctx, RequestIDKey, "req-456")
    return ctx
}

func handler(ctx context.Context, userID string) {
    ctx = context.WithValue(ctx, UserIDKey, userID)

    // ビジネス層に渡す
    service(ctx)
}

func service(ctx context.Context) {
    // から Context 値の取得
    userID := ctx.Value(UserIDKey).(string)
    traceID := ctx.Value(TraceIDKey).(string)
    requestID := ctx.Value(RequestIDKey).(string)

    fmt.Printf("リクエストの処理: user=%s, trace=%s, request=%s\n",
        userID, traceID, requestID)
}

func main() {
    ctx := context.Background()
    ctx = middleware(ctx)
    handler(ctx, "user-007")
}
▶ 試してみよう
🔥 よくある間違い: context.WithValue 内の key はカスタム型でなければなりません。文字列を直接使用することはできません。2つのパッケージがともに "user_id" という文字列をキーとして使用すると、競合が発生します。カスタム型 type contextKey string を使用することがベストプラクティスです。

(2) WithValue のユースケース

シナリオ 推奨 非推奨
TraceID / RequestID ✅ WithValue 経由で渡された ❌ グローバル変数
認証トークン ✅ WithValue による検証に合格 ❌ 関数のパラメータ
データベース接続 ❌ 依存性注入により取得 ❌ WithValue
ビジネスパラメータ ❌ 明示的パラメータ ❌ WithValue による暗黙的渡し

7. コンテキスト・チェイニングのルール

GO
package main

import (
    "context"
    "fmt"
    "time"
)

func main() {
    root := context.Background()

    // チェーン式:WithCancel → WithTimeout → WithValue
    ctx1, cancel1 := context.WithCancel(root)
    defer cancel1()

    ctx2, cancel2 := context.WithTimeout(ctx1, 2*time.Second)
    defer cancel2()

    ctx3 := context.WithValue(ctx2, "trace", "trace-007")

    // ctx3 受け継がれた ctx1 の取り消し + ctx2 のタイムアウト + ctx3 の値
    fmt.Printf("ctx3 deadline: %v\n", ctx3.Deadline())
    fmt.Printf("ctx3 value: %v\n", ctx3.Value("trace"))

    // まずキャンセルする ctx1 → ctx2 と ctx3 すべてにキャンセル信号が届く
    cancel1()
    time.Sleep(10 * time.Millisecond)
    fmt.Printf("ctx2 err: %v\n", ctx2.Err())
    fmt.Printf("ctx3 err: %v\n", ctx3.Err())
}

(1) コンテキストの引き継ぎに関するルール

ルール 説明
最初の引数 関数のシグネチャにおける最初の引数は常に ctx context.Context です
構造体内に格納しないでください Contextを構造体のフィールドに格納せず、パラメータとして渡してください
関数間の引数渡し キャンセルやタイムアウトを処理する必要がある各関数には、コンテキストが渡されます
不変チェーン WithCancel/WithTimeout/WithValue の呼び出しごとに新しいコンテキストが返される
連鎖的なキャンセル 親アイテムをキャンセルすると → すべての子アイテムがキャンセルされます。子アイテムをキャンセルしても、親アイテムには影響しません

8. 完全な例:マイクロサービストレースのタイムアウト制御

GO
// microservice_chain.go
package main

import (
    "context"
    "fmt"
    "math/rand"
    "time"
)

// ---------- 下流サービスのシミュレーション ----------

// 在庫管理サービス
type InventoryService struct{}

func (s *InventoryService) Check(ctx context.Context, orderID string) (bool, error) {
    // ランダム遅延のシミュレーション
    delay := time.Duration(rand.Intn(1500)) * time.Millisecond
    select {
    case <-time.After(delay):
        return true, nil
    case <-ctx.Done():
        return false, ctx.Err()
    }
}

// 決済サービス
type PaymentService struct{}

func (s *PaymentService) Charge(ctx context.Context, amount float64) (string, error) {
    delay := time.Duration(rand.Intn(1500)) * time.Millisecond
    select {
    case <-time.After(delay):
        return "pay-" + fmt.Sprintf("%d", time.Now().UnixNano()), nil
    case <-ctx.Done():
        return "", ctx.Err()
    }
}

// 通知サービス
type NotificationService struct{}

func (s *NotificationService) Send(ctx context.Context, userID, message string) error {
    delay := time.Duration(rand.Intn(1500)) * time.Millisecond
    select {
    case <-time.After(delay):
        return nil
    case <-ctx.Done():
        return ctx.Err()
    }
}

// ---------- ビジネス層 ----------

type OrderService struct {
    inventory *InventoryService
    payment   *PaymentService
    notify    *NotificationService
}

func NewOrderService() *OrderService {
    return &OrderService{
        inventory: &InventoryService{},
        payment:   &PaymentService{},
        notify:    &NotificationService{},
    }
}

// PlaceOrder 使用 Context リンク全体のタイムアウトを制御する
func (s *OrderService) PlaceOrder(ctx context.Context, userID, orderID string, amount float64) error {
    // 1. 在庫確認(せいぜい 1 秒)
    invCtx, invCancel := context.WithTimeout(ctx, 1*time.Second)
    defer invCancel()

    ok, err := s.inventory.Check(invCtx, orderID)
    if err != nil {
        return fmt.Errorf("在庫確認に失敗しました: %w", err)
    }
    if !ok {
        return fmt.Errorf("在庫不足")
    }

    // 2. 支払い(せいぜい 2 秒)
    payCtx, payCancel := context.WithTimeout(ctx, 2*time.Second)
    defer payCancel()

    paymentID, err := s.payment.Charge(payCtx, amount)
    if err != nil {
        return fmt.Errorf("支払いに失敗しました: %w", err)
    }

    // 3. お知らせ(せいぜい 500ms)
    notifyCtx, notifyCancel := context.WithTimeout(ctx, 500*time.Millisecond)
    defer notifyCancel()

    err = s.notify.Send(notifyCtx, userID, "注文が完了しました: "+orderID)
    if err != nil {
        // 通知の失敗は注文に影響しません(非同期ロギング)
        fmt.Printf("通知に失敗しました(記録済み): %v\n", err)
    }

    fmt.Printf("注文が完了しました: user=%s, order=%s, payment=%s\n", userID, orderID, paymentID)
    return nil
}

// ---------- クライアント ----------

func main() {
    rand.Seed(time.Now().UnixNano())
    svc := NewOrderService()

    // リクエスト全体のタイムアウト 3 秒
    ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
    defer cancel()

    err := svc.PlaceOrder(ctx, "user-007", "order-123", 99.99)
    if err != nil {
        fmt.Printf("注文に失敗しました: %v\n", err)
    }
}
💡 ヒント: Err()Context メソッドは、context.Canceled(手動でキャンセルされた)または context.DeadlineExceeded(タイムアウト)の 2 つの値のいずれかを返します。チェーン内では、errors.Is(err, context.DeadlineExceeded) を使用して、どのタイプのキャンセルが発生したかを特定し、それに応じて再試行戦略を決定することができます。


❓ よくある質問

Q Context はどのような場合に使用すべきですか?
A タイムアウト制御、手動によるキャンセル、またはリクエストレベルでの値の受け渡しが必要なあらゆるシナリオで使用します。これには、HTTP リクエストの処理、データベースクエリ、RPC 呼び出し、およびスケジュールされたタスクが含まれます。キャンセルやタイムアウト制御を必要としない純粋な計算関数では、Context を使用しないでください。
Q Background()TODO() の違いは何ですか?
A どちらもキャンセルできないルートコンテキストですが、意味が異なります。Background() は、コード内で現在アクティブに使用しているルートです。TODO()はプレースホルダーであり、このコードセクションがまだコンテキストに統合されておらず、できるだけ早くリファクタリングする必要があることを示しています。
Q WithTimeoutWithDeadline、どちらを選べばいいですか?
A ほとんどの場合は WithTimeout を使用してください(「最大2秒待つ」という表現の方が直感的です)。タイムアウトが絶対時間(「15:30:00までに完了する」)である場合にのみ、WithDeadline を使用してください。WithTimeoutは内部でWithDeadlineを呼び出します。
Q Context は複数回キャンセルできますか?
A キャンセルは 1 回のみ発生します。cancel() を繰り返し呼び出しても問題ありません(2 回目以降の呼び出しは何も行われません)。Done() をリッスンしているすべての goroutine は、このシグナルを 1 回だけ受信します。
Q 親のキャンセルとタイムアウトが同時に発生した場合はどうなりますか?
A これらは独立してトリガーされ、先に発生した方が適用されます。親のコンテキストが手動でキャンセルされた場合、子コンテキストのタイムアウトがまだ切れていなくても、すべての子コンテキストは直ちにDone()シグナルを受け取ります。これにより、キャンセルの連鎖が最短経路で進行することが保証されます。
Q WithValue に渡された値は変更できますか?
A いいえ。context.WithValue の値は不変です。「変更」と呼ばれる操作は、本質的には新しいコンテキストを作成することに相当します。子コンテキストの WithValue は、親コンテキストには影響を与えません。これにより、並行処理の安全性が確保されます。
Q Context にはどのような種類のデータを格納するのに適していますか?
A リクエストレベルのメタデータ(TraceID、RequestID、UserID、認証トークンなど)の格納にのみ適しています。ビジネスパラメータ(価格や数量など)の格納には適しておらず、データベース接続や設定情報の格納にも適していません。これらは依存性注入を介して渡す必要があります。

📖 まとめ


📝 練習問題

  1. 基本演習(難易度 ⭐)context.WithTimeout を使用して HTTP リクエストのタイムアウトを制御する関数 FetchWithTimeout(ctx, url string, timeout time.Duration) を作成してください。タイムアウトが切れたら、その HTTP リクエストは自動的にキャンセルされるようにしてください。

  2. 上級問題(難易度 ⭐⭐)並行してキャンセル可能なスケジュールタスクマネージャーを実装してください。以下の機能をサポートする必要があります:(1) 複数のスケジュールタスクの登録(1つのgoroutineにつき1つ);(2) 個別のキャンセル(cancel関数の呼び出しによる);(3) 一括キャンセル(WithCancelの連鎖); (4) すべてのタスクが単一のルートコンテキストを共有すること。

  3. 課題(難易度 ⭐⭐⭐)分散トレーシングシステムをシミュレートしてください。要件:(1) WithValue を使用して、3 層の関数呼び出し(API → サービス → DB)にわたって TraceID を渡すこと; (2) 各層には独自のタイムアウト制御がある(Service:2秒、DB:500ミリ秒);(3) タイムアウトが発生した場合、現在の層はキャンセルされるが、上流の層には影響を与えない;(4) 各ステージの経過時間とTraceIDを出力する。ContextDeadline()を使用して残り時間を計算する。

Web-Tutorial.com

Web-Tutorial 技術チーム

複数の開発者によって共同維持されているプログラミングチュートリアルプラットフォーム。各チュートリアルは専門分野の開発者が執筆・レビューしています。正確で信頼性の高いコンテンツを目指しています — 問題を見つけた場合はお知らせください。

100%