Bounded Parallelism Patterns in Go Compared Honestly
Compare four ways to cap concurrency in Go: a semaphore channel, a worker pool, errgroup SetLimit and semaphore.Weighted, with per task overhead.

Every Go service eventually needs to run "a lot of things, but not all at once". Process ten thousand files with at most eight open. Call a downstream API with no more than twenty requests in flight. Resize images with one worker per core. The standard answers form a small family, and the advice you find online tends to present one member as the right answer. This article compares the four honestly: how each one looks, what each costs per task on a benchmark, and which question decides between them.
Tested with Go 1.25.6, golang.org/x/sync v0.19.0, Apple M1 Max (10 cores), macOS on arm64
It builds on worker pools, semaphores, errgroup and SetLimit, and belongs to the concurrency patterns cluster. Everything ran on Go 1.25.6 with golang.org/x/sync v0.19.0 on an Apple M1 Max.
The four patterns
Each version runs n tasks with at most limit active at once. The task itself is a function value, so the same benchmark can drive all four.
1. Semaphore channel plus a goroutine per task. A buffered channel of capacity limit is the semaphore: send to acquire, receive to release. The loop that submits work blocks when the channel is full, so it also paces itself.
2. Fixed worker pool. Start limit goroutines that read tasks from a channel until it closes. The tasks are sent down the channel, and goroutines are reused.
3. errgroup with SetLimit. The group does the counting; g.Go blocks while limit tasks are active, and Wait joins them.
4. semaphore.Weighted. From golang.org/x/sync/semaphore: Acquire(ctx, n) blocks until n units are free, Release(n) returns them. A task's weight can be anything.
package main
import (
"context"
"sync"
"golang.org/x/sync/errgroup"
"golang.org/x/sync/semaphore"
)
func viaChannel(n, limit int, task func()) {
sem := make(chan struct{}, limit)
var wg sync.WaitGroup
for range n {
sem <- struct{}{} // acquire; blocks the submitter when full
wg.Go(func() {
defer func() { <-sem }()
task()
})
}
wg.Wait()
}
func viaPool(n, limit int, task func()) {
jobs := make(chan struct{})
var wg sync.WaitGroup
for range limit {
wg.Go(func() {
for range jobs {
task()
}
})
}
for range n {
jobs <- struct{}{}
}
close(jobs)
wg.Wait()
}
func viaErrgroup(n, limit int, task func()) {
var g errgroup.Group
g.SetLimit(limit)
for range n {
g.Go(func() error {
task()
return nil
})
}
g.Wait()
}
func viaWeighted(n, limit int, task func()) {
sem := semaphore.NewWeighted(int64(limit))
ctx := context.Background()
var wg sync.WaitGroup
for range n {
sem.Acquire(ctx, 1)
wg.Go(func() {
defer sem.Release(1)
task()
})
}
wg.Wait()
}First, correctness: do they respect the limit
Before comparing speed, confirm each one does what it claims. The program below runs 200 tasks with a limit of 4 through each pattern and records the highest number of tasks that were ever running at the same time:
package main
import (
"fmt"
"sync/atomic"
"time"
)
func main() {
patterns := []struct {
name string
run func(n, limit int, task func())
}{
{"semaphore channel", viaChannel},
{"worker pool", viaPool},
{"errgroup SetLimit", viaErrgroup},
{"semaphore.Weighted", viaWeighted},
}
for _, p := range patterns {
var running, peak atomic.Int32
task := func() {
n := running.Add(1)
for {
old := peak.Load()
if n <= old || peak.CompareAndSwap(old, n) {
break
}
}
time.Sleep(time.Millisecond)
running.Add(-1)
}
start := time.Now()
p.run(200, 4, task)
fmt.Printf("%-19s peak concurrency %d, %v\n", p.name, peak.Load(), time.Since(start).Round(10*time.Millisecond))
}
}semaphore channel peak concurrency 4, 70ms
worker pool peak concurrency 4, 70ms
errgroup SetLimit peak concurrency 4, 80ms
semaphore.Weighted peak concurrency 4, 70msAll four hold the peak at 4 and finish 200 one-millisecond tasks in 70 to 80 ms: 200 tasks over 4 slots is 50 rounds, and a 1 ms sleep lasts a little longer than 1 ms. For tasks that take real time, which is nearly every task that justifies bounding in the first place, the patterns are indistinguishable in throughput. That is the first honest finding: if your tasks take a millisecond or more, pick on semantics, not on speed.
Overhead per task
For very small tasks the machinery shows. The benchmark runs 10,000 tasks that do almost nothing (an atomic increment) with a limit of 8, so the time per task is nearly all overhead:
package main
import (
"sync/atomic"
"testing"
)
func benchPattern(b *testing.B, run func(n, limit int, task func())) {
var sink atomic.Int64
for b.Loop() {
run(10_000, 8, func() { sink.Add(1) })
}
b.ReportMetric(float64(b.Elapsed().Nanoseconds())/float64(b.N)/10_000, "ns/task")
}
func BenchmarkSemaphoreChannel(b *testing.B) { benchPattern(b, viaChannel) }
func BenchmarkWorkerPool(b *testing.B) { benchPattern(b, viaPool) }
func BenchmarkErrgroup(b *testing.B) { benchPattern(b, viaErrgroup) }
func BenchmarkWeighted(b *testing.B) { benchPattern(b, viaWeighted) }goos: darwin
goarch: arm64
pkg: example.test/lab
cpu: Apple M1 Max
BenchmarkSemaphoreChannel-10 224 5380716 ns/op 538.1 ns/task 481252 B/op 20005 allocs/op
BenchmarkWorkerPool-10 470 2597872 ns/op 259.8 ns/task 610 B/op 19 allocs/op
BenchmarkErrgroup-10 205 5707059 ns/op 570.7 ns/task 400192 B/op 20003 allocs/op
BenchmarkWeighted-10 230 5294939 ns/op 529.5 ns/task 957020 B/op 28129 allocs/op
PASS
ok example.test/lab 5.028sRead the ns/task column. The worker pool, which reuses 8 goroutines and pays one channel hand-off per task, cost about 260 ns per task and made 19 allocations for the whole run. The three patterns that start a goroutine per task cost roughly double: about 538 ns for the channel semaphore, 571 ns for errgroup and 530 ns for semaphore.Weighted, with 20,000 or more allocations each, since every task pays for a goroutine, its stack and its teardown plus the acquire and release. semaphore.Weighted allocated the most (28,129 allocations) because each acquire builds waiter state. The spread is about 300 nanoseconds per task. A 300 ns difference is invisible when each task takes a millisecond, and it dominates when tasks are 200 ns of CPU, which is the one case where the benchmark ordering should drive the choice: use the worker pool, or better, hand each worker a batch of items so the per-item cost drops below the hand-off.
What decides it
The right pattern follows from a question about the tasks.
Do tasks return errors you want to collect, and should the first one cancel the rest? Use errgroup. WithContext gives you the cancellation, Wait returns the first error, and SetLimit bounds it. That is five lines for what the other patterns need twenty for (errgroup). If you want all errors rather than the first, collect them yourself into per-task slots.
Does each worker hold expensive, reusable state? A database connection, an open file, a parser with a large buffer, a client with a warmed connection pool. Use the worker pool. The state is created once per worker, not once per task, and the fixed set of goroutines is the natural place to hold it. A goroutine-per-task design would need sync.Pool or a separate pool of the resource to reach the same effect.
Do tasks cost different amounts of a shared resource? Use semaphore.Weighted. A task that needs 1 MiB and a task that needs 512 MiB cannot be correctly bounded by a count of tasks; a weight in megabytes expresses the real limit. Reading a small file and a huge one, or running a light query and a heavy query against one database, are the same shape. None of the other three patterns can say this.
Is the code simple and the task count modest? The channel semaphore with a WaitGroup is the smallest thing that works, needs no dependency, and is easy to modify: add a context check at the acquire, add a rate limiter, add logging. Its drawback is one goroutine per task, which is fine for thousands and wasteful for hundreds of millions.
Do you need the caller to feed tasks while results are consumed? A pool with a results channel, or a pipeline, fits streaming better than the others, which are written around "submit n tasks and wait".
Things that trip up all four
The submitting loop is part of the concurrency. In patterns 1, 3 and 4 the acquire happens in the loop that creates tasks, so the loop blocks when the limit is reached. That is the backpressure you want, and it means the loop must not hold locks or run on a goroutine that something else is waiting on.
Limits need contexts. An acquire that waits forever turns a stuck task into a stuck program. semaphore.Weighted.Acquire takes a context, so the wait can be abandoned; with the channel version, select on ctx.Done() alongside the send. Always release in a defer, or a panic in a task leaks a slot permanently.
Panics. A panic in a task crashes the process in all four, since none of them recovers. Whether to recover inside the task is a policy decision (recovering in goroutines).
The right number. A CPU-bound task wants a limit near the number of cores; an I/O-bound task wants a limit set by what the other end can take. Choosing the size of a worker pool goes through that calculation. When the limit is wrong, no pattern rescues it.
The package documentation for golang.org/x/sync/errgroup and semaphore describes the APIs compared here, and the Go blog's pipelines article gives the worker-pool shape that the second pattern follows.


