【Go言語】context.Context を比較してみる
Go言語を使用の皆さん、 context
パッケージは使用していますか?
context
パッケージは、締切・キャンセル信号の伝播や値の受け渡しを行うことができる型 context.Context
の提供を行っており、並行処理の扱いにおいて欠かせない機能です。
そんな context.Context
ですが、Go v1.21 リリースノート にてこんな記述がなされています。
An optimization means that the results of calling Background and TODO and converting them to a shared type can be considered equal. In previous releases they were always different. Comparing Context values for equality has never been well-defined, so this is not considered to be an incompatible change.
「context の仕様を変更していますが、比較について well-defined ではないので破壊的変更ではない」と述べています。
では、context を比較するとどうなるのでしょうか? 本記事では、context の比較をしてみながらその仕様について深掘りします。
本記事では context の基本的な使い方を知っていることを前提とします。
知らない方は、以下の記事などを参照していただければと思います。
なお、本記事では go v1.22.4 を前提としています。
context.Context
について
conetxt 同士を比較する前に、軽く context.Context
について触れておきます。
context.Context
概略
context.Context
は、以下の4つのメソッドを持つインターフェースです。
type Context interface {
Deadline() (deadline time.Time, ok bool)
Done() <-chan struct{}
Err() error
Value(key any) any
}
各々の意味は省略しますが、 context
パッケージは context.Context
のインターフェースを満たす構造体(総称して context と呼びます)を様々な用途に合わせて生成することができます。
また、ほとんどの context (子 context)は、生成時に 別の context (親 context)を指定します。このとき、
- 子 context は親 context の機能を引き継ぐ
- 親 context がキャンセルされたき、子 context もキャンセルされる(キャンセル信号の伝播)
といった性質を持ちます。
キャンセルされた context は Done()
チャンネルを閉じるので、以下のように select-case 文でキャンセルを検出できます。
select {
case <- ctx.Done():
// キャンセルされた際の処理
...
}
context 生成方法
context
パッケージは、以下の生成方法を提供しています。
生成方法 | 生成した context の機能 | キャンセル信号の伝播 |
---|---|---|
ctx := context.Background() ctx := context.TODO()
|
何も起こらない。キャンセルも発生しない | × |
ctx := context.WithValue(parentCtx, key, value) |
親 context の機能を引き継ぎつつ、 ctx.Value(key) = value として値を取り出せる |
○ |
ctx := context.WithoutCancel(parentCtx) |
親 context の機能を引き継ぎつつ、親からのキャンセル信号を伝播しない | × |
ctx, cancel := context.WithCancel(parentCtx) ctx, cancel := context.WithCancelCause(parentCtx)
|
親 context の機能を引き継ぎつつ、 cancel() または cancelCause(err) によって自身をキャンセルする |
○ |
ctx, cancel := context.WithDeadline(parentCtx, deadline) ctx, cancel := context.WithDeadlineCause(parentCtx, deadline)
|
親 context の機能を引き継ぎつつ、現在時刻が duration を過ぎたら自身をキャンセルする(締切を設定する) |
○ |
ctx, cancel := context.WithTimeout(parentCtx, timeout) ctx, cancel := context.WithTimeoutCause()
|
親 context の機能を引き継ぎつつ、 context 生成時から timeout 以上経過すると自身をキャンセルする |
○ |
なお、各生成方法によって生成される context は、内部では全て異なる型として実装されています。
いろんな context を比較してみる
さて、本題の context 比較をしてみましょう。
異なる型同士での比較
比べるまでもないかもしれませんが、同じ機能を持つが異なる生成方法で作成したもの同士を比較してみるとどうなるか試してみましょう。
package main
import (
"context"
"errors"
"fmt"
"time"
)
func main() {
// 機能:何も起こらない。キャンセルも発生しない
ctxBackground := context.Background()
ctxTODO := context.TODO()
fmt.Printf("context.Background() == context.TODO() : %t\n", ctxBackground == ctxTODO)
// 機能:キャンセルを発生させる
ctxWithCancel, cancel := context.WithCancel(ctxBackground)
defer cancel()
ctxWithCancelCause, cancelCause := context.WithCancelCause(ctxBackground)
defer cancelCause(errors.New("cancel"))
fmt.Printf("context.WithCancel() == context.WithCancelCause() : %t\n", ctxWithCancel == ctxWithCancelCause)
// 機能:context に締切を設定する
ctxWithDeadline, cancel := context.WithDeadline(ctxBackground, time.Now().Add(10*time.Second))
defer cancel()
ctxWithDeadlineCause, cancel := context.WithDeadlineCause(ctxBackground, time.Now().Add(10*time.Second), errors.New("deadline"))
defer cancel()
fmt.Printf("context.WithDeadline() == context.WithDeadlineCause() : %t\n", ctxWithDeadline == ctxWithDeadlineCause)
// 機能:context にタイムアウトを設定する
ctxWithTimeout, cancel := context.WithTimeout(ctxBackground, 10*time.Second)
defer cancel()
ctxWithTimeoutCause, cancel := context.WithTimeoutCause(ctxBackground, 10*time.Second, errors.New("timeout"))
defer cancel()
fmt.Printf("context.WithTimeout() == context.WithTimeoutCause() : %t\n", ctxWithTimeout == ctxWithTimeoutCause)
}
実行結果は以下の通りです。
% go run main.go
context.Background() == context.TODO() : false
context.WithCancel() == context.WithCancelCause() : false
context.WithDeadline() == context.WithDeadlineCause() : false
context.WithTimeout() == context.WithTimeoutCause() : false
もちろん全て false になります。そもそもが異なる型だからですね。
同一の生成方法同士での比較
では、同じ型・同じ生成方法、すなわち、context 生成メソッドに同一の値を設定した同士で比較する場合はどうなるでしょうか?
package main
import (
"context"
"errors"
"fmt"
"time"
)
type Key string
const key = Key("key")
func main() {
ctxBackground1 := context.Background()
ctxBackground2 := context.Background()
fmt.Printf("context.Background() == context.Background() : %t\n", ctxBackground1 == ctxBackground2)
ctxTODO1 := context.TODO()
ctxTODO2 := context.TODO()
fmt.Printf("context.TODO() == context.TODO() : %t\n", ctxTODO1 == ctxTODO2)
ctxWithValue1 := context.WithValue(ctxBackground1, key, "value")
ctxWithValue2 := context.WithValue(ctxBackground1, key, "value")
fmt.Printf("context.WithValue() == context.WithValue() : %t\n", ctxWithValue1 == ctxWithValue2)
ctxWithoutCancel1 := context.WithoutCancel(ctxBackground1)
ctxWithoutCancel2 := context.WithoutCancel(ctxBackground1)
fmt.Printf("context.WithoutCancel() == context.WithoutCancel() : %t\n", ctxWithoutCancel1 == ctxWithoutCancel2)
ctxWithCancel1, cancel := context.WithCancel(ctxBackground1)
defer cancel()
ctxWithCancel2, cancel := context.WithCancel(ctxBackground1)
defer cancel()
fmt.Printf("context.WithCancel() == context.WithCancel() : %t\n", ctxWithCancel1 == ctxWithCancel2)
cancelErr := errors.New("cancel")
ctxWithCancelCause1, cancelCause := context.WithCancelCause(ctxBackground1)
defer cancelCause(cancelErr)
ctxWithCancelCause2, cancelCause := context.WithCancelCause(ctxBackground1)
defer cancelCause(cancelErr)
fmt.Printf("context.WithCancelCause() == context.WithCancelCause() : %t\n", ctxWithCancelCause1 == ctxWithCancelCause2)
deadline := time.Now().Add(10 * time.Second)
ctxWithDeadline1, cancel := context.WithDeadline(ctxBackground1, deadline)
defer cancel()
ctxWithDeadline2, cancel := context.WithDeadline(ctxBackground1, deadline)
defer cancel()
fmt.Printf("context.WithDeadline() == context.WithDeadline() : %t\n", ctxWithDeadline1 == ctxWithDeadline2)
cancelDeadline := errors.New("deadline")
ctxWithDeadlineCause1, cancel := context.WithDeadlineCause(ctxBackground1, deadline, cancelDeadline)
defer cancel()
ctxWithDeadlineCause2, cancel := context.WithDeadlineCause(ctxBackground1, deadline, cancelDeadline)
defer cancel()
fmt.Printf("context.WithDeadlineCause() == context.WithDeadlineCause() : %t\n", ctxWithDeadlineCause1 == ctxWithDeadlineCause2)
timeout := 10 * time.Second
ctxWithTimeout1, cancel := context.WithTimeout(ctxBackground1, timeout)
defer cancel()
ctxWithTimeout2, cancel := context.WithTimeout(ctxBackground1, timeout)
defer cancel()
fmt.Printf("context.WithTimeout() == context.WithTimeout() : %t\n", ctxWithTimeout1 == ctxWithTimeout2)
cancelTimeout := errors.New("timeout")
ctxWithTimeoutCause1, cancel := context.WithTimeoutCause(ctxBackground1, timeout, cancelTimeout)
defer cancel()
ctxWithTimeoutCause2, cancel := context.WithTimeoutCause(ctxBackground1, timeout, cancelTimeout)
defer cancel()
fmt.Printf("context.WithTimeoutCause() == context.WithTimeoutCause() : %t\n", ctxWithTimeoutCause1 == ctxWithTimeoutCause2)
}
実行結果は以下の通りです。
% go run main.go
context.Background() == context.Background() : true
context.TODO() == context.TODO() : true
context.WithValue() == context.WithValue() : false
context.WithoutCancel() == context.WithoutCancel() : true
context.WithCancel() == context.WithCancel() : false
context.WithCancelCause() == context.WithCancelCause() : false
context.WithDeadline() == context.WithDeadline() : false
context.WithDeadlineCause() == context.WithDeadlineCause() : false
context.WithTimeout() == context.WithTimeout() : false
context.WithTimeoutCause() == context.WithTimeoutCause() : false
なんと、生成方法によって true・false が別れました。
true になったのは以下の3つです。
context.Background()
context.TODO()
context.WithoutCancel()
context 比較結果について
同一の生成方法で結果が分かれた理由
なぜ、生成方法によって比較の結果が分かれたのでしょうか?
context
パッケージのソースコード src/context/context.go をみると、その理由がわかります。
代表例として、 context.WithoutCancel()
と context.WithCancel()
のソースコードの一部を見てみましょう。
func WithoutCancel(parent Context) Context {
if parent == nil {
panic("cannot create context from nil parent")
}
return withoutCancelCtx{parent} /* context 構造体を返している */
}
type withoutCancelCtx struct {
c Context
}
...(省略)...
var Canceled = errors.New("context canceled")
func WithCancel(parent Context) (ctx Context, cancel CancelFunc) {
c := withCancel(parent)
return c, func() { c.cancel(true, Canceled, nil) }
}
func withCancel(parent Context) *cancelCtx {
if parent == nil {
panic("cannot create context from nil parent")
}
c := &cancelCtx{} /* context のポインタを返している */
c.propagateCancel(parent, c)
return c
}
func (c *cancelCtx) propagateCancel(parent Context, child canceler) {
c.Context = parent
...(省略)...
}
type cancelCtx struct {
Context
mu sync.Mutex // protects following fields
done atomic.Value // of chan struct{}, created lazily, closed by first cancel call
children map[canceler]struct{} // set to nil by the first cancel call
err error // set to non-nil by the first cancel call
cause error // set to non-nil by the first cancel call
}
...(省略)...
比較結果が true になる context.WithoutCancel()
は context の値を、false になるものは context のポインタを返しています(これは他の生成方法でも同様です)。
すなわち、true になっているものは context のフィールド値が一致するかの比較が、false になっているものはポインタが一致するかの比較が行われおり、このような結果になったのです。
構造体とポインタで返すことの違い
さて、比較結果をよく見てみると、context の生成方法は以下のように分類できることがわかります。
- キャンセル信号を伝播しない context:比較結果が true (context の値を返す)
- キャンセル信号を伝播する context:比較結果が false (context のポインタを返す)
この分類は何でしょうか?
筆者が調べた範囲ではこの違いについての記述は見当たらなかったのですが、以下のように推測しました。
仮に、context を値として返すがキャンセル信号を伝播する(つもりの) context.WithCancelByValue()
という生成方法があったと仮定します。すなわち、以下の通りです。
func WithCancelByValue(parent Context) (ctx Context, cancel CancelFunc) {
c := withCancelByValue(parent)
return c, func() { c.cancel(true, Canceled, nil) }
}
func withCancelByValue(parent Context) cancelCtx {
c := cancelCtx{} /* 値として返す */
c.propagateCancel(parent, c)
return c
}
func (c cancelCtx) propagateCancel(parent Context, child canceler) {
c.Context = parent
...(省略)...
}
これを使って 親 context を生成してみましょう。
ctxBase := context.Background()
ctxParent, cancel := context.WithCancelByValue(ctxBase)
ctxChild, cancel := context.WithCancel(ctxParent)
このとき、 ctxChild
が ctxParent
から伝播されるキャンセル信号を受け取るには、ctxParent
と全く同一のオブジェクトをフィールド値として持っていなければなりません。
しかし、 ctxChild
を context.WithCancelByValue()
で生成すると、
func WithCancel(parent Context) (ctx Context, cancel CancelFunc) {
...
この部分で parent
に値である ctxParent
が渡されるため、ctxParent
がコピーされてしまいます。
すると、ctxChild
が持つのは ctxParent
そのものではなくなるため、オリジナルの ctxParent
がキャンセルされても ctxChild
は ctxParent
のコピーからキャンセル信号を受け取れなくなってしまいます。
逆に言えば、context の値を返せば context のキャンセル信号の伝播は確実に阻止できます。
以上により、
- キャンセル信号を伝播しない context:比較結果が true (context の値を返す)
- キャンセル信号を伝播する context:比較結果が false (context のポインタを返す)
という分類が生まれたと推測しています。
まとめ
同一の生成方法から生成した context 同士の比較は、 context.Background()
などキャンセル信号を伝播しない context の場合は true 、 context.WithCancel()
などキャンセル信号を伝播する context の場合は false になります。
そのため、たとえばキャンセル信号を伝播しない context を利用して、テストコードで context の比較を用いて挙動を変えるようなモックを作ることはできません。注意しましょう。
package somepackage
import (
"context"
"errors"
"testing"
"github.com/golang/mock/gomock"
)
func TestSomeFunc(t *testing.T) {
ctxOK := context.Background()
ctxErr := context.Background()
ctrl := gomock.NewController(t)
defer ctrl.Finish()
someService := NewMockSomeService(ctrl)
someService.EXPECT().SomeFunc(gomock.Any(), gomock.Any()).DoAndReturn(
func(ctx context.Context, value int) error {
if ctx == ctxOK {
return nil
} else if ctx == ctxErr {
return errors.New("error")
} else {
return errors.New("unexpected context")
}
},
).AnyTimes()
}
Discussion