---
title: "Goのcontext canceledとdeadline exceededの切り分け｜原因の特定とWithDeadlineによる締切共有"
url: "https://www.issoh.co.jp/tech/details/3757/"
published: 2024-10-07
updated: 2026-08-05
categories: ["Go"]
publisher: "株式会社一創"
---

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

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

## まとめ

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

以下、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での検出

`WithCancel`・`WithTimeout`・`WithDeadline`が返す`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言語の並行処理を支える軽量スレッドの仕組みと使い方](/tech/details/4632/)で解説しています。

処理の途中で時間のかかるブロッキング呼び出しを挟む場合、`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の`WithTimeoutCause`と`WithDeadlineCause`があります。

```
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.Request`の`r.Context()`を下流へ渡すだけです。このcontextはクライアントが接続を切った時点でキャンセルされます。

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

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

## 関連記事

- [Go言語のcontext入門｜BackgroundとTODOの使い分け、キャンセルとタイムアウトの実装](/tech/details/3405/)
- [ゴルーチン（Goroutine）とは？Go言語の並行処理を支える軽量スレッドの仕組みと使い方](/tech/details/4632/)
- [GoroutineのコンテキストスイッチとM:Nスケジューリングモデル](/tech/details/4542/)
- [RWMutexとMutexの基本概念と並行処理における役割](/tech/details/5549/)
- [singleflightとは何か？Go言語における重複呼び出し抑制メカニズムの概要とパフォーマンス向上効果](/tech/details/9058/)

---

出典: [Goのcontext canceledとdeadline exceededの切り分け｜原因の特定とWithDeadlineによる締切共有](<https://www.issoh.co.jp/tech/details/3757/>)（株式会社一創）
