フューチャー技術ブログ

Go1.20リリース連載 contextパッケージのWithCancelCauseとCause

はじめに

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にサンプルコードも書かれています。

GoDocのWithCancelCauseサンプルコード(go 1.20rc3時点なので荒い)
ctx, cancel := context.WithCancelCause(parent)
cancel(myError)
ctx.Err() // returns context.Canceled
context.Cause(ctx) // returns myError

ツイートで発表された際の反応を見ると、喜びの声が多数でした。

プロポーサルは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() 関数でキャンセルしたかどうかを判定したいとします。

Go1.19以前での実装例
package main

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

func main() {
ctx, cancel := context.WithCancel(context.Background())
time.AfterFunc(10*time.Second, func() { cancel() }) // 全体で10秒まで
// ... 何かしらの前処理など
go doHeavyTask(ctx)
// ...
}

func doHeavyTask(ctx context.Context) {
ctx, cancel := context.WithCancel(ctx)
time.AfterFunc(5*time.Second, func() { cancel() }) // doHeavyTask関数で最大5秒まで
// ... 何かしらの処理
callHeavyWebAPI(ctx)
// ... 何かしらの処理
}

func callHeavyWebAPI(ctx context.Context) {
ctx, cancel := context.WithCancel(ctx)
time.AfterFunc(3*time.Second, func() { cancel() }) // callHeavyWebAPI関数で最大3秒まで

for {
select {
case <-ctx.Done():
switch ctx.Err() {
case context.DeadlineExceeded:
fmt.Println("context timeout exceeded")
case context.Canceled:
fmt.Println("context canceled") // ★どこでキャンセルされた?😭
}
return
default:
// ...外部API呼び出しなどの処理
}
}
}

最後のselect の部分の実装のように、キャンセルしたかは ctx.Err() を見ることで判断できます。しかし、main(), doHeavyTask(), callHeavyWebAPI() 関数それぞれで設定されたキャンセルのどれが直接の原因かは判断が付きません。

回避方法としては、キャンセル理由を連携するための channel を引き回すことを検討するなどが考えられますが、けっこう大変そうです。

contextパッケージにWithCancelCauseとCauseを追加

これを解決する方法として、context.WithCancelCause()Cause() 関数を利用します。context.WithCancelCause()はほぼ WithCancel()と使い勝手が同じですが、CancelCauseFunc(cause error) と引数に causeを取る部分が異なります。ここに error を渡すと何が理由でcontextがキャンセルされたか分かります。

Go1.20以降の実装例
package main

import (
"context"
"errors"
"fmt"
"time"
)

+var (
+ ErrTimeoutOuter = errors.New("outer timeout")
+ ErrTimeoutMiddle = errors.New("middle timeout")
+ ErrTimeoutInner = errors.New("inner timeout")
+)

func main() {
ctx, cancel := context.WithCancelCause(context.Background())
- time.AfterFunc(10*time.Second, func() { cancel() }) // 全体で10秒まで
+ time.AfterFunc(10*time.Second, func() { cancel(ErrTimeoutOuter) }) // 全体で10秒まで
// ... 何かしらの前処理など
go doHeavyTask(ctx)
// ...
}

func doHeavyTask(ctx context.Context) {
ctx, cancel := context.WithCancelCause(ctx)
- time.AfterFunc(5*time.Second, func() { cancel() }) // doHeavyTask関数で最大5秒まで
+ time.AfterFunc(5*time.Second, func() { cancel(ErrTimeoutMiddle) }) // doHeavyTask関数で最大5秒まで
// ... 何かしらの処理
callHeavyWebAPI(ctx)
// ... 何かしらの処理
}

func callHeavyWebAPI(ctx context.Context) {
ctx, cancel := context.WithCancelCause(ctx)
- time.AfterFunc(3*time.Second, func() { cancel() }) // callHeavyWebAPI関数で最大3秒まで
+ time.AfterFunc(3*time.Second, func() { cancel(ErrTimeoutInner) }) // callHeavyWebAPI関数で最大3秒まで

for {
select {
case <-ctx.Done():
switch ctx.Err() {
case context.DeadlineExceeded:
fmt.Println("context timeout exceeded")
case context.Canceled:
- fmt.Println("context canceled") // ★どこでキャンセルされた?😭
+ switch context.Cause(ctx) {
+ case ErrTimeoutOuter:
+ fmt.Println("mainのタイムアウトによりキャンセル")
+ case ErrTimeoutMiddle:
+ fmt.Println("doHeavyTaskのタイムアウトによりキャンセル")
+ case ErrTimeoutInner:
+ fmt.Println("callHeavyTaskのタイムアウトによりキャンセル")
+ default:
+ fmt.Println("その他のキャンセル")
}
}
return
default:
// ...外部API呼び出しなどの処理
}
}
}

