Go

Go言語のcontext入門|BackgroundとTODOの使い分け、キャンセルとタイムアウトの実装

golang(Go言語)のcontextは、複数のゴルーチンにまたがる処理へ「中断の合図」と「締め切り」を配るための標準パッケージです。使い始めで最初に迷うのがcontext.Background()context.TODO()のどちらを書くか。次に詰まるのがcancel関数の扱いと、タイムアウトを過ぎても処理が止まらない現象です。この記事では公式パッケージの定義に沿って、生成から伝播、キャンセル、タイムアウト、値の受け渡しまでを実際に動くコードで整理します。あわせてGo 1.20とGo 1.21で追加されたcontext.Causecontext.WithoutCancelcontext.AfterFuncの使いどころも扱います。

掲載しているコードはcontextをはじめとする標準パッケージのimport文を省略した断片です。

まとめ

Go言語のcontextで押さえるべき要点は次の5つです。

論点 結論
起点の生成 本番=Background/仮置き=TODO
キャンセル defer cancel()を必ず書く
中断理由 Go 1.20のcontext.Cause
タイムアウト 合図のみ・強制停止なし
値の受け渡し リクエストスコープ限定・独自キー型

起点となるcontextは、main関数や初期化処理ならcontext.Background()、渡すべきcontextが未定の箇所だけcontext.TODO()を書きます。WithCancel系が返すcancel関数は受け取った直後にdefer cancel()で予約し、中断の理由まで伝えたい場合はGo 1.20で追加されたcontext.Causeを併用します。値を載せてよいのはリクエストスコープのデータに限られ、キーには非公開の独自型を使います。

最も誤解が多いのはタイムアウトの挙動です。contextはゴルーチンを強制終了させる仕組みではなく、あくまで「やめてよい」という通知を配る仕組みです。この一点を取り違えると、タイムアウトを設定したのに処理が走り続ける、という典型的な不具合につながります。以降で各項目の実装を順に見ていきます。

contextが解決する問題:キャンセル伝播と締め切りの共有

Webサーバがリクエストを受けて、データベース照会と外部API呼び出しを並行で走らせる場面を考えます。クライアントが接続を切った時点で、裏で動いているGoの軽量スレッドであるゴルーチンは不要になります。ところが何も仕込まなければ処理は最後まで走り続け、接続断が積み重なるほど、使われない結果のために接続やメモリを掴んだままのゴルーチンが増えていく一方です。

contextは、この「もう不要になった」という情報を、呼び出しの親から子へ木構造で伝えるための仕組みです。親のcontextがキャンセルされると、そこから派生した子のcontextもすべて同時にキャンセルされます。逆方向には伝わらず、子をキャンセルしても親は動き続けます。

contextの実体は次の4つのメソッドを持つインターフェースです。

type Context interface {
	Deadline() (deadline time.Time, ok bool)
	Done() <-chan struct{}
	Err() error
	Value(key any) any
}

中断の合図を受け取る側はDone()が返すチャネルを監視します。キャンセルまたは締め切り超過が起きるとこのチャネルが閉じられ、受信がブロックせずに返るようになる仕組みです。Err()はまだ何も起きていなければnilを返し、中断後はcontext.Canceledcontext.DeadlineExceededのどちらかを返します。合図を受けて実際に処理を打ち切るのは、あくまで受け取った側のコードの責任になります。

context.Backgroundとcontext.TODOの使い分け

どちらも空のcontextを返し、キャンセルされることも締め切りを持つこともありません。実行時の振る舞いに違いはなく、差は「読み手に何を伝えるか」だけです。

context.Backgroundを置く場所

プログラムの最上位、つまり親となるcontextが存在しない場所で使います。公式パッケージが用途として挙げているのは、main関数、初期化処理、テスト、そしてサーバが受け取るリクエストの最上位contextの4つです。以降のcontextはすべてこれを起点に派生させる形になります。

func main() {
	ctx := context.Background()

	if err := run(ctx); err != nil {
		log.Fatal(err)
	}
}

func run(ctx context.Context) error {
	ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
	defer cancel()
	return fetchAll(ctx)
}

