あまり最近 Go を触っていないのですが、他の言語で開発しているときに時々「Go のキャンセル周りの設計」を思い出すので、この機会に調査して私なりの推測を整理し、まとめました。
概要
context.Context は Go の並行処理で頻繁に出会う概念です。便利な一方で「面倒だ」と言われることもしばしばあります
(チームのルールによって、すべての関数の最初の引数を context.Context にする必要がある場合があります。。。)。
この記事では次を中心に整理します。
- なぜ Go は 強制終了API を提供しないのか
- なぜ context を使ったキャンセルが採用されたのか
- context の仕組み(キャンセルの流れ)と標準ライブラリの使い方、実践的な注意点
Go にはなぜ kill(goroutine) が存在しないのか?
結論から言うと、Rustとは違って、Go のメモリモデル上、安全に強制終了できる保証が不可能:
- ミューテックスを lock 中の goroutine を kill → 永久デッドロック
- 半分だけ書き込まれたデータ構造 → メモリ破壊
- ファイル書き込み・DB トランザクションが中途で止まる
goroutine の強制終了する際、達成したい目標
- goroutine を安全にキャンセルできる ⭐⭐⭐
- 親はキャンセルされたら、子もキャンセル ⭐⭐
- タイムアウト/デッドラインを提供 ⭐
- ライブラリ間で統一できる ⭐⭐
...など
では、panic を使うのはどうでしょう
例えば、下記のようにパニックを出して一応実行を中止して回復できるパタンだと思いますね
func f() {
defer function() {
recover()
}()
panic()
}
Go の実装上「メモリ破壊」レベルの危険は panic + recover では原則起きないでしょう
ですが、下記のようなコードの存在もありえるでしょう
ミューテックス/ロックが解除されない
func cancelableF() {
defer func() { recover() }()
f() // f の内部で panic
}
func f() {
mu.Lock()
g() // panic 発生
mu.Unlock() // 到達しない
}
func g() {
panic("surprice!")
}
なかなか安全とは言えない状況ですね
追記:解決法
func f() {
mu.Lock()
defer mu.Unlock()
g() // panic 発生
}
半分だけ書き込まれたデータ構造
user := User{}
user.Name = "Alice"
panic()
user.Age = 20
panic のスタックアンワインドは高コスト
これは runtime の安全性の他に
- 再帰関数の深いスタック
- goroutine 大量生成
- defer が大量にある
などの状況では panic/recover が高コストで
context cancel の 100〜1000 倍遅くなることもあります。
結果として、強制終了(非協調的な停止)は「安全性」と「予測可能性」を壊しやすいため設計として採用されていません。
Rob Pike が Go の設計 Q&A で次のように述べています:
goroutine の強制終了は安全ではない。
正しい同期を保証できないため、Go は “cooperative cancellation” を採用した。
つまり Go は、ユーザーが「能動的に終了を確認」する方式を選んだわけです
context は何をしているのか(簡単なイメージ)
一言で言えば、Context は Go のチャネルを活用して、キャンセルメッセージを子 goroutine に送る仕組みです。
Go では、エラー処理の慣習として関数の最後の戻り値を error 型にするように、キャンセル可能な関数では第一引数を context.Context インターフェースにするという規約があります。
チャネルの「閉じる」動作を利用した一斉通知
Go のチャネルには、閉じた瞬間、そのチャネルを読み取っているすべての goroutine に通知が届くという特徴があります。
これを利用すると:
送信側(親): close(ch) を呼ぶ
受信側(子): <-ch が即座に返る(ゼロ値を受信)
という形で、明確な値を送らなくても 「終了していいよ」というシグナルを一斉に伝えられます。
Context はこの仕組みを内部で使い、複数の goroutine に対して効率的にキャンセルを伝播させています。
import "context"
func f(ctx context.Context, arg2 int, ...) {
// 子goroutineで処理するのが必須!!!
select {
case <-ctx.Done():
// goroutine を安全に停止させるための、協調的キャンセル
...
default:
...
}
...
子goroutineがそのcontextに提供されているキャンセルチャンネルを利用し
もしchanから「キャンセル」のメッセージが出たら
「マニュアル」で処理を止める仕組みです
Go の Context は「goroutine を安全に停止するための channel + select に基づいた標準化された契約(protocol)」であり、特別な魔法やランタイム機能ではない。
簡略化した実装イメージ:
type cancelCtx struct {
parent Context
done chan struct{} // <- ctx.Done() が返す
mu sync.Mutex
children map[*cancelCtx]struct{}
err error
}
func (c *cancelCtx) cancel(removeFromParent bool, err error) {
c.mu.Lock()
// すでにキャンセル済みなら何もしない
if c.err != nil {
c.mu.Unlock()
return
}
// キャンセル理由を保存
c.err = err
// done channel を close
close(c.done)
// 子 context もすべてキャンセル
for child := range c.children {
child.cancel(false, err)
}
// 親の children リストから削除
if removeFromParent {
removeChild(c.parent, c)
}
c.mu.Unlock()
}
Go の context はイミュータブルで、値やキャンセル設定を追加したい時は
必ず「親から新しい子 context を生成する」仕組みになっています。
- 複数の goroutine が同時に context を共有するため
途中で書き換え可能だと危険 - だから 変更不可(immutable)にして、
新しい機能を付けたい時は常に新しい context を作る という設計になっている。
[ Root Context ]
context.Background()
│
┌─────────────────┴──────────────────┐
│ │
[ctxA: WithCancel] [ctxB: WithDeadline]
│ │
(manual cancel) (auto cancel)
│ │
┌──────┴───────────┐ (child)
│ (none)
[ctxA1: WithTimeout]
│
(3s)
│
[ctxA1v: WithValue]
{ userID: 123 }
標準ライブラリでの扱い方(実例)
context を受け取る関数は「第一引数に context.Context を置く」のが慣習です。以下は典型的な利用パターンです。
コンテキストという名前になっていますが
あまりvalueが使用されてないので
個人的にcancelなどという名前を使ってもいいのかなとも思っています
Go のチャネルは「close するだけで通知になる
import (
"context"
"time"
)
// コンテキストを受け取り、キャンセルされるまでループする処理
func f(c context.Context) {
for {
select {
case <-c.Done():
// 親コンテキストからキャンセル/タイムアウト/デッドラインの通知が届いた
// cleanup(後処理)をここで実行し、終了する
return
default:
// メインのロジックを実行
// (CPU-heavy / IO / goroutine の業務ロジックなど)
}
}
}
// 手動キャンセル用の WithCancel
func fWithCancel(c context.Context) {
// 子コンテキストを作成(ctx がキャンセルされると f も終了する)
ctx, cancel := context.WithCancel(c)
// Go公式推奨:
// “Code should call cancel as soon as the operations running in this Context complete.”
// “Use defer cancel() when possible to guarantee this.”
// → 処理が終わったら必ず cancel() を呼ぶため、defer を使うのがベスト
defer cancel()
// キャンセル可能なコンテキストで goroutine を実行
go f(ctx)
}
// デッドライン(指定した時刻)で停止するコンテキスト
func fWithDeadline(c context.Context) {
// 今から3秒後をデッドラインとして設定
deadline := time.Now().Add(3 * time.Second)
// deadline 到達で Done() が close するコンテキスト
ctx, cancel := context.WithDeadline(c, deadline)
defer cancel() // 必ず cleanup として cancel を呼ぶ
go f(ctx)
}
// タイムアウト(相対時間)で停止するコンテキスト
func fWithTimeOut(c context.Context) {
// 5秒後に Done() が close されるコンテキスト
ctx, cancel := context.WithTimeout(c, 5*time.Second)
defer cancel() // ここも同様にキャンセルを必ず呼ぶ
go f(ctx)
}
実践的な注意点・ベストプラクティス
- context は関数の第一引数に
func Foo(ctx context.Context, ...) の形にするのが慣習です。 - context を構造体に保存しない
context はリクエストスコープや操作スコープの情報を保持するためのもの。長期保持すると誤用につながります。 - defer cancel() を忘れない
WithCancel/WithTimeout/WithDeadline を使うときは defer cancel() を使ってリソースリークを防ぐ。
まとめ
- Go が kill(goroutine) を提供しないのは「安全に強制終了できない」ため。強制終了はデッドロックやデータ破壊を引き起こしやすい。
- 代わりに採られたのが「協調的キャンセル」で、context はそのための標準化された 契約 であり、内部的にはチャネルを使用し、子goroutineへ通知。
- context は「こういうふうに書いてくださいね」という 慣習的なルール であり、実際にチャネルを直接活用しても同じ機能は実現できる。ただし、context を使うことで標準化され、コードの一貫性と可読性が向上する。