3-6. context.Context - キャンセルとメタデータ伝搬

所要時間: 40-50分 コミット内容: ~/learn/go/level3/day06/ に context を貫通させた HTTP ハンドラ + DB + 外部API 呼び出し


このレッスンのゴール

  • WithCancel / WithTimeout / WithDeadline / WithValue 4つを使い分け
  • context を関数の第一引数で受け取る慣習を守る
  • HTTP ハンドラから DB / 外部API まで context を貫通させる
  • defer cancel() を必ず書き、go vet の lostcancel 警告を回避
  • <-ctx.Done() で長時間処理のキャンセルを尊重
  • goroutine と context の寿命ミスマッチを回避

なぜ学ぶか - Go 特有の難しさが凝縮された章

context.Context を引数で持ち回るのが冗長」と思った瞬間に事故が始まる。これを 正しく貫通させないと、クライアントがリクエスト切ったのに DB クエリは走り続けメモリ・接続・CPU を浪費するゾンビ処理が積もる。Go の context は「キャンセル + 期限 + メタデータ」を 1つのインターフェースに統合した独特設計で、初学者には掴みにくいが これを乗り越えると Goの設計力が一段階上がる

前章とのつながり

3-1_database_sqldb.PingContext(ctx)3-2_CRUDハンドラQueryContext2-4_ミドルウェアcontext.WithValue - これらの 背後にある context の本質 を本章で解明する。さらに 3-5_認証ctx.Value(userIDKey) した値の 正しい使い方 を学ぶ。

これができると何が嬉しいか

  • クライアント切断で DB クエリも自動キャンセル - リソース無駄遣いゼロ
  • タイムアウトの階層化(全体30秒 / 個別API3秒)が綺麗に書ける
  • errgroup で並列呼び出し が安全に書ける - 1つ失敗で全部キャンセル
  • goroutine リーク を防ぐ設計判断ができる

ストーリー導入: context は「リレーする手紙の封筒」

ハンドラから service、service から repository、repository から DB driver、DB driver から PostgreSQL サーバー まで - 5層を渡る封筒(context)。各層は「封筒の中身をいじらず(不変)、新しい情報を追記して次に渡す」(WithValue/WithTimeout で派生)。途中で 「もう要らない」と一言(cancel)言えば、5層全部に届いて全員が手を止める。これが Go の context モデル。

context にロガー / DB を入れない理由

ctx.Value(loggerKey).(*slog.Logger) で取り出すパターンは便利そうだが、公式が推奨していないWithValue は「リクエストスコープのトレース ID」程度に限定。DB / Logger は 構造体フィールド で持つのが Go 流。


大前提: context は「Go 特有の難しさ」が凝縮されている

Java の Thread.interrupt()、Python の asyncio.CancelledError、Node.js の AbortController。各言語に「処理をキャンセルする仕組み」がありますが、Go の context.Context は「キャンセル」と「メタデータ伝搬」を同一インターフェースに統合した 独特の設計です。

この設計は美しい反面、初学者には掴みにくい。よくある誤解:

  • 「context = 引数を運ぶリュック」と思ってしまう → WithValue を濫用する
  • 「context.Background() でいいや」と関数内で生成してしまう → キャンセル伝搬が断ち切れる
  • ハンドラの r.Context() を下流に渡さない → DB が永久にブロック

これらが本番で「リクエスト切ったのにサーバーが落ち続ける」「タイムアウト効かない」「ゴルーチンリーク」として表面化します。本レッスンで全部潰します。


セッション①: context の本質と4つの WithXxx(25分)

0. 録画と作業ディレクトリ

mkdir -p ~/log ~/learn/go/level3/day06
cd ~/learn/go/level3/day06
script ~/log/go_level3_day06.log
go mod init example.com/ctxplay

1. context.Context は「読み取り専用の伝搬チャネル」

