Goのerrgroupとsync.WaitGroupの使い分け(WaitGroup.Go、WithContext、SetLimit)

Go
スポンサーリンク

Goで複数のgoroutineを動かして終わるのを待つとき、sync.WaitGroup と errgroup.Group(golang.org/x/sync/errgroup)のどちらを使うかを、待ち方・エラーの伝わり方・取消しの違いで整理しました。同じ3つの処理を両方で動かし、いつ Wait が戻るか、どのエラーが返るか、取消しで何が止まるかを経過時間つきで比べています。Go 1.25で追加された WaitGroup.Go と、同時に動かす数を絞る SetLimit も使います。

結論

A(300msで成功)、B(100msで失敗)、C(200msで失敗)の3つの処理を同時に動かしたときの結果です。

やりたいこと使うものWait が戻った時間返ったエラー
終わるのを待つだけsync.WaitGroup(Go 1.25以降は wg.Go)300msなし(返す仕組みがない)
全部のエラーを知りたいsync.WaitGroup と、エラーを入れるスライス、errors.Join300msBとCの両方
最初のエラーがわかればよいerrgroup.Group300msBだけ
1つが失敗したら残りをやめたいerrgroup.WithContext と、ctxを見て終わる処理100msBだけ
同時に動かす数を絞りたいerrgroup.Group の SetLimit(下の章)(下の章)
  • errgroup.Group の Wait も、全部の処理が終わるまで待ちます。最初のエラーが起きても、ほかの処理は止まりません。
  • WithContext がするのは、最初のエラーで ctx を取り消すことだけです。処理を途中で終わらせるのは、ctx を見ている処理自身のコードです。ctx を見ない処理は、WithContext を使っても300msまで動き続けました。
  • SetLimit で上限に達しているあいだは、g.Go の呼び出しそのものが、空きが出るまで戻りません。

動作確認した環境

  • Go 1.27.3(Dockerイメージ golang:1.27.3。digestは sha256:e519d3b0a5b285e5aa53ab9eef02bc6bbb9cb04608389b743a136a9ce75f0d1e)
  • golang.org/x/sync v0.24.0
  • Docker 29.7.2(Windows 11 の Docker Desktop、Linuxコンテナの設定、x86_64)
  • 確認日: 2026年11月9日

この記事は、MacとWindowsの両方の読者を対象にしています。Goのコードとgoのコマンドは、MacでもWindowsでも同じです。この記事のコマンドは、Claude(AI)が実行して確認しました。本文の出力は、Windows 11 のGit BashからDockerのコンテナを起動し、その中で上から順に実行した結果です。WindowsのPowerShell 7 では、下の起動のコマンドから -it と bash を除いてコマンドを直接渡し、フォルダが見えることと、go run . eg-ctx の結果が本文と同じことを確かめました。Macでは確認していません。

経過時間は10ms単位に丸めて表示しています。同時に動く処理の表示の順番や、ぴったり同じ時間に起きたことの順番は、実行のたびに変わることがあります。

準備

空のフォルダに、共通の部分を書いた main.go を作ります。各章のファイル(wg.go など)は、章ごとに同じフォルダに追加していきます。それぞれのファイルの init で、go run . wg-add のように名前で呼べるように登録します。

run は ctx を見ない処理、runCtx は ctx を見て、取り消されたら途中で終わる処理です。どちらも tasks の3つ(A、B、C)を使います。

package main

import (
	"context"
	"fmt"
	"os"
	"sync"
	"time"
)

// demos には、各ファイルの init で「名前 → 関数」を登録する
var demos = map[string]func(){}

var (
	start time.Time
	logMu sync.Mutex
)

// logf は、開始からの経過時間(10ミリ秒単位に丸める)をつけて1行表示する
func logf(format string, args ...any) {
	logMu.Lock()
	defer logMu.Unlock()
	ms := time.Since(start).Round(10 * time.Millisecond).Milliseconds()
	fmt.Printf("%4dms  %s\n", ms, fmt.Sprintf(format, args...))
}

// task は、時間のかかる処理(API の呼び出しなど)の代わり
type task struct {
	name string
	d    time.Duration // かかる時間
	fail bool          // true なら最後にエラーを返す
}

// A は300ms で成功、B は100ms で失敗、C は200ms で失敗する
var tasks = []task{
	{"A", 300 * time.Millisecond, false},
	{"B", 100 * time.Millisecond, true},
	{"C", 200 * time.Millisecond, true},
}