関数がcontextを受け取る場合、公式は第1引数に置き、名前をctxにすることを推奨しています。この慣習は標準ライブラリ全体で徹底されているため、外れると呼び出し側が引数の順序を毎回確認する羽目になる点に注意してください。

context.TODOを残さない運用

context.TODO()は「ここに渡すべきcontextがまだ決まっていない」という印です。既存コードにcontext対応を後付けする際、呼び出し元がまだcontextを持っていない中間関数に一時的に置く、といった使い方が想定されています。

func legacyHandler() error {
	// 呼び出し元がまだcontextを持たないため仮置き
	return repository.FindUser(context.TODO(), userID)
}

問題は、この仮置きが移行完了後もそのまま残りやすい点にあります。context.TODO()が残っていると、そこでcontextの連鎖が途切れ、上位でキャンセルしても下流に伝わりません。タイムアウトを設定したのに効かない、という不具合の温床です。移行が済んだ箇所では必ず実物のcontextへ置き換え、context.TODO()の出現箇所がコードベース全体でゼロになったことを完了条件にします。

一方で公式パッケージは「どのContextを使うべきか判断できない場合はcontext.TODOを渡すこと」と明記しています。迷った末にcontext.Background()で埋めてしまうと、それが意図した起点なのか単なる埋め合わせなのかが後から区別できません。仮置きだと分かるcontext.TODO()を残しておけば、検索一発で洗い出せます。

WithCancelによるキャンセルの実装

context.WithCancelは、派生contextとそれをキャンセルするための関数を返します。

defer cancelを必ず書く理由

func worker(ctx context.Context, id int) {
	for {
		select {
		case <-ctx.Done():
			fmt.Printf("worker %d 停止: %v\n", id, ctx.Err())
			return
		case <-time.After(200 * time.Millisecond):
			fmt.Printf("worker %d 実行中\n", id)
		}
	}
}

func main() {
	ctx, cancel := context.WithCancel(context.Background())
	defer cancel()

	for i := 1; i <= 3; i++ {
		go worker(ctx, i)
	}

	time.Sleep(500 * time.Millisecond)
	cancel()
	time.Sleep(100 * time.Millisecond)
}

cancel()を1回呼ぶだけで、同じcontextを受け取った3つのゴルーチンすべてでctx.Done()が返るようになります。このときctx.Err()context.Canceledを返します。

cancelを呼ばないまま関数を抜けると、親が子への参照を抱えたままになる場合があります。ここで効いてくるのが親の種類です。親自身がキャンセル可能なcontext、つまり上位のWithCancelWithTimeoutで作ったcontextだった場合、その親がキャンセルされるまで子は解放されず、そのままメモリの増加として現れます。親がcontext.Background()そのものなら親側に参照は登録されないため、この経路での滞留は起きません。ただしWithTimeoutWithDeadlineは設定時刻までタイマーを抱えたままになります。

親の種類ごとに条件を覚えるより、返り値のcancelを受け取ったら直後にdefer cancel()を書く、と順序を固定してしまうほうが取りこぼしが出ません。なおcancelは複数のゴルーチンから同時に呼んでも安全で、2回目以降の呼び出しは何もしないと公式に明記されています。すでにキャンセル済みのcontextに対してdefer cancel()が重ねて走っても問題は起きません。

WithCancelCauseとCauseによる中断理由の伝達

ctx.Err()が返すのは、中断前ならnil、中断後はcontext.Canceledcontext.DeadlineExceededのどちらかです。つまり「なぜ中断したのか」までは分かりません。ログにcontext canceledとだけ出ても原因の特定には使えないわけです。どちらのエラーが出たのかを起点に原因を絞り込む手順はGoのcontext canceledとdeadline exceededの切り分け|原因の特定とWithDeadlineによる締切共有で解説しています。

Go 1.20で追加されたcontext.WithCancelCausecontext.Causeは、この欠落を埋めます。キャンセル関数がエラーを引数に取り、そのエラーを後から取り出せます。

var ErrInvalidInput = errors.New("入力値が不正です")

func main() {
	ctx, cancel := context.WithCancelCause(context.Background())
	defer cancel(nil)

	cancel(ErrInvalidInput)

	fmt.Println(ctx.Err())            // context canceled
	fmt.Println(context.Cause(ctx))   // 入力値が不正です
	fmt.Println(errors.Is(context.Cause(ctx), ErrInvalidInput)) // true
}

