音速きなこおはぎ

技術ブログです。

Go の goroutine / channel は全然簡単じゃないので errgroup を使おう

技術記事です。今日は Go の golang.org/x/sync/errgroup についてです。

TL; DR

  • Go が並行処理を得意とするのは事実だけど、とはいえ正しく使うのは難しい(特に channel)。
  • errgroup なら「並行でダウンロードする」のような頻出パターンをとても簡単かつ安全に使えるので、まずはこれで美味しいところだけ頂いてしまおう。
  • 重い処理を並行にすればあなたのプログラムはカジュアルに数倍速くなる。
  • 多分 errgroup だけで現実の要件の85%くらいはカバーできるはず。
  • channel も含めてちゃんと使いこなしたいと思ったら、Go 言語による並行処理 がおすすめです。

errgroup とは

ドキュメントはここを参照してください。説明を読むよりコード例で見たほうが早いと思うのでこちらをどうぞ。

package main

import (
    "fmt"
    "net/http"

    "golang.org/x/sync/errgroup"
)

func main() {
    eg := errgroup.Group{} // STEP 1. グループを作る
    urls := []string{
        "http://www.golang.org/",
        "http://www.google.com/",
        "http://www.そんなものはない.hoge/",
    }
    for _, url := range urls {
        u := url // 注1
        eg.Go(func() error { // STEP 2. goroutine をどんどん起動する
            // GET する。
            // 前の GET の終了を待たずにどんどん並行で起動する。
            resp, err := http.Get(u)
            if err == nil {
                resp.Body.Close()
            }
            return err
        })
    }
    // STEP 3. すべての GET が終わるのを待つ
    if err := eg.Wait(); err != nil {
        log.Fatal(err)
    }
}

pkg.go.dev の Example をまんま引用しただけですが、少し解説を加えます。

STEP 1. グループを作る

errgroup.Group を作ります。特別な初期化等は必要ありません。

STEP 2. goroutine をどんどん起動する

関数を渡して goroutine を起動します。関数の終了を待たずに並行で goroutine (軽量スレッド) をどんどん起動しているので、単純に一個一個処理を行うより素早く完了できるというわけです。

u := url について

for 文でループして取り出している値(ここでは url)は、goroutine にそのまま渡すとバグるので、ここで u という別の変数にコピーして goroutine で使います。

余談ですが、この挙動は厄介な問題として認識されており、Go 1.21 あたりで「一旦代入する」手間をかけずとも意図通り動くように修正される計画があります。早く来てほしいー。(issue)

STEP 3. すべての GET が終わるのを待つ

最後に eg.Wait ですべての goroutine の終了を待ちます。すべての関数が成功だったら eg.Waitnil を返しますが、もしエラーを返した関数があれば(ここでは "http://www.そんなものはない.hoge/" の GET が該当するだろう)、eg.Wait はそれを返します。

複数エラーを返した関数があれば、それらのうち最初の一つだけを返します。もしすべてのエラーを知る必要があるなら、multierrgroup などを使うことを検討してください(あんまないと思うけど)。

errgroup の基本の使い方はこれだけです。多分これだけで現実の要件の85%くらいはカバーできると思います(適当)。どんどん並行処理を使ってあなたのプログラムをカジュアルに数倍速にしちゃいましょう。

errgroup の嬉しいところ

Go の並行処理は N:M モデルと言われ、他の言語のようにOSのスレッドやプロセスを起動するのではなく軽量な goroutine を起動するため、何個起動しても大丈夫という点が非常に嬉しいですね。大量のファイルへの並行書き込みなども、基本的には数を気にせずバンバン起動してしまってよいです。goroutine は湯水の如く使いましょう!

また、標準の sync.WaitGroup と比べても、「エラーを受け取る」「関数を渡す」という二点のおかげでより扱いやすくなっているので、実質的な上位互換です。WaitGroup より知名度が低いのだけが難点ですが、今日からはぜひ errgroup を使っていただきたいと思います。

Go の優れた言語機能の上に立脚しつつ、扱いやすくまとめられているのを指して、「美味しいところだけ頂こう」というわけです。

Context について

上記のコード例では、goroutine を起動したら起動しっぱなしで、途中キャンセルできません。どれか一つの処理でエラーが起きたら、他の処理を進める意味はないのでキャンセルしたいという場面は多いでしょう。

そんな要求にも errgroup は対応しています。グループを作る際に errgroup.WithContext でコンテキストを仕込み、各 goroutine で context によるキャンセルに対応すれば良い……のですが、この記事では割愛してこちらの記事に譲ります。なぜなら難しいから(´・ω・`)

しかしいつかは context から逃げられない日が来ると思うので、簡単にポイントだけかいつまんで置いておきます。

  • context はあくまでイディオムであり言語機能ではない。
  • context を渡すと、渡した先の関数が勝手にキャンセルに対応するわけではない。
  • あくまで context があると「context を通じてキャンセルされたかどうかを知ることができるよ」というだけなので、それを知って正しくキャンセルする処理を実装する必要がある。

その他 Go の並行処理機能について

goroutine も channel も、あとついでに sync や atomic や context も Go らしさを形成する非常に重要な機能なのですが、いきなり直接扱うのは困難です。

errgroup でカバーできない要件に遭遇したら、是非 Go 言語による並行処理という名著を読んで勉強していただきたいです。

www.oreilly.co.jp

Go 言語による並行処理は良本。

一通り習得したら、必要に応じて mattn さんのブログ記事など読みに行くとよいと思います。自分はそうしています。

mattn.kaoriya.net

それと errgroup 自身のコードを読むのも勉強になるかもしれません。実はけっこうシンプルなんですよ。pkg.go.dev でシンボル名をクリックするとソースにアクセスできます。

おしまい

Go の並行処理は簡単安全と言われており、たしかに事実だし私も同意するのですが、並行処理がそもそも難しいおかげでやっぱりいきなりだと難しいと思います。そんなときはまず errgroup を使いましょう。他は必要になったら勉強すればいいかと。