Day 016 — context パッケージ①(キャンセル伝播)

2026-08-13 🔵 中級者 / Phase 2 概念理解→実装 context パッケージ①(キャンセル伝播)

📚 背景知識(読んでから問題へ)

Day 009〜015でgoroutinechannelselectsyncパッケージを学び、複数のgoroutineを協調させる基本的な道具は揃いました。しかし1つ、重要な問題が手つかずのまま残っています。「起動したgoroutineに、途中でやめてほしいと伝える標準的な方法」です。

素朴には、done := make(chan struct{})のような「終了通知専用チャネル」を自前で用意し、close(done)で全workerに知らせる、という自作パターンで対応できます。実際、Goの初期にはこのパターンが広く使われていました。しかし実務のシステムでは、次のような要求が次々に出てきます。

  • HTTPリクエストの処理中に、クライアントが接続を切ったら、そのリクエストのために起動した全goroutineを止めたい
  • 「5秒でタイムアウト」のような時間制限つきキャンセルをしたい
  • ある処理をキャンセルしたら、その処理から呼び出している下位の処理(DBクエリ・外部API呼び出しなど)も連鎖的にキャンセルしたい
  • キャンセルの理由(タイムアウトなのか、明示的なキャンセルなのか)を末端のgoroutineまで伝えたい

これらをdone chan struct{}の自作パターンで毎回作り込むのは非効率かつバグの温床です。そこでGo 1.7で標準ライブラリに追加されたのがcontextパッケージです。context.Contextは「キャンセルシグナル・タイムアウト・リクエストスコープの値を、goroutineの呼び出しツリー全体に伝播させるための共通インターフェース」です。

context.Contextはツリー構造を持ちます。context.Background()(あるいはcontext.TODO())を根として、context.WithCancel(parent)のように親のcontextから子のcontextを派生させます。この派生関係が重要で、親がキャンセルされると、その親から派生した全ての子・孫contextも自動的にキャンセルされます(逆に子をキャンセルしても親には影響しません)。goroutineはctx.Done()が返す<-chan struct{}selectで監視し、このチャネルがcloseされたら「キャンセルされた」と判断して後片付けをして終了します。キャンセルされた理由はctx.Err()で取得でき、明示的なキャンセルならcontext.Canceled、タイムアウトならcontext.DeadlineExceededが返ります。

Goの設計思想として、context.Contextは「goroutineをまたぐキャンセル・タイムアウト・値伝播という、並行処理で繰り返し必要になる関心事を、言語機能ではなく1つの小さいインターフェース(Done()Err()Deadline()Value()の4メソッドのみ)に集約する」という形をとっています。これにより、標準ライブラリ(net/httpdatabase/sqlなど)からサードパーティのライブラリまで、context.Contextを第一引数として受け取る(func Do(ctx context.Context, ...))という統一された規約でキャンセル伝播を扱えるようになっています。

📝 問題

以下の要件を満たす、go run main.goでそのまま実行できる1つのGoプログラムを実装してください。

  1. worker(ctx context.Context, id int, wg *sync.WaitGroup)という関数を実装してください。この関数は、一定間隔(例: 100ミリ秒ごと)で「作業中」というログを出し続け、ctxがキャンセルされたらselectでそれを検知して、ctx.Err()の内容とともにログを出してから終了するようにしてください
  2. context.Background()を根とし、context.WithCancelで作った親contextを使って、3つのworkerをgoroutineとして起動してください
  3. 親contextからさらにcontext.WithCancel子contextを1つ派生させ、その子contextを使ってもう1つworkerを起動してください
  4. 500ミリ秒待ってから親contextだけを明示的にキャンセルし、子contextも連鎖してキャンセルされること(子contextのDone()も自動でcloseされ、対応するworkerも終了すること)を、プログラムの出力で確認できるようにしてください
  5. 全workerが終了するのをsync.WaitGroupで待ち、最後に親・子それぞれのctx.Err()の値を出力してください
  6. context.WithCancelで得たcancel関数は、リークを防ぐため必ず呼び出すようにしてください(deferの利用を推奨)

🔍 ヒント(段階的開示)

ヒント1 — 方向性

goroutineに「そろそろ終わってほしい」と伝える標準的な手段がcontext.ContextDone()です。Done()<-chan struct{}を返し、キャンセルされるとこのチャネルがcloseされます。select文でctx.Done()を他の作業(例えばタイマー)と一緒に監視すれば、「作業を続けるか、キャンセルされたらすぐ抜けるか」を1つのselectで表現できます。