type Context interface {
    Deadline() (deadline time.Time, ok bool) // 期限
    Done() <-chan struct{}                   // キャンセル通知チャネル
    Err() error                              // キャンセル理由
    Value(key any) any                       // メタデータ取得
}

context の本質を1行で

「リクエストスコープで キャンセル + 期限 + メタデータ を関数間に伝搬する標準インターフェース」

重要な性質:

  • 不変: 一度作った context は変更できない。変更したい時は WithXxx新しい context を派生 させる
  • 木構造: 親 → 子 → 孫の階層を作る。親がキャンセルされると子孫すべてがキャンセルされる
  • 読み取り専用: 関数は context から「値を取り出す・キャンセル通知を聞く」だけ

この設計は **関数型プログラミングの「環境」(environment)**に近い。Haskell の Reader モナドや Scala の implicit と同じ役割を、シンプルなインターフェースで実現している。

2. context.Background() と context.TODO()

ctx := context.Background() // ルート context
ctx := context.TODO()       // 将来的に context が必要だが今は決まっていない時のプレースホルダ

Background と TODO の使い分け

関数用途
context.Background()アプリのエントリポイント(main、テスト関数)で 新規ルート を作る
context.TODO()リファクタリング途中で「ここに context を流すべきだが未定」のプレースホルダ

機能的には全く同じ(両方とも空の context を返す)。意図を表すコメントとしての違いTODO() を grep で発見して「ここまだ context 引いてない」と気付けるのが利点。

3. WithCancel - 明示的キャンセル

ctx, cancel := context.WithCancel(context.Background())
defer cancel() // 必ず呼ぶ(後述)
 
go worker(ctx)
 
time.Sleep(100 * time.Millisecond)
cancel() // ここで worker にキャンセル通知が届く
 
func worker(ctx context.Context) {
    for {
        select {
        case <-ctx.Done():
            log.Println("キャンセル:", ctx.Err())
            return
        case <-time.After(10 * time.Millisecond):
            log.Println("working...")
        }
    }
}

WithCancel の使い所

「外から明示的にキャンセルしたい」処理。

  • goroutine 群を一括で止める: cancel を呼ぶと全 goroutine が <-ctx.Done() で離脱
  • 「最初に成功した方を採用」のレース処理: 複数 goroutine を起動、1つ成功したら他を cancel
  • シグナルでサーバー停止(後述 graceful shutdown と組み合わせ)

cancel を呼ばないとリソースリーク

WithCancel は内部で goroutine と channel を確保する。cancel() を呼ばないと、その context が GC されるまでリソースが残り続ける。

必ず defer cancel() をセットで書く。go vetlostcancel チェックがこれを検出してくれる。

ctx, cancel := context.WithCancel(parent)
defer cancel() // ← 必須
// ...

4. WithTimeout / WithDeadline

ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
 
ctx, cancel := context.WithDeadline(context.Background(), time.Date(2026, 12, 31, 23, 59, 59, 0, time.Local))
defer cancel()

Timeout と Deadline の違い

関数引数意味
WithTimeout(parent, dur)期間「今から dur 後にキャンセル」
WithDeadline(parent, time)絶対時刻time を過ぎたらキャンセル」

実装的には WithTimeout は内部で WithDeadline(parent, time.Now().Add(dur)) を呼んでいるだけ。意味的に「何分以内に」なら Timeout、「何時何分までに」なら Deadline

典型的な使い所:

  • HTTP リクエスト全体に5秒のタイムアウト: WithTimeout(ctx, 5*time.Second)
  • バッチ処理を 23:59 までに終わらせる: WithDeadline(ctx, batchEndTime)

5. WithValue - メタデータの伝搬

type ctxKey string
 
const requestIDKey ctxKey = "requestID"
 
ctx := context.WithValue(parent, requestIDKey, "req-123")
 
// 取り出し
reqID, ok := ctx.Value(requestIDKey).(string)
if !ok {
    reqID = "unknown"
}