// run は ctx を見ない。始めたら、d が過ぎるまで終わらない
func run(t task) error {
	time.Sleep(t.d)
	return finish(t)
}

// runCtx は ctx を見る。d が過ぎる前に取り消されたら、そこで終わる
func runCtx(ctx context.Context, t task) error {
	timer := time.NewTimer(t.d)
	defer timer.Stop()
	select {
	case <-timer.C:
		return finish(t)
	case <-ctx.Done():
		logf("%s: 取り消されたので終了", t.name)
		return ctx.Err()
	}
}

func finish(t task) error {
	if t.fail {
		logf("%s: エラー", t.name)
		return fmt.Errorf("%s failed", t.name)
	}
	logf("%s: 完了", t.name)
	return nil
}

func main() {
	if len(os.Args) < 2 || demos[os.Args[1]] == nil {
		fmt.Println("usage: go run . <demo>")
		os.Exit(2)
	}
	start = time.Now()
	demos[os.Args[1]]()
}

このフォルダで、Goのコンテナを起動します。フォルダをコンテナの /src に見せるので、ホストのエディタでファイルを追加すると、コンテナからも見えます。Macのターミナルでは次のとおりです(Macでは未確認。$(pwd) はDockerの公式ドキュメントのバインドマウントの例と同じ書き方です)。

$ docker run --rm -it -v "$(pwd):/src" -w /src golang:1.27.3 bash

WindowsのPowerShellでは、$(pwd) を ${PWD} にします。

PS> docker run --rm -it -v "${PWD}:/src" -w /src golang:1.27.3 bash

WindowsのGit Bashから起動する場合は、Git Bashが /src のような引数をWindowsのパスに変換しないようにします(Dockerの公式ドキュメントのトラブルシューティングにある MSYS_NO_PATHCONV=1)。本文の確認では、export MSYS_NO_PATHCONV=1 をしたうえで、フォルダを -v "$(pwd -W):/src" で指定しました。

ここから先の /src# で始まる行は、コンテナの中のシェルで打つコマンドです。モジュールを作ります。

/src# go version
go version go1.27.3 linux/amd64
/src# go mod init example.com/errgroup-demo
go: creating new go.mod: module example.com/errgroup-demo
go: to add module requirements and sums:
	go mod tidy

手元にGoを入れている場合は、コンテナを使わずに同じ go のコマンドで動かせるはずです(確認はしていません)。WaitGroup.Go にはGo 1.25以降、この記事の golang.org/x/sync v0.24.0 にはGo 1.26以降が必要です(v0.24.0 の go.mod の指定)。

sync.WaitGroup:終わるのを待つ

wg.go です。Add と Done で数える書き方、Go 1.25で追加された wg.Go を使う書き方、エラーを自分で集める書き方の3つです。

package main

import (
	"errors"
	"fmt"
	"sync"
)

func init() {
	demos["wg-add"] = wgAdd
	demos["wg-go"] = wgGo
	demos["wg-errors"] = wgErrors
}

// Add と Done で数える書き方(Go 1.24 までの定番)
func wgAdd() {
	var wg sync.WaitGroup
	for _, t := range tasks {
		wg.Add(1) // goroutine を起動する前に数を増やす
		go func() {
			defer wg.Done()
			_ = run(t) // WaitGroup には、エラーを返す仕組みがない
		}()
	}
	wg.Wait()
	logf("Wait が戻った")
}

// Go 1.25 で追加された WaitGroup.Go を使う書き方
func wgGo() {
	var wg sync.WaitGroup
	for _, t := range tasks {
		wg.Go(func() {
			_ = run(t)
		})
	}
	wg.Wait()
	logf("Wait が戻った")
}

// WaitGroup で、全部のエラーを集める
func wgErrors() {
	var wg sync.WaitGroup
	errs := make([]error, len(tasks))
	for i, t := range tasks {
		wg.Go(func() {
			errs[i] = run(t) // goroutine ごとに書き込む場所を分ける
		})
	}
	wg.Wait()
	logf("Wait が戻った")
	fmt.Println("err:", errors.Join(errs...))
}
/src# go run . wg-add
 100ms  B: エラー
 200ms  C: エラー
 300ms  A: 完了
 300ms  Wait が戻った
/src# go run . wg-go
 100ms  B: エラー
 200ms  C: エラー
 300ms  A: 完了
 300ms  Wait が戻った

