Go の context はキャンセル・締め切り・値の3つを下流へ運ぶ仕組みです。ただ入門記事の多くは WithCancel と WithTimeout で止まり、Go 1.20/1.21 で増えた Cause 系や WithoutCancel まで届きません。基本を押さえたうえで、本番のデバッグに効く新しい API を Go 1.26 時点の仕様で整理します。
context が運ぶ3つのシグナル
公式ドキュメントの冒頭はこう定義します。
Package context defines the Context type, which carries deadlines, cancellation signals, and other request-scoped values across API boundaries and between processes.
運ぶのは締め切り(deadline)・キャンセル信号(cancellation signal)・リクエストスコープの値の3つ。API 境界やプロセスをまたいでこれらを伝える。どの派生関数を選ぶかは、この3つのどれを使いたいかで決まります。
なぜ第1引数で渡すのか
context.Context は関数の第1引数に置き、名前は ctx にする。公式ドキュメントのサンプルもこの並びです。グローバル変数や構造体フィールドに隠さないのは、呼び出しの連鎖をそのままキャンセルの伝播経路にするため。ctx を引数で渡し続けるかぎり、上流の cancel() が末端の DB 呼び出しまで届きます。
Context インターフェースの4メソッド
Context が持つメソッドは4つだけ。
Deadline()– 締め切り時刻と、設定の有無を返すDone()– キャンセル時に閉じるチャネルを返すErr()– 終了理由(CanceledかDeadlineExceeded)を返すValue(key)– キーに対応する値を返す
派生関数はこのインターフェースを満たす子 Context を親から作る。親子の Done() がつながり、親が閉じれば子も閉じます。Done() には注意書きがあって、ドキュメントは「The close of the Done channel may happen asynchronously, after the cancel function returns」と明記する。cancel() を呼んだ瞬間にチャネルが閉じるとは限りません。
WithCancel でキャンセルを下流に伝える
一番素朴な使い方が WithCancel。親 Context から子と cancel 関数を作り、cancel() を呼ぶと子の Done() が閉じる。受け取った側は select で Done() を監視し、自分から抜けます。
package main
import (
"context"
"fmt"
"time"
)
func worker(ctx context.Context, id int) {
for {
select {
case <-ctx.Done():
fmt.Printf("worker %d: 停止 (%v)\n", id, ctx.Err())
return
case <-time.After(500 * time.Millisecond):
fmt.Printf("worker %d: 作業中\n", id)
}
}
}
func main() {
ctx, cancel := context.WithCancel(context.Background())
go worker(ctx, 1)
time.Sleep(1200 * time.Millisecond)
cancel() // 全 worker に停止を伝える
time.Sleep(100 * time.Millisecond)
}
実行結果。
worker 1: 作業中
worker 1: 作業中
worker 1: 停止 (context canceled)
500ミリ秒ごとに作業し、1200ミリ秒で cancel()。2回作業したところで Done() が閉じ、ctx.Err() が context canceled を返して抜けた。
defer cancel() を省くと goroutine が漏れる
アンチパターンは cancel() の呼び忘れ。cancel を呼ばないと、子の Done() は閉じないまま残る。Done() を待つ goroutine が解放されず、件数だけ膨らみます。
func leak(parent context.Context) {
ctx, cancel := context.WithCancel(parent)
_ = cancel // ← 呼ばない(アンチパターン)
go func() {
<-ctx.Done() // 親が Background なので永遠に閉じない
}()
}
func main() {
for i := 0; i < 1000; i++ {
leak(context.Background())
}
time.Sleep(100 * time.Millisecond)
fmt.Println("goroutine 数:", runtime.NumGoroutine())
}
goroutine 数: 1001
1000 個の goroutine が Done() 待ちで居座り、main と合わせて 1001。直し方は単純で、子を作った直後に defer cancel() を置く。
func noLeak(parent context.Context) {
ctx, cancel := context.WithCancel(parent)
defer cancel() // 関数を抜けるとき必ず閉じる
go func() { <-ctx.Done() }()
}
goroutine 数: 1
同じループでも漏れはゼロ。リークの調べ方は Goのgoroutineリーク対策—pprofとGo 1.26新機能の使い分けで扱っています。
タイムアウトと締め切りは何が違うか
「N秒で諦める」なら WithTimeout、「特定の時刻までに終わらせる」なら WithDeadline。引数の型が違うだけで、どちらも時間切れか cancel() で Done() が閉じます。
| 関数 | キャンセル契機 | 引数の型 | 向いている場面 |
|---|---|---|---|
WithCancel | cancel() のみ | なし | 手動で止める |
WithTimeout | 経過時間 / cancel() | time.Duration | 「3秒で諦める」 |
WithDeadline | 指定時刻 / cancel() | time.Time | 「12:00の締めに間に合わせる」 |
ドキュメントは WithTimeout を「Returns WithDeadline(parent, time.Now().Add(timeout))」と定義する。要は WithTimeout は WithDeadline の薄い糖衣構文。時間切れのとき ctx.Err() は context deadline exceeded を返します。
Cause でキャンセルの理由を残す
ctx.Err() が返すのは context canceled か context deadline exceeded の2つだけ。以前、本番ログにこの文字列だけが並び、どこが何の理由で打ち切ったのか追えなかった。タイムアウトなのか上流の cancel() なのか、原因が消えてしまう。これを埋めるのが Go 1.20 で入った WithCancelCause と Cause です。
ctx.Err() では誰が止めたか分からない
Err() は2種類の番兵エラーしか返さない。一方 context.Cause(ctx) は、キャンセル時に渡した任意のエラーをそのまま返します。両者の対応はこうなる。
| 状態 | ctx.Err() | context.Cause(ctx) |
|---|---|---|
| まだ有効 | nil | nil |
cancel(err) で停止 | context.Canceled | 渡した err |
| タイムアウト(Cause付き) | context.DeadlineExceeded | 設定した cause |
WithCancelCause と Cause で原因を載せる
WithCancelCause は cancel の代わりに CancelCauseFunc を返す。これに非 nil のエラーを渡すと、その理由が Cause(ctx) から取り出せます。
var errQuotaExceeded = errors.New("クォータ超過")
func main() {
ctx, cancel := context.WithCancelCause(context.Background())
cancel(errQuotaExceeded) // 理由付きで止める
fmt.Println("Err(): ", ctx.Err())
fmt.Println("Cause():", context.Cause(ctx))
fmt.Println("一致判定:", errors.Is(context.Cause(ctx), errQuotaExceeded))
}
Err(): context canceled
Cause(): クォータ超過
一致判定: true
Err() は従来どおり context canceled。だが Cause() は errQuotaExceeded をそのまま返し、errors.Is で型判定もできる。ログに「なぜ止まったか」を一行残せるようになります。
タイムアウトにも理由を付ける WithTimeoutCause
タイムアウト側にも同じ仕組みがある。WithTimeoutCause は Go 1.21 で追加され、ドキュメントは「Behaves like WithTimeout but also sets the cause of the returned Context when the timeout expires」と説明します。自前のタイムアウトと上流のキャンセルを、Cause() の中身で区別できる。
ctx, cancel := context.WithTimeoutCause(
context.Background(), 50*time.Millisecond,
errors.New("DB応答が50msを超過"),
)
defer cancel()
<-ctx.Done()
fmt.Println("Err(): ", ctx.Err())
fmt.Println("Cause():", context.Cause(ctx))
Err(): context deadline exceeded
Cause(): DB応答が50msを超過
ひとつ落とし穴。ドキュメントは「The returned CancelFunc does not set the cause」と書く。cancel() 経由で止めた場合は cause が付かず、時間切れのときだけ設定した文言が載ります。
親が死んでも続けたい処理は WithoutCancel
HTTP ハンドラの r.Context() は、レスポンスを返した時点でキャンセルされる。だが非同期のメトリクス送信や監査ログの書き込みは、リクエスト終了後も完走させたい。親の ctx をそのまま渡すと、レスポンス直後に Done() が閉じて処理が途中で死にます。
リクエスト終了で道連れになる背景処理
Go 1.21 の WithoutCancel は、親の値だけを引き継ぎ、キャンセルは引き継がない Context を返す。トレース ID などはそのまま使えて、親が閉じても巻き込まれません。
func recordMetrics(ctx context.Context) {
time.Sleep(100 * time.Millisecond) // 親キャンセル後も走らせたい
fmt.Println("メトリクス記録完了 ctx.Err():", ctx.Err())
}
func main() {
parent, cancel := context.WithCancel(context.Background())
go recordMetrics(context.WithoutCancel(parent))
cancel() // リクエスト終了で親を止める
time.Sleep(200 * time.Millisecond)
}
メトリクス記録完了 ctx.Err(): <nil>
親を cancel() しても、切り離した側の Err() は nil のまま。ドキュメントどおり「The returned context returns no Deadline or Err, and its Done channel is nil」で、Done() も閉じません。
キャンセル時の後始末を AfterFunc で書く
キャンセルされたら一時ファイルを消す、接続を閉じる。こうした後始末を、これまでは go func(){ <-ctx.Done(); cleanup() }() と手書きしていた。Go 1.21 の AfterFunc はこれを1行に畳みます。
func main() {
ctx, cancel := context.WithCancel(context.Background())
stop := context.AfterFunc(ctx, func() {
fmt.Println("後始末: 一時ファイルを削除")
})
_ = stop
time.Sleep(50 * time.Millisecond)
cancel() // ここで登録した関数が別 goroutine で走る
time.Sleep(50 * time.Millisecond)
}
後始末: 一時ファイルを削除
cancel() の後、登録した関数が自前の goroutine で実行された。複数回 AfterFunc を呼んでも互いに独立で、片方がもう片方を上書きしません。
stop() で登録を解除する
戻り値の stop を呼ぶと登録を取り消せる。後始末が不要になった場面で使う。stop() は実行を止められたとき true を返します。
stop := context.AfterFunc(ctx, cleanup)
if stop() {
fmt.Println("登録解除に成功。cleanup は実行されない")
}
登録解除に成功。cleanup は実行されない
WithValue の使いどころと誤用
WithValue は値を運ぶ。ただ何でも入れていいわけではない。ドキュメントは用途を絞り込みます。
Use context Values only for request-scoped data that transits processes and APIs, not for passing optional parameters to functions.
何を入れて何を入れないか
入れていいのはリクエストID・トレースID・認証済みユーザーなど、そのリクエストに紐づくデータ。設定値・DB ハンドル・関数のオプション引数を WithValue 経由で渡すのは誤用です。引数で渡せばコンパイラが型を見てくれる。Value() 経由だと any になり、型アサーションの失敗が実行時まで分かりません。
キーは独自型にして衝突を防ぐ
キーに string をそのまま使うと、別パッケージの同名キーと衝突する。go vet も警告します。
// アンチパターン: 組み込み型をキーにする
ctx = context.WithValue(ctx, "userID", 42)
id := ctx.Value("userID")
$ go vet
./main.go: should not use built-in type string as key for value;
define your own type to avoid collisions
キーを未公開の独自型にすれば、他パッケージから同じキーを作れず衝突しない。型アサーションも安全になります。
type ctxKey string
const userIDKey ctxKey = "userID"
ctx = context.WithValue(ctx, userIDKey, 42)
id, ok := ctx.Value(userIDKey).(int)
fmt.Println(id, ok)
42 true
まとめ
context が運ぶのは締め切り・キャンセル・値の3つ。基本の3関数に Go 1.20/1.21 の新 API を足すと、キャンセルの理由をエラーとしてログに残せます。
WithCancel/WithTimeout/WithDeadlineでDone()を下流に伝える。子を作ったらdefer cancel()WithCancelCauseとcontext.Causeで「誰が・なぜ止めたか」をエラーとして残すWithTimeoutCauseで時間切れの理由を載せる。ただし手動cancel()には cause が付かないWithoutCancelは値だけ引き継ぎ、親キャンセルから切り離す。背景タスク向けAfterFuncで後始末を登録し、stop()で取り消すWithValueはリクエストスコープのデータ限定。キーは独自型にする
並行処理のエラー集約まで広げるなら Goのerrgroupで並行処理のエラーを集約する—WaitGroupとの使い分け、goroutine と channel の基礎は Go言語の並行処理入門:goroutineとchannelの使い方を完全解説が続きになります。
よくある質問
cancel() を2回以上呼んでも大丈夫? 問題ありません。ドキュメントは CancelFunc について「After the first call, subsequent calls to a CancelFunc do nothing」と書く。2回目以降は何もしません。
WithTimeout なら defer cancel() は要らない? 要ります。時間切れで自動キャンセルされても、内部のタイマーは cancel() を呼ぶまで残る。早期に解放するため、戻り値の cancel は必ず defer で呼んでください。

