Go

Goのcontext canceledとdeadline exceededの切り分け|原因の特定とWithDeadlineによる締切共有

Goのサーバログにcontext canceledとだけ並び、誰が処理を止めたのか分からない、という状況はよく起こります。これはctx.Err()が2種類の値しか返さないという仕様に由来します。この記事では、context canceledcontext deadline exceededのどちらが出たのかを起点に原因を特定する手順へ絞って整理します。クライアント側とサーバ側で観測されるエラーがずれる非対称性、複数の処理で1つの締切を共有するWithDeadlineの使い方、Go 1.20以降のWithCancelCauseによる理由の記録までを、go1.26.5で実行を確認したコードとともに扱います。contextの基本と各生成関数の使い方(BackgroundTODOの使い分け、WithValueによる値の伝播、Go 1.21で追加されたWithoutCancelAfterFunc)はGo言語のcontext入門|BackgroundとTODOの使い分け、キャンセルとタイムアウトの実装で解説しています。

まとめ

切り分けで押さえるべき点は次の4つです。第一に、ctx.Err()が返すのはcontext.Canceledcontext.DeadlineExceededの2種類だけで、判定はerrors.Isで行います。第二に、context canceledは「誰かがcancel()を呼んだ、あるいは親が止まった」という意味であり、タイムアウトではありません。取り違えるとタイムアウト値の調整で無駄な時間を使います。第三に、HTTPではクライアントがDeadlineExceededを、サーバがcanceledを観測するという非対称性があります。第四に、2値より詳しい理由が必要ならWithCancelCausecontext.Causeを使い、期限側にはWithTimeoutCauseWithDeadlineCauseを使います。

以下、2値の意味の確定から順に、それぞれの根拠と実装を見ていきます。

context canceledとdeadline exceededの切り分け

ログにcontext canceledとだけ出て原因が追えない、という状況はcontextの設計そのものに理由があります。ctx.Err()が返す値は2つしかありません。公式ドキュメントの定義は次のとおりです。

エラー値 文字列 返される条件 判定
context.Canceled context canceled 期限以外の理由でキャンセルされた errors.Is(err, context.Canceled)
context.DeadlineExceeded context deadline exceeded 期限の経過でキャンセルされた errors.Is(err, context.DeadlineExceeded)

つまりcontext canceledは「誰かが明示的にcancel()を呼んだ、あるいは親が止まった」という意味であり、タイムアウトではありません。逆にcontext deadline exceededが出たなら、設定した期限を処理が超えたということです。この2つを取り違えると、タイムアウト値の調整で解決しようとして無駄な時間を使います。

errors.Isによる判定と誤った文字列比較

判定にはerrors.Isを使います。err.Error() == "context canceled"のような文字列比較は、ライブラリがエラーをラップした瞬間に成立しなくなるため使ってはいけません。

if errors.Is(err, context.DeadlineExceeded) {
    // 期限超過。リトライやタイムアウト値の見直しが対象
} else if errors.Is(err, context.Canceled) {
    // 上流が処理を打ち切った。リトライしても無意味
}

この区別は運用上の判断に直結します。DeadlineExceededは自分側の遅さの問題なのでリトライやバックエンド調査に意味がありますが、Canceledは上流が既に結果を必要としていない状態です。Canceledをエラーレベルでアラートに載せるとノイズになるため、ログレベルを下げる設計が現実的です。

HTTPサーバ側にcanceledで届く非対称性

クライアントとサーバでエラーの見え方が変わる点は、実装時に混乱しやすいところです。クライアントがWithTimeout付きのリクエストを送り、期限超過で打ち切ったとき、両者が観測する値は一致しません。

seen := make(chan error, 1)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
    select {
    case <-r.Context().Done():
        seen <- r.Context().Err()
    case <-time.After(3 * time.Second):
        seen <- nil
    }
}))
defer srv.Close()

ctx, cancel := context.WithTimeout(context.Background(), 150*time.Millisecond)
defer cancel()
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, srv.URL, nil)

