Goのコンテキスト
コンテキストは、Goにおける並行処理制御の要です。これにより、タイムアウト、キャンセル、および値が、goroutineの呼び出しチェーンを通じて円滑に渡されるようになります。
サービスが数十もの外部依存関係を同時に処理する必要がある場合(それぞれに独自のタイムアウトやキャンセルロジックがある)、それらを一貫性を持って管理するにはどうすればよいでしょうか?このレッスンでは、Goのcontextパッケージを包括的に活用する方法を学びます。
1. 学習内容
context.Background()およびcontext.TODO()context.WithCancel手動によるキャンセルcontext.WithTimeoutはタイムアウト後に自動的にキャンセルされますcontext.WithDeadline締切を削除context.WithValue: リクエストレベルでの値の受け渡し- 背景:チェーンパスのルール
- 実践ガイド:マイクロサービスチェーンにおけるタイムアウトの連鎖を防ぐ
2. バックエンドエンジニアの実話
(1) 課題:上流のコンポーネントの1つでタイムアウトが発生すると、システム全体がクラッシュしてしまう。
Xiaoliは順序システムのエンジニアです。彼女は、3つの下流サービスに依存するREST APIを担当しています:
「順序配置APIは、在庫サービス、決済サービス、通知サービスの3つのサービスを呼び出していました。ある日、在庫サービスの応答に20秒かかってしまい、すべてのgoroutineがその応答を待っていたため、私のサービスのメモリ使用量が急増しました。その結果、他のユーザーからのリクエストも処理できなくなってしまいました。上司から『なぜ順序ページが完全にダウンしているんだ?』と尋ねられました。」
問題の分析:
// 不良コード:タイムアウト制御なし
func PlaceOrder(ctx context.Context, order Order) error {
// もし InventoryCheck 詰まる 30 秒,goroutine ただ待っているだけ 30 秒
ok, err := InventoryCheck(ctx, order.Items)
if err != nil {
return err
}
// 決済サービスがタイムアウトした場合,すでに前の方で無駄にしてしまった 30 秒,ユーザーはとっくに諦めている
err = Charge(ctx, order.Total)
if err != nil {
return err
}
// 通知サービスもフリーズしている……goroutine 漏れが上限に達した → OOM
return Notify(ctx, order.UserID)
}
(2) Goの解決策:背景
// context_demo.go
パッケージ main
import (
"context"
"fmt"
"time"
)
func main() {
// 根 Context
root := context.Background()
// で WithTimeout 小包:2 秒のタイムアウト
ctx, cancel := context.WithTimeout(root, 2*time.Second)
defer cancel() // リソースの解放を確実に行う
result := PlaceOrder(ctx, "順序-123")
fmt.Println(result)
}
func PlaceOrder(ctx context.Context, orderID 文字列) 文字列 {
// 確認 Context キャンセルされましたか
選択 {
case <-ctx.Done():
return fmt.Sprintf("キャンセル: %v", ctx.Err())
default:
}
// 各下流呼び出しに、より短いタイムアウトを割り当てる
checkCtx, cancel := context.WithTimeout(ctx, 1*time.Second)
defer cancel()
ok := InventoryCheck(checkCtx, orderID)
if !ok {
return "在庫不足"
}
return "注文が完了しました"
}
func InventoryCheck(ctx context.Context, orderID 文字列) bool {
// 時間がかかる呼び出しのシミュレーション
選択 {
case <-time.After(500 * time.Millisecond):
return true
case <-ctx.Done():
fmt.Printf("InventoryCheck 取り消された: %v\n", ctx.Err())
return false
}
}
出力(通常):
注文が完了しました
(3) メリット:文脈がある場合とない場合
| 状況 | 結果 |
|---|---|
| タイムアウト制御なし | ゴルーチンリーク、システムのメモリ不足 (OOM) |
| 手動による時間設定。チェック後 | コードが散在しており、各関数に独自のタイムアウトが設定されている |
| 統合コンテキスト制御 | 親コンテキストがキャンセルされると、すべての子コンテキストがカスケード方式でキャンセルされます |
3. ルートコンテキスト
package main
import (
"context"
"fmt"
)
func main() {
// Background():根 Context,決してキャンセルされることはありません
// 用途: main 関数、初期化、最上位のリクエスト
ctx := context.Background()
fmt.Printf("Background: %v\n", ctx)
// TODO():何を使えばいいか分からないときは Context 時,まずは TODO プレースホルダー
// タグはまだ接続されていません Context このコードはリファクタリングが必要だ
todo := context.TODO()
fmt.Printf("TODO: %v\n", todo)
}
(1) 背景 vs やるべきこと
| 背景 | 目的 | 中止されるのか? |
|---|---|---|
Background() |
ルートノード、すべてのコンテキストの起点 | なし |
TODO() |
コードがContextにまだ統合されていないことを示すプレースホルダー | なし |
context.Background() はすべてのコンテキストツリーのルートノードであり、削除されることはありません。context.TODO() は、まだコンテキストチェーンに統合されていないコードを示すために使用されます。できるだけ早く、適切なコンテキストに置き換えてください。
4. context.WithCancel: 手動によるキャンセル
▶ サンプル:ゴルーチンを手動でキャンセルする
パッケージ main
import (
"context"
"fmt"
"time"
)
func Worker(ctx context.Context, id int) {
for {
選択 {
case <-ctx.Done():
fmt.Printf("Worker %d 停止: %v\n", id, ctx.Err())
return
default:
fmt.Printf("Worker %d 仕事中...\n", id)
time.Sleep(500 * time.Millisecond)
}
}
}
func main() {
ctx, cancel := context.WithCancel(context.Background())
go Worker(ctx, 1)
go Worker(ctx, 2)
time.Sleep(2 * time.Second)
fmt.Println("主 goroutine 開始・キャンセル...")
cancel() // みんなに伝える Worker 停止
// 待つ goroutine 終了
time.Sleep(500 * time.Millisecond)
}
▶ サンプル:カスケードキャンセル
package main
import (
"context"
"fmt"
"time"
)
func handler(ctx context.Context) {
// 子 Context 父の遺志を継いで Context
childCtx, cancel := context.WithCancel(ctx)
defer cancel()
go subTask(childCtx, "task-1")
go subTask(childCtx, "task-2")
// 父 Context キャンセル → 子 Context 自動キャンセル
select {
case <-time.After(1 * time.Second):
fmt.Println("Handler 完了")
case <-ctx.Done():
fmt.Println("Handler 取り消された")
}
}
func subTask(ctx context.Context, name string) {
select {
case <-time.After(3 * time.Second):
fmt.Printf("%s 完了\n", name)
case <-ctx.Done():
fmt.Printf("%s 取り消された: %v\n", name, ctx.Err())
}
}
func main() {
ctx, cancel := context.WithCancel(context.Background())
go handler(ctx)
time.Sleep(500 * time.Millisecond)
cancel() // キャンセル → handler → subTask すべてをカスケードで解除
time.Sleep(1 * time.Second)
}
sequenceDiagram
participant Main as main()
participant H as handler
participant ST1 as subTask-1
participant ST2 as subTask-2
Main->>H: WithCancel
H->>ST1: WithCancel
H->>ST2: WithCancel
Note over Main,ST2: 正常に実行される
Main->>Main: cancel()
Main-->>H: ctx.Done()
H-->>ST1: ctx.Done()
H-->>ST2: ctx.Done()
Note over Main,ST2: すべてをカスケードで解除
cancel() を呼び出す必要があります。WithTimeout を使用する場合でも、cancel() を遅延実行する必要があります。そうしないと、コンテキストのリソース(タイマー、ゴルーチン)が解放されません。ルール:WithCancel、WithTimeout、またはWithDeadlineを作成した場合は、直ちにdefer cancel()を実行すること。
5. context.WithTimeout: タイムアウト時の自動キャンセル
▶ サンプル:タイムアウト制御
package main
import (
"context"
"fmt"
"time"
)
func callExternalAPI(ctx context.Context, name string, delay time.Duration) (string, error) {
select {
case <-time.After(delay):
return fmt.Sprintf("%s 応答", name), nil
case <-ctx.Done():
return "", ctx.Err()
}
}
func main() {
// 1 秒のタイムアウト
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
defer cancel()
// 2つの下流サービスを呼び出す
result1 := make(chan string, 1)
result2 := make(chan string, 1)
go func() {
r, err := callExternalAPI(ctx, "サービスA", 500*time.Millisecond)
if err != nil {
result1 <- fmt.Sprintf("サービスA 失敗: %v", err)
return
}
result1 <- r
}()
go func() {
r, err := callExternalAPI(ctx, "サービスB", 1500*time.Millisecond)
if err != nil {
result2 <- fmt.Sprintf("サービスB 失敗: %v", err)
return
}
result2 <- r
}()
fmt.Println(<-result1) // サービスA 応答(500ms < 1s タイムアウト)
fmt.Println(<-result2) // サービスB 失敗: context deadline exceeded(1500ms > 1s)
}
▶ サンプル:WithTimeout 対 WithDeadline
package main
import (
"context"
"fmt"
"time"
)
func main() {
// WithTimeout:今から 2 秒後にタイムアウト
timeoutCtx, cancel1 := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel1()
// WithDeadline:絶対時間を指定する
deadline := time.Now().Add(2 * time.Second)
deadlineCtx, cancel2 := context.WithDeadline(context.Background(), deadline)
defer cancel2()
// 両者の効果は同じである
fmt.Printf("timeoutCtx deadline: %v\n", timeoutCtx.Deadline())
fmt.Printf("deadlineCtx deadline: %v\n", deadlineCtx.Deadline())
}
(2) WithTimeout 対 WithDeadline
| メソッド | パラメータ | 目的 |
|---|---|---|
WithTimeout(parent, 2*time.Second) |
相対時間 | 最もよく使われる表現:「最大2秒待つ」 |
WithDeadline(parent, time.Time) |
絶対時間 | 締切時刻を指定します。「15:30までに完了」 |
6. context.WithValue を使用したリクエストレベルでの値の渡し方
▶ サンプル:WithValue
package main
import (
"context"
"fmt"
)
// カスタマイズ key 種類(対立を避ける)
type contextKey string
const (
UserIDKey contextKey = "user_id"
TraceIDKey contextKey = "trace_id"
RequestIDKey contextKey = "request_id"
)
func middleware(ctx context.Context) context.Context {
// リクエストヘッダーから取得する trace ID
ctx = context.WithValue(ctx, TraceIDKey, "trace-123")
ctx = context.WithValue(ctx, RequestIDKey, "req-456")
return ctx
}
func handler(ctx context.Context, userID string) {
ctx = context.WithValue(ctx, UserIDKey, userID)
// ビジネス層に渡す
service(ctx)
}
func service(ctx context.Context) {
// から Context 値の取得
userID := ctx.Value(UserIDKey).(string)
traceID := ctx.Value(TraceIDKey).(string)
requestID := ctx.Value(RequestIDKey).(string)
fmt.Printf("リクエストの処理: user=%s, trace=%s, request=%s\n",
userID, traceID, requestID)
}
func main() {
ctx := context.Background()
ctx = middleware(ctx)
handler(ctx, "user-007")
}
context.WithValue 内の key はカスタム型でなければなりません。文字列を直接使用することはできません。2つのパッケージがともに "user_id" という文字列をキーとして使用すると、競合が発生します。カスタム型 type contextKey string を使用することがベストプラクティスです。
(2) WithValue のユースケース
| シナリオ | 推奨 | 非推奨 |
|---|---|---|
| TraceID / RequestID | ✅ WithValue 経由で渡された | ❌ グローバル変数 |
| 認証トークン | ✅ WithValue による検証に合格 | ❌ 関数のパラメータ |
| データベース接続 | ❌ 依存性注入により取得 | ❌ WithValue |
| ビジネスパラメータ | ❌ 明示的パラメータ | ❌ WithValue による暗黙的渡し |
7. コンテキスト・チェイニングのルール
package main
import (
"context"
"fmt"
"time"
)
func main() {
root := context.Background()
// チェーン式:WithCancel → WithTimeout → WithValue
ctx1, cancel1 := context.WithCancel(root)
defer cancel1()
ctx2, cancel2 := context.WithTimeout(ctx1, 2*time.Second)
defer cancel2()
ctx3 := context.WithValue(ctx2, "trace", "trace-007")
// ctx3 受け継がれた ctx1 の取り消し + ctx2 のタイムアウト + ctx3 の値
fmt.Printf("ctx3 deadline: %v\n", ctx3.Deadline())
fmt.Printf("ctx3 value: %v\n", ctx3.Value("trace"))
// まずキャンセルする ctx1 → ctx2 と ctx3 すべてにキャンセル信号が届く
cancel1()
time.Sleep(10 * time.Millisecond)
fmt.Printf("ctx2 err: %v\n", ctx2.Err())
fmt.Printf("ctx3 err: %v\n", ctx3.Err())
}
(1) コンテキストの引き継ぎに関するルール
| ルール | 説明 |
|---|---|
| 最初の引数 | 関数のシグネチャにおける最初の引数は常に ctx context.Context です |
| 構造体内に格納しないでください | Contextを構造体のフィールドに格納せず、パラメータとして渡してください |
| 関数間の引数渡し | キャンセルやタイムアウトを処理する必要がある各関数には、コンテキストが渡されます |
| 不変チェーン | WithCancel/WithTimeout/WithValue の呼び出しごとに新しいコンテキストが返される |
| 連鎖的なキャンセル | 親アイテムをキャンセルすると → すべての子アイテムがキャンセルされます。子アイテムをキャンセルしても、親アイテムには影響しません |
8. 完全な例:マイクロサービストレースのタイムアウト制御
// microservice_chain.go
package main
import (
"context"
"fmt"
"math/rand"
"time"
)
// ---------- 下流サービスのシミュレーション ----------
// 在庫管理サービス
type InventoryService struct{}
func (s *InventoryService) Check(ctx context.Context, orderID string) (bool, error) {
// ランダム遅延のシミュレーション
delay := time.Duration(rand.Intn(1500)) * time.Millisecond
select {
case <-time.After(delay):
return true, nil
case <-ctx.Done():
return false, ctx.Err()
}
}
// 決済サービス
type PaymentService struct{}
func (s *PaymentService) Charge(ctx context.Context, amount float64) (string, error) {
delay := time.Duration(rand.Intn(1500)) * time.Millisecond
select {
case <-time.After(delay):
return "pay-" + fmt.Sprintf("%d", time.Now().UnixNano()), nil
case <-ctx.Done():
return "", ctx.Err()
}
}
// 通知サービス
type NotificationService struct{}
func (s *NotificationService) Send(ctx context.Context, userID, message string) error {
delay := time.Duration(rand.Intn(1500)) * time.Millisecond
select {
case <-time.After(delay):
return nil
case <-ctx.Done():
return ctx.Err()
}
}
// ---------- ビジネス層 ----------
type OrderService struct {
inventory *InventoryService
payment *PaymentService
notify *NotificationService
}
func NewOrderService() *OrderService {
return &OrderService{
inventory: &InventoryService{},
payment: &PaymentService{},
notify: &NotificationService{},
}
}
// PlaceOrder 使用 Context リンク全体のタイムアウトを制御する
func (s *OrderService) PlaceOrder(ctx context.Context, userID, orderID string, amount float64) error {
// 1. 在庫確認(せいぜい 1 秒)
invCtx, invCancel := context.WithTimeout(ctx, 1*time.Second)
defer invCancel()
ok, err := s.inventory.Check(invCtx, orderID)
if err != nil {
return fmt.Errorf("在庫確認に失敗しました: %w", err)
}
if !ok {
return fmt.Errorf("在庫不足")
}
// 2. 支払い(せいぜい 2 秒)
payCtx, payCancel := context.WithTimeout(ctx, 2*time.Second)
defer payCancel()
paymentID, err := s.payment.Charge(payCtx, amount)
if err != nil {
return fmt.Errorf("支払いに失敗しました: %w", err)
}
// 3. お知らせ(せいぜい 500ms)
notifyCtx, notifyCancel := context.WithTimeout(ctx, 500*time.Millisecond)
defer notifyCancel()
err = s.notify.Send(notifyCtx, userID, "注文が完了しました: "+orderID)
if err != nil {
// 通知の失敗は注文に影響しません(非同期ロギング)
fmt.Printf("通知に失敗しました(記録済み): %v\n", err)
}
fmt.Printf("注文が完了しました: user=%s, order=%s, payment=%s\n", userID, orderID, paymentID)
return nil
}
// ---------- クライアント ----------
func main() {
rand.Seed(time.Now().UnixNano())
svc := NewOrderService()
// リクエスト全体のタイムアウト 3 秒
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
err := svc.PlaceOrder(ctx, "user-007", "order-123", 99.99)
if err != nil {
fmt.Printf("注文に失敗しました: %v\n", err)
}
}
Err() の Context メソッドは、context.Canceled(手動でキャンセルされた)または context.DeadlineExceeded(タイムアウト)の 2 つの値のいずれかを返します。チェーン内では、errors.Is(err, context.DeadlineExceeded) を使用して、どのタイプのキャンセルが発生したかを特定し、それに応じて再試行戦略を決定することができます。
❓ よくある質問
Background() と TODO() の違いは何ですか?Background() は、コード内で現在アクティブに使用しているルートです。TODO()はプレースホルダーであり、このコードセクションがまだコンテキストに統合されておらず、できるだけ早くリファクタリングする必要があることを示しています。WithTimeout と WithDeadline、どちらを選べばいいですか?WithTimeout を使用してください(「最大2秒待つ」という表現の方が直感的です)。タイムアウトが絶対時間(「15:30:00までに完了する」)である場合にのみ、WithDeadline を使用してください。WithTimeoutは内部でWithDeadlineを呼び出します。Context は複数回キャンセルできますか?cancel() を繰り返し呼び出しても問題ありません(2 回目以降の呼び出しは何も行われません)。Done() をリッスンしているすべての goroutine は、このシグナルを 1 回だけ受信します。WithValue に渡された値は変更できますか?context.WithValue の値は不変です。「変更」と呼ばれる操作は、本質的には新しいコンテキストを作成することに相当します。子コンテキストの WithValue は、親コンテキストには影響を与えません。これにより、並行処理の安全性が確保されます。📖 まとめ
- Background() / TODO(): 2種類のルートコンテキスト。キャンセルすることはできない
- WithCancel: ゴルーチンの中止を手動で制御する
- WithTimeout / WithDeadline:タイムアウト時に自動的にキャンセルされる
- WithValue:リクエストレベルでの値の渡し(カスタムキー型)
- チェーンパッシング:最初の引数は常にContextです
- カスケードキャンセル:親項目のキャンセル → すべての子項目がキャンセルされる
cancel()はdeferを使用して呼び出す必要があります- 実践ガイド:マイクロサービスチェーンにおけるタイムアウトの連鎖を防ぐ
📝 練習問題
-
基本演習(難易度 ⭐):
context.WithTimeoutを使用して HTTP リクエストのタイムアウトを制御する関数FetchWithTimeout(ctx, url string, timeout time.Duration)を作成してください。タイムアウトが切れたら、その HTTP リクエストは自動的にキャンセルされるようにしてください。 -
上級問題(難易度 ⭐⭐):並行してキャンセル可能なスケジュールタスクマネージャーを実装してください。以下の機能をサポートする必要があります:(1) 複数のスケジュールタスクの登録(1つのgoroutineにつき1つ);(2) 個別のキャンセル(
cancel関数の呼び出しによる);(3) 一括キャンセル(WithCancelの連鎖); (4) すべてのタスクが単一のルートコンテキストを共有すること。 -
課題(難易度 ⭐⭐⭐):分散トレーシングシステムをシミュレートしてください。要件:(1)
WithValueを使用して、3 層の関数呼び出し(API → サービス → DB)にわたって TraceID を渡すこと; (2) 各層には独自のタイムアウト制御がある(Service:2秒、DB:500ミリ秒);(3) タイムアウトが発生した場合、現在の層はキャンセルされるが、上流の層には影響を与えない;(4) 各ステージの経過時間とTraceIDを出力する。ContextのDeadline()を使用して残り時間を計算する。



