🐹

【Go】Contextの魅力を感じる

2024/07/31に公開

はじめに

Goの世界では、Contextがいろんなgoroutine間で値を共有するための手段として使われています。
Contextは、goroutineのキャンセルやタイムアウト、デッドラインの設定などを行うためのものです。
この記事では、Contextの魅力及び使い方について紹介します。

Contextとは

Goでの並列処理を行う際に、goroutineは各自独立な作業者として動作し、それぞれのgoroutineを管理するための仕組みがContextです。
goroutineのキャンセルはもちろん、タイムアウトやデッドラインの設定なども行うことができます。

Contextの本質

Contextは、本質的下記のような構造体です。

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

この構造体に四つのメソッドがあり、それぞれ以下のような役割があります。

  • Deadline() : Contextがキャンセルされるデッドラインを返す
  • Done() : Contextがキャンセルされたことを通知するチャネルを返す
  • Err() : Contextがキャンセルされた際のエラーを返す
  • Value() : Contextに関連付けられた値を返す

Contextの特性

Contextは以下のような特性を持っています。

  • キャンセル可能:Contextはそれぞれのgoroutineにキャンセルを通知するための仕組みを持っています
  • 階層構造:Contextは親子関係を持つことができ、親のContextがキャンセルされた場合、子のContextもキャンセルされます
  • 値の伝達:Contextは値を伝達するための仕組みを持っています
  • コルーチンセーフ:Contextは複数のgoroutineから安全にアクセスすることができます

Contextの使い方

いくつの例を通じて、Contextの使い方を紹介します。

root Contextの作成

まずは、root Contextを作成する方法です。
Goの標準ライブラリには、context.Background()context.TODO()という関数が用意されています。

func Background() Context 
func TODO() Context
  • Background() : 空のContextを返す、通常ではこの関数を使ってroot Contextを作成します
  • TODO() : どのようなContextを使うか決まっていない場合に使います
package main

import (
    "context"
    "fmt"
)

func main() {
    ctx := context.Background()
    fmt.Println(ctx)
}

---
context.Background

Derivative Contextの作成

次に、root Contextから派生したContextを作成する方法です。

1. WithCancel: キャンセル通知を受け取るためのContextを作成します

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

例:

package main

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

func main() {
	ctx, cancel := context.WithCancel(context.Background())
	defer cancel() // 関数終了時にcancelを呼び出す

	go func() {
		for {
			select {
			case <-ctx.Done():
				fmt.Println("ゴルーチンがキャンセルされました")
				return
			default:
				fmt.Println("ゴルーチンが実行中です")
				time.Sleep(time.Second)
			}
		}
	}()

	// メインプログラムを5秒間実行し、ゴルーチンに実行時間を与える
	time.Sleep(5 * time.Second)

	// ゴルーチンをキャンセル
	cancel()

	// キャンセルメッセージを確認するためにさらに1秒待つ
	time.Sleep(2 * time.Second)
}

---

ゴルーチンが実行中です
ゴルーチンが実行中です
ゴルーチンが実行中です
ゴルーチンが実行中です
ゴルーチンが実行中です
ゴルーチンがキャンセルされました

2. WithDeadline: 指定したデッドラインまでの時間を設定したContextを作成します

func WithDeadline(parent Context, deadline time.Time) (Context, CancelFunc)

例:

package main

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

func main() {
	ctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(5*time.Second))
	defer cancel()

	start := time.Now()

	go func() {
		for {
			select {
			case <-ctx.Done():
				elapsed := time.Since(start).Round(time.Second)
				fmt.Printf("%d秒経過: ゴルーチンがキャンセルされました\n", int(elapsed.Seconds()))
				return
			default:
				elapsed := time.Since(start).Round(time.Second)
				fmt.Printf("%d秒経過: ゴルーチンが実行中です\n", int(elapsed.Seconds()))
				time.Sleep(time.Second)
			}
		}
	}()

	// メインプログラムを5秒間実行し、ゴルーチンに実行時間を与える
	<-ctx.Done()

	// キャンセルメッセージを確認するためにさらに2秒待つ
	time.Sleep(2 * time.Second)
}

