LoginSignup
6
2

GoのContextをわかりやすく解説したい

Last updated at Posted at 2024-01-02

はじめに

Goのcontextついてわかりやすく説明することを目標に記事を書きました。

要点

・contextはAPIの境界を越えて、デッドライン、キャンセルシグナル、その他の値を運ぶ。
・Goにおいてはcontextを使用せずに処理のキャンセル内容を知る術がない。
・contextはReactのpropsに近い存在で、contextは子で変更して親に返せない。(CancelFunc型の値は引数に渡せる)

そもそもContextとは?

Goのhttp.Request型の中身を見てみると、下記のプロパティが存在します

ctx context.Context

context.Contextの説明文を見てみると

A Context carries a deadline, a cancellation signal, and other values across API boundaries.
Context's methods may be called by multiple goroutines simultaneously.

まとめると
・ContextはAPIの境界を越えて、デッドライン、キャンセルシグナル、その他の値を運ぶ。
・Contextのメソッドは、複数のゴルーチンから同時に呼び出されることがある。
どうやら処理のキャンセルやその他の値を伝えてくれる存在のようです。
Reactを触ったことがある方に対してはpropsに近い存在だと言えば腑に落ちやすいのではないでしょうか。実際、contextは子で変更して親に返すということはできません。(CancelFuncの型の値は引数に渡せる)
コンテキストはr *http.Requestを受け取ると、r.Contextに格納されます。ここで理解しておきたい重要なことはAPIのエンドポイントの実装において、クライアントからの通信切断、タイムアウトはcontext.Context型の値でしか送られてこないのです。それゆえにGoにおいてはcontextを使用せずにキャンセル内容を知る術はありません。
では実際にContext 型の中身を見てみましょう。

type Context interface {
    Deadline() (deadline time.Time, ok bool)
    Done() <-chan struct{}
    Err() error
    Value(key any) any
}

中身をみると、タイムアウト、処理中断の報告、エラー、何かしらの値の4つのプロパティを格納できるようです。
では具体的にどう使用するのか、contextに搭載されているメソッドとともに確認していきましょう。

特定の条件を満たしたらcontextでキャンセルを通知したい

contextで処理のキャンセルを通知したい場合はWithCancelを使用します。わかりやすいサンプルとしては下のようなコードが挙げられるでしょう。よくある一定時間経過したら処理を中断する処理です。

package main

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

func worker(ctx context.Context) {
	for {
		select {
		case <-ctx.Done():
			fmt.Println("Worker: Received cancellation signal. Exiting...")
			return
		default:
			// 何か処理を行う
			fmt.Println("Worker: Doing some work...")
			time.Sleep(1 * time.Second)
		}
	}
}

func main() {
	// 親コンテキストを作成
	parentContext := context.Background()

	// WithCancelを使用して新しい子コンテキストとキャンセル関数を作成
	childContext, cancel := context.WithCancel(parentContext)

	// ゴルーチンを起動してキャンセルを監視する
	go worker(childContext)

	// 例として、一定時間待機後にキャンセルを実行
	time.Sleep(3 * time.Second)
	cancel() // キャンセル関数を呼び出してコンテキストをキャンセル

	// この時点でworkerゴルーチンはキャンセルされ、終了するまで待機される
	time.Sleep(2 * time.Second)
	fmt.Println("Main: Exiting...")
}

WithCancelを使用する際は、まずcontext.Backgroundを使用してcontextを作成します。ここでBackgroundは空のContextを返します。

parentContext := context.Background()

次にWithCancelを使用して新しい子コンテキストとキャンセル関数を作成します。

childContext, cancel := context.WithCancel(parentContext)

第2引数のcancel関数を作動させたときに、contextに中止命令(Done() <-chan struct{})が入ります。

cancel()

cancelの中身は下記の通り。いろいろ書いてありますが、基本的にDone() をcloseしてチャネルを閉じています。

// cancel closes c.done, cancels each of c's children, and, if
// removeFromParent is true, removes c from its parent's children.
func (c *cancelCtx) cancel(removeFromParent bool, err error) {
    if err == nil {
        panic("context: internal error: missing cancel error")
    }
    c.mu.Lock()
    if c.err != nil {
        c.mu.Unlock()
        return // already canceled
    }
    c.err = err
    d, _ := c.done.Load().(chan struct{})
    if d == nil {
        c.done.Store(closedchan)
    } else {
        close(d)
    }
    for child := range c.children {
        // NOTE: acquiring the child's lock while holding parent's lock.
        child.cancel(false, err)
    }
    c.children = nil
    c.mu.Unlock()

    if removeFromParent {
        removeChild(c.Context, c)
    }
}

特定の時間経過でキャンセルしたい

上の例だと記述が冗長になってしまうので、一定の経過時間が終了したら自動でcontextにタイムアウトを設定するcontext.WithTimeoutを見てみましょう。

package main

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

func main() {
	// 親コンテキストを作成
	parentContext := context.Background()

	// WithTimeoutを使用して新しい子コンテキストとキャンセル関数を作成
	// この例では、5秒後にタイムアウトするように設定
	timeoutContext, cancel := context.WithTimeout(parentContext, 5*time.Second)

	// 関数が終了したらキャンセル関数を呼び出してコンテキストをキャンセル
	defer cancel()

	// タイムアウトが発生する前に何か処理を行う
	select {
	case <-timeoutContext.Done():
		// タイムアウトが発生した場合
		fmt.Println("Operation timed out")
	case <-time.After(3 * time.Second):
		// 何か処理を行う(3秒後に完了したと仮定)
		fmt.Println("Operation completed successfully")
	}

	// この時点で、timeoutContextを使った処理が終了
	fmt.Println("Main: Exiting...")
}

ここでtimeoutContextを設定している5*time.Secondの5秒を3秒未満に設定してみてください。すると3秒経たずに処理がタイムアウトしたと判断し、"Operation timed out”が出力されます。時間経過で処理を行う場合はこちらのほうがクリーンに書けます。

contextに値を追加したい

contextに値を追加する時はcontext.WithValueを使用します。第1引数に値を追加したいcontext、第2引数に追加する値のkey、第3引数にcontextに追加する値を指定します。
ここで注意したいのはkeyにリテラルで値を直指定すると、keyの衝突が生じてしまう可能性があります。そのため、空の構造体を指定する、configでkeyを重複しないよう管理するなどして対応するほうがよいです。

package main

import (
    "context"
    "fmt"
)

type ID string
type IDKey struct{}

// context設定用関数
func SetID(ctx context.Context, id ID) context.Context {
    return context.WithValue(ctx, IDKey{}, id)
}

// context取得用関数
func GetID(ctx context.Context) ID {
    if v, ok := ctx.Value(IDKey{}).(ID); ok {
        return v
    }
    return ""
}

func main(){
    ctx := context.Background()
    fmt.Printf("trace id = %q\n", GetID(ctx)) // 何も設定していないので出力はない
    ctx = SetID(ctx, "test-id")
    fmt.Printf("trace id = %q\n", GetID(ctx)) // keyに格納したID(test-id)が出力される
}
6
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
6
2