はじめに
こんにちは、エンジニアの清水です。
私は業務で Go を書いているのですが、context についての理解が浅いことでエラーに遭遇したので、勉強のために記事を書いてみました。
この記事では、実際のコード例を交えながら Go 言語の context パッケージの基本から実践的な使用方法まで解説していきます。
また実際に私が遭遇したエラーの例も交えて context の陥りやすい落とし穴についても解説します。
context の基本
context とは何か
context
は、Go の標準ライブラリに含まれるパッケージで、API やプロセス間でリクエストスコープの値、キャンセル信号、デッドラインなどを伝播するための仕組みを提供します。
なぜ context が必要なのか
- リソースの適切な管理:不要になった処理を適切にキャンセルし、メモリや CPU などのリソースを解放できます。
- タイムアウト制御:長時間実行される処理に対して、適切なタイムアウトを設定できます。
- リクエストスコープの値の伝播: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 を管理することで、より堅牢なアプリケーションを構築することができると思いました。
効果的な使用のためのベストプラクティス
- 適切なタイムアウトの設定
- キャンセル処理の適切な実装
- goroutine での慎重な使用
- 長時間実行処理には独立した context の作成