重要な考え方として、cancel(err) を設定しても、ctx.Err()の値は変わりません。ctx.Err() は従来どおり、context.DeadlineExceededcontext.Canceled が取得できます。つまり互換性が保たれています。エラー理由をトレースしたい場合のみ、context.Cause(ctx) を呼び出します。最初は使い分けなんだろうとか、やや面倒だなと思いましたが、考えてみると順当な判断です。

  • ctx.Err()
    • Doneが未設定の場合、nil を返す
    • Doneが設定されたら、context.Canceledcontext.DeadlineExceeded を返す
  • context.Cause(ctx)
    • ユーザーが設定した独自の error を返す。設定した場合、ctx.Err()context.Canceled を返す

これから新規にハンドリングしたい人は、 ctx.Err() を用いず、一気に context.Cause(ctx) を使っても良いかもしれません。

Cause()ですが、以下のように context.Context のインタフェースにCause()といった関数を追加してくれた方が利用者としては便利じゃないかと思いますよね。これはGo1互換性ポリシーに書いてあるように、パッケージエクスポートされたインタフェースに新しい関数を追加することは許可されてないということで否定されていました(そのため、context.Contextを引数にとる現在のかたちで提供されています)。

互換性をぶっ壊すAPIイメージ
type Context interface {
Deadline() (deadline time.Time, ok bool)
Done() <-chan struct{}
Err() error
Value(key any) any
+ Cause() error // ★後方互換性を壊すためインターフェースに新規関数の追加はできない
}

少しだけ惜しい気もしますが、すぐに覚えられるレベルかなと思います。

2023.1.30 追記:

このあたりの互換性を保ったAPI提供については、syumaiさんのライブラリとして公開したGoのinterfaceを変更するのは難しいと言う話 - 焼売飯店 が詳しいです!

使い方について

追加された関数について、パッとどのような挙動になるか確信が持てなかった3ケースを動かしてみます。

1. cancel(nil) を呼んだら?

結論→ context.Cause(ctx)context.Canceledを返します。少し意外な結果に思うかもしれません。

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

fmt.Println(ctx.Err() == context.Canceled) // true
fmt.Println(context.Cause(ctx) == context.Canceled) // true
}

nil を返さないことで、ctx.Err() を用いず context.Cause()context のハンドリングができるようするための理由だと思います。

2回呼んだらどうなる?

結論→ 最初に設定された cause が常に取得される。

func main() {
ctx, cancel := context.WithCancelCause(context.Background())
cancel(errors.New("1. timeout"))
cancel(errors.New("2. connection dropped"))

fmt.Println(context.Cause(ctx)) // 1. timeout
fmt.Println(context.Cause(ctx)) // 1. timeout
}

3. 親子contextでそれぞれcancelさせた場合

例えば、下記のように親子contextを作成し、親→子の順番でキャンセルさせました。

func main() {
parentCtx, parentCancel := context.WithCancelCause(context.Background())
childCtx, childCancel := context.WithCancelCause(parentCtx)

parentCancel(errors.New("parent timeout"))
childCancel(errors.New("child timeout"))

fmt.Println(context.Cause(parentCtx)) // parent timeout
fmt.Println(context.Cause(childCtx)) // parent timeout
}

この例の場合は、親のキャンセル内容が優先されるます。 parentCancelchildCancel の呼び出し位置を変えてみると出力が変わるので(基本は子は親に影響しない。親が先にキャンセルしていたら、子はそれを引き継ぐ)、動かしてみると良いかなと思います。

まとめ

  • 従来では、特に親子関係を持ったcontextでそれぞれキャンセルが発生しうるときに切り分けが難しかったが、Go 1.20 から追加された、 WithCancelCause()context.Cause() で解決でき、どこでキャンセルされたんだ問題を解決に導いてくれる
  • インタフェースは context.Context への関数追加ではなく、context パッケージへのヘルパー関数である

次は川口さんのWrapping multiple errorsです。