3-6. context.Context - キャンセルとメタデータ伝搬
所要時間: 40-50分 コミット内容:
~/learn/go/level3/day06/に context を貫通させた HTTP ハンドラ + DB + 外部API 呼び出し
このレッスンのゴール
-
WithCancel/WithTimeout/WithDeadline/WithValue4つを使い分け - 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_sql の db.PingContext(ctx)、3-2_CRUDハンドラ の QueryContext、2-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/ctxplay1. 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 vetのlostcancelチェックがこれを検出してくれる。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(...) // NGcontext なし 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/sqlやhttp.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 vetがlostcancelで警告する。必ずdefer cancel()。
ctx.Done() を聞かない長時間処理
ループでキャンセルチェックなしの数十秒処理。クライアントが切ってもサーバーは走り続ける → リソース浪費。
練習課題
- HTTP ハンドラ全体に5秒タイムアウト をミドルウェアで適用
- 外部 API クライアント に個別 3秒タイムアウト
- DB クエリ で
QueryContextを使い、上流キャンセルが伝わることを確認 - 長時間ループ処理 に
select { case <-ctx.Done(): return }を入れる - errgroup で複数 API を並列呼び出し、片方失敗で全部キャンセル
- 非同期 fire-and-forget で
context.Background()から派生した context を使う - 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, dur N秒後にキャンセル 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)
-
BackgroundとTODOの使い分け - context 階層化(全体→個別)でタイムアウトが連動する仕組み
-
ctx.Err()の3つの値(nil/Canceled/DeadlineExceeded)
やらかし回避
- 関数内で
context.Background()を作らない -
defer cancel()を必ず書く - WithValue にロガー/DB を入れない
- string キーで Value 保存しない
- 構造体に context を保持しない
詰まった時のチートシート
| やりたいこと | 書き方 |
|---|---|
| ルート context | context.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, ...) |
| 並列 goroutine | errgroup.WithContext(ctx) + g.Go(...) + g.Wait() |
「実務OK」基準
- context を引数で運ぶ意義を語れる: キャンセル伝搬とリクエストスコープ
WithValueの濫用を回避できる: 「構造体に持つべきもの」と「context に乗せるもの」の境界を判断- タイムアウトの階層化を設計できる: 全体 → 個別の親子関係
- goroutine と context の寿命ミスマッチを察知できる
<-ctx.Done()を反射的に書ける: ループや待ち処理で
さらに深掘りするなら
- 公式: pkg.go.dev/context
- The Go Blog: Go Concurrency Patterns: Context
- Go Code Review Comments: Contexts
- 書籍: 『Concurrency in Go』 (Katherine Cox-Buday) - 並行処理の決定版
- OSS:
kubernetes/kubernetesの context 伝搬。Kubelet/APIServer のリクエスト処理 - 実例: Dave Cheney: Context isn’t for cancellation - 反論記事も読むと理解が深まる
次のレッスン
3-7 本番品質の API で本番品質の API を仕上げる: graceful shutdown、構造化ログ、メトリクス、本番チェックリスト。
つながりの予告
- 本章の
signal.Notify+srv.Shutdown(ctx)で graceful shutdown の核心 - リクエスト ID context が 構造化ログの request_id フィールド に直結
- 外部 API のタイムアウトが リトライ + サーキットブレーカー と組合さる