どちらも、いちばん遅いAが終わった300msで Wait が戻りました。BとCはエラーを返していますが、WaitGroup にはエラーを受け取る仕組みがないので、呼び出し側には伝わりません。

wg.Go(f) は、Add(1)、goroutineの起動、f が戻ったときの Done をまとめたものです。公式ドキュメントでは、Add と Done より Go を使うよう勧めています。Add を使う場合は、数が0のときの Add を Wait より前に呼ぶ必要があり、ふつうはgoroutineを起動する文の前に書きます。wg.Go に渡す関数は、panicしてはいけないとされています。

ループの中の関数で t や i をそのまま使っているのは、Go 1.22から for のループ変数が繰り返しごとに別の変数になったためです。

全部のエラーを集める

wgErrors は、goroutineごとに errs の別の要素へ結果を書き込み、Wait の後で errors.Join にまとめます。公式ドキュメントによると、wg.Go に渡した関数が戻ることは、それによって戻る Wait より前に起きます(Goのメモリモデルの「synchronizes before」)。書き込む場所が重ならず、読むのは Wait の後なので、ロックは使っていません。

/src# go run . wg-errors
 100ms  B: エラー
 200ms  C: エラー
 300ms  A: 完了
 300ms  Wait が戻った
err: B failed
C failed

BとCの両方のエラーが返りました(errors.Join は改行でつないで表示します)。全部の処理を最後まで動かし、失敗をすべて知りたいときは、この形が使えます。

errgroup.Group:最初のエラーを返す

eg.go です。ゼロ値の errgroup.Group で、同じ3つの処理を動かします。g.Go には、error を返す関数を渡します。

package main

