【Go】Contextの魅力を感じる
はじめに
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.WithTimeout
と context.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つの並行検索操作をシミュレートする -
goroutine
とchannel
を使って、並列処理を行う -
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はすごく便利ですが、実際に使用する際にはパフォーマンスの考慮も必要があります。
- 使いすぎない:全ての関数にContextを渡すのは避ける
- 適切なタイムアウトを設定する:短すぎると頻繁的なキャンセルが発生し、長すぎるとリソースの浪費が発生する
- Valueの設定を慎重に:ContextではValueを探す際に、全ての親Contextを探索するため、遅くなる可能性がある
- goroutineリークを避ける:親Contextがキャンセルされる際に、全ての子Contextもキャンセルされるようにする
まとめ
Contextは、Go言語においてとても重要な役割を果たしています。
今回は、Contextの基本的な使い方や実装例について紹介しました。
しかし強力な機能を持つ一方、誤った使い方をするとパフォーマンスの問題やバグの原因になることもあるため、適切に使うことも重要です。
Discussion