Go の select と time.After でタイムアウトを実装する

channel からの受信を待つ処理で、一定時間内に応答がなければ諦めたいことがあります。selecttime.After を組み合わせることで、シンプルにタイムアウトを実装できます。

time.After の基本

time.After は指定した時間が経過すると値を送信する channel を返します。

package main

import (
    "fmt"
    "time"
)

func main() {
    fmt.Println("開始")
    <-time.After(2 * time.Second)
    fmt.Println("2秒経過")
}

単独で使うと time.Sleep と同じですが、select と組み合わせることで真価を発揮します。

select でタイムアウトを実装する

select は複数の channel 操作を同時に待ち、最初に準備できたものを実行します。処理結果の channel と time.After の channel を同時に待つことで、タイムアウトを実現できます。

package main

import (
    "fmt"
    "time"
)

func slowOperation() <-chan string {
    ch := make(chan string)
    go func() {
        time.Sleep(3 * time.Second) // 重い処理をシミュレート
        ch <- "処理完了"
    }()
    return ch
}

func main() {
    result := slowOperation()

    select {
    case msg := <-result:
        fmt.Println(msg)
    case <-time.After(2 * time.Second):
        fmt.Println("タイムアウト")
    }
}

この例では処理に3秒かかりますが、タイムアウトは2秒なので「タイムアウト」が出力されます。

実践例:API リクエストのタイムアウト

外部 API を呼び出す際にタイムアウトを設定する例です。

package main

import (
    "fmt"
    "math/rand"
    "time"
)

func fetchData(id int) <-chan string {
    ch := make(chan string)
    go func() {
        // ランダムな遅延(0〜3秒)
        delay := time.Duration(rand.Intn(3000)) * time.Millisecond
        time.Sleep(delay)
        ch <- fmt.Sprintf("データ%d(%v で取得)", id, delay)
    }()
    return ch
}

func main() {
    rand.Seed(time.Now().UnixNano())

    for i := 1; i <= 5; i++ {
        ch := fetchData(i)

        select {
        case data := <-ch:
            fmt.Println("成功:", data)
        case <-time.After(1 * time.Second):
            fmt.Printf("リクエスト%d: タイムアウト\n", i)
        }
    }
}

各リクエストに1秒のタイムアウトを設定しています。1秒以内に応答があれば成功、なければタイムアウトとなります。

複数の channel とタイムアウト

複数の処理を並行して待ちつつ、全体にタイムアウトを設ける場合です。

package main

import (
    "fmt"
    "time"
)

func main() {
    ch1 := make(chan string)
    ch2 := make(chan string)

    go func() {
        time.Sleep(1 * time.Second)
        ch1 <- "ch1 完了"
    }()

    go func() {
        time.Sleep(2 * time.Second)
        ch2 <- "ch2 完了"
    }()

    timeout := time.After(3 * time.Second)

    for i := 0; i < 2; i++ {
        select {
        case msg := <-ch1:
            fmt.Println(msg)
        case msg := <-ch2:
            fmt.Println(msg)
        case <-timeout:
            fmt.Println("全体タイムアウト")
            return
        }
    }
    fmt.Println("すべて完了")
}

time.After をループの外で一度だけ呼ぶことで、全体で3秒という制限時間を設けています。

time.After の注意点

time.After はループ内で呼ぶと毎回新しいタイマーが作られます。

ループ内で time.After を呼ぶ

毎回新しいタイマーが生成される。各イテレーションごとにタイムアウトをリセットしたい場合に適切。

ループ外で time.After を呼ぶ

1つのタイマーを共有する。全体の制限時間を設けたい場合に適切。

また、タイムアウトしても goroutine 自体は止まりません。リソースリークを防ぐには context を使った適切なキャンセル処理が必要です。

package main

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

func doWork(ctx context.Context) <-chan string {
    ch := make(chan string)
    go func() {
        select {
        case <-time.After(3 * time.Second):
            ch <- "処理完了"
        case <-ctx.Done():
            fmt.Println("キャンセルされました")
            return
        }
    }()
    return ch
}

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

    select {
    case result := <-doWork(ctx):
        fmt.Println(result)
    case <-ctx.Done():
        fmt.Println("タイムアウト")
    }

    time.Sleep(100 * time.Millisecond) // goroutine の出力を待つ
}

time.After は簡易的なタイムアウトに便利ですが、本格的なキャンセル処理には context を使うのがベストプラクティスです。