import (
	"fmt"

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

func init() {
	demos["eg-first"] = egFirst
}

// ゼロ値の errgroup.Group。取消しも同時数の制限もない
func egFirst() {
	var g errgroup.Group
	for _, t := range tasks {
		g.Go(func() error {
			return run(t)
		})
	}
	err := g.Wait()
	logf("Wait が戻った")
	fmt.Println("err:", err)
}

errgroup は標準ライブラリではないので、版を指定して追加し、go mod tidy で整えます。

/src# go get golang.org/x/sync@v0.24.0
go: downloading golang.org/x/sync v0.24.0
go: added golang.org/x/sync v0.24.0
/src# go mod tidy
/src# cat go.mod
module example.com/errgroup-demo

go 1.27.3

require golang.org/x/sync v0.24.0
/src# go run . eg-first
 100ms  B: エラー
 200ms  C: エラー
 300ms  A: 完了
 300ms  Wait が戻った
err: B failed

Bが100msで失敗した後も、Cは200msまで、Aは300msまで動き、Wait は300msで戻りました。返ったのは最初のエラー(B)だけで、Cのエラーは返りません。公式ドキュメントでも、Wait は全部の関数が戻るまで待ち、最初のnilでないエラーを返す、とされています。また、ゼロ値の Group は、エラーで取り消しをしません。

errgroup.WithContext:残りを取り消す

ctx.go です。どちらも errgroup.WithContext を使いますが、egCtxIgnore の処理は ctx を見ず、egCtx の処理は ctx を見ます(main.go の runCtx)。

package main

import (
	"context"
	"fmt"

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

func init() {
	demos["eg-ctx-ignore"] = egCtxIgnore
	demos["eg-ctx"] = egCtx
}

// WithContext を使うが、各処理が ctx を見ない
func egCtxIgnore() {
	g, _ := errgroup.WithContext(context.Background())
	for _, t := range tasks {
		g.Go(func() error {
			return run(t)
		})
	}
	err := g.Wait()
	logf("Wait が戻った")
	fmt.Println("err:", err)
}

// WithContext を使い、各処理が ctx を見て終わる
func egCtx() {
	g, ctx := errgroup.WithContext(context.Background())
	for _, t := range tasks {
		g.Go(func() error {
			return runCtx(ctx, t)
		})
	}
	err := g.Wait()
	logf("Wait が戻った")
	fmt.Println("err:", err)
	fmt.Println("ctx.Err():", ctx.Err())
	fmt.Println("context.Cause(ctx):", context.Cause(ctx))
}

ctx を見ない処理は止まらない

/src# go run . eg-ctx-ignore
 100ms  B: エラー
 200ms  C: エラー
 300ms  A: 完了
 300ms  Wait が戻った
err: B failed

ゼロ値の Group と同じく、300msまで全部が動きました。Bの失敗で ctx は取り消されていますが、run は time.Sleep で待っていて ctx を見ないので、止まりません。

ctx を見る処理は、取消しで終わる

/src# go run . eg-ctx
 100ms  B: エラー
 100ms  C: 取り消されたので終了
 100ms  A: 取り消されたので終了
 100ms  Wait が戻った
err: B failed
ctx.Err(): context canceled
context.Cause(ctx): B failed

Bが失敗した100msで、AとCが取り消しに気づいて終わり、Wait も100msで戻りました。公式ドキュメントによると、WithContext が返す ctx は、g.Go に渡した関数が初めてnilでないエラーを返したとき、または Wait が初めて戻ったときの、どちらか早いほうで取り消されます。

AとCは ctx.Err()(context canceled)を返していますが、Wait が返したのは最初のエラーの B failed です。また、context.Cause(ctx) で取消しの原因として B failed が取れました。v0.24.0 のソースでは、WithContext は context.WithCancelCause を使い、最初のエラーを原因にして取り消しています。

エラーがなくても、Wait が戻った時点でこの ctx は取り消されます。Wait の後の処理には、この ctx を使い回さないようにします。

SetLimit:同時に動かす数を絞る

limit.go です。egLimit は、100msかかる6個の処理を、SetLimit(2) で同時に2個までにして動かします。実行中の数と、その最大値を記録しています。egLimitCtx は、WithContext と組み合わせ、task1が50msで失敗するようにしたものです。

package main

import (
	"context"
	"fmt"
	"sync"
	"time"

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

func init() {
	demos["eg-limit"] = egLimit
	demos["eg-limit-ctx"] = egLimitCtx
}

// 6個の処理(各100ms)を、同時に2個までで動かす
func egLimit() {
	var g errgroup.Group
	g.SetLimit(2)

	var mu sync.Mutex
	active, maxActive := 0, 0
	for i := 1; i <= 6; i++ {
		g.Go(func() error {
			mu.Lock()
			active++
			maxActive = max(maxActive, active)
			n := active
			mu.Unlock()
			logf("task%d: 開始(このとき %d 個が実行中)", i, n)

			time.Sleep(100 * time.Millisecond)

			mu.Lock()
			active--
			mu.Unlock()
			return nil
		})
	}
	logf("6回目の g.Go から戻った")
	err := g.Wait()
	logf("Wait が戻った")
	fmt.Println("err:", err, "/ 同時に動いた最大の数:", maxActive)
}

// SetLimit と WithContext を組み合わせ、task1 が50ms で失敗する
func egLimitCtx() {
	g, ctx := errgroup.WithContext(context.Background())
	g.SetLimit(2)
	for i := 1; i <= 6; i++ {
		if ctx.Err() != nil { // 取り消されたら、残りは g.Go に渡さない
			logf("task%d から後は始めない", i)
			break
		}
		t := task{fmt.Sprintf("task%d", i), 100 * time.Millisecond, false}
		if i == 1 {
			t.d, t.fail = 50*time.Millisecond, true
		}
		g.Go(func() error {
			logf("%s: 開始", t.name)
			return runCtx(ctx, t)
		})
	}
	err := g.Wait()
	logf("Wait が戻った")
	fmt.Println("err:", err)
}
/src# go run . eg-limit
   0ms  task2: 開始(このとき 1 個が実行中)
   0ms  task1: 開始(このとき 2 個が実行中)
 100ms  task4: 開始(このとき 1 個が実行中)
 100ms  task3: 開始(このとき 2 個が実行中)
 200ms  task5: 開始(このとき 2 個が実行中)
 200ms  6回目の g.Go から戻った
 200ms  task6: 開始(このとき 2 個が実行中)
 300ms  Wait が戻った
err: <nil> / 同時に動いた最大の数: 2

0ms、100ms、200msに2個ずつ始まり、全体は300msかかりました。同時に動いた数の最大は2です。「6回目の g.Go から戻った」が200msなので、ループの中の g.Go は、空きが出るまで戻らずに待っていたことがわかります。公式ドキュメントでも、上限に達しているあいだ g.Go はブロックするとされています。待たずに、空きがなければあきらめたいときは TryGo があります(起動できたかを bool で返します)。

SetLimit と WithContext を組み合わせる

/src# go run . eg-limit-ctx
   0ms  task2: 開始
   0ms  task1: 開始
  50ms  task1: エラー
  50ms  task4 から後は始めない
  50ms  task3: 開始
  50ms  task3: 取り消されたので終了
  50ms  task2: 取り消されたので終了
  50ms  Wait が戻った
err: task1 failed

task1が50msで失敗すると、実行中だったtask2は取り消しで終わりました。task3は、ループの中の g.Go で空きを待っていたので、取り消しの後に始まり、すぐに取り消しに気づいて終わりました。g.Go は、ctx が取り消された後でも、渡された関数を起動するということです。

残りを始めたくないときは、この例のように、g.Go を呼ぶ前に ctx.Err() を見てループを抜けます。今回はtask4の前で抜けました。ただし、この確認で止められるのは、確認した時点ですでに取り消されている場合だけです。task3のように、確認の後や、g.Go で空きを待っているあいだに取り消されたものは起動されるので、処理の側でも ctx を見る必要があります。「task4 から後は始めない」が「task3: 開始」より先に表示されているのは、g.Go が空きを取った時点で戻り、task3のgoroutineが表示するより先に、ループが次に進んだためです。

go test -race で競合を見つける

複数のgoroutineが、同期(sync.Mutex など)なしに同じ変数を読み書きし、少なくとも1つが書き込みのとき、データ競合になります。race_test.go で、100個のgoroutineから count を増やすテストを書きます。

package main

import (
	"sync"
	"testing"
)

func TestCount(t *testing.T) {
	var wg sync.WaitGroup
	count := 0
	for range 100 {
		wg.Go(func() {
			count++ // 複数の goroutine が同じ変数を書き換える
		})
	}
	wg.Wait()
	if count != 100 {
		t.Errorf("count = %d, want 100", count)
	}
}

-race をつけてテストを実行します。出力は長いので、1つ目の警告の先頭と、最後の部分だけを載せます(途中を「(略)」としました)。

/src# go test -race .
==================
WARNING: DATA RACE
Read at 0x00c000194178 by goroutine 9:
  example.com/errgroup-demo.TestCount.func1()
      /src/race_test.go:13 +0x2e

Previous write at 0x00c000194178 by goroutine 19:
  example.com/errgroup-demo.TestCount.func1()
      /src/race_test.go:13 +0x44
(略)
--- FAIL: TestCount (0.00s)
    testing.go:1867: race detected during execution of test
FAIL
FAIL	example.com/errgroup-demo	0.010s
FAIL

今回の実行では count は100になったので、t.Errorf のメッセージは出ていません。それでも -race は、13行目(count++)の読み書きの競合を検出し、テストを失敗にしました。公式ドキュメントによると、race detector が見つけられるのは実行中に実際に起きた競合だけで、実行されなかったコードの競合は見つけられません。

sync.Mutex で守るように直します。

package main

import (
	"sync"
	"testing"
)

func TestCount(t *testing.T) {
	var wg sync.WaitGroup
	var mu sync.Mutex
	count := 0
	for range 100 {
		wg.Go(func() {
			mu.Lock()
			count++
			mu.Unlock()
		})
	}
	wg.Wait()
	if count != 100 {
		t.Errorf("count = %d, want 100", count)
	}
}
/src# go test -race .
ok  	example.com/errgroup-demo	1.011s

-race にはcgoが必要で、Mac以外ではCコンパイラも必要です(公式ドキュメント)。今回の golang:1.27.3 のイメージでは、そのまま実行できました。Windowsで直接 -race を使う場合は、mingw-w64 のランタイムライブラリの版8以降を含むCコンパイラが必要とされています(確認していません)。また、公式ドキュメントでは、ふつうのプログラムでメモリの使用量が5〜10倍、実行時間が2〜20倍になることがあるとされています。

ほかに気をつけること

  • errgroup.Group は、別の仕事のために使い回さないこととされています(公式ドキュメント)。
  • SetLimit の上限は、グループのgoroutineが動いているあいだに変えてはいけないとされています(公式ドキュメント)。この記事のコードでは、g.Go を呼ぶ前に設定しています。
  • v0.24.0 の errgroup のソースには、関数の中のpanicを Wait を呼んだ側へ伝えない理由がコメントで書かれています。panicの扱いは、この記事では試していません。

参考

スポンサーリンク
Go
スポンサーリンク
シェアする
hayoをフォローする

コメント

タイトルとURLをコピーしました