Go Benchmark
WaitGroup vs Errgroup vs Semaphore
What the popular goroutine fan-out patterns cost per batch of tasks.
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.
1 CPU
32 CPUs
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.
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.
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.
Contributors