_, err := http.DefaultClient.Do(req)
fmt.Println("client:", errors.Is(err, context.DeadlineExceeded))
fmt.Println("server:", <-seen)

httptestのサーバに対してgo1.26.5で実行した結果です。

client: true
server: context canceled

クライアントは期限超過を認識しますが、サーバ側のr.Context()接続が切れたという事実しか受け取らないためcontext canceledになります。サーバのログにcontext canceledが並ぶとき、その多くはクライアント側のタイムアウトかユーザーのページ離脱です。サーバ側の期限超過を区別したければ、ハンドラ内でWithTimeoutを重ねて自分の予算を明示するしかありません。

派生contextのキャンセルが伝わる方向

派生関数は必ず親contextを引数に取り、親子関係のツリーを作ります。キャンセルは親から子孫への一方向にのみ伝わります。親をキャンセルすれば配下のすべてが止まりますが、子をキャンセルしても親や兄弟には影響しません。身に覚えのないcontext canceledを追うときは、まず上流の親contextを止めた箇所を探すことになります。

期限も同様に継承されます。親が3秒の期限を持つとき、子に10秒のWithTimeoutを設定しても実際には3秒で切れます。派生で期限を延ばすことはできない、と覚えておくと設計を誤りません。タイムアウト値は上流ほど長く、下流ほど短くするのが基本形です。子で設定したはずの期限より早くdeadline exceededが出る場合は、親側の期限が先に到達しています。

締切を共有するWithDeadlineと相対時間のWithTimeout

WithTimeoutは現在時刻からの相対時間、WithDeadlineは絶対時刻で期限を指定します。実際、WithTimeout(parent, d)WithDeadline(parent, time.Now().Add(d))と等価です。

ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel()

select {
case <-time.After(300 * time.Millisecond):
    fmt.Println("done")
case <-ctx.Done():
    fmt.Println("ctx.Err() =", ctx.Err())
    fmt.Println("is DeadlineExceeded =", errors.Is(ctx.Err(), context.DeadlineExceeded))
}

go1.26.5での実行結果は次のとおりです。100ミリ秒でDoneチャネルが閉じ、300ミリ秒の処理は打ち切られます。

ctx.Err() = context deadline exceeded
is DeadlineExceeded = true

WithTimeoutとWithDeadlineを選ぶ基準

迷ったらWithTimeoutを選びます。「このAPI呼び出しは2秒まで」といった処理単体の予算は相対時間で表すのが自然だからです。

WithDeadlineが要るのは、複数の処理をまたいで1つの締切を共有する場合です。たとえばHTTPハンドラ全体で500ミリ秒という予算があり、その中で認証・DB参照・外部API呼び出しを順に行うケース。各段でWithTimeoutを切ると予算が積み上がって全体が超過しますが、入口でdeadline := time.Now().Add(500*time.Millisecond)を決めて各段に同じWithDeadlineを渡せば、全体の締切は動きません。バッチ処理の「毎時00分までに終える」といった要件も絶対時刻なのでWithDeadlineが向きます。

deadline := time.Now().Add(500 * time.Millisecond)

steps := []struct {
    name string
    work time.Duration
}{{"auth", 100 * time.Millisecond}, {"db", 200 * time.Millisecond}, {"api", 400 * time.Millisecond}}

for _, s := range steps {
    ctx, cancel := context.WithDeadline(context.Background(), deadline)
    err := step(ctx, s.name, s.work)
    cancel()
    if err != nil {
        fmt.Println("abort:", err)
        return
    }
}

各段が同じdeadlineを共有するため、処理時間は積み上がっても締切は動きません。go1.26.5での実行結果は次のとおりで、auth(100ミリ秒)とdb(200ミリ秒)は通り、残り200ミリ秒しかない状態で400ミリ秒を要するapiが期限に達して打ち切られます。

auth: ok
db: ok
abort: api: context deadline exceeded

cancel呼び忘れによるリークとgo vetでの検出