ctx.Err()は従来どおりcontext.Canceledを返し、理由はcontext.Cause(ctx)から取ります。既存のerrors.Is(err, context.Canceled)による分岐を壊さずに理由だけを足せる設計。キャンセル経路が複数ある処理、とりわけ並行して走る複数のゴルーチンのうちどれが最初に失敗したのかを追いたい場面で効きます。なお公式は、すでにキャンセル済みのcontextに対してcancelを呼んでもcauseは設定されない、と明記しています。そのため正常終了時の後始末でdefer cancel(nil)が走っても、先に記録した理由が塗り替えられることはありません。まだキャンセルされていない状態でnilを渡した場合だけ、causeがcontext.Canceledになります。

WithTimeoutとWithDeadlineによる締め切り制御

WithTimeoutは現在時刻からの相対時間、WithDeadlineは絶対時刻で締め切りを設定します。WithTimeout(parent, d)WithDeadline(parent, time.Now().Add(d))と等価です。締め切りを過ぎるとcontextは自動的にキャンセルされ、ctx.Err()context.DeadlineExceededを返します。

HTTPリクエストとデータベース照会への適用

外部API呼び出しにはhttp.NewRequestWithContextを使います。http.ClientTimeoutフィールドと違い、contextなら呼び出し側の締め切りを下流まで一貫して伝えられます。

func fetch(ctx context.Context, url string) ([]byte, error) {
	ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
	defer cancel()

	req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
	if err != nil {
		return nil, err
	}

	resp, err := http.DefaultClient.Do(req)
	if err != nil {
		if errors.Is(err, context.DeadlineExceeded) {
			return nil, fmt.Errorf("3秒でタイムアウトしました: %w", err)
		}
		return nil, err
	}
	defer resp.Body.Close()

	return io.ReadAll(resp.Body)
}

ここでWithTimeoutに渡している親はcontext.Background()ではなく引数のctxです。親にすでに5秒の締め切りが設定されていれば、実際の締め切りは短いほうが採用されます。公式パッケージの定義どおり、派生contextの締め切りは親の締め切りより後ろにはできません。

データベースアクセスでも同じ考え方が通ります。標準のdatabase/sqlならQueryContextExecContextが第1引数にcontextを取り、ORMでも終端のメソッドにcontextを渡せる設計が主流です。HTTPハンドラが受け取ったcontextをそのまま流し込めば、クライアント切断時に発行中のクエリまでキャンセルが届きます。ORM側の書き方はGORMのジェネリクスAPIによる実装パターンにまとめています。

Webフレームワークを使う場合は、フレームワーク独自のコンテキスト型とcontext.Contextを取り違えないよう注意してください。Echoのecho.ContextやGinの*gin.Contextはリクエスト処理用の器であって、そのままでは締め切りやキャンセルの合図を運びません。Echoならc.Request().Context()で本来のcontext.Contextを取り出し、それを下流へ渡すのが基本形です。フレームワーク自体の構成はGo Echoフレームワークの実践ガイドで扱っています。

WithTimeoutCauseとWithDeadlineCauseによる原因の明示

Go 1.21でcontext.WithTimeoutCausecontext.WithDeadlineCauseが追加され、締め切り超過にも理由を持たせられるようになりました。多段のタイムアウトが重なる処理で、どの階層の締め切りに引っかかったのかを判別できます。

var ErrUpstreamSlow = errors.New("上流APIが3秒以内に応答しませんでした")

func main() {
	ctx, cancel := context.WithTimeoutCause(
		context.Background(), 3*time.Second, ErrUpstreamSlow)
	defer cancel()

	<-ctx.Done()

	fmt.Println(ctx.Err())          // context deadline exceeded
	fmt.Println(context.Cause(ctx)) // 上流APIが3秒以内に応答しませんでした
}

親と子の両方に締め切りがある構成では、ctx.Err()はどちらでもcontext.DeadlineExceededになってしまい区別できません。階層ごとに異なるエラー値をcauseに渡しておけば、ログを見ただけで原因の階層が特定できます。

WithValueによる値の受け渡し

WithValueに載せてよいデータの範囲