---

0秒経過: ゴルーチンが実行中です
1秒経過: ゴルーチンが実行中です
2秒経過: ゴルーチンが実行中です
3秒経過: ゴルーチンが実行中です
4秒経過: ゴルーチンが実行中です
5秒経過: ゴルーチンがキャンセルされました

3. WithValue: Contextに値を関連付けるためのContextを作成します

func WithValue(parent Context, key, val interface{}) Context

例:

package main

import (
    "context"
    "fmt"
)

func main() {
    ctx := context.WithValue(context.Background(), "key", "value")
    fmt.Println(ctx.Value("key"))
}

---

value

4. WithTimeout: 指定した時間までのタイムアウトを設定したContextを作成します

context.WithTimeoutcontext.WithDeadline は、似たような目的で使用されますが、若干異なる点があります:

指定方法:

  • WithTimeout: 現在時刻からの相対的な期間(Duration)を指定する
  • WithDeadline: 絶対的な時間(時刻)を指定する

使用シーンの例:

  • WithTimeout: 「この操作を5秒間だけ実行する」というような、相対的な制限時間を設定する場合に適切
  • WithDeadline: 「この操作を今日の23:59:59までに完了する」というような、特定の時刻までに完了させたい場合に適切

内部実装:

WithTimeoutは内部でWithDeadlineを使用しており、指定された期間を現在時刻に加算してデッドラインを計算します。
基本的な機能は同じで、コンテキストに制限時間を設定するという点では一致していますが、時間の指定方法が異なります。使用する状況に応じて、より適切な方を選択することができます。

func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc)

例:

package main

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

func main() {
    ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
    defer cancel()

    start := time.Now()

    go func() {
        for {
            select {
            case <-ctx.Done():
                elapsed := time.Since(start).Round(time.Second)
                fmt.Printf("%d秒経過: ゴルーチンがキャンセルされました\n", int(elapsed.Seconds()))
                return
            default:
                elapsed := time.Since(start).Round(time.Second)
                fmt.Printf("%d秒経過: ゴルーチンが実行中です\n", int(elapsed.Seconds()))
                time.Sleep(time.Second)
            }
        }
    }()

    // メインプログラムを5秒間実行し、ゴルーチンに実行時間を与える
    <-ctx.Done()

    // キャンセルメッセージを確認するためにさらに2秒待つ
    time.Sleep(2 * time.Second)
}

--- 出力結果 ---

0秒経過: ゴルーチンが実行中です
1秒経過: ゴルーチンが実行中です
2秒経過: ゴルーチンが実行中です
3秒経過: ゴルーチンが実行中です
4秒経過: ゴルーチンが実行中です
5秒経過: ゴルーチンがキャンセルされました

Context実装のベストプラクティス

関数間でContextを渡す際に、第一引数にContextを指定する

関数間でContextを渡す際には、ctxという名前で第一引数に指定することが一般的です。

func DoSomething(ctx context.Context, arg1, arg2 string) {
    // 何か処理
}

Contextを保存しない

Contextは、関数間で値を渡すためのものであり、保存するためのものではありません。

WithCancelを使う際には、defer cancel()を使う

WithCancelを使う際には、defer cancel()を使って、関数が終了した際に必ずcancelを呼び出すようにします。

func DoSomething(ctx context.Context) {
    ctx, cancel := context.WithCancel(ctx)
    defer cancel()
}

Contextの値のキーはエクスポートしない

カスタムキーを使ってContextに値を関連付ける場合、コンフリクトを避けるために、キーはエクスポートしないようにします。

type key int

const myKey key = 0

nilのContextを転送しない

どのContextを使うか決まっていない場合、context.TODO()を使うようにします。

Contextの実装例

以下は、Contextを使った実装例です。

この例では、

  • /searchエンドポイントで検索操作を行う
  • handleSearch関数で、5秒のタイムアウトを持つContextを作成する
  • performSearch関数で、3つの並行検索操作をシミュレートする
  • goroutinechannelを使って、並列処理を行う
  • selectを使って、最初の結果を待つか、Contextのキャンセルを待つ

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

package main

