📚

【Go言語】context.Context を比較してみる

2024/07/05に公開

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 の基本的な使い方を知っていることを前提とします。
知らない方は、以下の記事などを参照していただければと思います。

https://zenn.dev/hsaki/books/golang-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 比較をしてみましょう。

異なる型同士での比較

比べるまでもないかもしれませんが、同じ機能を持つが異なる生成方法で作成したもの同士を比較してみるとどうなるか試してみましょう。

main.go
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)
}

実行結果は以下の通りです。

shell
% 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 生成メソッドに同一の値を設定した同士で比較する場合はどうなるでしょうか?

main.go
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)
}

実行結果は以下の通りです。

shell
% 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() のソースコードの一部を見てみましょう。

src/context/context.go
func WithoutCancel(parent Context) Context {
	if parent == nil {
		panic("cannot create context from nil parent")
	}
	return withoutCancelCtx{parent} /* context 構造体を返している */
}

type withoutCancelCtx struct {
	c Context
}
...(省略)...
src/context/context.go
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() という生成方法があったと仮定します。すなわち、以下の通りです。

src/context/context.go(仮想)
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 を生成してみましょう。

main.go
ctxBase := context.Background()
ctxParent, cancel := context.WithCancelByValue(ctxBase)
ctxChild, cancel := context.WithCancel(ctxParent)

このとき、 ctxChild が ctxParent から伝播されるキャンセル信号を受け取るには、ctxParent と全く同一のオブジェクトをフィールド値として持っていなければなりません。
しかし、 ctxChildcontext.WithCancelByValue() で生成すると、

func WithCancel(parent Context) (ctx Context, cancel CancelFunc) {
...

この部分で parent に値である ctxParent が渡されるため、ctxParent がコピーされてしまいます。
すると、ctxChild が持つのは ctxParent そのものではなくなるため、オリジナルの ctxParent がキャンセルされても ctxChildctxParent のコピーからキャンセル信号を受け取れなくなってしまいます。

逆に言えば、context の値を返せば context のキャンセル信号の伝播は確実に阻止できます。

以上により、

  • キャンセル信号を伝播しない context:比較結果が true (context の値を返す)
  • キャンセル信号を伝播する context:比較結果が false (context のポインタを返す)

という分類が生まれたと推測しています。

まとめ

同一の生成方法から生成した context 同士の比較は、 context.Background() などキャンセル信号を伝播しない context の場合は true 、 context.WithCancel() などキャンセル信号を伝播する context の場合は false になります。
そのため、たとえばキャンセル信号を伝播しない context を利用して、テストコードで context の比較を用いて挙動を変えるようなモックを作ることはできません。注意しましょう。

some_test.go
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