Go の errgroup で goroutine のエラーをまとめて扱う

複数の goroutine を起動して、どれか 1 つでもエラーが発生したら全体を中断したい場面は多いです。標準の sync.WaitGroup だけではエラーの収集や伝播が難しいため、golang.org/x/sync/errgroup パッケージが便利です。

errgroup の基本

errgroup は WaitGroup に似ていますが、エラーを返すことができます。

package main

import (
    "fmt"
    "golang.org/x/sync/errgroup"
)

func main() {
    var g errgroup.Group

    g.Go(func() error {
        fmt.Println("タスク 1 実行")
        return nil
    })

    g.Go(func() error {
        fmt.Println("タスク 2 実行")
        return nil
    })

    if err := g.Wait(); err != nil {
        fmt.Println("エラー:", err)
    } else {
        fmt.Println("すべて成功")
    }
}

g.Go で goroutine を起動し、g.Wait ですべての完了を待ちます。いずれかがエラーを返すと、Wait はそのエラーを返します。

エラーが発生した場合

どれか 1 つでもエラーを返すと、Wait はそのエラーを返します。

func main() {
    var g errgroup.Group

    g.Go(func() error {
        return nil
    })

    g.Go(func() error {
        return fmt.Errorf("タスク 2 で失敗")
    })

    g.Go(func() error {
        return nil
    })

    if err := g.Wait(); err != nil {
        fmt.Println("エラー:", err) // タスク 2 で失敗
    }
}

複数のエラーが発生した場合、最初のエラーだけが返されます。すべてのエラーを収集したい場合は別の方法が必要です。

context との組み合わせ

errgroup.WithContext を使うと、エラー発生時に他の goroutine をキャンセルできます。

func main() {
    g, ctx := errgroup.WithContext(context.Background())

    g.Go(func() error {
        for i := 0; i < 10; i++ {
            select {
            case <-ctx.Done():
                fmt.Println("タスク 1 キャンセル")
                return ctx.Err()
            default:
                fmt.Println("タスク 1:", i)
                time.Sleep(100 * time.Millisecond)
            }
        }
        return nil
    })

    g.Go(func() error {
        time.Sleep(300 * time.Millisecond)
        return fmt.Errorf("タスク 2 でエラー発生")
    })

    if err := g.Wait(); err != nil {
        fmt.Println("結果:", err)
    }
}

タスク 2 がエラーを返すと、ctx がキャンセルされ、タスク 1 もそれを検知して終了します。これにより、無駄な処理を早期に打ち切れます。

並列データ取得の例

複数の API からデータを並列取得する場面を考えてみましょう。

type Result struct {
    Users    []string
    Products []string
}

func fetchData() (*Result, error) {
    var result Result
    var mu sync.Mutex

    g, ctx := errgroup.WithContext(context.Background())

    g.Go(func() error {
        // ユーザー API を呼び出し
        select {
        case <-ctx.Done():
            return ctx.Err()
        case <-time.After(100 * time.Millisecond):
            mu.Lock()
            result.Users = []string{"Alice", "Bob"}
            mu.Unlock()
            return nil
        }
    })

    g.Go(func() error {
        // 商品 API を呼び出し
        select {
        case <-ctx.Done():
            return ctx.Err()
        case <-time.After(150 * time.Millisecond):
            mu.Lock()
            result.Products = []string{"iPhone", "MacBook"}
            mu.Unlock()
            return nil
        }
    })

    if err := g.Wait(); err != nil {
        return nil, err
    }
    return &result, nil
}

両方の API 呼び出しが成功すれば結果を返し、どちらかが失敗すれば即座に中断してエラーを返します。

同時実行数の制限

Go 1.20 以降では SetLimit で同時実行数を制限できます。

func main() {
    g := new(errgroup.Group)
    g.SetLimit(3) // 同時に 3 つまで

    for i := 0; i < 10; i++ {
        i := i
        g.Go(func() error {
            fmt.Printf("タスク %d 開始\n", i)
            time.Sleep(500 * time.Millisecond)
            fmt.Printf("タスク %d 完了\n", i)
            return nil
        })
    }

    g.Wait()
}

10 個のタスクがありますが、同時に実行されるのは 3 つまでです。API のレート制限を守りたい場合などに便利です。

WaitGroup との比較

errgroup と WaitGroup の違いを整理します。

sync.WaitGroup

エラーを返す仕組みがない。単純に goroutine の完了を待つだけ。

errgroup.Group

エラーを収集・伝播できる。context と連携してキャンセルも可能。SetLimit で同時実行数も制御できる。

エラーハンドリングが必要な並行処理には errgroup を使うのがおすすめです。標準ライブラリではありませんが、準標準として広く使われています。インストールは go get golang.org/x/sync/errgroup で行えます。