import (
	"context"
	"fmt"
	"github.com/gofiber/fiber/v2"
	"time"
)

func main() {
	app := fiber.New()

	app.Get("/search", handleSearch)

	app.Listen(":3000")
}

func handleSearch(c *fiber.Ctx) error {
	// 5秒のタイムアウトを持つContextを作成
	ctx, cancel := context.WithTimeout(c.Context(), 5*time.Second)
	defer cancel()

	// クエリパラメータを取得
	query := c.Query("q")

	// 検索操作を実行
	result, err := performSearch(ctx, query)
	if err != nil {
		return c.Status(fiber.StatusInternalServerError).SendString(fmt.Sprintf("エラー: %s", err.Error()))
	}

	// 結果を整形して返す
	response := fmt.Sprintf("クエリ: %s\n結果: %s\n", query, result)
	return c.SendString(response)
}

func performSearch(ctx context.Context, query string) (string, error) {
	// 3つの並行検索操作をシミュレート
	resultChan := make(chan string, 3)
	searchFuncs := []func(context.Context, string) (string, error){
		searchDatabase,
		searchAPI,
		searchCache,
	}

	for _, searchFunc := range searchFuncs {
		go func(f func(context.Context, string) (string, error)) {
			result, err := f(ctx, query)
			if err == nil {
				select {
				case resultChan <- result:
				case <-ctx.Done():
				}
			}
		}(searchFunc)
	}

	// 最初の結果を待つか、Contextのキャンセルを待つ
	select {
	case result := <-resultChan:
		return result, nil
	case <-ctx.Done():
		return "", ctx.Err()
	}
}

func searchDatabase(ctx context.Context, query string) (string, error) {
	time.Sleep(4 * time.Second) // 時間のかかる操作をシミュレート
	return "データベースの結果: " + query, nil
}

func searchAPI(ctx context.Context, query string) (string, error) {
	time.Sleep(3 * time.Second) // 時間のかかる操作をシミュレート
	return "APIの結果: " + query, nil
}

func searchCache(ctx context.Context, query string) (string, error) {
	time.Sleep(1 * time.Second) // 時間のかかる操作をシミュレート
	return "キャッシュの結果: " + query, nil
}

Contextを使って、タイムアウトやキャンセルを扱えることがわかります。
検索が何らかの原因で遅れたとしても、リクエストが予定内に完了かキャンセルされることが保証されます。

curl http://localhost:3000/search\?q\=test
クエリ: test
結果: キャッシュの結果: test

Contextの実装例 その2

Contextの基礎的な使い方を理解したら、下記のケースでも実装可能なので、試してみましょう。

カスタムContext

Goの標準ライブラリのContextでは要件を満たせない場合、カスタムContextを作成することができます。

package main

import (
	"context"
	"fmt"
)

type CustomContext struct {
	ctx   context.Context
	value string
}

func (c *CustomContext) Value() string {
	return c.value
}

func WithCustomContext(ctx context.Context, value string) *CustomContext {
	return &CustomContext{
		ctx:   ctx,
		value: value,
	}
}

func main() {
	ctx := WithCustomContext(context.Background(), "custom value")
	fmt.Println(ctx.Value())
}


--- 出力結果 ---

custom value

Context nodeの管理

複雑なアプリケーションでは、膨大なContext nodeが生まれる可能性があります。
それを管理するために、下記のような関数で管理可能です。

package main

import (
	"context"
	"fmt"
)

type ContextNode struct {
	ctx      context.Context
	cancel   context.CancelFunc
	children []*ContextNode
	id       string
}

func NewContextNode(root context.Context, id string) *ContextNode {
	ctx, cancel := context.WithCancel(root)
	return &ContextNode{
		ctx:    ctx,
		cancel: cancel,
		id:     id,
	}
}

func (n *ContextNode) AddChild(id string) *ContextNode {
	child := NewContextNode(n.ctx, id)
	n.children = append(n.children, child)
	return child
}

func (n *ContextNode) CancelBranch() {
	n.cancel()
}