WithValue は「関数引数を増やしたくない時の最後の手段」

例: HTTP リクエストの ID をログに含めたい時、全関数に reqID string 引数を追加すると地獄。context に乗せて「気付かない人は無視、必要な層だけ取り出す」ようにする。

適切な用途(少ない):

  • request ID(トレース用)
  • 認証済みユーザーID
  • 言語ロケール
  • リクエスト発信元 IP

不適切な用途(多い):

  • ビジネスロジックに必要なデータ
  • 関数のメイン引数として渡すべきもの
  • DB接続、ロガー、Config

WithValue の濫用は Go の悪手として有名

// NG
ctx := context.WithValue(ctx, "db", db)
ctx = context.WithValue(ctx, "logger", logger)
ctx = context.WithValue(ctx, "config", cfg)
 
// ハンドラ
db := ctx.Value("db").(*sql.DB) // 型アサーションで毎回壊れる可能性

何が context に入っているか不明、型安全性が消える、テストでモック注入できない。

正解: 構造体にフィールドとして持つ。

type Server struct {
    DB     *sql.DB
    Logger *slog.Logger
    Cfg    *Config
}
func (s *Server) Handle(w http.ResponseWriter, r *http.Request) {
    // s.DB, s.Logger を使う
}

key の型は独自型にする

// NG
ctx.Value("requestID") // string キー → 他パッケージと衝突可能
 
// OK
type contextKey string
const requestIDKey contextKey = "requestID"
ctx.Value(requestIDKey)

string をキーにすると、他パッケージが同じ文字列を使っていた場合に上書き事故。パッケージ独自型 を定義することで、その型を持つキーは自パッケージからしか参照できない(暗黙のスコープ)。


セッション②: context を貫通させる設計(25-30分)

6. context は関数の第一引数

func GetUser(ctx context.Context, id int64) (*User, error) { ... }

context は第一引数の慣習

Go コミュニティの強い慣習: context は関数の第一引数、ctx という名前

// OK
func GetUser(ctx context.Context, id int64) (*User, error)
 
// NG(順序)
func GetUser(id int64, ctx context.Context) (*User, error)
 
// NG(構造体に持つ)
type Repo struct { ctx context.Context } // ← 長寿命の context は構造体に入れない

構造体フィールドに context を保存しない。context はリクエストスコープなので、構造体の寿命とミスマッチ。「メソッド呼び出しごとに渡す」が原則。

7. HTTP ハンドラから DB まで貫通

func (h *UserHandler) Get(w http.ResponseWriter, r *http.Request) {
    ctx := r.Context() // ハンドラの context = リクエスト寿命
 
    u, err := h.Svc.GetUser(ctx, idFromPath(r))
    if err != nil {
        http.Error(w, "internal", http.StatusInternalServerError)
        return
    }
    json.NewEncoder(w).Encode(u)
}
 
func (s *UserService) GetUser(ctx context.Context, id int64) (*User, error) {
    return s.Repo.GetByID(ctx, id)
}
 
func (r *UserSQLRepo) GetByID(ctx context.Context, id int64) (*User, error) {
    var u User
    err := r.DB.QueryRowContext(ctx, // ← ここまで context を渡す
        "SELECT id, email, name FROM users WHERE id = ?", id).
        Scan(&u.ID, &u.Email, &u.Name)
    return &u, err
}

context を貫通させる意義

クライアントが Ctrl+C / ブラウザの閉じる / タイムアウトでリクエストを切ると、http.Serverそのリクエストの context をキャンセル する。

その context が DB クエリまで伝わっていれば:

  • QueryRowContext がキャンセルを検知 → クエリを中断
  • DB サーバーに KILL を送って、進行中のクエリを止める
  • サーバーリソースが解放される

貫通させないと: クライアントが切っても DB クエリは走り続け、CPU と接続をひたすら消費する。「もう要らない結果」を返すために、サーバーが死ぬ。