ヒント2 — アプローチ
  • context.WithCancel(parent)(ctx context.Context, cancel context.CancelFunc)という2値を返します。cancel()を呼ぶとそのctx(および、そのctxから派生した子・孫すべて)がキャンセルされます
  • 子contextを作るときは、context.Background()からではなく、すでに存在する親のctxを渡してください(context.WithCancel(parentCtx))。これにより親子のツリー関係が形成されます
  • worker内のループはtime.NewTickerなどで一定間隔の作業を表現し、select { case <-ctx.Done(): ... case <-ticker.C: ... }という形にします
  • cancel関数は「呼ばなくてもいずれ親がGCされれば問題にはならない」場合もありますが、Goのベストプラクティスとして必ずdefer cancel()することがgo vetlostcancelチェック)でも推奨されています
ヒント3 — コード骨格
package main

import (
	"context"
	"fmt"
	"sync"
	"time"
)

func worker(ctx context.Context, id int, wg *sync.WaitGroup) {
	defer wg.Done()
	ticker := time.NewTicker(100 * time.Millisecond)
	defer ticker.Stop()

	for {
		select {
		case <-ctx.Done():
			// TODO: ctx.Err() を使って終了ログを出す
			return
		case <-ticker.C:
			// TODO: 作業中ログを出す
		}
	}
}

func main() {
	parentCtx, cancelParent := context.WithCancel(context.Background())
	defer cancelParent()

	var wg sync.WaitGroup
	// TODO: parentCtx を使って worker を3つ起動する

	// TODO: parentCtx から childCtx を派生させ、cancelChild も defer する
	// TODO: childCtx を使って worker をもう1つ起動する

	time.Sleep(500 * time.Millisecond)
	// TODO: 親contextだけをキャンセルする

	wg.Wait()
	// TODO: parentCtx.Err() / childCtx.Err() を出力する
}

模範解答

package main

import (
	"context"
	"fmt"
	"sync"
	"time"
)

// worker はctxがキャンセルされるまで一定間隔で作業を行い、
// キャンセルされたら理由(ctx.Err())を出力して終了する。
func worker(ctx context.Context, id int, wg *sync.WaitGroup) {
	defer wg.Done()
	ticker := time.NewTicker(100 * time.Millisecond)
	defer ticker.Stop()

	for {
		select {
		case <-ctx.Done():
			fmt.Printf("worker %d: 停止しました(理由: %v)\n", id, ctx.Err())
			return
		case <-ticker.C:
			fmt.Printf("worker %d: 作業中...\n", id)
		}
	}
}

func main() {
	// 親context: 全workerの生存期間を制御する根。
	parentCtx, cancelParent := context.WithCancel(context.Background())
	defer cancelParent() // 早期returnやpanicがあってもリークしないよう必ずdeferする

	var wg sync.WaitGroup
	for i := 1; i <= 3; i++ {
		wg.Add(1)
		go worker(parentCtx, i, &wg)
	}

	// 子context: 親から派生させることで、親がキャンセルされると自動的に連鎖する。
	childCtx, cancelChild := context.WithCancel(parentCtx)
	defer cancelChild()

	wg.Add(1)
	go worker(childCtx, 99, &wg)

	time.Sleep(500 * time.Millisecond)
	fmt.Println("--- 親contextをキャンセルします ---")
	cancelParent()

	wg.Wait()

	fmt.Println("親ctx.Err():", parentCtx.Err())
	fmt.Println("子ctx.Err():", childCtx.Err())
}
▶ 実行結果を見る(go run main.go で検証済み)
worker 1: 作業中...
worker 2: 作業中...
worker 3: 作業中...
worker 99: 作業中...
worker 1: 作業中...
worker 2: 作業中...
worker 3: 作業中...
worker 99: 作業中...
...(500msの間、100msごとに繰り返し)...
--- 親contextをキャンセルします ---
worker 1: 停止しました(理由: context canceled)
worker 2: 停止しました(理由: context canceled)
worker 3: 停止しました(理由: context canceled)
worker 99: 停止しました(理由: context canceled)
親ctx.Err(): context canceled
子ctx.Err(): context canceled

※ 親contextに紐づくworker(1・2・3)だけでなく、親から派生した子contextに紐づくworker(99)も、cancelParent()だけを呼んだ結果として停止していることが確認できます。子context自身のcancelChild()は一度も呼んでいませんが、childCtx.Err()context canceledになっています。

🪜 Step-by-Step 解説