context.WithValueはcontextに任意のデータを載せますが、公式パッケージは用途を明確に限定しています。リクエストスコープのデータでプロセスやAPIをまたいで運ばれるものに限る、関数への任意引数を渡す手段として使ってはならない、という指定です。リクエストID、認証済みユーザーの識別子、トレース情報あたりが適合します。データベース接続や設定値、ロガーの実体は該当しません。それらは構造体のフィールドや明示的な引数で渡します。

独自キー型による衝突の回避

キーの型にはもう1つ制約があります。公式の記述では、キーは比較可能でなければならず、パッケージ間の衝突を避けるためstring型やその他の組み込み型にすべきではない、とされています。ctx.Value("user_id")のように文字列を直接キーにすると、別のライブラリが同じ文字列を使った瞬間に値が上書きされる事故が起こります。回避策は、パッケージ外から参照できない独自型を定義することです。

type requestIDKey struct{}

func WithRequestID(ctx context.Context, id string) context.Context {
	return context.WithValue(ctx, requestIDKey{}, id)
}

func RequestID(ctx context.Context) (string, bool) {
	id, ok := ctx.Value(requestIDKey{}).(string)
	return id, ok
}

requestIDKeyは非公開の空構造体なので、他パッケージが同名の型を定義しても型が異なるため衝突しません。空構造体を使うのは、インターフェースへ代入する際のメモリ割り当てを避けるためです。取り出し側は型アサーションの成否を必ず確認します。ctx.Valueは該当キーが無ければnilを返すため、確認を省くと実行時パニックになります。

値の取得と設定を上記のような専用関数で包むと、キーの型が外部に漏れず、格納する値の型も1か所に固定できます。呼び出し側にctx.Valueを直接書かせない形にしておくと、キーの追加や型変更が1ファイルの修正で済みます。

Go 1.21で追加されたAPIの使いどころ

context.WithoutCancelcontext.AfterFuncはGo 1.21(2023年8月)で追加されたAPIで、それ以前のバージョンを前提に書かれた資料には載っていません。どちらも実運用で困りやすい場面に正面から効きます。

WithoutCancelでキャンセルだけを切り離す条件

context.WithoutCancel(parent)は、親の値は引き継ぎつつキャンセルだけを切り離したcontextを返します。返されたcontextは締め切りを持たず、Done()はnilチャネルを返し、親がキャンセルされても影響を受けません。context.Causeを呼んでもnilが返ります。

func handler(w http.ResponseWriter, r *http.Request) {
	ctx := r.Context()

	if err := process(ctx); err != nil {
		http.Error(w, err.Error(), http.StatusInternalServerError)
		return
	}

	// リクエストが切れても監査ログは書き切る。リクエストIDは引き継がれる
	logCtx := context.WithoutCancel(ctx)
	go writeAuditLog(logCtx)

	w.WriteHeader(http.StatusOK)
}

クライアント切断で本処理は止めたいが、監査ログの書き込みや後片付けは完了させたい、という要求はよくあります。Go 1.21より前はこの用途のためにcontext.Background()から新しいcontextを作り直す必要があり、リクエストIDなどの値を手作業で詰め直していました。WithoutCancelなら値を保ったまま切り離せます。

AfterFuncによるキャンセル時クリーンアップの登録

context.AfterFunc(ctx, f)は、contextがキャンセルされた後に関数fを専用のゴルーチンで実行するよう登録します。戻り値のstopは登録を解除する関数です。

func serve(ctx context.Context, conn net.Conn) error {
	stop := context.AfterFunc(ctx, func() {
		conn.Close()
	})
	defer stop()

	return handleConn(conn)
}

stopは解除に成功したかどうかをboolで返し、falseならすでにfが起動済みだったことを意味します。ただしstopfの完了を待たない点には注意が必要です。

同じ処理をselectDone()で書こうとすると、監視専用のゴルーチンを立て、正常終了時にそれを確実に終わらせる後始末まで自前で用意することになります。AfterFuncはその定型処理を標準ライブラリ側に寄せたもの。キャンセル時にリソースを閉じるだけの単純な用途なら、記述量も取りこぼしも減らせます。