func main() {
	root := NewContextNode(context.Background(), "root")
	child1 := root.AddChild("child1")
	child2 := root.AddChild("child2")

	grandchild1 := child1.AddChild("grandchild1")

	// 確認
	fmt.Println("root.id:", root.id)
	fmt.Println("child1.id:", child1.id)
	fmt.Println("child2.id:", child2.id)
	fmt.Println("grandchild1.id:", grandchild1.id)

	// 子ノードをキャンセルする
	child1.CancelBranch()

	// 子ノードがキャンセルされたことを確認する
	select {
	case <-child1.ctx.Done():
		fmt.Println("child1 is canceled")
	default:
		fmt.Println("child1 is not canceled")
	}
}

--- 出力結果 ---

root.id: root
child1.id: child1
child2.id: child2
grandchild1.id: grandchild1
child1 is canceled

Contextの監視とログ

Contextの監視とログを取得することで、よりシステムの動きを理解しやすくなります。

package main

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

func MonitorContext(ctx context.Context, name string) {
	go func() {
		for {
			select {
			case <-ctx.Done():
				fmt.Printf("%s Contextがキャンセルされました、原因: %v\n", name, ctx.Err())
				return
			default:
				fmt.Printf("%s Contextが実行中です\n", name)
				time.Sleep(time.Second)
			}
		}
	}()
}

func main() {
	ctx, cancel := context.WithCancel(context.Background())
	defer cancel()

	MonitorContext(ctx, "root")

	childCtx, childCancel := context.WithCancel(ctx)
	defer childCancel()

	MonitorContext(childCtx, "child")

	// 3秒後にキャンセル
	time.AfterFunc(3*time.Second, cancel)

	// 5秒待つ
	time.Sleep(5 * time.Second)
}


--- 出力結果 ---

child Contextが実行中です
root Contextが実行中です
child Contextが実行中です
root Contextが実行中です
root Contextが実行中です
child Contextが実行中です
child Contextがキャンセルされました、原因: context canceled
root Contextがキャンセルされました、原因: context canceled

sync.WaitGroupと合わせて使う

sync.WaitGroupと合わせて使うことで、全てのgoroutineが正確に完了することができます。

package main

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

func main() {
    ctx, cancel := context.WithCancel(context.Background())
    defer cancel()

    var wg sync.WaitGroup

    for i := 0; i < 3; i++ {
        wg.Add(1)
        go func(i int) {
            defer wg.Done()
            select {
            case <-ctx.Done():
                fmt.Printf("goroutine %d: キャンセルされました\n", i)
            default:
                fmt.Printf("goroutine %d: 実行中です\n", i)
                time.Sleep(time.Second)
            }
        }(i)
    }

    // 3秒後にキャンセル
    time.AfterFunc(3*time.Second, cancel)

    // 全てのgoroutineが完了するまで待つ
    wg.Wait()
}

--- 出力結果 ---
goroutine 2: 実行中です
goroutine 1: 実行中です
goroutine 0: 実行中です
全てのgoroutineが終了しました

Contextの実装例 その3

最後にプロジェクトの実装例を通じて、Contextの使い方を理解しましょう。

HTTPサーバーでのリクエスト処理

HTTPサーバーの中では、リクエストごとにContextを作成すべきであり、リクエストのライフサイクルや関連情報を管理するために使われます。

下記例では、

  • リクエストごとにContextを作成
  • User-IDヘッダーからuser_id情報を取得し、Contextに関連付ける
  • processRequest関数で、user_id情報を取得してリクエストを処理
  • リクエストが正常に処理された場合、user_id情報を含むレスポンスを返す
package main

import (
	"context"
	"encoding/json"
	"fmt"
	"log"
	"net/http"
	"time"
)

type contextKey string

const userIDKey contextKey = "user_id"

type Response struct {
	Status  string `json:"status"`
	Message string `json:"message"`
	UserID  string `json:"user_id,omitempty"`
}

func handleRequest(w http.ResponseWriter, r *http.Request) {
	ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
	defer cancel()

	userID := r.Header.Get("User-ID")
	if userID != "" {
		ctx = context.WithValue(ctx, userIDKey, userID)
	}

	result, err := processRequest(ctx)
	if err != nil {
		log.Printf("Error processing request: %v", err)
		sendJSONResponse(w, http.StatusInternalServerError, Response{
			Status:  "error",
			Message: err.Error(),
		})
		return
	}

	sendJSONResponse(w, http.StatusOK, result)
}

