My personal style here is to use a number of goroutines equal to the desired concurrency. I know goroutines are cheap and there is no particular reason to conserve them most of the time, but this feels more natural to me than using a semaphore--although it is less flexible.
Simplified:
var wg sync.WaitGroup
wg.Add(jobs)
ch := make(chan workItem, jobs)
for i := 0; i < jobs; i++ {
go func() {
defer wg.Done()
for item := range ch {
processItem(item)
}
}()
}
for _, item := range items {
ch <- item
}
close(ch)
wg.Wait()