1
worker が ctx.Done() を監視する
func worker(ctx context.Context, id int, wg *sync.WaitGroup) {
	defer wg.Done()
	ticker := time.NewTicker(100 * time.Millisecond)
	defer ticker.Stop()

	for {
		select {
		case <-ctx.Done():
			fmt.Printf("worker %d: 停止しました(理由: %v)\n", id, ctx.Err())
			return
		case <-ticker.C:
			fmt.Printf("worker %d: 作業中...\n", id)
		}
	}
}
ctx.Done()は「まだキャンセルされていなければ、決してcloseされないチャネル」を返します。selectは複数のチャネルを同時に監視し、どちらか先に準備できた方caseを実行します。通常はticker.Cが100msごとに準備できて「作業中」ログが出続けますが、ctxがキャンセルされた瞬間にctx.Done()がcloseされ、次のselectの評価でそのcaseが選ばれてreturnします。ctx.Err()Done()がcloseされる直前に確定するため、この時点で必ずnil以外の値(context.Canceledなど)を安全に読み取れます。
2
親contextから3つのworkerを起動する
parentCtx, cancelParent := context.WithCancel(context.Background())
defer cancelParent()

var wg sync.WaitGroup
for i := 1; i <= 3; i++ {
	wg.Add(1)
	go worker(parentCtx, i, &wg)
}
context.Background()はどのcontextにも属さない「空の根」で、main関数やリクエスト処理の最上位で使うのが定石です。context.WithCancelはこの根から「キャンセル可能な」子contextを1つ作ります。3つのworkerは全て同じparentCtxを共有しているため、cancelParent()を1回呼ぶだけで3つ全てに停止シグナルが伝わります。
3
子contextを派生させ、親子のツリーを作る
childCtx, cancelChild := context.WithCancel(parentCtx)
defer cancelChild()

wg.Add(1)
go worker(childCtx, 99, &wg)
ここがこの問題の核心です。context.WithCancel(parentCtx)のように、context.Background()ではなく既存のparentCtxを親として渡すことで、childCtxparentCtxのツリーの子になります。Go内部では、parentCtxがキャンセルされたときに、自分から派生した全ての子contextにもキャンセルを伝播させる仕組みが組み込まれています(parentは自分の子contextたちを内部で追跡しており、cancelが呼ばれるとその一覧を辿って子側のDone()もcloseします)。そのため、cancelChild()を一度も呼ばなくても、cancelParent()だけでchildCtx.Done()もcloseされ、worker(childCtx, 99, ...)も停止します。
4
cancelParent() の呼び出しと、後片付けの確認
time.Sleep(500 * time.Millisecond)
fmt.Println("--- 親contextをキャンセルします ---")
cancelParent()

wg.Wait()

fmt.Println("親ctx.Err():", parentCtx.Err())
fmt.Println("子ctx.Err():", childCtx.Err())
wg.Wait()で全4つのworkerが実際にreturnするまでブロックすることで、「キャンセルシグナルを送った」だけでなく「実際に全workerが後片付けをして終了した」ことまで保証しています。最後にparentCtx.Err()childCtx.Err()の両方がcontext.Canceledになっていることを確認すれば、キャンセルが親から子へ正しく連鎖したことの証拠になります。

💡 設計思想・なぜこう書くのか

📌
小さいインターフェースがエコシステムの共通言語になる: context.Contextが「1つの巨大な機能」ではなく、Done()Err()Deadline()Value()というわずか4メソッドの小さいインターフェースとして設計されている点は、Day 010で扱った「小さいインターフェース」の思想の直接的な延長です。この小ささのおかげで、net/httpのリクエストハンドラも、database/sqlのクエリ実行も、gRPCのRPC呼び出しも、全て同じcontext.Contextという共通言語でキャンセル・タイムアウトを扱えます。もしcontextがなければ、ライブラリごとに独自の「キャンセルできる何か」を発明することになり、それらを組み合わせて使うたびに変換コードが必要になっていたはずです。
📌
ツリー構造・イミュータブルであることの意味: contextがツリー構造かつイミュータブル(不変)である点も重要です。context.WithCancel(parent)parent自体を変更せず、新しい子contextを返します。これにより、「この関数呼び出しの先だけキャンセル可能にする・タイムアウトを追加する」という局所的な制御が、呼び出し元の状態に一切影響を与えずに行えます。goroutineに何かを「伝える」ときは、共有変数を書き換えるのではなく、不変なcontextを引数として渡していく、というGoらしい設計です。

🌐 他言語との比較

