Once we know goroutines, channels, select, and sync, the next step is combining them into patterns. These are the building blocks that show up in real backend code — and in interviews.
Worker pool
A worker pool spins up a fixed number of goroutines that all pull from the same jobs channel and push to a results channel. We control concurrency by choosing how many workers to start.
In simple language, think of it like a checkout line with N cashiers. Customers (jobs) line up, and whichever cashier is free grabs the next one.
func worker(jobs <-chan int, results chan<- int, wg *sync.WaitGroup) {
defer wg.Done()
for j := range jobs { // pulls until jobs is closed
results <- j * 2 // do the work, send the result
}
}
func main() {
jobs := make(chan int, 100)
results := make(chan int, 100)
var wg sync.WaitGroup
for w := 1; w <= 3; w++ { // start 3 workers
wg.Add(1)
go worker(jobs, results, &wg)
}
for j := 1; j <= 9; j++ {
jobs <- j
}
close(jobs) // tell workers no more jobs are coming
go func() { wg.Wait(); close(results) }() // close results once all workers done
for r := range results {
fmt.Println(r)
}
}
Note the trick at the end: we close(jobs) so the workers’ range loops finish, then close results in a separate goroutine once the WaitGroup confirms everyone’s done. That lets the final range results terminate cleanly.
Fan-out / fan-in
Fan-out means several goroutines read from the same channel to spread work across cores. Fan-in means we merge several channels back into one. The worker pool above is already fan-out; fan-in is the merge step.
func merge(cs ...<-chan int) <-chan int {
out := make(chan int)
var wg sync.WaitGroup
for _, c := range cs {
wg.Add(1)
go func(ch <-chan int) {
defer wg.Done()
for v := range ch {
out <- v // funnel every input channel into out
}
}(c)
}
go func() { wg.Wait(); close(out) }() // close out when all inputs drained
return out
}
Pipeline
A pipeline is a chain of stages, each a goroutine, connected by channels. The output of one stage is the input of the next — like an assembly line.
func gen(nums ...int) <-chan int {
out := make(chan int)
go func() {
for _, n := range nums {
out <- n
}
close(out)
}()
return out
}
func square(in <-chan int) <-chan int {
out := make(chan int)
go func() {
for n := range in {
out <- n * n // transform each value
}
close(out)
}()
return out
}
// usage: for v := range square(gen(2, 3, 4)) { ... } // 4, 9, 16
Each stage takes a receive-only input channel and returns a receive-only output channel. Stages run concurrently, and closing flows downstream.
Rate limiting with a ticker
A time.Ticker fires on a channel at a fixed interval. We read from it before each action to throttle ourselves.
limiter := time.NewTicker(200 * time.Millisecond) // 5 ops/sec
defer limiter.Stop()
for req := range requests {
<-limiter.C // wait for the next tick before proceeding
go handle(req)
}
Done-channel cancellation
When we want to stop a pipeline early, we pass a done channel into each stage and select on it. Closing done signals everyone to quit. (In real code, context usually plays this role — same idea, standardized.)
func square(done <-chan struct{}, in <-chan int) <-chan int {
out := make(chan int)
go func() {
defer close(out)
for n := range in {
select {
case out <- n * n:
case <-done: // bail out early if cancelled
return
}
}
}()
return out
}
Interview soundbite
The core Go concurrency patterns are: worker pools (N goroutines draining a shared jobs channel, coordinated with a WaitGroup), fan-out/fan-in (spread work across goroutines then merge results), pipelines (chained stages connected by channels where closing flows downstream), rate limiting with a ticker, and done-channel cancellation. The recurring discipline is: close channels from the sender side and propagate cancellation downstream so nothing leaks.