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期連続の黒字決算。

ABOUT RJC RJCがどんな会社か知る 考え方、研修、働き方、福利厚生、社員の前職まで。RJCのことが丸わかり! RJC丸わかりページへ JOB DESCRIPTION 仕事内容・待遇を見てみる 仕事内容、給与・待遇、選考の流れ。経験者も未経験も!応募前に知りたいこと、まとめました! 募集要項を見る ENTRY エントリーする エントリーは1〜2分・履歴書不要です。まずは話を聞いてみたい、という方でも歓迎です! エントリーフォームへ

RJCで一緒に開発しながら、 成長を楽しみませんか?