14
11

Go 言語の context を基礎から実践まで解説

Last updated at Posted at 2024-09-24

はじめに

こんにちは、エンジニアの清水です。

私は業務で Go を書いているのですが、context についての理解が浅いことでエラーに遭遇したので、勉強のために記事を書いてみました。

この記事では、実際のコード例を交えながら Go 言語の context パッケージの基本から実践的な使用方法まで解説していきます。
また実際に私が遭遇したエラーの例も交えて context の陥りやすい落とし穴についても解説します。

context の基本

context とは何か

contextは、Go の標準ライブラリに含まれるパッケージで、API やプロセス間でリクエストスコープの値、キャンセル信号、デッドラインなどを伝播するための仕組みを提供します。

なぜ context が必要なのか

  1. リソースの適切な管理:不要になった処理を適切にキャンセルし、メモリや CPU などのリソースを解放できます。
  2. タイムアウト制御:長時間実行される処理に対して、適切なタイムアウトを設定できます。
  3. リクエストスコープの値の伝播:HTTP リクエストのトレース ID(リクエストを追跡するための一意の識別子)など、処理全体で共有すべき値を安全に伝達できます。

context の主な用途

  • HTTP リクエストのキャンセル制御:クライアントがリクエストをキャンセルした場合に、サーバー側の処理も中止できます。
  • データベースクエリのタイムアウト設定:長時間実行されるクエリに制限時間を設けることができます。
  • goroutine 間でのキャンセル信号の伝播:複数の goroutine 間で処理の中止を連携できます。
  • マイクロサービス間での情報伝達:複数の小さなサービス間でリクエスト情報を受け渡せます。

context の使用方法

基本的な使い方

package main

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

func doSomething(ctx context.Context) error {
    // ctxを使用して処理を実行
    select {
    case <-ctx.Done():
        // contextがキャンセルされた場合の処理
        // ctx.Done()はcontextがキャンセルされたときに閉じられるチャネルを返す
        return ctx.Err()
    default:
        // 通常の処理
        // contextがキャンセルされていない場合はここが実行される
        fmt.Println("処理を実行中...")
        time.Sleep(2 * time.Second) // 処理のシミュレーション
        fmt.Println("処理が完了しました")
        return nil
    }
}

func main() {
    ctx := context.Background()
    err := doSomething(ctx)
    if err != nil {
        fmt.Printf("Error: %v\n", err)
    }
}

この例では、select文を使って context のキャンセル状態を確認しています。ctx.Done()は context がキャンセルされたときに閉じられるチャネル(goroutine 間の通信に使用される仕組み)を返します。

タイムアウトの設定

package main

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

func doSomething(ctx context.Context) error {
    select {
    case <-ctx.Done():
        return ctx.Err()
    default:
        fmt.Println("処理を実行中...")
        time.Sleep(2 * time.Second)
        fmt.Println("処理が完了しました")
        return nil
    }
}

func main() {
    // context.Background()は空のcontextを作成
    // WithTimeoutは指定した時間後にキャンセルされるcontextを作成
    ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
    // 関数終了時に必ずcancelを呼び出す
    defer cancel()

    err := doSomething(ctx)
    if err != nil {
        log.Printf("Error: %v", err)
    }
}

ここでは、5 秒のタイムアウトを設定しています。context.Background()は空の context を作成し、それを基に新しい context を作成しています。defer cancel()は関数終了時に必ず cancel を呼び出すようにしています。

キャンセル処理の実装

package main

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

func main() {
    // キャンセル可能なcontextを作成
    ctx, cancel := context.WithCancel(context.Background())
    go func() {
        // 何らかの条件でキャンセルを実行
        time.Sleep(2 * time.Second)
        cancel() // 2秒後にcontextをキャンセル
    }()

    select {
    case <-ctx.Done():
        fmt.Println("処理がキャンセルされました")
    case <-time.After(5 * time.Second):
        fmt.Println("処理が完了しました")
    }
}

この例では、goroutine を使って 2 秒後に context をキャンセルしています。メイン処理ではselect文を使って、context のキャンセルと 5 秒のタイムアウトのどちらが先に発生するかを待っています。

値の受け渡し

package main

import (
    "context"
    "fmt"
)

func main() {
    // contextに値を関連付ける
    ctx := context.WithValue(context.Background(), "key", "value")

    // 値の取得
    if v, ok := ctx.Value("key").(string); ok {
        fmt.Println(v) // "value"
    }
}

context.WithValueを使うと、context に値を関連付けることができます。ここでは、"key"というキーに"value"という値を関連付けています。

context の落とし穴と注意点

API の context を長時間実行処理に渡す危険性

私が実際に経験した問題です。API の context をファイルエクスポート処理のような長時間実行される処理に渡してしまうと、API レスポンスが返った時点で context がキャンセルされ、処理が中断されてしまう可能性があります。

誤った使用例

package main

import (
    "context"
    "net/http"
)

func handleExport(w http.ResponseWriter, r *http.Request) {
    // 誤った使用例
    go exportFile(r.Context()) // APIのcontextを長時間処理に渡している
    w.WriteHeader(http.StatusOK)
}

func exportFile(ctx context.Context) {
    // 長時間の処理
    // ctx.Done()が呼ばれると中断される可能性がある
    // APIレスポンスが返った後にcontextがキャンセルされる可能性がある
}

