When speed matters more than order
You have a list of 10,000 images to resize. Reading them from disk is fast. Resizing takes CPU time. If you read one image, resize it, save it, then read the next, your disk sits idle waiting for the CPU. You want the disk to keep feeding images while the CPU crunches the previous batch. That's a producer-consumer setup. The producer feeds work. The consumer does the work. They run at different speeds, and a buffer sits between them so neither blocks the other unnecessarily.
Go solves this with goroutines and channels. A goroutine is a lightweight thread managed by the Go runtime. A channel is a typed conduit that lets goroutines send and receive values. The producer goroutine sends jobs into a channel. The consumer goroutine reads jobs from the channel. The channel synchronizes them.
The restaurant kitchen analogy
Think of a restaurant kitchen. The waiter takes orders and drops them on a pass-through shelf. The cooks grab orders and cook them. The shelf is the buffer. If the shelf fills up, the waiter stops taking orders until a cook clears space. If the shelf is empty, the cooks wait. The waiter and cooks work at different speeds, but the shelf keeps the flow smooth.
In Go, the shelf is a channel. The waiter is a goroutine. The cooks are goroutines. The channel capacity determines how many orders fit on the shelf. A capacity of zero means the waiter must hand the order directly to a cook. A capacity of ten means the waiter can stack ten orders before stopping.
Minimal producer-consumer
Here's the skeleton: a buffered channel, a producer that sends values and closes the channel, and a consumer that ranges over the channel.
package main
import "fmt"
func main() {
// buffered to 3 so the producer can send a few items before the consumer starts
jobs := make(chan int, 3)
// producer goroutine sends three integers then closes the channel
go func() {
for i := 1; i <= 3; i++ {
jobs <- i
}
close(jobs) // signal that no more work is coming
}()
// consumer loop reads until the channel is closed
for j := range jobs {
fmt.Println(j)
}
}
Close the channel when the producer is done. Range handles the rest.
What happens at runtime
The make call allocates a channel with a buffer of size 3. The producer goroutine runs concurrently. It sends 1, 2, 3. Since the buffer holds 3, all sends complete immediately without blocking. Then close(jobs) runs. The consumer is in a range loop. It pulls values from the channel. When the channel is closed and empty, the range loop terminates. The program exits.
If the buffer were size 0, the producer would block on the first send until the consumer received. The buffer size controls how much the producer can get ahead. A buffer of 1 means the producer blocks after one send. A buffer of 1000 means the producer can flood the channel. If the buffer is too large, you consume memory and hide latency. If it's too small, you serialize the producer and consumer. Pick a size based on your throughput and memory budget. A common heuristic is to size the buffer for one batch of work.
The range loop over a channel is idiomatic Go. It blocks until a value is available. When the channel is closed and empty, it exits. You don't need a select statement or a boolean flag. The channel state carries the signal. This reduces boilerplate and prevents race conditions on a separate done flag.
Realistic worker pool
Real systems rarely have a single consumer. You usually want multiple workers to process jobs in parallel. Here's a worker pool with three consumers.
// Worker reads from jobs, processes, and writes to results.
func worker(id int, jobs <-chan int, results chan<- int, wg *sync.WaitGroup) {
defer wg.Done() // ensure the wait group decrements even if a panic occurs
for j := range jobs {
// simulate heavy computation
results <- j * 2
}
}
The worker function takes directional channels. jobs <-chan int means this function can only receive from jobs. results chan<- int means it can only send to results. Go convention favors directional channels in function signatures. Writing jobs <-chan int tells the reader this function only sends, never receives. It prevents accidental sends inside the worker. The compiler enforces this direction once the channel is passed.
The sync.WaitGroup tracks active goroutines. Add(1) increments the counter. Done() decrements it. Wait() blocks until the counter hits zero. The counter must never go negative. Call Add before starting the goroutine. Call Done inside the goroutine, usually in a defer. If you call Done too many times, the program panics.
func main() {
jobs := make(chan int, 100) // buffer absorbs bursts of incoming work
results := make(chan int, 100)
var wg sync.WaitGroup
// launch three concurrent workers
for w := 1; w <= 3; w++ {
wg.Add(1)
go worker(w, jobs, results, &wg)
}
// producer runs in background to feed the channel
go func() {
for j := 1; j <= 5; j++ {
jobs <- j
}
close(jobs) // signal workers that production is done
}()
// close results only after all workers have finished
go func() {
wg.Wait()
close(results)
}()
// main goroutine drains results until the channel closes
for r := range results {
fmt.Println(r)
}
}
Workers drain the channel. The main goroutine drains the results. Everyone has a job.
Context is plumbing. In a real system, the worker function takes ctx context.Context as the first argument. If the context cancels, the worker stops and returns. This prevents goroutine leaks when the server shuts down. Functions that take a context should respect cancellation and deadlines. The context flows through the call chain.
Pitfalls and errors
The most common bug is a goroutine leak. If the producer closes the channel but a worker panics, the other workers might hang forever waiting for more jobs. Always ensure the channel closes when production ends. If you try to send on a closed channel, the program panics with panic: send on closed channel. The compiler won't catch this; it's a runtime error.
Another trap is deadlock. If you have a zero-buffer channel and the producer tries to send before the consumer starts, or if you forget to start a consumer, the program halts with fatal error: all goroutines are asleep - deadlock!. The runtime detects that no goroutine can make progress.
Goroutine leaks happen when a goroutine waits on a channel that never closes. Always design a path for every goroutine to exit. If the producer closes the channel, the consumer's range loop ends. If the consumer panics, the producer might block forever. Use recover or errgroup to handle panics gracefully. The worst goroutine bug is the one that never logs.
If the worker returns an error, handle it. if err != nil { return err } is verbose by design. The community accepts the boilerplate because it makes the unhappy path visible. Don't swallow errors. If you don't need a return value, discard it with _. result, _ := process() says "I considered the second return value and chose to drop it". Use it sparingly with errors.
Run gofmt on your code. It formats the channel operations consistently. Don't argue about indentation. Most editors run it on save. Trust gofmt. Argue logic, not formatting.
When to use what
Use a buffered channel when the producer can generate work faster than the consumer can process it and you want to absorb bursts without blocking.
Use an unbuffered channel when you need strict synchronization so the producer and consumer meet at every send and receive.
Use a worker pool with multiple goroutines when you need to parallelize CPU-bound work or fan out to multiple downstream services.
Use a single consumer goroutine when the work must happen sequentially or the downstream system cannot handle concurrent requests.
Use sync.WaitGroup when you need to wait for multiple goroutines to finish before proceeding.
Use golang.org/x/sync/errgroup when you need to cancel all workers if any single worker returns an error.
Use plain sequential code when the latency is acceptable and concurrency adds unnecessary complexity.
Concurrency is a tool, not a goal. Add it only when the bottleneck demands it.