🕌

goの再学習 お作法で書いてるcontextとはなんなのか

に公開

goでHello Worldを書いたあと、ステップアップとしてライブラリを使うときにcontextという引数が必要だと言われる。ネット記事で見た通りに

ctx := context.Background()

を生成し、関数に渡してみる。ちゃんと動いたし、これからやりたいことがいっぱいあるので一旦contextという概念を後回しにして先を急ぐ。という流れはgo初学者あるあるではないだろうか?かくいう自分も最初はそうだった。
今回はGoエコシステムの根幹をなしているものの、意外と無視されやすいgoのcontextについて解説する。

GoのContextとは何か

contextはgoの標準パッケージであり、キャンセル信号、タイムアウト期限、リクエスト固有の値を受け渡す役割がある。
https://pkg.go.dev/context
contextがもつ3つの機能の詳細はそれぞれ順を追って説明するが、contextはGoroutineを使ってマルチスレッド処理を簡単に生み出せるgoの中で、システムがカオスにならないようにするために必要不可欠な存在であるということは予め述べておく。

キャンセル信号

contextで最も一般的な使い方としてキャンセルの伝搬がある。これはあるプロセスが終了した際に同じcontextの別のプロセスも終了するために使われる。

package main

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

func main() {
	// 1. ContextとCancel関数を生成
	ctx, cancel := context.WithCancel(context.Background())

	fmt.Println("main: 処理を開始")

	// 2. func1 を別のゴルーチンで起動
	go func1(ctx)

	// 3. メイン側で少し待機(処理中)
	time.Sleep(2 * time.Second)

	fmt.Println("main: 処理を中止")
	
	// 4. キャンセルを実行
	// これにより、ctx を持っている全ての関数に「終了シグナル」が送られる
	cancel()

	// 停止処理が見えるように少し待機して終了
	time.Sleep(1 * time.Second)
	fmt.Println("main: 終了")
}

// 重い処理をシミュレーションする関数
func func1(ctx context.Context) {
	fmt.Println("func1: 処理開始...")

	for {
		select {
		// 5. キャンセルシグナルの受信待機
		// cancel() が呼ばれると、この ctx.Done() チャネルから受信できる
		case <-ctx.Done():
			fmt.Println("func1: 処理を中止")
			return
		// キャンセルが来ていない場合の通常処理(1秒ごとの作業)
		default:
			fmt.Println("func1: 作業中...")
			time.Sleep(1 * time.Second)
		}
	}
}

この例ではmainからfunc1を呼び、main内でキャンセルをしたときにfunc1もキャンセルされる。また、contextから別のcontext(子コンテキスト)を生成することも可能で、その場合は親コンテキストのキャンセルは子に伝搬するが、子コンテキストのキャンセルは親に伝搬されない。

タイムアウト期限

contextを使うことでタイムアウトを設定することもできる。

package main

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

func main() {
	// 1. タイムアウト付きContextを生成
	// ここでは2秒を制限時間に設定
	ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
	
	// タイムアウトしても、リソース解放のためにcancelを呼ぶのがお作法
	defer cancel()

	fmt.Println("main: 重い処理を開始します(制限時間: 2秒)")

	// 処理結果を受け取るためのチャネル
	resultChan := make(chan string)

	// 別ゴルーチンでfunc2を実行
	go func2(ctx, resultChan)

	// 2. 処理完了かタイムアウトか、早い方を待つ
	select {
	case res := <-resultChan:
		fmt.Println("main: 成功:", res)
	case <-ctx.Done():
		// 制限時間を過ぎるとここに来る
		fmt.Println("main: タイムアウト")
		fmt.Println("main: エラー内容:", ctx.Err()) // "context deadline exceeded"
	}
}

func func2(ctx context.Context, ch chan<- string) {
	// わざと制限時間(2秒)より長い3秒かかる処理にする
	select {
	case <-time.After(3 * time.Second):
		ch <- "完了データ" // ここには到達できない
	case <-ctx.Done():
		// main側でタイムアウト判定されたら、ここも即座に中断処理をする
		// (ここを書いておかないと、mainが終わっても裏で動き続けてしまう)
		return
	}
}

値の受け渡し

コンテキストに値をもたせることもできる。ただし、やたらとオプショナル値を持たせることは推奨されておらず、リクエストスコープの値を入れるのが良いとされる。

package main

import (
	"context"
	"fmt"
)

type keyType string

const (
	requestIDKey keyType = "requestID"
	userIDKey    keyType = "userID"
)

func main() {
	// 1. 値を持つContextを生成
	// 親のContextにkey-valueのペアを追加して、新しいctxを作る
	ctx := context.Background()
	ctx = context.WithValue(ctx, requestIDKey, "req-12345")
	ctx = context.WithValue(ctx, userIDKey, 1001)

	fmt.Println("main: 処理開始")
	
	// 関数呼び出し(引数にIDなどを個別に渡さなくて良い)
	func3(ctx)
}

func func3(ctx context.Context) {
	// 中間処理
	func4(ctx)
}

func func4(ctx context.Context) {
	// 2. Contextから値を取り出す
	// ctx.Value(キー) で値を取り出せる
	
	reqID := ctx.Value(requestIDKey)
	uID := ctx.Value(userIDKey)

	fmt.Printf("DB処理: RequestID=[%v], UserID=[%v] のデータを検索...\n", reqID, uID)
    queryFunc(reqID, uID) // クエリ用ダミー関数
}

外部ライブラリでの使い方

実践ではnet/httpのhttp.NewRequestWithContextなどコンテキストを渡す関数に使うことができる。引数に渡すcontextに対して上記で紹介したcontextを渡すことでライブラリ側の処理もキャンセルやタイムアウトなどができる。

まとめ

goにおいてcontextはcontext.Backgrouund()さえ渡してしまえば動いてしまうので無視されがちだが、うまく使うことでgoのエコシステムであるgoroutineのポテンシャルを引き出すことができる。

Discussion