func main() {
    http.HandleFunc("/export", handleExport)
    http.ListenAndServe(":8080", nil)
}

この例では、HTTP リクエストの context をそのままファイルエクスポート処理に渡しています。これにより、API レスポンスが返された後に context がキャンセルされ、エクスポート処理が途中で中断される可能性があります。

正しい使用例

package main

import (
    "context"
    "net/http"
    "time"
)

func handleExport(w http.ResponseWriter, r *http.Request) {
    // 新しいcontextを作成(APIのcontextとは独立)
    ctx := context.Background()
    go exportFile(ctx)
    w.WriteHeader(http.StatusOK)
}

func exportFile(ctx context.Context) {
    // 長時間の処理
    // 必要に応じてタイムアウトを設定
    ctx, cancel := context.WithTimeout(ctx, 1*time.Hour)
    defer cancel() // 処理終了時にcancelを呼び出す

    // 処理の実行
    // このcontextはAPIレスポンスとは独立しているため、
    // APIレスポンスが返された後も処理が継続される
}

func main() {
    http.HandleFunc("/export", handleExport)
    http.ListenAndServe(":8080", nil)
}

この例では、API の context ではなく新しい context を作成してファイルエクスポート処理に渡しています。これにより、API レスポンスが返された後もエクスポート処理が継続できます。

goroutine での context 使用時の注意点

goroutine 内で context を使用する際は、親の context がキャンセルされた場合の動作を適切に処理する必要があります。

package main

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

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

    go childFunction(ctx)

    // 何らかの条件でキャンセルを実行
    time.Sleep(2 * time.Second)
    cancel() // 2秒後にcontextをキャンセル
}

func childFunction(ctx context.Context) {
    select {
    case <-ctx.Done():
        fmt.Println("親のcontextがキャンセルされました")
        return
    case <-time.After(5 * time.Second):
        fmt.Println("処理が完了しました")
    }
}

func main() {
    parentFunction()
}

この例では、parentFunction内で作成された context をchildFunctionに渡しています。親の context がキャンセルされると、子の goroutine も適切に終了します。

context のキャンセルタイミングの重要性

context のキャンセルタイミングを適切に管理することが重要です。早すぎるキャンセルや、キャンセルし忘れによるリソースリーク(プログラムが使用しているリソースが適切に解放されない状態)に注意が必要です。

package main

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

func processWithTimeout() {
    ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
    defer cancel() // 関数終了時に必ずcancelを呼び出す

    select {
    case <-ctx.Done():
        fmt.Println("タイムアウトまたはキャンセルされました:", ctx.Err())
    case <-time.After(10 * time.Second):
        fmt.Println("処理が完了しました")
    }
}

func main() {
    processWithTimeout()
}

この例では、5 秒のタイムアウトを設定しています。defer cancel()を使用して、関数終了時に必ずキャンセルを呼び出すことで、リソースリークを防いでいます。タイムアウトが発生すると、ctx.Done()が閉じられ、適切に処理が中断されます。

実践的な使用例

外部サービス呼び出し時の context 管理

package main

import (
    "context"
    "net/http"
    "time"
)

func callExternalService(ctx context.Context) error {
    // 10秒のタイムアウトを設定
    ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
    defer cancel() // 関数終了時にcancelを呼び出す

    // contextを含むHTTPリクエストを作成
    req, err := http.NewRequestWithContext(ctx, "GET", "https://api.example.com", nil)
    if err != nil {
        return err
    }

    client := &http.Client{}
    resp, err := client.Do(req)
    if err != nil {
        return err
    }
    defer resp.Body.Close()

    // レスポンスの処理
    return nil
}

func main() {
    ctx := context.Background()
    err := callExternalService(ctx)
    if err != nil {
        fmt.Printf("Error: %v\n", err)
    }
}

この例では、外部サービスの呼び出しに 10 秒のタイムアウトを設定しています。http.NewRequestWithContextを使用して、HTTP リクエストに context を関連付けています。

長時間実行処理での context 作成方法

package main

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

func longRunningTask() {
    // 基本となるcontextを作成
    ctx := context.Background()
    // キャンセル可能なcontextを作成
    ctx, cancel := context.WithCancel(ctx)
    defer cancel() // 関数終了時にcancelを呼び出す

    go func() {
        // 何らかの条件でキャンセルを実行
        time.Sleep(1 * time.Hour)
        cancel() // 1時間後にcontextをキャンセル
    }()

    // 長時間の処理
    for {
        select {
        case <-ctx.Done():
            fmt.Println("処理がキャンセルされました")
            return
        default:
            // 処理の実行
            // contextがキャンセルされていない場合はここが実行される
        }
    }
}

func main() {
    longRunningTask()
}

この例では、長時間実行される処理のための context を作成しています。goroutine を使って 1 時間後に context をキャンセルし、メインの処理ループ内で context のキャンセル状態を定期的にチェックしています。

まとめ

context の適切な使用は、Go を書く上で重要だと感じました。API の context を長時間実行処理に渡すなどの落とし穴に注意しながら、適切に context を管理することで、より堅牢なアプリケーションを構築することができると思いました。

効果的な使用のためのベストプラクティス

  1. 適切なタイムアウトの設定
  2. キャンセル処理の適切な実装
  3. goroutine での慎重な使用
  4. 長時間実行処理には独立した context の作成

参考資料

14
11
1

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
14
11