WithCancelWithTimeoutWithDeadlineが返すcancel関数は、処理が終わった時点で必ず呼びます。公式ドキュメントは「Canceling this context releases resources associated with it, so code should call cancel as soon as the operations running in this Context complete.」と述べています。期限付きcontextはタイマーと親への登録を内部に持つため、cancelを呼ばないと期限が来るまでそれらが解放されません。

次のようにcancel_で捨てるコードは、静的解析で検出できます。

func leak() context.Context {
    ctx, _ := context.WithTimeout(context.Background(), time.Second)
    return ctx
}

go vet ./...を実行すると、go1.26.5では次のメッセージが出ます(行番号はファイル構成で変わります)。

b.go:10:7: the cancel function returned by context.WithTimeout should be called, not discarded, to avoid a context leak

このチェックはvetのlostcancelで、公式には「check cancel func returned by context.WithCancel is called」と定義されています。go testは既定でvetの一部を走らせますが、lostcancelは対象外です。CIにはgo vet ./...を独立したステップとして入れてください。なおcancelは複数のgoroutineから同時に呼んでも安全で、2回目以降の呼び出しは何もしません。defer cancel()と明示的なcancel()が重複しても問題ありません。

goroutineへのキャンセル伝播と停止の確認

起動したgoroutineを外から止める標準的な手段がctx.Done()の監視です。goroutineは自分で終了判断をするしかないため、ループの中でDoneチャネルを見て自発的に抜ける形にします。

func worker(ctx context.Context, id int) {
    for {
        select {
        case <-ctx.Done():
            fmt.Printf("worker %d stop: %v\n", id, ctx.Err())
            return
        default:
            // 1単位分の処理
            time.Sleep(10 * time.Millisecond)
        }
    }
}

ctx, cancel := context.WithCancel(context.Background())
for i := 1; i <= 3; i++ {
    go worker(ctx, i)
}
time.Sleep(50 * time.Millisecond)
cancel() // 3本すべてに伝わる

1つのcontextを複数のgoroutineへ渡せば、cancel()1回で全員に停止が伝わります。このとき各workerが記録するのはcontext canceledであり、期限を設定していない以上deadline exceededにはなりません。ここで注意したいのは、cancel()は停止を指示するだけで、完了を待たない点です。公式ドキュメントも「A CancelFunc does not wait for the work to stop.」と明記しています。全goroutineの後片付けまで待ちたければsync.WaitGroupを併用します。goroutineそのものの動作原理はゴルーチン(Goroutine)とは?Go言語の並行処理を支える軽量スレッドの仕組みと使い方で解説しています。

処理の途中で時間のかかるブロッキング呼び出しを挟む場合、default句のポーリングでは反応が遅れます。ネットワークI/Oやデータベースアクセスは、QueryContextのようにcontext対応のAPIを使い、ライブラリ側にキャンセルを届けるのが正解です。

キャンセル理由を特定するWithCancelCauseとCause

Go 1.20で追加されたWithCancelCauseは、この「原因が分からない」問題への標準的な回答です。返されるcancel関数はCancelCauseFunc型で、任意のエラーを引数に取ります。渡したエラーはcontext.Cause(ctx)で取り出せます。

ctx, cancel := context.WithCancelCause(context.Background())
cancel(errors.New("upstream 503"))

fmt.Println("ctx.Err() =", ctx.Err())
fmt.Println("Cause    =", context.Cause(ctx))

実行結果は次のようになります。

ctx.Err() = context canceled
Cause    = upstream 503

ctx.Err()は互換性のためcontext canceledのままですが、Causeだけが具体的な理由を返します。公式の定義は「c自身またはその親のいずれかで最初に起きたキャンセルが原因を決める。そのキャンセルがCancelCauseFunc(err)経由ならerrを返し、そうでなければc.Err()と同じ値を返す。まだキャンセルされていなければnil」です。cancel(nil)と呼んだ場合の原因はcontext.Canceledになります。なおcontext.Causeは通常のWithCancelで作ったcontextにも使えます。その場合はctx.Err()と同じ値が返るため、エラーハンドリングを先にCause基準へ寄せておいても既存の挙動は変わりません。

