10行で書くタイムアウト
context.Context の役目は、いつ止めるべきかをコードに伝えることです。ここでは遅い操作に50ミリ秒を与え、コンテキストがそう言ったらあきらめさせています。
1回目の呼び出しは10ミリ秒で終わり、rows <nil> を返します。2回目は200ミリ秒必要ですが、コンテキストは(作られた時点から数えて)50ミリ秒で期限切れになるので、context deadline exceeded を返します。
何も強制的に止められるわけではありません。Goには外からゴルーチンを殺す方法がありません。コンテキストは合図であり、コードがそれをチェックする必要があります。ctx.Done() をselectする、手順の合間に ctx.Err() をチェックする、あるいはチェックしてくれるライブラリの呼び出し(http.NewRequestWithContext、db.QueryContext、exec.CommandContext)に ctx を渡す、といった方法です。
Contextインターフェース
type Context interface {
Deadline() (deadline time.Time, ok bool)
Done() <-chan struct{}
Err() error
Value(key any) any
}
| メソッド | 返すもの |
|---|---|
Done() | コンテキストがキャンセルされるかタイムアウトしたときに閉じられるチャネル(決してキャンセルされないコンテキストでは nil) |
Err() | 有効な間は nil、その後は context.Canceled か context.DeadlineExceeded |
Deadline() | 期限と true、期限がなければ ok == false |
Value(key) | このコンテキストか祖先で key の下に保存された値、なければ nil |
コンテキストは変更できません。変更するのではなく、With 関数のどれかで子を派生させ、子がキャンセルの合図、期限、値を追加します。
コンテキストの出どころ
どのコンテキストのツリーもルートから始まります。
context.Background()はmain、init、テスト、サーバーのトップレベルの準備処理のためのものです。context.TODO()は、関数がコンテキストを受け取るべきなのに呼び出し側がまだ持っていないときに使います。Backgroundとまったく同じように振る舞い、名前は後でリファクタリングするための目印です。
HTTPハンドラの中では、ルートを作りません。r.Context() を使います。これはクライアントが切断したときやハンドラが戻ったときにサーバーがキャンセルします。
WithCancel:必要なときに止める
context.WithCancel は子のコンテキストと cancel 関数を返します。cancel を呼ぶと、子の Done チャネルと、そこから派生したすべての Done チャネルが閉じられます。
生産者の送信は ctx.Done() と並んで select の中にあります。それが止まれる理由です。単独の out <- i だと、消費者が読むのをやめた途端に永遠にブロックし、ゴルーチンがリークします。最後の for range nums は生産者がチャネルを閉じるまで待ちます。これが空にしている間に、生産者が値を1つか2つ送れてしまうこともあります。select の両方のケースが準備できているとき、Goはランダムに1つを選ぶからです。キャンセルは速やかですが、即座ではありません。
cancel は何度呼んでも、どのゴルーチンから呼んでも安全です。何かをするのは最初の呼び出しだけです。
WithTimeoutとWithDeadline
WithTimeout(parent, d) は WithDeadline(parent, time.Now().Add(d)) と同じです。「長くてもこれだけ」にはタイムアウトを、絶対的な時刻があるときは期限を使います。
時間が過ぎると Done が閉じられ、Err は context.DeadlineExceeded を返します。先に cancel が呼ばれれば、Err は context.Canceled を返します。ライブラリはたいていエラーをラップするので、どちらかは errors.Is でチェックします。
cancel は必ず呼びます。 自然に発火するタイムアウトであってもです。コンテキストは、どちらかが起きるまでタイマーと親の中の枠を保持しており、defer cancel() は関数が戻った時点で両方を解放します。go vet は捨てられたcancel関数を報告します:the cancel function returned by context.WithTimeout should be called, not discarded, to avoid a context leak。
子は親より長生きできない
コンテキストはツリーを作ります。親をキャンセルすると、すべての子孫がキャンセルされます。子は親より短い期限を持てますが、長い期限は持てません。常に早いほうの期限が勝ちます。
これがコンテキストを層をまたいで役立つものにしています。HTTPハンドラはリクエストとともに終わるコンテキストを受け取り、3層下のデータベースの呼び出しはそこから2秒のタイムアウトを派生させます。クライアントが100ミリ秒後に切断すれば、クエリは2秒後ではなくその時点でキャンセルされます。
ブロックするときは必ずctx.Done()をselectする
待つゴルーチン(チャネルの送信、受信、タイマー)はすべて、同時に ctx.Done() も待つべきです。決してブロックしないCPUバウンドのループでは、ときどき ctx.Err() をチェックします。
for i, item := range items {
if i%1000 == 0 {
if err := ctx.Err(); err != nil {
return err
}
}
process(item)
}
単純な待機には select の中で time.After を使えますが、待機が頻繁にキャンセルされうるなら、止められるタイマー(またはコンテキストのタイムアウト)を使いましょう。
キャンセルの原因(Go 1.20と1.21)
ctx.Err() が伝えるのは canceled か deadline exceeded だけです。理由を記録するには Cause 版を使います。
WithCancelCause はGo 1.20で、WithTimeoutCause と WithDeadlineCause はGo 1.21で入りました。既存のチェックが動き続けるよう Err は標準の値を返し続け、詳細は context.Cause が教えてくれます。
WithValueは控えめに
context.WithValue(parent, key, value) は値を1つ付け加えます。ctx.Value(key) は親の連鎖をたどってそれを探します。
値のルールは次のとおりです。
- キーには、普通の
stringではなく非公開の型を使います。どちらも"user"を使う2つのパッケージは互いを上書きしてしまいます(go vetはこれを検出しませんが、staticcheckは検出します)。 - 呼び出し側が
anyやキーを目にしなくて済むよう、WithRequestIDやRequestIDのような型付きのヘルパー関数でアクセスを包みます。 - 保存するのは、APIを通過するリクエストスコープのデータだけにします。トレースIDやリクエストID、認証済みのユーザー、ロガーなどです。省略可能な引数、データベースのハンドル、設定は決して入れません。それらは、コンパイラがチェックでき読む人にも見える、関数の引数や構造体のフィールドに置きます。
- 検索は親を1つずつたどるので、値を追加するたびに他の値の検索が1ステップ長くなります。
慣習
- I/Oをしたり、ブロックしたり、そうするものを呼んだりする関数では、
ctx context.Contextを 最初の引数 にします:func Fetch(ctx context.Context, url string) error。 - コンテキストを構造体に保存しない。 メソッドの呼び出しごとに渡します。コンテキストは1つの操作に属し、構造体はたいていそれより長く生きます(例外は
http.Requestのように1つの操作を表す型です)。 - コンテキストとして 決して
nilを渡さない。他にないならcontext.TODO()を使います。 - コンテキストが理由で止まるときは
ctx.Err()を返すか、%wでラップします。そうすれば呼び出し側はタイムアウトと本当の失敗を区別できます。
HTTPのサーバーとクライアントでのcontext
サーバー側:r.Context() は、クライアントが切断したとき、ハンドラが戻ったとき、HTTP/2のストリームがリセットされたときにキャンセルされます。クライアント側:http.NewRequestWithContext で、リクエストがタイムアウトやキャンセルを尊重するようになります。このプログラムは httptest で両端を動かします。
クライアントは50ミリ秒であきらめて接続を閉じます。サーバーはそれに気づき、リクエストのコンテキストがキャンセルされ、ハンドラは誰も読まないレポートにさらに450ミリ秒を費やさずに止まります。実際のハンドラでは、r.Context() をすべてのデータベースやHTTPの呼び出しに渡し、それらがまとめて止まるようにします。
その他のヘルパー(Go 1.21)
context.WithoutCancel(ctx)は、同じ値を持ちつつctxがキャンセルされてもキャンセルされないコンテキストを返します。監査ログの書き込みのように、リクエストが終わった後にも完了させなければならない処理に使います。context.AfterFunc(ctx, f)は、ctxが終わったら専用のゴルーチンでfを実行し、登録を取り消すためのstop関数を返します。
よくある間違い
cancelを呼ばない。WithCancel、WithTimeout、WithDeadlineの直後に必ずdefer cancel()とします。ctxを無視するゴルーチンを開始する。ctx.Done()をselectせずにブロックすると、キャンセルしても何も起きず、ゴルーチンがリークします。- 呼び出しの連鎖の深いところで新しい
context.Background()を作る。 呼び出し側の期限やキャンセルとのつながりが切れます。受け取ったctxを渡します。 - エラーを
==で比較する。 ほとんどのライブラリはラップするので、errors.Is(err, context.DeadlineExceeded)を使います。 - 依存関係に
WithValueを使う。 コンテキストに隠したデータベースのハンドルは、コンパイラがもうチェックできない引数です。 - キャンセルが即座だと思う。 コードが気づくのは次のチェックのときだけです。チェックのない長いループは動き続けます。
よくある質問
Goのcontextは何に使いますか?
context.Context は、関数とそれが呼ぶすべてのものに、いつあきらめるべきかを伝えます。呼び出し側がキャンセルしたから、期限が過ぎたから、クライアントが切断したから、といった理由です。リクエストIDのようなリクエストスコープの値を運ぶこともできます。慣習として、最初の引数に ctx という名前で置きます。
context.Backgroundとcontext.TODOの違いは何ですか?
どちらも、決してキャンセルされず、期限も値も持たない空のコンテキストを返します。振る舞いはまったく同じです。Background() は main、テスト、トップレベルの準備処理のためのルートです。TODO() は、本来はちゃんとしたコンテキストを渡すべきなのに周りのコードがまだそれを持っていない場所の目印で、後で見つけやすくするためのものです。
なぜcontext.WithTimeoutの後にcancelを呼ばなければならないのですか?
WithTimeout、WithDeadline、WithCancel は新しいコンテキストを親に登録し、タイマーを開始することもあります。cancel を呼ぶと、タイムアウトが発火したり親がキャンセルされたりするのを待たず、用が済んだ時点でそれらのリソースが解放されます。作った直後に defer cancel() と書きましょう。go vet はcancel関数が捨てられていると警告します。
Goの「context deadline exceeded」とはどういう意味ですか?
コンテキストの期限が過ぎた後に ctx.Err() が返すエラー、context.DeadlineExceeded のテキストです。HTTPクライアントやデータベースドライバのようにコンテキストを尊重する関数は、時間切れになるとこれを(たいていはラップして)返します。errors.Is(err, context.DeadlineExceeded) でチェックします。
引数を渡すのにcontext.WithValueを使うべきですか?
いいえ。トレースIDや認証済みのユーザーのように、APIの境界をまたぎ、途中の関数が知る必要のないリクエストスコープのデータにだけ使います。関数が仕事をするのに必要なものは、コンパイラがチェックできる引数に置きます。