📚 背景知識(読んでから問題へ)
Day 016ではcontext.WithCancelを使い、「明示的にcancel()を呼ぶことでキャンセルを親から子へ連鎖させる」パターンを学びました。しかし実務でもっとよく使うのは、「〇〇ミリ秒以内に終わらなければ自動的にキャンセルする」というタイムアウト制御です。DBクエリ・外部API呼び出し・複数リソースの並行取得など、「時間がかかりすぎたら諦めて先に進む」という判断はあらゆる箇所で必要になります。
contextパッケージはこれを2つの関数で提供します。
context.WithTimeout(parent, duration)— 「今からduration後」に自動キャンセルされるcontextを作るcontext.WithDeadline(parent, time.Time)— 「指定した時刻になったら」自動キャンセルされるcontextを作る
両者は本質的に同じもので、WithTimeout(parent, d)は内部的にWithDeadline(parent, time.Now().Add(d))を呼んでいるだけです。「あと何ミリ秒」という相対時間で考えたいときはWithTimeout、「12:00:00までに」という絶対時刻で考えたいとき(例えば上流のサービスから伝わってきた締切時刻をそのまま使うとき)はWithDeadlineを使う、という使い分けになります。どちらも制限時間に達するとctx.Done()が自動的にcloseされ、ctx.Err()はcontext.DeadlineExceededになります。Day 016のWithCancelのときはctx.Err()がcontext.Canceledでしたが、原因によって値が変わる、という設計です。
もう1つ、contextパッケージにはcontext.WithValue(parent, key, value)という関数があります。これは「リクエストスコープの値(リクエストID・認証済みユーザー情報など)を、呼び出しツリーの奥深くまで明示的な引数を増やさずに伝播させる」ための仕組みです。ただしGoの公式ドキュメントは「関数のオプション引数の代わりに使ってはいけない」と明確に警告しています。WithValueは「その処理全体を貫くリクエストのメタデータ(トレースID・認証情報など)」のためのものであり、ビジネスロジックのパラメータを渡す手段ではありません。乱用するとコードの見た目からは何が渡っているか分からなくなり、型安全性も失われるためです。
WithValueを安全に使うために、Goコミュニティでは「キーには文字列リテラルではなく、パッケージ内だけで通用する非公開の独自型を使う」という規約が定着しています。これは、もし複数のパッケージが同じ文字列(例えば"userID")をキーとして使ってしまうと、意図せず値が上書き・衝突する可能性があるためです。独自型(例えばtype ctxKey string)を使えば、たとえ値が同じ文字列でも型が異なるため衝突しません。
📝 問題
以下の要件を満たす、go run main.goでそのまま実行できる1つのGoプログラムを実装してください。
type ctxKey stringのような非公開の独自型をキーとして定義し、context.WithValueを使って"req-8f3c1a"というリクエストIDをcontextに埋め込んでください。キーとして文字列リテラルを直接使うのは禁止ですrequestIDFrom(ctx context.Context) stringという関数を実装し、ctxからリクエストIDを安全に取り出してください(型アサーションが失敗した場合は"unknown"を返すこと)fetchResource(ctx context.Context, id int) fetchResultという関数を実装してください。fetchResultはid int・msg string・err errorを持つ構造体とします。この関数は「id * 120ミリ秒だけ時間がかかるリソース取得」をselectで模擬し、時間内に終わればmsgにrequestIDFrom(ctx)で取得したリクエストIDを含む成功メッセージを、ctxが先にキャンセルされたらerrにctx.Err()を含むエラーを設定して返してくださいcontext.WithTimeoutを使って全体の制限時間400ミリ秒を設定したcontextを作り、deferで必ずcancel()を呼んでくださいidが1から5までの5つのfetchResourceをgoroutineとして並行実行し、それぞれの結果をchannel経由で集約し、成功したものと失敗(タイムアウト)したものを区別して出力してください- 全ての結果を出力し終えたあと、最終的な
ctx.Err()を出力してください(context.DeadlineExceededになっているはずです)
🔍 ヒント(段階的開示)
ヒント1 — 方向性
time.After(duration)は「duration後に1つの値が送られてくるチャネル」を返します。これをctx.Done()と一緒にselectで監視すれば、「作業が時間内に終わるか」「contextが先にタイムアウトするか」のどちらが先に起きるかを1つのselectで判定できます。Day 016で使ったticker.Cをtime.After(delay)に置き換えるイメージです。
ヒント2 — アプローチ
context.WithTimeout(parent, 400*time.Millisecond)は(ctx, cancel)を返します。400msが経過すると自動的にctx.Done()がcloseされますcontext.WithValue(parent, key, value)はキーにinterface{}(Go 1.18以降はany)を取ります。取り出すときもctx.Value(key)はanyを返すので、v, ok := ctx.Value(key).(string)のように型アサーションで安全に取り出してください- goroutineから結果を集めるには、バッファ付きchannel(
make(chan fetchResult, 5))に各goroutineが結果を送り、sync.WaitGroupで全goroutineの完了を待ってからchannelをcloseする、という定石パターンを使います idが4・5のものは120ms * 4 = 480ms・120ms * 5 = 600msかかる設計なので、400msのタイムアウトより後になり、ctx.Done()側のcaseが選ばれるはずです
ヒント3 — コード骨格
package main
import (
"context"
"fmt"
"sync"
"time"
)
type ctxKey string
const requestIDKey ctxKey = "requestID"
func requestIDFrom(ctx context.Context) string {
// TODO: ctx.Value(requestIDKey) を安全に string に変換する
}
type fetchResult struct {
id int
msg string
err error
}
func fetchResource(ctx context.Context, id int) fetchResult {
reqID := requestIDFrom(ctx)
delay := time.Duration(id) * 120 * time.Millisecond
select {
case <-time.After(delay):
// TODO: 成功時の fetchResult を返す
case <-ctx.Done():
// TODO: 失敗時(タイムアウト)の fetchResult を返す
}
}
func main() {
parentCtx := context.WithValue(context.Background(), requestIDKey, "req-8f3c1a")
ctx, cancel := context.WithTimeout(parentCtx, 400*time.Millisecond)
defer cancel()
var wg sync.WaitGroup
results := make(chan fetchResult, 5)
for id := 1; id <= 5; id++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
// TODO: fetchResource を呼び results に送る
}(id)
}
// TODO: 全goroutine完了後に results を close する
// TODO: results を range で受け取り、成功/失敗を分けて出力する
// TODO: 最終的な ctx.Err() を出力する
}
✅ 模範解答
package main
import (
"context"
"fmt"
"sync"
"time"
)
// ctxKey はcontext.WithValueのキーに使う非公開の独自型。
// 文字列リテラルを直接キーにすると他パッケージと衝突しうるため、
// パッケージ内だけで通用する型を使うのがGoの規約。
type ctxKey string
const requestIDKey ctxKey = "requestID"
// requestIDFrom はctxからリクエストIDを安全に取り出す。
// 型アサーションが失敗する(キーが設定されていない)場合は "unknown" を返す。
func requestIDFrom(ctx context.Context) string {
if v, ok := ctx.Value(requestIDKey).(string); ok {
return v
}
return "unknown"
}
// fetchResult はfetchResourceの結果(成功メッセージ or エラー)を表す。
type fetchResult struct {
id int
msg string
err error
}
// fetchResource はネットワーク越しのリソース取得を模擬する。
// idが大きいほど時間がかかる設計にし、タイムアウトする/しないの両方を再現する。
func fetchResource(ctx context.Context, id int) fetchResult {
reqID := requestIDFrom(ctx)
delay := time.Duration(id) * 120 * time.Millisecond
select {
case <-time.After(delay):
return fetchResult{
id: id,
msg: fmt.Sprintf("resource-%d を取得しました(request: %s)", id, reqID),
}
case <-ctx.Done():
return fetchResult{
id: id,
err: fmt.Errorf("resource-%d の取得中止(request: %s, 理由: %w)", id, reqID, ctx.Err()),
}
}
}
func main() {
// 1. リクエストスコープの値(リクエストID)をcontextに埋め込む。
parentCtx := context.WithValue(context.Background(), requestIDKey, "req-8f3c1a")
// 2. 全体の制限時間400msを設定する。400ms経過で自動的にDeadlineExceededになる。
ctx, cancel := context.WithTimeout(parentCtx, 400*time.Millisecond)
defer cancel()
var wg sync.WaitGroup
results := make(chan fetchResult, 5)
for id := 1; id <= 5; id++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
results <- fetchResource(ctx, id)
}(id)
}
go func() {
wg.Wait()
close(results)
}()
for r := range results {
if r.err != nil {
fmt.Println("失敗:", r.err)
} else {
fmt.Println("成功:", r.msg)
}
}
fmt.Println("最終的なctx.Err():", ctx.Err())
}
▶ 実行結果を見る(go run main.go で検証済み)
成功: resource-1 を取得しました(request: req-8f3c1a)
成功: resource-2 を取得しました(request: req-8f3c1a)
成功: resource-3 を取得しました(request: req-8f3c1a)
失敗: resource-4 の取得中止(request: req-8f3c1a, 理由: context deadline exceeded)
失敗: resource-5 の取得中止(request: req-8f3c1a, 理由: context deadline exceeded)
最終的なctx.Err(): context deadline exceeded
※ id=1,2,3(120ms・240ms・360ms)は制限時間400msより先に終わるため成功し、id=4,5(480ms・600ms)はタイムアウトの方が先に来るため失敗します。プログラム全体の実行時間も約400ms程度で終わり、id=5の600msを律儀に待つことはありません。これがcontextによるタイムアウト制御の効果です。
🪜 Step-by-Step 解説
type ctxKey string
const requestIDKey ctxKey = "requestID"
func requestIDFrom(ctx context.Context) string {
if v, ok := ctx.Value(requestIDKey).(string); ok {
return v
}
return "unknown"
}
ctxKeyという非公開の独自型を定義し、その型の値requestIDKeyをキーとして使います。もし他のパッケージが"requestID"という文字列そのものをキーにしていたとしても、型がctxKeyとstringで異なるため衝突しません。ctx.Value(key)はany(interface{})を返すため、使う側は必ず型アサーション(.(string))で安全に取り出す必要があります。ここでは「カンマOKイディオム」(Day 006で学んだパターン)を使い、キーが存在しない場合にpanicせず"unknown"にフォールバックしています。
func fetchResource(ctx context.Context, id int) fetchResult {
reqID := requestIDFrom(ctx)
delay := time.Duration(id) * 120 * time.Millisecond
select {
case <-time.After(delay):
return fetchResult{id: id, msg: fmt.Sprintf("resource-%d を取得しました(request: %s)", id, reqID)}
case <-ctx.Done():
return fetchResult{id: id, err: fmt.Errorf("resource-%d の取得中止(request: %s, 理由: %w)", id, reqID, ctx.Err())}
}
}
time.After(delay)は「delay後に1つの値が届くチャネル」です。selectはtime.After(delay)とctx.Done()のどちらが先に準備できるかを競わせます。idが小さければtime.After側が先に発火して成功、idが大きく400msの制限時間を超える場合はctx.Done()側が先に発火して失敗、という分岐が自然に実現されます。エラーメッセージには%wでctx.Err()をラップしており、Day 018で本格的に扱うerrors.Isで後からcontext.DeadlineExceededかどうかを判定できる形になっています。
ctx, cancel := context.WithTimeout(parentCtx, 400*time.Millisecond)
defer cancel()
context.WithTimeout(parentCtx, 400*time.Millisecond)は、parentCtxから派生した新しいcontextを作り、作成した瞬間からタイマーが動き出し、400ms後に自動的にキャンセル(ctx.Done()のclose、ctx.Err()がcontext.DeadlineExceededに)します。context.WithCancelとの違いは、「誰かが明示的にcancel()を呼ぶ」のではなく「時間経過という条件で自動的に発火する」点です。ただし、タイムアウトを待たずに全処理が正常終了した場合でも、内部で確保されたタイマーリソースを解放するためにdefer cancel()は省略せず呼ぶ必要があります(go vetのlostcancelもこれを検出します)。
var wg sync.WaitGroup
results := make(chan fetchResult, 5)
for id := 1; id <= 5; id++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
results <- fetchResource(ctx, id)
}(id)
}
go func() {
wg.Wait()
close(results)
}()
for r := range results {
if r.err != nil {
fmt.Println("失敗:", r.err)
} else {
fmt.Println("成功:", r.msg)
}
}
5つのfetchResourceを並行実行し、全て同じctxを共有しているため、400msの制限時間も5つ全てに一斉に適用されます。結果はバッファ付きchannelresultsに送り、別のgoroutineでwg.Wait()してからclose(results)することで、main側は単純にfor r := range resultsするだけで「全結果を受け取り終えたらループが自然に終わる」形にしています(closeされていないchannelをrangeし続けるとデッドロックするため、closeのタイミングを別goroutineに任せるのがGoの定石パターンです)。
fmt.Println("最終的なctx.Err():", ctx.Err())
全てのfetchResourceの結果を受け取り終えた時点(=400ms経過後)でctx.Err()を見ると、context.DeadlineExceededになっています。これは「制限時間に達したこと」の証拠であり、実務では「このリクエストは一部(または全部)タイムアウトした」というログ・メトリクスを出す際の判定材料としてよく使われます。
💡 設計思想・なぜこう書くのか
WithTimeoutがWithDeadlineの薄いラッパーとして実装されている(WithTimeout(parent, d) ≒ WithDeadline(parent, time.Now().Add(d)))のは、Goの「小さい部品を組み合わせる」思想の表れです。実際、Goの標準ライブラリのソースコードでもWithTimeoutは数行でWithDeadlineを呼び出しているだけの実装になっています。新しい概念を1つ追加するのではなく、既存のWithDeadlineに「相対時間を絶対時刻に変換する」という薄い便利関数を被せる、というアプローチです。WithValueが「オプション引数の代わりに使うべきではない」と明確に制限されている点も重要です。Goには他言語のような「デフォルト引数」や「キーワード引数」がなく、関数のシグネチャに現れる引数だけが「その関数が何を必要としているか」を表現する唯一の手段です。もしWithValueでビジネスロジックのパラメータまで自由に渡せてしまうと、関数のシグネチャを見ただけでは何が必要なのか分からなくなり、Goが大切にしている「明示性(明示的に書かれたコードだけを信頼する)」という設計思想が崩れます。WithValueはあくまで「リクエスト全体を横断する、ビジネスロジックとは無関係なメタデータ(トレースID・認証情報など)」に用途を限定することで、この明示性を守っています。🌐 他言語との比較
| 観点 | Go | Java | Python | Node.js |
|---|---|---|---|---|
| タイムアウト付きキャンセル | context.WithTimeout/WithDeadlineで親から派生させ、呼び出しツリー全体に自動伝播する | Future.get(timeout, unit)やCompletableFuture.orTimeout()など、呼び出し箇所ごとに個別指定することが多い | asyncio.wait_for(coro, timeout)でコルーチンをラップする形。呼び出しごとに個別設定 | AbortController + setTimeoutでAbortSignalを渡す。Fetch APIなど一部APIが対応 |
| リクエストスコープの値伝播 | context.WithValueで明示的にcontextへ埋め込み、呼び出しツリーに沿って引数として伝播させる | ThreadLocal<T>でスレッドに紐づけて暗黙的に保持する(引数として渡さない) | contextvars.ContextVarでタスク単位の暗黙的な変数を持つ(asyncioのタスク境界を越えて伝播) | AsyncLocalStorageでリクエスト単位の暗黙的なコンテキストを保持する |
| 値の伝わり方 | 明示的: 関数はctxを引数として必ず受け取る。値を読むにはctx.Value(key)を明示的に呼ぶ必要がある | 暗黙的: ThreadLocal.get()はどのスレッドにいるかに依存し、関数シグネチャには現れない | 暗黙的: ContextVar.get()は現在のタスクのコンテキストに依存し、関数シグネチャには現れない | 暗黙的: AsyncLocalStorage.getStore()は現在の非同期実行コンテキストに依存する |
Goが際立っているのは、キャンセル・タイムアウトも値の伝播も、すべてctxという1つの明示的な引数を通じて行われる点です。他言語のThreadLocalやContextVarのような「暗黙的にどこかに紐づいている状態」を持たないため、ある関数が何に依存しているかは、その関数のシグネチャ(ctx context.Contextを受け取っているかどうか)を見れば分かります。この一貫した明示性が、Goのコードレビューやテストのしやすさに直結しています。
🏆 実務での使いどころ
- 外部API呼び出しの制限時間管理: 複数の外部サービスに並行してリクエストを送る際、
context.WithTimeoutで全体の制限時間を1箇所に設定するだけで、遅い一部のサービスを待たずに「間に合った分だけ」で処理を進める、といったSLA遵守の実装が簡潔に書けます - 分散トレーシングのトレースID伝播:
context.WithValueでトレースID・スパンIDをcontextに埋め込み、複数のサービス呼び出し・goroutineをまたいで一貫したトレースID付きログを出力する(OpenTelemetryなどの分散トレーシングライブラリは内部でこのパターンを使っています) - 認証済みユーザー情報の伝播: HTTPミドルウェアで認証を行った後、認証済みユーザー情報を
context.WithValueでリクエストのcontextに埋め込み、後段のハンドラ・サービス層でctxから取り出して使う(Phase 2後半のWeb API実装テーマで本格的に扱います) - バッチ処理・並行取得の部分的タイムアウト: 今回のように「複数のリソースを並行取得し、制限時間内に取れた分だけ使う」というパターンは、検索結果の複数ソース統合・レコメンデーションの複数モデル呼び出しなど、実務のあらゆる箇所に現れます
⚠️ よくある誤解・ミス
| 誤解・ミス | なぜ起こるか | 正しい理解 |
|---|---|---|
| WithValueのキーに文字列リテラルを直接使ってしまう(ctx.Value("userID")) | 手軽で直感的に書けるため | 複数パッケージが同じ文字列キーを使うと値が衝突・上書きされる危険がある。必ず非公開の独自型(例: type ctxKey string)をキーにするのがGoの規約 |
| WithValueで関数の必須パラメータまで渡してしまう | 「引数を増やしたくない」という気持ちから乱用してしまう | WithValueはリクエストを横断する付随的なメタデータ専用。ビジネスロジックが必要とする値は、公式ドキュメントの指針どおり通常の引数として明示的に渡すべき |
| WithTimeoutのcancelを呼び忘れる(タイムアウトで自動的に片付くから不要と思い込む) | 「時間が来れば勝手にキャンセルされるからcancel()はいらない」という誤解 | タイムアウトより先に処理が正常終了した場合、cancel()を呼ばないとタイマーなど内部リソースがタイムアウト時刻まで解放されない。成功・失敗に関わらずdefer cancel()は必須 |
| ctx.Err()がnilかnon-nilかだけを見て、CanceledとDeadlineExceededを区別しない | エラーの有無だけをチェックする習慣 | errors.Is(ctx.Err(), context.DeadlineExceeded)のように原因を区別すれば、「明示的にキャンセルされた」のか「時間切れだった」のかで後続の処理(リトライすべきか等)を変えられる |
🚀 次のステップ
- 発展:
context.WithDeadline(parentCtx, time.Now().Add(400*time.Millisecond))に書き換えても、今回のWithTimeout版と全く同じ結果になることを実際に確認してみましょう。さらに、複数のサービスから伝わってきた締切時刻(time.Time)をそのままWithDeadlineに渡すケースを想像すると、WithTimeoutとWithDeadlineの使い分けがより実感できます - 次回予告: Day 018 — エラーラッピング(errors.Is/As/Unwrap)(実装問題)。今回
fmt.Errorfで%wを使ってラップしたctx.Err()を、errors.Isで正確に判定する方法を学びます