観点GoJavaPythonRust
キャンセル伝播の仕組みcontext.Contextをツリー状に伝播。Done()をselectで能動的に監視する協調的キャンセルThread.interrupt()でフラグを立て、各所でisInterrupted()をチェックするかInterruptedExceptionを捕捉する協調的キャンセルasyncioではTask.cancel()が対象コルーチンにCancelledErrorを送出する。協調的だが例外ベース標準では言語機能なし。tokio_util::sync::CancellationTokenなど非同期ランタイムのエコシステムがGoのcontextに近い設計を提供
タイムアウトの表現context.WithTimeout/WithDeadlineで親から派生させ、期限超過で自動的にDone()がcloseFuture.get(timeout, unit)のように呼び出し側で個別に指定することが多く、伝播の仕組みは統一されていないasyncio.wait_for(coro, timeout)でラップする形。呼び出しごとに個別設定ランタイム依存(tokio::time::timeoutなど)。個別のAPIとして提供され統一インターフェースはない
ライブラリ間の統一性標準ライブラリ・サードパーティともにctx context.Contextを第一引数に取る規約がエコシステム全体に浸透している統一された規約はなく、フレームワークごとに独自のキャンセル機構を持つことが多いasyncioのTask/Futureに統一されているが、同期コードとは別世界クレートごとに設計が異なり、統一規約はエコシステム全体には存在しない

Goのcontextが際立っているのは、「言語仕様の一部ではなくただのライブラリの型」でありながら、事実上エコシステム全体の共通言語になっている点です。これはfunc Do(ctx context.Context, ...)という「第一引数にctxを取る」というコミュニティの規約(convention)によって支えられており、Go自体がこれを強制しているわけではありません。

🏆 実務での使いどころ

  • HTTPサーバーのリクエストスコープ管理: net/httpのハンドラはr.Context()でリクエストに紐づくcontextを取得できます。クライアントが接続を切ると、このcontextが自動的にキャンセルされるため、ハンドラ内で起動したDBクエリや外部API呼び出しも連鎖して中断でき、無駄な処理を継続しません
  • DBクエリのタイムアウト制御: database/sqlQueryContext(ctx, ...)のようなAPIにcontext.WithTimeoutで作ったcontextを渡すことで、「このクエリは3秒でタイムアウトさせる」という制御を、DBドライバの実装に関わらず統一的に行えます
  • マイクロサービス間のリクエストチェーン: サービスAがサービスBを呼び、BがサービスCを呼ぶような構成で、Aへのリクエストがタイムアウトしたときに、B・Cへの呼び出しも連鎖してキャンセルすることで、無駄なリソース消費(ゾンビリクエスト)を防げます
  • graceful shutdown: アプリケーション終了シグナル(SIGTERMなど)を受けたときに、ルートのcontextをキャンセルすることで、稼働中の全goroutineに一斉に「後片付けして終了して」と伝える設計がよく使われます(Phase 3で扱う「graceful shutdown実装」テーマに直結します)

⚠️ よくある誤解・ミス

誤解・ミスなぜ起こるか正しい理解
cancel()を呼び忘れる(defer cancel()をつけない)「キャンセルしたいときだけ呼べばいい」という直感context.WithCancelで作ったcontextは、cancel()を呼ぶまで内部で親子関係の追跡情報を保持し続けメモリリークになりうる。使い終わるタイミングが早期returnやpanicで複雑な場合でも確実に呼ばれるよう、生成直後にdefer cancel()するのが定石(go vetlostcancelがこれを検出する)
context.Contextをstructのフィールドとして保持してしまう「毎回引数で渡すのは面倒」という発想公式ドキュメントでも明示的に非推奨とされている。contextはリクエスト・処理のスコープごとに変わるべきものであり、構造体に埋め込むとスコープの異なるcontextを誤って使い回すバグの温床になる。必ず関数の第一引数として明示的に渡すのが規約
ctx.Done()をチェックせず、キャンセルされても処理を続けてしまうselectを使わず単純なループだけを書いてしまうcontextはあくまで「協調的(cooperative)」なキャンセルの仕組みであり、Goランタイムが強制的にgoroutineを止めてくれるわけではない。goroutine側がselectなどで能動的にctx.Done()を確認して初めてキャンセルが機能する
子contextをキャンセルすれば親もキャンセルされると思い込む親子関係を「双方向に影響する」と誤解するキャンセルの伝播は親から子への一方向のみ。子をキャンセルしても親や兄弟のcontextには一切影響しない。逆に親がキャンセルされると、子・孫全てに連鎖する

🚀 次のステップ

  • 発展: context.WithTimeout(parent, 3*time.Second)を使い、明示的なcancel()を呼ばなくても3秒経過で自動的にctx.Err()context.DeadlineExceededになることを実際に確認してみましょう。今回学んだWithCancelとの違い(誰が・いつキャンセルを引き起こすか)が明確になります
  • 次回予告: Day 017 — context パッケージ②(タイムアウト・値の伝播)(実装問題)。WithTimeout/WithDeadlineと、リクエストスコープの値を安全に伝播させるWithValueの正しい使い方を学びます

🎯 自己評価

自分の回答

気づき・メモ