Go の context でキャンセルとタイムアウトを制御する

並行処理を行う際、goroutine をいつ終了させるかは重要な問題です。Go の context パッケージは、キャンセル信号やタイムアウトを goroutine 間で伝播させる仕組みを提供します。

context の基本

context は親から子へ伝わる「コンテキスト」を表します。親がキャンセルされると、その子もすべてキャンセルされます。

package main

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

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

context.Background() はルートとなる空のコンテキストを返します。これを起点に、キャンセルやタイムアウト付きのコンテキストを派生させていきます。

WithCancel で手動キャンセル

context.WithCancel は、手動でキャンセルできるコンテキストを作成します。

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

    go func() {
        for {
            select {
            case <-ctx.Done():
                fmt.Println("キャンセルされました")
                return
            default:
                fmt.Println("処理中...")
                time.Sleep(500 * time.Millisecond)
            }
        }
    }()

    time.Sleep(2 * time.Second)
    cancel() // ここでキャンセル
    time.Sleep(1 * time.Second)
}

cancel() を呼ぶと、ctx.Done() チャネルがクローズされ、goroutine 内の select がそれを検知します。

WithTimeout で自動タイムアウト

context.WithTimeout は、指定時間が経過すると自動的にキャンセルされるコンテキストを作成します。

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

    select {
    case <-time.After(5 * time.Second):
        fmt.Println("処理完了")
    case <-ctx.Done():
        fmt.Println("タイムアウト:", ctx.Err())
    }
}

この例では 3 秒でタイムアウトするため、5 秒かかる処理は完了せず「タイムアウト」が出力されます。defer cancel() は、タイムアウト前に処理が終わった場合にリソースを解放するために必要です。

WithDeadline で期限を指定

context.WithDeadline は、特定の時刻を期限として設定します。

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

    select {
    case <-time.After(3 * time.Second):
        fmt.Println("処理完了")
    case <-ctx.Done():
        fmt.Println("期限切れ:", ctx.Err())
    }
}

WithTimeout と似ていますが、相対的な時間ではなく絶対的な時刻を指定する点が異なります。

実践的な使い方:HTTP リクエストのタイムアウト

context は HTTP クライアントなど、標準ライブラリの多くの場所で使われています。

func fetchWithTimeout(url string) error {
    ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
    defer cancel()

    req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
    if err != nil {
        return err
    }

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

    fmt.Println("ステータス:", resp.Status)
    return nil
}

http.NewRequestWithContext を使うことで、リクエストにタイムアウトを設定できます。サーバーが 5 秒以内に応答しなければ、リクエストは自動的にキャンセルされます。

context を関数に渡す慣習

Go では、context を受け取る関数は第一引数に ctx context.Context を置くのが慣習です。

func doWork(ctx context.Context, data string) error {
    select {
    case <-ctx.Done():
        return ctx.Err()
    default:
        // 処理を実行
        fmt.Println("処理:", data)
        return nil
    }
}

この慣習に従うことで、キャンセル可能な関数であることが一目でわかります。