🍣

errgroupのはまりどころと回避策

2021/12/19に公開

はじめに

errgroup を使うときに closures and groutines の話ではまってしまうよね、という自分向けの備忘録です。

golang.org/x/sync/errgroup

golang.org/x/sync/errgroup は、複数の goroutine を実行して、それらのうちにエラーがあったときにエラーを知る、ということを可能にしてくれるライブラリです。sync.WaitGroup は実行した goroutine が終わるのを待ちますが、エラーがあったかどうかはわかりません。各 goroutine で発生したエラーを知りたければ別途処理する必要があります。errgroupsync.WaitGroup+error といったイメージで、どれかの goroutine でエラーがあったら最初のひとつを知ることができます(要するに「全部がうまくいったかどうか?」を知ることができます)。ctx を組み合わせても使えるようになっているので、goroutine のどれかがエラーになったら処理を切り上げる、という使い方ができて便利です。

func main() {
    eg, ctx := errgroup.WithContext(context.TODO())
    for i := 0; i < 10; i++ {
        eg.Go(func() error {
            // eg.Go() で実行された関数にエラーを返すものがいれば、ctx はキャンセルされる
            return doSomething(ctx) // ので、この処理の中で ctx.Done() を受け取ったら適切に処理を停止する、というようにできる
        })
    }
    // すべての goroutine が終わるのを待って、エラーが発生していれば(最初の)エラーを返します
    if err := eg.Wait(); err != nil {
        fmt.Printf("error :%v\n", err)
    }
}

はまりどころ

とても便利な errgroup ですが、eg.Go() が関数を受け取るところがくせ者です。
いまは、単純のために ctx は扱わないで、10個の goroutine を起動して、エラーがあればそれを返すようなコードを書いてみましょう。
下のコードは動作はしますが、間違ったコードです。

func main(){
	eg := new(errgroup.Group)
	for i := 0; i < 10; i++ {
		eg.Go(func() error {
			if i == 5 || i == 7 {
				return fmt.Errorf("!!! error (%d)!!!", i)
			}
			return nil
		})
	}
	if err := eg.Wait(); err != nil {
		fmt.Println(err)
	}
}

for文で10個の goroutine を eg.Go() によって起動します。5番目と7番目の goroutine はエラーを返すようにしています。なので、eg.Wait() はエラーのうち最初に発生したどちらか(5か7)のエラーを表示します。

なので期待する出力は

 $ go run main.go
!!! error (5)!!!

もしくは、

 $ go run main.go
!!! error (7)!!!

になるはずです。ところが、何もエラーを返さずに終了することがあります。もっとわかりやすく i の値を表示するようにしてみましょう。

func main(){
	eg := new(errgroup.Group)
	for i := 0; i < 10; i++ {
		eg.Go(func() error {
			fmt.Println(i)
			if i == 5 || i == 7 {
				return fmt.Errorf("!!! error (%d)!!!", i)
			}
			return nil
		})
	}
	if err := eg.Wait(); err != nil {
		fmt.Println(err)
	}
}	

出力はこうなりました。

$ go run main.go
3
10
10
10
10
10
8
10
10
10

エラーにもなりません。i の値も期待と違います。

これは、Go のループ変数は繰り返しごとには作成されないということに由来しています。ループ変数はループ内で共通なので、これを goroutine のクロージャの中に入れてしまうと壊れてしまう、という Go で(僕が)最も難しいところのひとつと思っている問題です。Go のドキュメントにもclosures and groutinesとして解説があります。

どうやってこの間違いを避けるか?

いったん errgroup のことは忘れて、goroutine のこの問題をどうやって回避すればいいか考えてみましょう。それは Go のドキュメントにclosures and groutines に2つの方法が紹介されています。

解説で紹介されているコードは以下のようなコードです。これは goroutine で abc が表示されると思っているけど、やってみると c ばっかり表示されたりする、というような例です。

func main() {
    done := make(chan bool)

    values := []string{"a", "b", "c"}
    for _, v := range values {
        go func() {
            fmt.Println(v)
            done <- true
        }()
    }

    // wait for all goroutines to complete before exiting
    for _ = range values {
        <-done
    }
}

1. 変数を引数としてクロージャに渡す

このように修正します。こうすると、v は引数としてコピーされるので、意図通りの動作になります。

    for _, v := range values {
        go func(x string) {
            fmt.Println(x)
            done <- true
        }(v)
    }

2. コピーしてから使う

奇妙なコピーですが、これで正しく動きます。

    for _, v := range values {
        v := v // 新しい v が作られる(for の v とは別物)
        go func() {
            fmt.Println(v)
            done <- true
        }()
    }

もちろん v := v でなくて、もっとわかりやすく x := v として、クロージャの中に v を使わないようにしてもいいです。でもコード書いていると、v のまま使いたいことが多いので v := v してしまうことが多い気がします。

どっちが好み? 好みの問題か?

僕は関数の引数で渡すのが好みです。なぜなら、v := v みたいなのは忘れてしまうので・・・。それよりも、好みの方法で解決するのはよいとして、問題なのはそもそもこの現象に気づきにくいということではないでしょうか?

Go ではこれに気づけるように go vet で警告するようになっています。クロージャの中にループの変数を入れたコードがあると次のように警告してくれます。

$ go vet main.go
# command-line-arguments
./main.go:16:16: loop variable i captured by func literal

errgroup.Go() ではどうしたらいいか?

では、最初に戻って、errgroup の場合を考えてみましょう。eg.Go() は関数を受け取るので、ここにクロージャを渡したければ、v := v のスタイルでコピーしておいてやるしかないように思えますが、関数の引数で渡したい場合は、eg.Go() 自体を関数の中に入れてしまって、引数経由で渡してやることもできます。

func main() {
	eg := new(errgroup.Group)
	for i := 0; i < 10; i++ {
		func(i int) {
			eg.Go(func() error {
				fmt.Println(i)
				if i == 5 || i == 7 {
					return fmt.Errorf("!!! error (%d)!!!", i)
				}
				return nil
			})
		}(i)
	}
	if err := eg.Wait(); err != nil {
		fmt.Println(err)
	}
}

変数をコピーするだけなのに関数でラップするのはどうか、というのは意見が分かれそうなところですが、errgroup のときもいつもの goroutine の作法に従いたければこの方法もありかなと思います。

やはり、奇妙なコピーのほうがネストも深くならずよいでしょうか?

func main() {
	eg := new(errgroup.Group)
	for i := 0; i < 10; i++ {
		i := i
		eg.Go(func() error {
			fmt.Println(i)
			if i == 5 || i == 7 {
				return fmt.Errorf("!!! error (%d)!!!", i)
			}
			return nil
		})
	}
	if err := eg.Wait(); err != nil {
		fmt.Println(err)
	}
}

まとめ

goroutine 使ったり、errgroup 使ったりするときは絶対 go vet しような!

とあらためて思いました。

Happy hacking!

Discussion