はじめに
TIG真野です。Go 1.20リリース連載の2本目は、Minor changes to the libraryの context についてです。Go 1.7で context.Context
が入ってから、context界隈において久しぶりのアップデートです。
contextの歴史やダイジェストは、澁川さんのGo 1.16のsignal.NotifyContext() 記事に書かれていますので、ぜひ確認ください。context自体にの説明は、さき(H.Saki)さんの よくわかるcontextの使い方というZenn Bookを読むとすごく詳しくなれると思います。
リリースノートには、WithCancelCause()
と Cause()
関数が追加され、キャンセル理由を追加・取得できるようにするという内容です。なぜか2023.1.22時点(go 1.20rc3)だとGoDocにはWithDeadlineCause()
・WithTimeoutCause()
があるものの実装は無く、context: add APIs for writing and reading cancelation cause #51365 を見る限り、Go 1.21で追加されそうです。
Go 1.20リリースノートより(2023.1.22 DRAFT RELEASE NOTESより)
The new WithCancelCause function provides a way to cancel a context with a given error. That error can be retrieved by calling the new Cause function.
新しく追加されたWithCancelCause関数はerror付きでcontextをキャンセルする方法を提供します。errorは新しく追加したCause関数を呼び出すことで取得できます。
GoDocのcontextパッケージのfunc WithCancelCauseにサンプルコードも書かれています。
ctx, cancel := context.WithCancelCause(parent) |
ツイートで発表された際の反応を見ると、喜びの声が多数でした。
Probably in Go 1.20:
— inanc (@inancgumus) December 3, 2022
context.WithCancelCause 🎉
ctx.Err() will return why a context is canceled if that context is derived with a cancel cause. Instead of mere `Canceled` and `DeadlineExceeded` errors.
Proposal → https://t.co/H1jMbp5HGM#golang pic.twitter.com/bRFFXB1DFx
プロポーサルはcontext: add APIs for writing and reading cancelation cause #51365 です。起票が2022.2.26ですので、これだけ見れば10ヶ月ほどで入ったように見えます。実際はそれ以前にも似たような議論があり、例えばcontext: ease debugging of where a context was canceled? #26356 は2018年に、proposal: context: add WithCancelReason #46273は2021年に起票されています。#26356や#46273にて条件、対応案、プロトタイプの実装などが整理されたことがあってこそ実現できたスピード感じゃないかと思います。
コンテキストがどこでキャンセルされたかデバッグ難しい問題
Go 1.20より前の時代では、contextのキャンセルでよく上がる課題に、コンテキストのキャンセルがどこで行われたか切り分けしにくいことがあります。「Who the hell canceled my context?(だれが私のcontextをキャンセルしたか?)」と冗談混じりでの悩みをちょくちょく聞きます。
例えば、下記のように多段にcontextにタイムアウトを設定するケースです。仮に一番下流の callHeavyWebAPI()
関数でキャンセルしたかどうかを判定したいとします。
package main |
最後のselect
の部分の実装のように、キャンセルしたかは ctx.Err()
を見ることで判断できます。しかし、main()
, doHeavyTask()
, callHeavyWebAPI()
関数それぞれで設定されたキャンセルのどれが直接の原因かは判断が付きません。
回避方法としては、キャンセル理由を連携するための channel
を引き回すことを検討するなどが考えられますが、けっこう大変そうです。
contextパッケージにWithCancelCauseとCauseを追加
これを解決する方法として、context.WithCancelCause()
と Cause()
関数を利用します。context.WithCancelCause()
はほぼ WithCancel()
と使い勝手が同じですが、CancelCauseFunc(cause error)
と引数に cause
を取る部分が異なります。ここに error
を渡すと何が理由でcontext
がキャンセルされたか分かります。
package main |
重要な考え方として、cancel(err)
を設定しても、ctx.Err()
の値は変わりません。ctx.Err()
は従来どおり、context.DeadlineExceeded
や context.Canceled
が取得できます。つまり互換性が保たれています。エラー理由をトレースしたい場合のみ、context.Cause(ctx)
を呼び出します。最初は使い分けなんだろうとか、やや面倒だなと思いましたが、考えてみると順当な判断です。
ctx.Err()
- Doneが未設定の場合、
nil
を返す - Doneが設定されたら、
context.Canceled
かcontext.DeadlineExceeded
を返す
- Doneが未設定の場合、
context.Cause(ctx)
- ユーザーが設定した独自の
error
を返す。設定した場合、ctx.Err()
はcontext.Canceled
を返す
- ユーザーが設定した独自の
これから新規にハンドリングしたい人は、 ctx.Err()
を用いず、一気に context.Cause(ctx)
を使っても良いかもしれません。
Cause()
ですが、以下のように context.Context
のインタフェースにCause()
といった関数を追加してくれた方が利用者としては便利じゃないかと思いますよね。これはGo1互換性ポリシーに書いてあるように、パッケージエクスポートされたインタフェースに新しい関数を追加することは許可されてないということで否定されていました(そのため、context.Contextを引数にとる現在のかたちで提供されています)。
type Context interface { |
少しだけ惜しい気もしますが、すぐに覚えられるレベルかなと思います。
2023.1.30 追記:
このあたりの互換性を保ったAPI提供については、syumaiさんのライブラリとして公開したGoのinterfaceを変更するのは難しいと言う話 - 焼売飯店 が詳しいです!
使い方について
追加された関数について、パッとどのような挙動になるか確信が持てなかった3ケースを動かしてみます。
1. cancel(nil) を呼んだら?
結論→ context.Cause(ctx)
が context.Canceled
を返します。少し意外な結果に思うかもしれません。
func main() { |
nil
を返さないことで、ctx.Err()
を用いず context.Cause()
で context
のハンドリングができるようするための理由だと思います。
2回呼んだらどうなる?
結論→ 最初に設定された cause
が常に取得される。
func main() { |
3. 親子contextでそれぞれcancel
させた場合
例えば、下記のように親子contextを作成し、親→子の順番でキャンセルさせました。
func main() { |
この例の場合は、親のキャンセル内容が優先されるます。 parentCancel
、childCancel
の呼び出し位置を変えてみると出力が変わるので(基本は子は親に影響しない。親が先にキャンセルしていたら、子はそれを引き継ぐ)、動かしてみると良いかなと思います。
まとめ
- 従来では、特に親子関係を持った
context
でそれぞれキャンセルが発生しうるときに切り分けが難しかったが、Go 1.20 から追加された、WithCancelCause()
とcontext.Cause()
で解決でき、どこでキャンセルされたんだ問題を解決に導いてくれる - インタフェースは
context.Context
への関数追加ではなく、context
パッケージへのヘルパー関数である
次は川口さんのWrapping multiple errorsです。