Understanding Go Concurrency Patterns
Understanding Go Concurrency Patterns
Concurrency is one of Go’s strongest features. Unlike many languages where concurrent programming feels bolted on, Go was designed from the ground up with concurrency in mind. In this post, I’ll walk through the most useful concurrency patterns I use daily.
Goroutines and Channels: The Basics
A goroutine is a lightweight thread managed by the Go runtime. Channels are the pipes that connect them. Here’s the simplest example:
func main() {
ch := make(chan string)
go func() {
ch <- "hello from goroutine"
}()
msg := <-ch
fmt.Println(msg)
}This is straightforward, but real-world applications need more sophisticated patterns.
Pattern 1: Fan-Out / Fan-In
When you have a CPU-intensive task that can be parallelized, fan-out distributes work across multiple goroutines, and fan-in collects the results.
func fanOut(input <-chan int, workers int) []<-chan int {
channels := make([]<-chan int, workers)
for i := 0; i < workers; i++ {
channels[i] = process(input)
}
return channels
}
func fanIn(channels ...<-chan int) <-chan int {
var wg sync.WaitGroup
merged := make(chan int)
for _, ch := range channels {
wg.Add(1)
go func(c <-chan int) {
defer wg.Done()
for val := range c {
merged <- val
}
}(ch)
}
go func() {
wg.Wait()
close(merged)
}()
return merged
}I use this pattern frequently when building microservices that need to aggregate data from multiple sources — for example, fetching user profiles, order history, and recommendations in parallel.
Pattern 2: Worker Pool
A worker pool limits the number of concurrent operations. This is essential when you’re calling external APIs or databases with connection limits.
func workerPool(jobs <-chan Job, results chan<- Result, numWorkers int) {
var wg sync.WaitGroup
for i := 0; i < numWorkers; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
for job := range jobs {
result := processJob(job)
results <- result
}
}(i)
}
go func() {
wg.Wait()
close(results)
}()
}The key insight: the jobs channel acts as a natural queue. Workers pull from it as they become available, giving you automatic load balancing.
Pattern 3: Context for Cancellation
The context package is how Go handles cancellation, deadlines, and request-scoped values. Every long-running operation should accept a context.
func fetchData(ctx context.Context, url string) ([]byte, error) {
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return nil, err
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
return io.ReadAll(resp.Body)
}
// Usage with timeout
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
data, err := fetchData(ctx, "https://api.example.com/data")
if err != nil {
log.Fatal(err)
}
fmt.Println(string(data))
}Pattern 4: Select for Multiplexing
The select statement lets you wait on multiple channel operations simultaneously. Combined with time.After, it’s perfect for implementing timeouts and heartbeats.
func processWithTimeout(input <-chan Data) {
for {
select {
case data := <-input:
handle(data)
case <-time.After(30 * time.Second):
log.Println("No data received for 30s, checking health...")
healthCheck()
}
}
}Pattern 5: Pipeline
Pipelines chain stages together, where each stage is a group of goroutines running the same function. Each stage takes values in via inbound channels and sends values out via outbound channels.
func generate(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
}
close(out)
}()
return out
}
func main() {
ch := generate(2, 3, 4)
out := square(ch)
for result := range out {
fmt.Println(result) // 4, 9, 16
}
}Common Pitfalls
Goroutine leaks — Always ensure goroutines can exit. Use context cancellation or done channels.
Race conditions — Use go run -race during development. It catches most data races at runtime.
Channel deadlocks — If all goroutines are blocked waiting on channels, Go panics with “all goroutines are asleep.” Use buffered channels or restructure your pipeline.
Wrapping Up
Go’s concurrency model is powerful because it’s simple. Goroutines are cheap, channels are safe, and select gives you flexible control flow. Start with these five patterns, and you’ll be able to handle most concurrent programming challenges.
The key principle: don’t communicate by sharing memory; share memory by communicating.
Happy coding!