なおAfterFuncに渡した関数は、登録時点ですでにcontextがキャンセル済みなら即座に、やはり専用のゴルーチンで実行されます。同じcontextに複数登録した場合はそれぞれが独立して並行に走るため、実行順序に依存する書き方は避けてください。これらのAPIが使えるかどうかは処理系のバージョン次第なので、移行前にGoのバージョン確認と移行の手順で使用中のバージョンを確かめておくと移行の手戻りが出ません。

contextを使うべきでない場面

contextは万能ではありません。代表的なのは次の誤用です。

構造体のフィールドに持たせるのは公式が明確に禁じています。パッケージのドキュメントには「Contextを構造体型の中に格納してはならない。代わりに、それを必要とする各関数へ明示的にContextを渡すこと」と書かれています。構造体に持たせると、そのインスタンスを共有する複数のリクエストが同じcontextを見ることになり、片方のキャンセルが無関係な処理を巻き込みます。寿命の異なるものを1つのオブジェクトに同居させているのが原因なので、引数で渡す形に直す以外の回避策はありません。

関数の任意引数の代わりに使うのも避けます。WithValueで設定値やオプションを運ぶと、その関数が何に依存しているかがシグネチャから消え、コンパイラの型チェックも効きません。渡し忘れは実行時のnil参照として初めて表面化します。引数が増えて煩雑なら、オプション構造体を定義すれば、渡し忘れをコンパイラが検出します。

処理の強制停止を期待するのは仕組みの誤解です。WithTimeoutで3秒を設定しても、呼び出した先がctx.Done()を一度も確認しないCPU集約的なループなら、処理は最後まで走り切ります。contextができるのは合図を送るところまでで、止まるかどうかは受け取る側の実装次第です。長いループには反復ごとにctx.Err()を確認する分岐を入れる、外部ライブラリを呼ぶならcontext対応のAPIがあるかを先に調べる、といった対応が要ります。

同じ処理が同時に何度も走るのを抑えたい場合も、contextの担当範囲ではありません。キャッシュ失効時に同一キーへのリクエストが集中する状況では、重複呼び出しを1回にまとめるsingleflightのような専用の仕組みと組み合わせます。

よくある質問

context.TODOとcontext.Backgroundはどちらを使えばよいですか?

実行時の振る舞いは同じで、どちらも空でキャンセルされないcontextを返します。main関数や初期化処理、テストなど起点となる場所ではcontext.Background()を使います。既存コードへの後付けなどで本来渡すべきcontextが決まっていない箇所だけcontext.TODO()を置き、移行完了後に検索して潰すという使い分けです。

cancelを呼ばないと何が起きますか?

親がキャンセル可能なcontextだった場合、その親がキャンセルされるまで子への参照が保持され、メモリの滞留として現れます。WithTimeoutWithDeadlineでは設定時刻までタイマーも残ったままです。go vetのlostcancel検査が、WithCancelなどの戻り値のcancel関数が使われないまま破棄される経路を検出してくれるので、CIに組み込んでおくと取りこぼしを減らせます。

ctx.Err()とcontext.Cause()の違いは何ですか?

ctx.Err()は中断前ならnil、中断後はcontext.Canceledcontext.DeadlineExceededという定型のエラー値を返します。context.Cause(ctx)はGo 1.20で追加された関数で、WithCancelCauseWithTimeoutCauseで指定した具体的な理由を返します。理由を指定していない場合はctx.Err()と同じ値になるという違いです。

contextを構造体のフィールドに持たせてはいけないのはなぜですか?

contextの寿命はリクエストなどの処理単位に紐づくのに対し、構造体のインスタンスは複数の処理で共有されることが多く、寿命が一致しないためです。公式パッケージも構造体への格納を禁じ、必要な関数へ第1引数として明示的に渡すよう指定しています。

WithTimeoutで設定した時間を過ぎても処理が終わらないのはなぜですか?

contextは中断の合図を配るだけで、実行中のゴルーチンを強制終了する機能を持たないためです。呼び出し先がctx.Done()を監視していない、あるいはcontext非対応のAPIを使っていると合図が届きません。selectctx.Done()を受ける、http.NewRequestWithContextのようなcontext対応の関数を使う、といった受け側の実装が必要です。

関連記事

資料請求

RELATED POSTS 関連記事