All Benchmarks

Go Benchmark

WaitGroup vs Errgroup vs Semaphore

What the popular goroutine fan-out patterns cost per batch of tasks.

waitgrouperrgroupsemaphoregoroutinesfan-outconcurrency

Fanning out N tasks to goroutines and waiting for all of them is the bread-and-butter concurrency pattern in Go, and there are three popular ways to coordinate it: sync.WaitGroup, errgroup.Group from golang.org/x/sync, and a weighted semaphore.Weighted. This benchmark launches 100 trivial tasks per iteration with each primitive, isolating the pure coordination overhead: goroutine spawning, counter bookkeeping, and the final wait. The differences are real but small compared to any actual work the tasks would do — so the practical takeaway is to pick the primitive whose semantics you need (error propagation, concurrency limits) rather than the one that microbenchmarks fastest.

linux/amd64AMD Ryzen 9 9950X3D 16-Core Processorbenchmarks/waitgroup-vs-errgroup-vs-semaphore
Compare atCPUs

1 CPU

Fastest

Wait Group

12.91 µs/op

Slowest

Semaphore

13.93 µs/op

32 CPUs

Fastest

Wait Group

24.01 µs/op

Slowest

Semaphore

25.25 µs/op

Performance Comparison (lower is better)
CPU:
#1

Wait Group #

Fastest

The classic pattern: wg.Add(n) up front, wg.Done() in each goroutine, wg.Wait() at the end. A WaitGroup is a single atomic counter with no allocations per task, making it the leanest option — but it only counts; errors and panics from the tasks are entirely your problem.

CPU Scaling (lower is better)
// taskCount is the number of goroutines each fan-out spawns per iteration.
const taskCount = 100

func BenchmarkWaitGroup_run(b *testing.B) {
	var done atomic.Int64
	b.ResetTimer()
	for i := 0; i < b.N; i++ {
		var wg sync.WaitGroup
		wg.Add(taskCount)
		for j := 0; j < taskCount; j++ {
			go func() {
				done.Add(1)
				wg.Done()
			}()
		}
		wg.Wait()
	}
	sink = done.Load()
}
1 CPU
1.1xfaster(8%)thanErrgroup
1.1xfaster(8%)thanSemaphore
32 CPUs
1xfaster(3%)thanErrgroup
1.1xfaster(5%)thanSemaphore
#2

Errgroup #

errgroup.Group from golang.org/x/sync. g.Go wraps each task in a closure that captures the first returned error, and g.Wait() returns it. Under the hood it is a WaitGroup plus a sync.Once for the error, so the overhead over a plain WaitGroup is modest — a fair price for error propagation, and it also supports context cancellation and concurrency limits via SetLimit.

CPU Scaling (lower is better)
// taskCount is the number of goroutines each fan-out spawns per iteration.
const taskCount = 100

func BenchmarkErrgroup_run(b *testing.B) {
	var done atomic.Int64
	b.ResetTimer()
	for i := 0; i < b.N; i++ {
		var g errgroup.Group
		for j := 0; j < taskCount; j++ {
			g.Go(func() error {
				done.Add(1)
				return nil
			})
		}
		_ = g.Wait()
	}
	sink = done.Load()
}
1 CPU
Same speed asSemaphore
1.1xslower(8%)thanWait Group
32 CPUs
1xfaster(2%)thanSemaphore
1xslower(3%)thanWait Group
#3

Semaphore #

Slowest

semaphore.Weighted from golang.org/x/sync: acquire one unit before launching each task, release it when the task finishes, then acquire the full weight to wait for stragglers. Each Acquire/Release takes an internal mutex, which costs more than the atomic counters above — but this is the only pattern here that can also cap how many tasks run at once by lowering the semaphore's weight.

CPU Scaling (lower is better)
// taskCount is the number of goroutines each fan-out spawns per iteration.
const taskCount = 100

func BenchmarkSemaphore_run(b *testing.B) {
	var done atomic.Int64
	ctx := context.Background()
	sem := semaphore.NewWeighted(taskCount)
	b.ResetTimer()
	for i := 0; i < b.N; i++ {
		for j := 0; j < taskCount; j++ {
			_ = sem.Acquire(ctx, 1)
			go func() {
				done.Add(1)
				sem.Release(1)
			}()
		}
		// Acquiring the full weight waits until all tasks have released.
		_ = sem.Acquire(ctx, taskCount)
		sem.Release(taskCount)
	}
	sink = done.Load()
}
1 CPU
Same speed asErrgroup
1.1xslower(8%)thanWait Group
32 CPUs
1xslower(2%)thanErrgroup
1.1xslower(5%)thanWait Group

Contributors