Go Goroutines and Channels: A Practical Concurrency Guide
You've probably written Go code that works fine until it doesn't—maybe a data race crashes your service under load, or a deadlock freezes your API. Concurrency is one of Go's biggest selling points, but it's also where many developers get stuck. Goroutines and channels are the building blocks, but using them correctly requires understanding their behavior and common pitfalls.
This guide walks through practical patterns for using goroutines and channels, with code examples and advice you can apply immediately.
What Are Goroutines?
A goroutine is a lightweight thread managed by the Go runtime. You create one by prefixing a function call with go:
go doSomething()
Goroutines start with a small stack (a few kilobytes) that grows as needed. This makes it cheap to spawn thousands or even millions of them. The Go scheduler multiplexes goroutines onto OS threads, so you don't manage threads directly.
But spawning a goroutine without coordination is a recipe for chaos. You need a way to wait for them, pass data, or cancel them—that's where channels come in.
Channels: Communication Between Goroutines
Channels are typed conduits for sending and receiving values. They provide synchronization: a send blocks until a receiver is ready, and a receive blocks until a value is available. This makes channels ideal for coordinating goroutines.
Create a channel with make(chan T):
ch := make(chan int)
go func() {
ch <- 42 // send
}()
value := <-ch // receive
By default, channels are unbuffered, meaning sends and receives happen in lockstep. Buffered channels allow a limited number of sends without a receiver:
ch := make(chan int, 3) // buffer size 3
ch <- 1
ch <- 2
ch <- 3
// ch <- 4 would block until someone receives
Buffered channels can smooth out bursts but don't eliminate the need for coordination.
Common Concurrency Patterns
1. Worker Pool
Distribute tasks across a fixed number of workers to limit resource usage:
func worker(id int, jobs <-chan int, results chan<- int) {
for j := range jobs {
results <- j * 2
}
}
func main() {
jobs := make(chan int, 100)
results := make(chan int, 100)
for w := 1; w <= 3; w++ {
go worker(w, jobs, results)
}
for i := 1; i <= 5; i++ {
jobs <- i
}
close(jobs)
for r := 1; r <= 5; r++ {
<-results
}
}
This pattern is great for CPU-bound or IO-bound tasks where you want to control parallelism.
2. Fan-Out, Fan-In
Fan-out means multiple goroutines read from the same channel. Fan-in means merging results from multiple channels into one:
func fanIn(chs ...<-chan int) <-chan int {
out := make(chan int)
var wg sync.WaitGroup
wg.Add(len(chs))
for _, ch := range chs {
go func(c <-chan int) {
defer wg.Done()
for v := range c {
out <- v
}
}(ch)
}
go func() {
wg.Wait()
close(out)
}()
return out
}
Use this when you have multiple producers and want a single stream of results.
3. Select for Multiplexing
The select statement lets a goroutine wait on multiple channel operations:
select {
case msg := <-ch1:
fmt.Println("received from ch1:", msg)
case ch2 <- 42:
fmt.Println("sent to ch2")
case <-time.After(1 * time.Second):
fmt.Println("timeout")
}
Use select with a default case for non-blocking operations, or with a timeout to avoid waiting forever.
Pitfalls and How to Avoid Them
- Goroutine leaks: A goroutine blocked on a channel send/receive forever will never be garbage collected. Always ensure channels are closed or use context cancellation.
- Deadlocks: Circular dependencies between channels cause deadlocks. Use timeouts or design without cycles.
- Data races: Accessing shared memory without synchronization leads to races. Use channels or mutexes to protect shared state.
- Closing channels: Only the sender should close a channel. Closing from the receiver can cause panics. Also, sending on a closed channel panics.
Best Practices
- Prefer channels for coordination, mutexes for state: Use channels to pass data and signal between goroutines; use
sync.Mutexto protect simple shared state. - Use context for cancellation: Propagate
context.Contextto allow graceful shutdown and timeouts. - Limit concurrency: Use worker pools or semaphores to avoid overwhelming resources.
- Test with the race detector: Run
go test -raceto catch data races early. - Keep it simple: Don't over-engineer. Often a mutex is simpler and faster than a complex channel choreography.
Comparison: Channels vs. Mutexes
| Aspect | Channels | Mutexes |
|---|---|---|
| Use case | Passing data, coordination | Protecting shared state |
| Complexity | Higher for simple tasks | Lower for simple tasks |
| Performance | Overhead from synchronization | Usually faster for short critical sections |
| Risk | Deadlocks, leaks | Deadlocks if not careful |
FAQ
When should I use a buffered channel?
Use a buffered channel when you want to decouple senders and receivers to handle bursts, or when you know the exact number of sends and want to avoid blocking. However, unbuffered channels provide stronger synchronization guarantees and are often easier to reason about.
How do I avoid goroutine leaks?
Ensure every goroutine has a clear exit path. Use context.Context to signal cancellation, close channels when no more values will be sent, and avoid blocking indefinitely on channel operations without a timeout or default case.
Can I use channels for everything?
No. While channels are powerful, they add overhead and complexity. For simple shared state, a mutex is often more straightforward and efficient. The Go proverb says: "Don't communicate by sharing memory; share memory by communicating," but that doesn't mean channels are always the answer.
If you're debugging concurrency issues or analyzing logs from your Go services, the Nginx Log Analyzer can help you spot patterns and errors quickly.