func processRequest(ctx context.Context) (Response, error) {
	userID, ok := ctx.Value(userIDKey).(string)
	if !ok {
		return Response{}, fmt.Errorf("user ID not found in context")
	}

	timer := time.NewTimer(3 * time.Second)
	defer timer.Stop()

	select {
	case <-timer.C:
		log.Printf("Request processed for user: %s", userID)
		return Response{
			Status:  "success",
			Message: fmt.Sprintf("ユーザー %s のリクエストが処理されました", userID),
			UserID:  userID,
		}, nil
	case <-ctx.Done():
		return Response{}, ctx.Err()
	}
}

func sendJSONResponse(w http.ResponseWriter, statusCode int, data interface{}) {
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(statusCode)
	if err := json.NewEncoder(w).Encode(data); err != nil {
		log.Printf("Error encoding JSON: %v", err)
	}
}

func main() {
	http.HandleFunc("/", handleRequest)
	log.Println("Starting server on :8080")
	log.Fatal(http.ListenAndServe(":8080", nil))
}

CURLコマンドを使ってリクエストを送信し、レスポンスを確認します。

curl -H "User-ID: user123" http://localhost:8080
{"status":"success","message":"ユーザー user123 のリクエストが処理されました","user_id":"user123"}

データベースのタイムアウト処理

長時間のクエリによってデータベース性能劣化を引き起こす可能性があるため、Contextを使ってタイムアウト処理を行います。

下記例では、

  • WithTimeoutを使って5秒のタイムアウトを設定
  • queryDatabase関数で、タイムアウト処理を行う
  • タイムアウトが発生した場合、エラーメッセージを返す
package main

import (
	"context"
	"database/sql"
	"fmt"
	"log"
	"time"

	_ "github.com/go-sql-driver/mysql"
)

func queryDatabase(ctx context.Context, db *sql.DB, query string) ([]string, error) {
	rows, err := db.QueryContext(ctx, query)
	if err != nil {
		return nil, fmt.Errorf("クエリの実行に失敗しました: %w", err)
	}
	defer rows.Close()

	var results []string
	for rows.Next() {
		var result string
		if err := rows.Scan(&result); err != nil {
			return nil, fmt.Errorf("行のスキャンに失敗しました: %w", err)
		}
		results = append(results, result)
	}

	if err := rows.Err(); err != nil {
		return nil, fmt.Errorf("行の取得に失敗しました: %w", err)
	}

	return results, nil
}

func main() {
	db, err := sql.Open("mysql", "user:password@/dbname")
	if err != nil {
		log.Fatalf("データベース接続に失敗しました: %v", err)
	}
	defer db.Close()

    // 5秒のタイムアウトを設定
	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
	defer cancel()

	result, err := queryDatabase(ctx, db, "SELECT * FROM table")
	if err != nil {
		switch {
		case err == context.DeadlineExceeded:
			log.Println("データベースクエリがタイムアウトしました")
		default:
			log.Printf("データベースクエリエラー: %v", err)
		}
		return
	}

	fmt.Println(result)
}

Contextのパフォーマンス

Contextはすごく便利ですが、実際に使用する際にはパフォーマンスの考慮も必要があります。

  1. 使いすぎない:全ての関数にContextを渡すのは避ける
  2. 適切なタイムアウトを設定する:短すぎると頻繁的なキャンセルが発生し、長すぎるとリソースの浪費が発生する
  3. Valueの設定を慎重に:ContextではValueを探す際に、全ての親Contextを探索するため、遅くなる可能性がある
  4. goroutineリークを避ける:親Contextがキャンセルされる際に、全ての子Contextもキャンセルされるようにする

まとめ

Contextは、Go言語においてとても重要な役割を果たしています。
今回は、Contextの基本的な使い方や実装例について紹介しました。
しかし強力な機能を持つ一方、誤った使い方をするとパフォーマンスの問題やバグの原因になることもあるため、適切に使うことも重要です。

Discussion