期限側にはGo 1.21のWithTimeoutCauseWithDeadlineCauseがあります。

ctx, cancel := context.WithTimeoutCause(context.Background(), 10*time.Millisecond,
    errors.New("db query budget over"))
defer cancel()
<-ctx.Done()

fmt.Println("ctx.Err() =", ctx.Err())  // context deadline exceeded
fmt.Println("Cause    =", context.Cause(ctx)) // db query budget over

どのタイムアウトが発火したのかをエラー文言で識別できるため、多段のタイムアウトを持つサービスでは切り分けの時間が大きく変わります。マイクロサービス構成でリクエストが複数ホップする場合、各ホップでWithTimeoutCauseにホップ名を含めたエラーを渡しておくと、どのホップのタイムアウトが発火したかをエラー文言だけで判別できます。ログ収集側でエラー文字列をそのまま集計対象にできる点も利点です。

原因記録APIの追加バージョン

原因を記録する系のAPIは比較的新しく、Go 1.19以前に書かれた解説では扱われていません。導入前に対象プロジェクトのGoバージョンを確認してください。以下の追加バージョンは、Go配布物に同梱されるAPI一覧($GOROOT/api/go1.20.txtなど)とpkg.go.devの「added in」表記で確認したものです。

関数 役割 追加バージョン
context.WithCancelCause(parent) キャンセル理由を記録できる派生 go1.20
context.Cause(ctx) 記録されたキャンセル理由の取得 go1.20
context.WithDeadlineCause(parent, t, cause) 期限超過時の理由を絶対時刻で設定 go1.21.0
context.WithTimeoutCause(parent, d, cause) 期限超過時の理由を相対時間で設定 go1.21.0

導入時の注意は、Causeを見る側の実装を忘れないことです。ctx.Err()しか見ていないコードのままでは、理由を記録しても誰も読みません。エラーハンドリングの共通関数をcontext.Cause(ctx)優先に切り替えてから、WithCancelCauseを広げるのが安全な順序です。

よくある質問

「context canceled」というエラーは何が原因ですか?

期限切れ以外の理由でキャンセルされたことを示します。具体的には、コード内でcancel()が呼ばれたか、親contextがキャンセルされたかのどちらかです。タイムアウトの場合はcontext deadline exceededという別のエラーになるため、タイムアウト値を延ばしても解決しません。HTTPサーバのログに出る場合は、クライアント側のタイムアウトかユーザーのページ離脱による接続断がほとんどです。原因を特定したい場合はWithCancelCauseでキャンセル時にエラーを渡し、context.Cause(ctx)で取り出します。

http.Requestにcontextを渡すにはどう書きますか?

クライアント側はhttp.NewRequestWithContext(ctx, method, url, body)でリクエストを作ります。http.NewRequestで作ってからreq.WithContext(ctx)を呼ぶ書き方もありますが、後者はリクエストの浅いコピーを作るため、前者を使うほうが簡潔です。設定したcontextが期限切れになると、進行中の通信が中断されDoがエラーを返します。サーバ側は逆に、受け取った*http.Requestr.Context()を下流へ渡すだけです。このcontextはクライアントが接続を切った時点でキャンセルされます。

WithCancelCauseはWithCancelから置き換えても安全ですか?

戻り値の型がCancelFuncからCancelCauseFuncへ変わるため、cancel()の呼び出しをcancel(err)またはcancel(nil)へ書き換える必要があります。契約面での非互換はそれだけで、ctx.Err()は従来どおりcontext.Canceledを返すので、既存のエラー判定は壊れません。ただしGo 1.20以降でしかコンパイルできない点は確認してください。原因を読む側のcontext.Cause(ctx)は通常のWithCancelにも使えるため、先に読む側を移行してから生成側を置き換えると安全です。

関連記事

資料請求

RELATED POSTS 関連記事