context.Background() を関数内で勝手に作らない

// NG
func (r *UserSQLRepo) GetByID(ctx context.Context, id int64) (*User, error) {
    // ctx を捨てて Background を使う
    return r.DB.QueryRowContext(context.Background(), "...", id) // ← キャンセル伝搬死亡
}

こうしてしまうと、上流からのキャンセルが効かない。

唯一の例外: 「リクエストが終わった後も継続したい非同期処理」。例えば「ユーザー登録後にメール送信」を fire-and-forget する場合、メール送信は元リクエストの寿命と独立させたい。この時だけ意図的に context.Background() から新しい派生を作る。

8. 外部 API 呼び出し

func FetchExternalUser(ctx context.Context, id int64) (*ExternalUser, error) {
    // 個別 API 呼び出しに3秒タイムアウト
    ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
    defer cancel()
 
    req, err := http.NewRequestWithContext(ctx, http.MethodGet,
        fmt.Sprintf("https://api.example.com/users/%d", id), nil)
    if err != nil {
        return nil, err
    }
    resp, err := http.DefaultClient.Do(req)
    if err != nil {
        return nil, err
    }
    defer resp.Body.Close()
    // ...
}

外部 API は必ず短いタイムアウトを切る

http.Client には Timeout フィールドもあるが、http.NewRequestWithContext + WithTimeout の組み合わせの方が柔軟

  • 個別の API ごとに違うタイムアウトを設定できる
  • 上流の context のタイムアウトと連動する(早い方が勝つ
  • キャンセル理由が context.Canceled / context.DeadlineExceeded で分かる

タイムアウトのない外部呼び出しは事故の元。相手の API が DNS 失敗で 30秒待ったり、TLS handshake で詰まったりすると、こちらが連動して詰む。

タイムアウトの階層化

  • グローバル: アプリ全体の HTTP リクエストタイムアウト 30秒
  • リクエスト: 個別のハンドラのデッドライン 10秒
  • 下流呼び出し: 個別 API ごとに 3秒

親 context のデッドラインが先に来れば、子 context は自動的にキャンセルされる。階層化することで「上流の期限を子が超えない」設計が自動的に成立する。

9. DB クエリと context

// すべて context 渡し版を使う
db.QueryContext(ctx, ...)
db.QueryRowContext(ctx, ...)
db.ExecContext(ctx, ...)
db.PingContext(ctx)
tx, err := db.BeginTx(ctx, nil)
tx.QueryContext(ctx, ...)
 
// 古い API(context なし)は使わない
db.Query(...) // NG

context なし API はキャンセル不能

Go の database/sql は context 対応前の API(db.Query, db.Exec)が残っているが、新規コードでは使わない。これらは キャンセルを検知できない

一部の古い OSS は context なし API を使っているが、modernize する時の第一候補。

10. ctx.Done() でキャンセル検知

func longRunningJob(ctx context.Context) error {
    for i := 0; i < 1_000_000; i++ {
        select {
        case <-ctx.Done():
            return ctx.Err()
        default:
        }
        // 重い処理
        processItem(i)
    }
    return nil
}

自前ループでキャンセルを尊重する

database/sqlhttp.Client は内部でキャンセルを聞いてくれるが、自前のループ処理では明示的に <-ctx.Done() をチェックする責任 がある。

ループの先頭で:

select {
case <-ctx.Done():
    return ctx.Err()
default:
}

このパターンは「毎イテレーションで非ブロッキングにキャンセルを聞く」イディオム。

ctx.Err() の値

意味
nilキャンセルされていない
context.Canceled明示的 cancel() で停止
context.DeadlineExceededタイムアウトで停止

後続処理で errors.Is(err, context.DeadlineExceeded) で「タイムアウトだったか」を判別できる。

11. goroutine 起動時のキャンセル伝搬の落とし穴

// NG: parent の cancel が伝わらない
func handler(w http.ResponseWriter, r *http.Request) {
    go doAsync()                  // context 渡してない
    w.WriteHeader(http.StatusOK)
}
 
// NG: リクエストが終わると ctx もキャンセルされ、非同期処理が即死
func handler(w http.ResponseWriter, r *http.Request) {
    go doAsync(r.Context())       // リクエスト終了で ctx がキャンセル
    w.WriteHeader(http.StatusOK)
}
 
// OK: 新しい context を作る or 専用の親 context を使う
func handler(w http.ResponseWriter, r *http.Request) {
    asyncCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
    go func() {
        defer cancel()
        doAsync(asyncCtx)
    }()
    w.WriteHeader(http.StatusOK)
}

goroutine と context の寿命ミスマッチ

リクエストの context は レスポンスを書き終えた時点でキャンセル される。これを非同期 goroutine に渡すと、レスポンス送信直後に goroutine が中断される。

「リクエストとは独立に動かしたい」非同期処理は、新しいルート context を作るのが正解。ただし無制限ではなく、適切なタイムアウトを設定する。

errgroup で goroutine を束ねる

import "golang.org/x/sync/errgroup"
 
g, gctx := errgroup.WithContext(ctx)
g.Go(func() error { return fetchA(gctx) })
g.Go(func() error { return fetchB(gctx) })
if err := g.Wait(); err != nil {
    return err
}

errgroup は「複数 goroutine が並列で動き、どれか1つがエラーで全部キャンセル」というパターンを綺麗に書ける。Go の並行処理で頻出。

12. アンチパターン総まとめ

context を関数引数に持たせない

func DoWork() error {
    ctx := context.Background() // ← 関数内で生成
    return queryDB(ctx)
}

上流からのキャンセルが効かない。context は必ず引数で受け取る

WithValue に大量のデータを詰める

ctx = context.WithValue(ctx, "user", user)
ctx = context.WithValue(ctx, "session", session)
ctx = context.WithValue(ctx, "settings", settings)

「context = 共有メモリ」感覚はアンチパターン。構造体に持たせる、関数引数で渡す が原則。WithValue はトレース ID くらいに限定。

string キーで Value を保存

context.WithValue(ctx, "userID", id) // 他パッケージと衝突可能

独自型のキーを使う。

cancel を呼び忘れる

ctx, _ := context.WithCancel(parent) // cancel を捨てる

go vetlostcancel で警告する。必ず defer cancel()

ctx.Done() を聞かない長時間処理

ループでキャンセルチェックなしの数十秒処理。クライアントが切ってもサーバーは走り続ける → リソース浪費。


練習課題

  1. HTTP ハンドラ全体に5秒タイムアウト をミドルウェアで適用
  2. 外部 API クライアント に個別 3秒タイムアウト
  3. DB クエリQueryContext を使い、上流キャンセルが伝わることを確認
  4. 長時間ループ処理select { case <-ctx.Done(): return } を入れる
  5. errgroup で複数 API を並列呼び出し、片方失敗で全部キャンセル
  6. 非同期 fire-and-forgetcontext.Background() から派生した context を使う
  7. request_id を context に乗せて、全レイヤーのログに含める

締め: git で証跡を残す

cd ~/learn/go/level3/day06
git add .
git commit -m "feat(go): context.Context をハンドラ→サービス→DB まで貫通"

アンチパターン集 - やらかし事例(再掲含む)

context 関連の本番事故

1. 関数内で context.Background() を作る 上流キャンセル断絶。クライアント切ってもクエリ走り続ける。

2. cancel を呼び忘れ WithCancel/WithTimeout が確保した goroutine/channel がリーク。defer cancel() 必須。

3. WithValue にロガー/DB/Configを乗せる 型安全性消滅、テストモック注入不能。構造体フィールドへ。

4. string キーで Value 保存 他パッケージと衝突。独自型 type contextKey string に。

5. 構造体に context を保持

type Repo struct { ctx context.Context }  // ← NG

リクエストスコープの context を長寿命構造体に持たせる設計ミス。メソッド呼び出しごとに渡す

6. goroutine と context 寿命のミスマッチ

go doAsync(r.Context())  // ← レスポンス送信で即キャンセル

非同期 fire-and-forget は context.Background() 派生で。

7. ループで ctx.Done() 聞かない 100万件処理中にキャンセルされても止まらない。select { case <-ctx.Done(): return ctx.Err(); default: } を入れる。

対比表で違いを明確化

4つの WithXxx

関数引数用途
WithCancelparent明示的キャンセル可能
WithTimeoutparent, durN秒後にキャンセル
WithDeadlineparent, time指定時刻にキャンセル
WithValueparent, key, valメタデータ伝搬(限定的)

context.Background vs context.TODO

関数用途
Background()アプリエントリポイント、テスト、main
TODO()リファクタ途中の「context必要だが未定」プレースホルダ

機能は同じ。意図を表すコメントとしての差別化

context 経由 vs 構造体フィールド

入れるものcontext.WithValue構造体フィールド
リクエスト ID×
認証済みユーザー ID×
DB 接続×
Logger×
Config×
業務データ×引数

自己評価チェックリスト

手を動かせた

  • WithTimeout(ctx, 3*time.Second) + defer cancel()
  • WithCancel で goroutine を一括停止
  • WithValue でリクエスト ID を伝搬(独自型キー)
  • ハンドラの r.Context() を DB の QueryContext まで貫通
  • 外部 API に http.NewRequestWithContext で個別タイムアウト
  • ループに select { case <-ctx.Done(): return ctx.Err(); default: }
  • 非同期処理に context.Background() 派生の新 context
  • errgroup.WithContext(ctx) + g.Go(...) + g.Wait()

説明できる

  • context.Context の4メソッド(Deadline/Done/Err/Value)
  • BackgroundTODO の使い分け
  • context 階層化(全体→個別)でタイムアウトが連動する仕組み
  • ctx.Err() の3つの値(nil/Canceled/DeadlineExceeded)

やらかし回避

  • 関数内で context.Background() を作らない
  • defer cancel() を必ず書く
  • WithValue にロガー/DB を入れない
  • string キーで Value 保存しない
  • 構造体に context を保持しない

詰まった時のチートシート

やりたいこと書き方
ルート contextcontext.Background()
キャンセル可能ctx, cancel := context.WithCancel(parent)
タイムアウトctx, cancel := context.WithTimeout(parent, 3*time.Second)
期限指定ctx, cancel := context.WithDeadline(parent, deadline)
値伝搬ctx = context.WithValue(ctx, myKey, val)
キャンセル検知<-ctx.Done()
キャンセル理由ctx.Err()
HTTP リクエストhttp.NewRequestWithContext(ctx, ...)
DB クエリdb.QueryContext(ctx, ...)
並列 goroutineerrgroup.WithContext(ctx) + g.Go(...) + g.Wait()

「実務OK」基準

  • context を引数で運ぶ意義を語れる: キャンセル伝搬とリクエストスコープ
  • WithValue の濫用を回避できる: 「構造体に持つべきもの」と「context に乗せるもの」の境界を判断
  • タイムアウトの階層化を設計できる: 全体 → 個別の親子関係
  • goroutine と context の寿命ミスマッチを察知できる
  • <-ctx.Done() を反射的に書ける: ループや待ち処理で

さらに深掘りするなら


次のレッスン

3-7 本番品質の API で本番品質の API を仕上げる: graceful shutdown、構造化ログ、メトリクス、本番チェックリスト。

つながりの予告

  • 本章の signal.Notify + srv.Shutdown(ctx)graceful shutdown の核心
  • リクエスト ID context が 構造化ログの request_id フィールド に直結
  • 外部 API のタイムアウトが リトライ + サーキットブレーカー と組合さる