Bounded Parallelism with a Semaphore
The worker pool bounds concurrency by starting a fixed number of goroutines that pull from a queue. But sometimes you genuinely want one goroutine per item — the code is simpler, each item's context stays local — and you only need to stop too many from running at the same instant. Say you're fetching 200 URLs: one goroutine each reads cleanly, but 200 simultaneous connections would get you rate-limited. You want all 200 goroutines, at most 10 in flight.
That's a semaphore: a permit system. And in Go you don't import one — a buffered channel already is one.
A buffered channel is a counting semaphore
Make a buffered channel of capacity N. Sending into it takes a permit;
receiving from it returns one. When the buffer is full, the next send blocks
until someone receives — which is exactly "wait until a slot frees up":
sem := make(chan struct{}, N) // N permits
sem <- struct{}{} // acquire (blocks if N are already out)
// ... do the guarded work ...
<-sem // release
struct{} is the empty struct — zero bytes — because the values carry no
information; only the count of items in the buffer matters. N in flight fills
the buffer; goroutine N+1 parks on its send until one of the first N
releases.
Guarding one goroutine per item
Spawn a goroutine per element, but make each one acquire before working and
release after. Put the release in a defer so it runs no matter how the work
exits:
func process(items []int) {
var wg sync.WaitGroup
sem := make(chan struct{}, 10) // at most 10 at once
for i, item := range items {
wg.Add(1)
go func(i, item int) {
defer wg.Done()
sem <- struct{}{} // acquire a permit
defer func() { <-sem }() // release on the way out
result[i] = work(item)
}(i, item)
}
wg.Wait()
}
Every item gets its own goroutine, launched right away — but only ten are ever
past the sem <- struct{}{} line at once. The eleventh blocks there until a
permit frees. The WaitGroup still waits for all of them; the semaphore
only throttles how many run concurrently, not how many exist.
Order the two defers carefully: defer wg.Done() is registered first so it runs
last, after the permit is released. And acquire inside the goroutine, not
before go — acquiring before the go statement would serialize the launches
and defeat the point.
Semaphore vs. worker pool
Two ways to bound concurrency, each idiomatic:
- Worker pool — a fixed set of goroutines pulling from a shared jobs channel. Goroutine count is constant; jobs flow through them. Best when jobs are cheap and uniform and you want to reuse goroutines.
- Semaphore — one goroutine per item, gated by a permit channel. Goroutine
count scales with the work, but only
Nrun at once. Best when per-item code is naturally self-contained and you'd rather not marshal everything through a queue.
They solve the same problem — don't let unbounded concurrency exhaust memory, sockets, or a downstream service — from opposite directions. Reaching for a buffered channel as the throttle, rather than a mutex-and-counter of your own, is the idiomatic Go move: the language's built-in blocking is the waiting.
In the exercise you'll write parallelMap: one goroutine per element, capped by
a semaphore, results written back in input order. Deterministic output, bounded
concurrency — the pattern you'll reach for whenever "all of them, but not all at
once" is the requirement.
Practice
1. Parallel Map
AdvancedImplement
parallelMapso it appliesfnto every element concurrently — one goroutine per element — but with at mostlimitrunning at the same time:func parallelMap(nums []int, limit int, fn func(int) int) []int- Pre-size the result with
make([]int, len(nums)). - Use a buffered channel of capacity
limitas a counting semaphore. Each goroutine acquires a permit withsem <- struct{}{}before callingfn, and releases it with<-semafterwards (usedeferso it always releases). Whenlimitpermits are out, the next goroutine blocks on its send until one frees. - Each goroutine writes into
result[i]— its own index — so the output stays in input order no matter which finishes first. - Wait for every goroutine with a
sync.WaitGroupbefore returning. - Order must match the input:
parallelMap([]int{1, 2, 3, 4}, 2, func(n int) int { return n * 10 })returns[]int{10, 20, 30, 40}. - An empty or nil input returns an empty slice.
limit == 1(fully serialized) andlimit >= len(nums)must both still be correct.
Unlike the worker pool's fixed set of goroutines, here you launch one goroutine per item and let the semaphore cap how many run at once.
package main import "fmt" // parallelMap applies fn to every element of nums concurrently — one goroutine // per element — with at most `limit` running at the same time, returning the // results in input order. func parallelMap(nums []int, limit int, fn func(int) int) []int { // TODO: make a result slice, a buffered channel of capacity `limit` as a // counting semaphore, and a sync.WaitGroup. Start one goroutine per element // that acquires a permit (sem <- struct{}{}), computes fn, releases the // permit, and writes result[i]. Wait for all before returning. // // This stub returns an all-zero slice so the tests fail on the values // instead of hanging. Replace it. return make([]int, len(nums)) } func main() { fmt.Println(parallelMap([]int{1, 2, 3, 4}, 2, func(n int) int { return n * 10 })) }Sign in to write and run your solution in the browser.
Sign in for hints and the solution.
- Pre-size the result with