- Go言語
Go contextの使い方|WithTimeout・WithCancel入門
Goのcontextとは、「この処理はもう止めてよい」という合図(キャンセル・制限時間)と、リクエストに付随する少量の値を、関数からgoroutineへ受け渡すための標準の仕組みです。 使い方の基本は3つだけで、①context.Background() から始める、②WithTimeout や WithCancel で子を作り defer cancel() を書く、③受け取った側は <-ctx.Done() を待つ、という流れになります。
この記事では、基本の4関数(WithCancel / WithTimeout / WithDeadline / WithValue)に加えて、Go 1.20〜1.21で加わった WithCancelCause・WithTimeoutCause・AfterFunc・WithoutCancel までを1枚の早見表にまとめました。掲載したコードは package main から始まる完成形で、Go 1.24.7(linux/amd64)で go run した出力をそのまま載せています。動かしていない箇所(database/sql と errgroup の例)は「未実行」と明記しています。
contextの早見表
作る関数(これだけ見れば選べます)
| やりたいこと | 関数 | 戻り値 | 追加された版 | 終了時の ctx.Err() |
|---|---|---|---|---|
| 最初の1個を作る(main・初期化・テスト) | context.Background() |
ctx | 1.7 | 終了しない(nil) |
| 渡すctxがまだ決まっていない場所の仮置き | context.TODO() |
ctx | 1.7 | 終了しない(nil) |
| 好きなタイミングで止める | context.WithCancel(parent) |
ctx, cancel | 1.7 | context.Canceled |
| 一定時間で打ち切る | context.WithTimeout(parent, d) |
ctx, cancel | 1.7 | context.DeadlineExceeded |
| 決まった時刻で打ち切る | context.WithDeadline(parent, t) |
ctx, cancel | 1.7 | context.DeadlineExceeded |
| リクエストIDなどの値を運ぶ | context.WithValue(parent, key, val) |
ctx | 1.7 | 親と同じ |
| 止めた理由をerrorで残す | context.WithCancelCause(parent) |
ctx, cancel(err) | 1.20 | context.Canceled |
| 時間切れの理由をerrorで残す | context.WithTimeoutCause(parent, d, cause) |
ctx, cancel | 1.21 | context.DeadlineExceeded |
| 期限切れの理由をerrorで残す | context.WithDeadlineCause(parent, t, cause) |
ctx, cancel | 1.21 | context.DeadlineExceeded |
| 終了時に後始末の関数を呼ぶ | context.AfterFunc(ctx, f) |
stop | 1.21 | (ctxは変わらない) |
| 値は引き継ぎ、キャンセルは切り離す | context.WithoutCancel(parent) |
ctx | 1.21 | 終了しない(nil) |
受け取る側で使うもの
| 知りたいこと | 書き方 | 返るもの |
|---|---|---|
| 終了の合図を待つ | <-ctx.Done() |
終了時に閉じられるチャネル |
| なぜ終わったか(2種類の区別) | ctx.Err() |
nil / Canceled / DeadlineExceeded |
| なぜ終わったか(独自の理由) | context.Cause(ctx) |
cancel(err) などで渡したerror。無ければ ctx.Err() と同じ |
| 期限はいつか | ctx.Deadline() |
時刻と、期限の有無(bool) |
| 運ばれてきた値 | ctx.Value(key) |
値(無ければ nil) |
| 種類の判別 | errors.Is(err, context.DeadlineExceeded) |
bool |
追加された版と関数のシグネチャは、今回 pkg.go.dev の context パッケージのページを開いて確認しました(1.7 は context が標準ライブラリに入った版で、こちらは一般に知られた仕様です)。
contextの全体像を30秒でつかむ
contextは親から子へ枝分かれする木になっています。親が終了すると、その下にある子はすべて終了します。逆に、子を終了させても親には影響しません。
flowchart TD
BG["context.Background()"] --> T["WithTimeout 5秒<br>リクエスト全体"]
T --> V["WithValue<br>request_id を付ける"]
V --> C1["WithCancel<br>並行処理A"]
V --> C2["WithTimeout 1秒<br>外部API呼び出し"]
V --> W["WithoutCancel<br>終了後も続けるログ保存"]
| 決まりごと | 理由 |
|---|---|
ctxは関数の第1引数に ctx context.Context という名前で渡す |
標準ライブラリを含め、Goのコード全体でそろっている形だから |
| 構造体のフィールドには入れず、呼び出しのたびに引数で渡す | どのリクエストのctxかが引数を見るだけでわかるから |
cancel を受け取ったら、すぐ次の行に defer cancel() |
タイマーや親への登録を確実に解放できるから |
nil は渡さない。迷ったら context.TODO() |
受け取る側が ctx.Done() を安心して呼べるから |
| WithValueには処理に必須の引数を入れない | 関数のシグネチャから読み取れなくなるから |
BackgroundとTODOの違いと使い分け
context.Background() と context.TODO() は、動作としてはまったく同じ「空のcontext」です。 違いは「読む人へのメッセージ」だけです。実際に比べてみます。
package main
import (
"context"
"fmt"
)
func main() {
bg := context.Background()
todo := context.TODO()
fmt.Println(bg, "/", todo)
fmt.Println("Err:", bg.Err(), todo.Err())
fmt.Println("Done == nil:", bg.Done() == nil, todo.Done() == nil)
_, ok := bg.Deadline()
fmt.Println("期限あり:", ok)
}
実行結果(go run main.go):
context.Background / context.TODO
Err: <nil> <nil>
Done == nil: true true
期限あり: false
どちらもキャンセルされず、期限も値も持ちません。Done() が nil を返すので、<-ctx.Done() は永久に待ち続けます(select の中では「選ばれない分岐」になります)。
| 関数 | 使う場所 | 読む人に伝わること |
|---|---|---|
context.Background() |
main関数、初期化処理、テスト、リクエストを受ける入口 | 「ここが木の根です」 |
context.TODO() |
上位の関数がまだctxを受け取っていない場所への仮置き | 「あとで本物のctxに差し替えます」 |
既存のコードにctxを通していく途中では TODO を置き、通し終えたら引数のctxに置き換える、という使い方が自然です。TODO で検索すれば、差し替え待ちの箇所を一覧できます。
WithCancelで複数のgoroutineをまとめて止める
cancel() を1回呼ぶだけで、同じctxを受け取ったすべてのgoroutineに合図が届きます。 Done() のチャネルが閉じられ、待っていた全員が同時に動き出す仕組みです。
package main
import (
"context"
"fmt"
"sync"
"time"
)
func worker(ctx context.Context, id int, wg *sync.WaitGroup) {
defer wg.Done()
for i := 1; ; i++ {
select {
case <-ctx.Done(): // キャンセルされると閉じられる
fmt.Printf("worker%d: 停止 (%v)\n", id, ctx.Err())
return
case <-time.After(100 * time.Millisecond):
fmt.Printf("worker%d: %d回目の処理\n", id, i)
}
}
}
func main() {
ctx, cancel := context.WithCancel(context.Background())
defer cancel() // 途中でreturnしても必ず解放する
var wg sync.WaitGroup
for id := 1; id <= 3; id++ {
wg.Add(1)
go worker(ctx, id, &wg)
}
time.Sleep(250 * time.Millisecond)
fmt.Println("main: cancel() を呼ぶ")
cancel() // 3つのgoroutineすべてに届く
wg.Wait()
fmt.Println("main: 全workerの終了を確認")
}
実行結果(worker の表示順は実行のたびに入れ替わります):
worker1: 1回目の処理
worker2: 1回目の処理
worker3: 1回目の処理
worker3: 2回目の処理
worker2: 2回目の処理
worker1: 2回目の処理
main: cancel() を呼ぶ
worker3: 停止 (context canceled)
worker1: 停止 (context canceled)
worker2: 停止 (context canceled)
main: 全workerの終了を確認
ポイントは3つです。
- goroutine側は select で
ctx.Done()と本来の処理を並べて待ちます。 ctxを渡しただけでは止まらず、受け取った側が合図を見ることで初めて止まります。 cancel()は何回呼んでも問題ありません。 2回目以降は何も起きないので、「途中で明示的に呼ぶ」と「defer cancel()」を併用できます。sync.WaitGroupで終了を待つと、合図を送ったあとに全員が本当に終わったことを確かめられます。
WithTimeoutでタイムアウトを付ける
制限時間を付けたいときは context.WithTimeout(parent, 時間) を使います。 時間が来ると自動で終了し、ctx.Err() は context.DeadlineExceeded(表示は context deadline exceeded)になります。
package main
import (
"context"
"fmt"
"time"
)
// 時間のかかる処理。ctxが終わったら途中で戻る
func fetch(ctx context.Context, cost time.Duration) (string, error) {
select {
case <-time.After(cost):
return "結果データ", nil
case <-ctx.Done():
return "", ctx.Err()
}
}
func run(cost time.Duration) {
ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
defer cancel()
start := time.Now()
res, err := fetch(ctx, cost)
elapsed := time.Since(start).Round(10 * time.Millisecond)
fmt.Printf("処理時間%v → res=%q err=%v (経過 %v)\n", cost, res, err, elapsed)
}
func main() {
run(50 * time.Millisecond) // 制限時間内に終わる
run(500 * time.Millisecond) // 200msで打ち切られる
}
実行結果:
処理時間50ms → res="結果データ" err=<nil> (経過 50ms)
処理時間500ms → res="" err=context deadline exceeded (経過 200ms)
500msかかる処理が、制限時間の200msで戻ってきていることがわかります。制限時間内に終わった場合でも defer cancel() は必要です。 早く終わったときに、残り時間ぶんのタイマーをその場で解放できます。
defer cancel() を書かなかったときのgo vet警告
cancel を _ で受け取って捨てると、go vet が知らせてくれます。
package main
import (
"context"
"fmt"
"time"
)
func main() {
ctx, _ := context.WithTimeout(context.Background(), time.Second)
fmt.Println(ctx.Err())
}
go vet ./... の出力(Go 1.24.7、終了コードは1):
./main.go:10:7: the cancel function returned by context.WithTimeout should be called, not discarded, to avoid a context leak
ビルドと実行はそのまま通るため、気づくきっかけは vet になります。直し方は1行足すだけです。
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
この形に直して go vet ./... を実行すると、何も表示されず終了コード0になりました。なお、cancel を変数で受け取ったうえで「早期returnの経路だけ呼び忘れている」短い例も試しましたが、今回の環境では警告は出ませんでした。vetに頼り切らず、「受け取った次の行に defer cancel()」を手の形にしておくのが確実です。
| 書き方 | go vet | 何が起きるか |
|---|---|---|
ctx, _ := context.WithTimeout(...) |
警告あり(上の文面) | 制限時間が来るまでタイマーと親への登録が残る |
ctx, cancel := ... のあと defer cancel() |
警告なし | 関数を抜けた時点で解放される |
ctx, cancel := ... で cancel を一度も使わない |
コンパイルエラー(declared and not used) | ビルドの段階で気づける |
WithDeadlineで時刻を指定する
WithTimeout(parent, d) は WithDeadline(parent, time.Now().Add(d)) と同じ意味です。 「あと何秒」ではなく「何時何分まで」と決まっているときは WithDeadline が読みやすくなります。
package main
import (
"context"
"fmt"
"time"
)
func main() {
limit := time.Now().Add(150 * time.Millisecond)
ctx, cancel := context.WithDeadline(context.Background(), limit)
defer cancel()
d, ok := ctx.Deadline()
fmt.Println("期限あり:", ok, "/ 残り約", time.Until(d).Round(10*time.Millisecond))
// 子に長いタイムアウトを付けても、親の期限より後にはならない
child, cancel2 := context.WithTimeout(ctx, 10*time.Second)
defer cancel2()
cd, _ := child.Deadline()
fmt.Println("子の期限は親と同じ:", cd.Equal(d))
<-child.Done()
fmt.Println("子:", child.Err())
fmt.Println("親:", ctx.Err())
}
実行結果:
期限あり: true / 残り約 150ms
子の期限は親と同じ: true
子: context deadline exceeded
親: context deadline exceeded
子に長い時間を指定しても、親の期限より後ろには延びません。 短いほうが常に採用されるので、「リクエスト全体で5秒、そのうち外部APIは1秒まで」のように、内側ほど短く刻んでいく設計ができます。
| 比べる点 | WithTimeout | WithDeadline |
|---|---|---|
| 指定するもの | 長さ(2*time.Second) |
時刻(time.Time) |
| 向いている場面 | 1回の呼び出しに上限を付ける | 受け取った締め切り時刻をそのまま伝える |
| 終了時のエラー | context.DeadlineExceeded |
context.DeadlineExceeded |
WithValueでリクエストIDなどを運ぶ
WithValueは、リクエストIDや認証済みユーザーなど「リクエストに付いて回る情報」を運ぶためのものです。 キーには自分で定義した型を使います。
package main
import (
"context"
"fmt"
)
// キーは自分のパッケージ専用の型にする(他パッケージと衝突しない)
type ctxKey int
const requestIDKey ctxKey = 1
func WithRequestID(ctx context.Context, id string) context.Context {
return context.WithValue(ctx, requestIDKey, id)
}
func RequestID(ctx context.Context) (string, bool) {
id, ok := ctx.Value(requestIDKey).(string)
return id, ok
}
func handle(ctx context.Context) {
if id, ok := RequestID(ctx); ok {
fmt.Println("request_id =", id)
} else {
fmt.Println("request_id は未設定")
}
}
func main() {
base := context.Background()
handle(base)
ctx := WithRequestID(base, "req-0001")
handle(ctx)
// 子のcontextからも親の値が読める
child, cancel := context.WithCancel(ctx)
defer cancel()
handle(child)
// 型が違うキーは別物として扱われる
fmt.Println("intの1で探す:", ctx.Value(1))
}
実行結果:
request_id は未設定
request_id = req-0001
request_id = req-0001
intの1で探す: <nil>
最後の行に注目してください。requestIDKey の中身は 1 ですが、型が ctxKey なので、ただの int の 1 とは別のキーとして扱われます。 これが「キーに string や int などの組み込み型をそのまま使わない」と言われる理由で、別のパッケージがたまたま同じ文字列を使っても衝突しません。
| 入れてよいもの | 引数で渡すほうがよいもの |
|---|---|
| リクエストID、トレースID | DB接続やロガー本体(依存として渡す) |
| 認証済みのユーザーID | 関数の動作を左右する必須パラメータ |
| ロケールなど横断的な付加情報 | 省略できる設定値(オプション引数にする) |
キーの型は外に公開せず、WithRequestID / RequestID のような出し入れ用の関数だけを公開すると、型アサーションが1か所にまとまり、呼び出し側がすっきりします。
CanceledとDeadlineExceededを判別する
contextが終わる理由は大きく2種類で、エラーの値も2つに分かれています。
| エラー値 | 表示される文字列 | 発生する場面 |
|---|---|---|
context.Canceled |
context canceled |
cancel() が呼ばれた |
context.DeadlineExceeded |
context deadline exceeded |
WithTimeout / WithDeadline の時刻を過ぎた |
判別には errors.Is を使います。 途中の関数が fmt.Errorf("...: %w", err) で包んで返しても、正しく見分けられます。
package main
import (
"context"
"errors"
"fmt"
"time"
)
func slow(ctx context.Context) error {
select {
case <-time.After(300 * time.Millisecond):
return nil
case <-ctx.Done():
// %w で包んで返しても errors.Is で判別できる
return fmt.Errorf("注文一覧の取得: %w", ctx.Err())
}
}
func classify(err error) string {
switch {
case err == nil:
return "成功"
case errors.Is(err, context.DeadlineExceeded):
return "時間切れ → 再試行や制限時間の見直しを検討"
case errors.Is(err, context.Canceled):
return "呼び出し側が中断 → 再試行しない"
default:
return "その他のエラー"
}
}
func main() {
// A: 時間切れ
ctxA, cancelA := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancelA()
errA := slow(ctxA)
fmt.Printf("A: %v\n → %s\n", errA, classify(errA))
// B: 手動キャンセル
ctxB, cancelB := context.WithCancel(context.Background())
go func() {
time.Sleep(50 * time.Millisecond)
cancelB()
}()
errB := slow(ctxB)
fmt.Printf("B: %v\n → %s\n", errB, classify(errB))
// C: 制限時間内に完了
ctxC, cancelC := context.WithTimeout(context.Background(), time.Second)
defer cancelC()
errC := slow(ctxC)
fmt.Printf("C: %v\n → %s\n", errC, classify(errC))
// == での比較は、包まれたエラーでは false になる
fmt.Println("errA == context.DeadlineExceeded:", errA == context.DeadlineExceeded)
fmt.Println("errors.Is(errA, context.DeadlineExceeded):", errors.Is(errA, context.DeadlineExceeded))
// DeadlineExceeded は Timeout() を持つので、net.Error風の判定にも反応する
var te interface{ Timeout() bool }
fmt.Println("Timeout()を持つ:", errors.As(errA, &te) && te.Timeout())
}
実行結果:
A: 注文一覧の取得: context deadline exceeded
→ 時間切れ → 再試行や制限時間の見直しを検討
B: 注文一覧の取得: context canceled
→ 呼び出し側が中断 → 再試行しない
C: <nil>
→ 成功
errA == context.DeadlineExceeded: false
errors.Is(errA, context.DeadlineExceeded): true
Timeout()を持つ: true
== で比べると、包まれたエラーでは false になる点が出力からわかります。比較は errors.Is に統一しておくと、呼び出しの階層が増えても同じ書き方で通用します。
| 判別結果 | よくある対応 |
|---|---|
DeadlineExceeded |
再試行する、制限時間を見直す、時間切れとしてログに残す |
Canceled |
利用者が画面を閉じたなど呼び出し側の都合。再試行せず静かに戻る |
| それ以外 | 本来の処理のエラーとして扱う |
WithCancelCauseとWithTimeoutCauseで「止めた理由」を残す
context canceled だけでは、どこがなぜ止めたのかまでは読み取れません。Go 1.20で加わった WithCancelCause と context.Cause を使うと、止めた理由をerrorとして一緒に運べます。 Go 1.21では時間切れ用の WithTimeoutCause / WithDeadlineCause も加わりました。
package main
import (
"context"
"errors"
"fmt"
"time"
)
var ErrStockEmpty = errors.New("在庫が0件のため中断")
var ErrSlowAPI = errors.New("在庫APIの応答が100msを超えた")
func main() {
// 1) WithCancelCause: キャンセルの理由をerrorで渡す
ctx1, cancel1 := context.WithCancelCause(context.Background())
cancel1(ErrStockEmpty)
fmt.Println("[1] Err :", ctx1.Err())
fmt.Println("[1] Cause:", context.Cause(ctx1))
fmt.Println("[1] errors.Is(Cause, ErrStockEmpty):", errors.Is(context.Cause(ctx1), ErrStockEmpty))
// 2) WithTimeoutCause: 時間切れのときの理由を決めておく
ctx2, cancel2 := context.WithTimeoutCause(context.Background(), 100*time.Millisecond, ErrSlowAPI)
defer cancel2()
<-ctx2.Done()
fmt.Println("[2] Err :", ctx2.Err())
fmt.Println("[2] Cause:", context.Cause(ctx2))
// 3) 理由なしのWithCancelでは、CauseはErrと同じ値になる
ctx3, cancel3 := context.WithCancel(context.Background())
cancel3()
fmt.Println("[3] Err :", ctx3.Err())
fmt.Println("[3] Cause:", context.Cause(ctx3))
// 4) cancel(nil) も Canceled 扱い。まだ終わっていないctxのCauseはnil
ctx4, cancel4 := context.WithCancelCause(context.Background())
fmt.Println("[4] 終了前のCause:", context.Cause(ctx4))
cancel4(nil)
fmt.Println("[4] cancel(nil)後:", context.Cause(ctx4))
// 5) 親の理由は子にも伝わる
parent, cancelP := context.WithCancelCause(context.Background())
child, cancelC := context.WithTimeout(parent, time.Second)
defer cancelC()
cancelP(ErrStockEmpty)
<-child.Done()
fmt.Println("[5] 子のErr :", child.Err())
fmt.Println("[5] 子のCause:", context.Cause(child))
}
実行結果:
[1] Err : context canceled
[1] Cause: 在庫が0件のため中断
[1] errors.Is(Cause, ErrStockEmpty): true
[2] Err : context deadline exceeded
[2] Cause: 在庫APIの応答が100msを超えた
[3] Err : context canceled
[3] Cause: context canceled
[4] 終了前のCause: <nil>
[4] cancel(nil)後: context canceled
[5] 子のErr : context canceled
[5] 子のCause: 在庫が0件のため中断
出力からわかる性質を表にまとめます。
| 確認したこと | 結果 |
|---|---|
ctx.Err() は理由を渡しても変わるか |
変わらない。Canceled / DeadlineExceeded のまま([1][2]) |
| 理由を取り出す方法 | context.Cause(ctx)([1][2]) |
| 理由を渡さない WithCancel の Cause | ctx.Err() と同じ値([3]) |
| まだ終わっていないctxの Cause | nil([4]) |
cancel(nil) としたとき |
context.Canceled が理由になる([4]) |
| 親の理由は子に伝わるか | 伝わる。子の Cause でも同じerrorが取れる([5]) |
ctx.Err() で「キャンセルか時間切れか」を判別し、context.Cause(ctx) で「具体的な理由」をログに出す、という二段構えにすると、既存の errors.Is(err, context.Canceled) の判定を変えずに情報だけ増やせます。
WithTimeoutCause が返す cancel は引数なしの通常の CancelFunc です。時間切れになる前に cancel() を呼んだ場合は、理由は context.Canceled になります(指定した cause が使われるのは時間切れのときだけ、という点は pkg.go.dev の説明で確認しました)。
AfterFuncでキャンセル時の後始末を登録する
context.AfterFunc(ctx, f) は、ctxが終了したときに f を別のgoroutineで1回だけ呼ぶ関数です(Go 1.21)。 「<-ctx.Done() を待つだけのgoroutine」を自分で書く代わりに使えます。
package main
import (
"context"
"fmt"
"time"
)
func main() {
// 1) キャンセルされたら後始末を1回だけ実行する
ctx, cancel := context.WithCancel(context.Background())
context.AfterFunc(ctx, func() {
fmt.Println("[1] AfterFunc: 接続を閉じる (", ctx.Err(), ")")
})
fmt.Println("[1] cancel前")
cancel()
time.Sleep(50 * time.Millisecond) // fは別goroutineで動くので少し待つ
// 2) stop() で登録を取り消す
ctx2, cancel2 := context.WithCancel(context.Background())
stop := context.AfterFunc(ctx2, func() {
fmt.Println("[2] これは表示されない")
})
fmt.Println("[2] stop() =", stop()) // true: 実行前に取り消せた
cancel2()
time.Sleep(50 * time.Millisecond)
fmt.Println("[2] 2回目のstop() =", stop()) // false: すでに取り消し済み
// 3) 登録時点で終了済みなら、すぐに実行される
ctx3, cancel3 := context.WithCancel(context.Background())
cancel3()
done := make(chan struct{})
context.AfterFunc(ctx3, func() {
fmt.Println("[3] 終了済みctxでもすぐ実行")
close(done)
})
<-done
}
実行結果:
[1] cancel前
[1] AfterFunc: 接続を閉じる ( context canceled )
[2] stop() = true
[2] 2回目のstop() = false
[3] 終了済みctxでもすぐ実行
戻り値 stop() の結果 |
意味 |
|---|---|
true |
f が始まる前に登録を取り消せた(f は呼ばれない) |
false |
すでに f が始まっている、または取り消し済み |
f は別のgoroutineで動くため、cancel() の直後に f が終わっているとは限りません。 上の例で短い Sleep やチャネルで待っているのはそのためです。ctxの終了に合わせて接続を閉じる、条件変数を起こす、といった「合図を別の仕組みに橋渡しする」用途に向いています。
WithoutCancelでリクエスト終了後も処理を続ける
HTTPハンドラが戻ると、そのリクエストのctxは終了します。操作ログの保存や通知の送信など、レスポンスを返したあとも続けたい処理に、リクエストのctxをそのまま渡すと途中で止まります。 context.WithoutCancel(parent)(Go 1.21)は、親の値は引き継ぎつつ、キャンセルと期限だけを切り離したctxを作ります。
package main
import (
"context"
"fmt"
"sync"
"time"
)
type ctxKey string
const userKey ctxKey = "user"
// リクエストが終わったあとも続けたい処理(操作ログの保存など)
func saveLog(ctx context.Context, wg *sync.WaitGroup) {
defer wg.Done()
select {
case <-time.After(150 * time.Millisecond):
fmt.Println("ログ保存完了 user =", ctx.Value(userKey), "/ Err =", ctx.Err())
case <-ctx.Done():
fmt.Println("ログ保存を中断:", context.Cause(ctx))
}
}
func main() {
reqCtx, cancel := context.WithCancel(context.Background())
reqCtx = context.WithValue(reqCtx, userKey, "taro")
var wg sync.WaitGroup
wg.Add(2)
// A: リクエストのctxをそのまま渡す
go saveLog(reqCtx, &wg)
// B: 値だけ引き継ぎ、キャンセルは切り離す。暴走しないよう独自の期限を付ける
detached, cancelD := context.WithTimeout(context.WithoutCancel(reqCtx), 2*time.Second)
defer cancelD()
go saveLog(detached, &wg)
time.Sleep(50 * time.Millisecond)
cancel() // レスポンスを返し終えた、という想定
fmt.Println("リクエスト終了: reqCtx.Err() =", reqCtx.Err())
wg.Wait()
_, hasDeadline := context.WithoutCancel(reqCtx).Deadline()
fmt.Println("WithoutCancel直後の期限あり:", hasDeadline)
}
実行結果:
リクエスト終了: reqCtx.Err() = context canceled
ログ保存を中断: context canceled
ログ保存完了 user = taro / Err = <nil>
WithoutCancel直後の期限あり: false
A(リクエストのctxをそのまま渡した側)は中断され、B(WithoutCancelを通した側)は最後まで動き、値 taro も読めています。
WithoutCancelで作ったctxは期限を持たなくなるので(最後の行の false)、例のように WithTimeout を重ねて独自の上限を付けておくと、処理が長引いたときも自動で終わります。
| 渡し方 | 親の値 | 親のキャンセル | 向いている処理 |
|---|---|---|---|
reqCtx をそのまま |
引き継ぐ | 伝わる | レスポンスに必要な処理 |
context.WithoutCancel(reqCtx) |
引き継ぐ | 伝わらない | 終了後のログ保存・通知・後片付け |
context.Background() を新しく |
引き継がない | 伝わらない | リクエストと無関係な定期処理 |
標準ライブラリやerrgroupへの渡し方
HTTPクライアントに渡す(NewRequestWithContext)
HTTPリクエストには http.NewRequestWithContext(ctx, ...) でctxを付けます。 下の例は確認用のサーバーも同じファイルに入っているので、1ファイルで動きます(ポート8531を使います)。
package main
import (
"context"
"errors"
"fmt"
"net"
"net/http"
"time"
)
func main() {
// 確認用のサーバー: /slow は応答に2秒かかる
mux := http.NewServeMux()
mux.HandleFunc("GET /slow", func(w http.ResponseWriter, r *http.Request) {
select {
case <-time.After(2 * time.Second):
fmt.Fprintln(w, "done")
case <-r.Context().Done(): // クライアントが切断すると届く
fmt.Println("server: 中断を検知 →", r.Context().Err())
}
})
mux.HandleFunc("GET /fast", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "ok")
})
ln, err := net.Listen("tcp", "127.0.0.1:8531")
if err != nil {
panic(err)
}
srv := &http.Server{Handler: mux}
go srv.Serve(ln)
defer srv.Close()
get := func(path string) {
ctx, cancel := context.WithTimeout(context.Background(), 300*time.Millisecond)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://127.0.0.1:8531"+path, nil)
if err != nil {
panic(err)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
fmt.Println("client:", err)
fmt.Println("client: 時間切れか =", errors.Is(err, context.DeadlineExceeded))
return
}
defer resp.Body.Close()
fmt.Println("client:", path, resp.Status)
}
get("/fast")
get("/slow")
time.Sleep(100 * time.Millisecond) // サーバー側のログを待つ
}
実行結果:
client: /fast 200 OK
server: 中断を検知 → context canceled
client: Get "http://127.0.0.1:8531/slow": context deadline exceeded
client: 時間切れか = true
読み取れることは2つです。
- クライアント側:エラーは
Get "URL": context deadline exceededの形で返り、errors.Isで時間切れと判別できます。 - サーバー側:クライアントが接続を切ると、ハンドラの
r.Context()がcontext canceledで終了します。ハンドラの中の処理にr.Context()を渡しておけば、利用者が離れた時点で後続の処理も止められます。
http.Client の Timeout フィールドはクライアント全体の上限、ctxはリクエスト1回ごとの上限、と使い分けられます。
database/sqlに渡す(QueryContext・ExecContext)※未実行
database/sql には、ctxを受け取る 〜Context 付きのメソッドがそろっています。この例はDBドライバ(外部モジュール)が必要なため、今回は未実行です。 メソッド名と引数の形は標準ライブラリのものです。
// 未実行の例(ドライバのimportと接続文字列は利用するDBに合わせてください)
func findTasks(ctx context.Context, db *sql.DB, userID int) ([]string, error) {
ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
defer cancel()
rows, err := db.QueryContext(ctx, "SELECT title FROM tasks WHERE user_id = $1", userID)
if err != nil {
return nil, fmt.Errorf("タスク一覧の取得: %w", err)
}
defer rows.Close()
var titles []string
for rows.Next() {
var t string
if err := rows.Scan(&t); err != nil {
return nil, err
}
titles = append(titles, t)
}
return titles, rows.Err()
}
| ctxなし | ctxあり |
|---|---|
db.Query |
db.QueryContext(ctx, ...) |
db.QueryRow |
db.QueryRowContext(ctx, ...) |
db.Exec |
db.ExecContext(ctx, ...) |
db.Begin |
db.BeginTx(ctx, opts) |
db.Ping |
db.PingContext(ctx) |
errgroupに渡す(errgroup.WithContext)※未実行
golang.org/x/sync/errgroup は、複数のgoroutineのうち1つがエラーを返したら、残りにキャンセルを伝えるためのパッケージです。外部モジュールのため、今回の環境では取得できず未実行です。
// 未実行の例(go get golang.org/x/sync が必要)
g, ctx := errgroup.WithContext(ctx)
for _, url := range urls {
g.Go(func() error {
return fetch(ctx, url) // どれかがerrorを返すとctxが終了する
})
}
if err := g.Wait(); err != nil { // 最初のerrorが返る
return err
}
同じ考え方を標準ライブラリだけで書いたものは、実際に動かして確認しました。WithCancelCause を使うと「最初のエラー」をそのまま理由として残せます。
package main
import (
"context"
"errors"
"fmt"
"sync"
"time"
)
// 標準ライブラリだけで「どれか1つがエラーになったら全員止める」
func main() {
ctx, cancel := context.WithCancelCause(context.Background())
defer cancel(nil)
tasks := []struct {
name string
cost time.Duration
err error
}{
{"会員", 300 * time.Millisecond, nil},
{"注文", 100 * time.Millisecond, errors.New("注文APIが500を返した")},
{"在庫", 300 * time.Millisecond, nil},
}
var wg sync.WaitGroup
for _, t := range tasks {
wg.Add(1)
go func() {
defer wg.Done()
select {
case <-time.After(t.cost):
if t.err != nil {
fmt.Println(t.name, ": エラー発生 → 全体を中断")
cancel(t.err) // 最初の1回だけが理由として残る
return
}
fmt.Println(t.name, ": 完了")
case <-ctx.Done():
fmt.Println(t.name, ": 中断された")
}
}()
}
wg.Wait()
fmt.Println("最初のエラー:", context.Cause(ctx))
}
実行結果(「中断された」の2行は順序が入れ替わることがあります):
注文 : エラー発生 → 全体を中断
会員 : 中断された
在庫 : 中断された
最初のエラー: 注文APIが500を返した
for の中で t をそのままgoroutineから参照しているのは、Go 1.22以降でループ変数が周回ごとに新しく作られるようになったためです。
つまずきやすい点と直し方
| 起きること | 原因 | 直し方 |
|---|---|---|
| cancelしてもgoroutineが止まらない | 受け取った側が ctx.Done() を見ていない |
select に case <-ctx.Done(): を入れる。ループなら周回ごとに ctx.Err() を確認する |
go vet が should be called, not discarded と表示 |
cancel を _ で捨てている |
変数で受け取り、次の行に defer cancel() |
| 包んだエラーの比較が false になる | err == context.DeadlineExceeded と書いている |
errors.Is(err, context.DeadlineExceeded) |
レスポンス後の処理が context canceled で止まる |
リクエストのctxをそのまま渡している | context.WithoutCancel(ctx) に独自のタイムアウトを重ねる |
ctx.Value(key) が nil になる |
キーの型が違う(string と独自型など) | 出し入れを専用の関数にまとめ、同じキー定数を使う |
| 子に長いタイムアウトを付けたのに早く終わる | 親の期限のほうが短い | ctx.Deadline() で親の期限を確認し、根元の制限時間を見直す |
ログに context canceled としか出ない |
理由を渡していない | WithCancelCause と context.Cause(ctx) を使う |
ループ内で defer cancel() が溜まる |
defer は関数を抜けるまで実行されない | 1周ぶんを関数に切り出すか、周回の最後で cancel() を直接呼ぶ |
長いループをキャンセルに対応させる書き方
チャネルを待たない計算ループでは、周回ごとに ctx.Err() を見るだけで対応できます(関数だけの断片で、単体では未実行です)。
func sumAll(ctx context.Context, items []int) (int, error) {
total := 0
for i, v := range items {
if i%1000 == 0 { // 毎回ではなく、ときどき確認すれば十分
if err := ctx.Err(); err != nil {
return 0, err
}
}
total += v
}
return total, nil
}
よくある質問
Q. context.Background() と context.TODO() はどちらを使えばよいですか? A. 動作は同じです。mainやテスト、リクエストの入口など「ここが起点」と言える場所は Background、上位からctxを受け取れるようにする途中の仮置きは TODO、と使い分けます。実行結果でも、どちらも Err が nil・期限なしであることを確認しました。
Q. 時間内に処理が終わった場合も cancel() を呼ぶ必要はありますか?
A. 呼びます。defer cancel() を書いておけば、処理が早く終わった時点でタイマーが解放されます。書かずに _ で捨てると、go vet が「should be called, not discarded, to avoid a context leak」と知らせてくれます。
Q. ctx.Err() と context.Cause(ctx) は何が違いますか?
A. ctx.Err() は Canceled か DeadlineExceeded の2種類だけを返します。context.Cause(ctx) は、WithCancelCause の cancel(err) や WithTimeoutCause で渡した独自のerrorを返します。理由を渡していない場合は両者が同じ値になります。
Q. WithValueには何を入れてもよいですか? A. リクエストIDやユーザーIDのように、リクエスト単位で付いて回る情報に絞るのがおすすめです。関数の動作に必須の値は引数で渡すと、シグネチャから読み取れてテストもしやすくなります。キーには独自の型を使います。
まとめ
- 起点は
context.Background()、差し替え待ちの仮置きはcontext.TODO()。動作は同じで、読む人への意図が違います。 - WithCancel / WithTimeout / WithDeadline は
cancelを返すので、受け取った次の行にdefer cancel()を書きます。捨てると go vet が知らせてくれます。 - 受け取る側は
<-ctx.Done()を select で待つか、ループの中でctx.Err()を確認します。 - 判別は
errors.Is。context.Canceled(中断)とcontext.DeadlineExceeded(時間切れ)で対応を分けられます。 - 止めた理由を残すなら WithCancelCause / WithTimeoutCause と
context.Cause。ctx.Err()の値は従来どおりなので既存の判定はそのまま使えます。 - 後始末の登録は AfterFunc、リクエスト終了後も続けたい処理は WithoutCancel+独自のタイムアウト。
- HTTPは
NewRequestWithContext、DBはQueryContext/ExecContext、並行処理は errgroup か WithCancelCause でctxを通します。
参考・出典
- Go公式パッケージドキュメント context(pkg.go.dev/context)― 各関数のシグネチャ、追加された版、Background / TODO / WithValue のキーに関する説明を確認
- 本文の実行結果は Go 1.24.7(linux/amd64)で
go run/go vetを実行したものです。database/sql と errgroup の例は未実行です。
株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。
WE ARE HIRING
RJCで一緒に開発しながら、
成長を楽しみませんか?
RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!
- 127日年間休日
- 12時間平均残業時間
- 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!
ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。