Go Concurrency Patterns

A Comprehensive Guide

Go concurrency patterns use goroutines and channels to coordinate tasks, as shown by the Fibonacci pipeline example.

The assembly line of Go

You are building a scraper that needs to fetch a thousand URLs. You write a simple loop and run it. It takes an hour because each request waits for the network. You try adding go before the fetch call to run them all at once. Your program crashes immediately with fatal error: all goroutines are asleep - deadlock!. You added concurrency but forgot how the pieces talk.

Go concurrency is not about spinning up OS threads and managing locks. It is about structuring data flow. Goroutines run the code. Channels carry the data. The most reliable pattern is the pipeline: a chain of goroutines where each stage reads from a channel, transforms the data, and sends the result to the next channel. This keeps state local and communication explicit.

Concept: workers and wires

A goroutine is a function executing in the background. The Go runtime manages a pool of OS threads and schedules goroutines onto them. A single goroutine starts with a few kilobytes of stack memory. You can spawn tens of thousands without exhausting system resources.

A channel is a typed conduit between goroutines. You create a channel with make(chan T). The type T defines what data flows through. If you create an unbuffered channel, a send operation blocks until another goroutine receives the value. A receive operation blocks until a value is available. This synchronization happens automatically. You do not need mutexes to coordinate access.

Think of a factory assembly line. Each station is a goroutine. The conveyor belt is the channel. Parts move from station to station. No station reaches into another station's toolbox. If a station finishes early, it waits for the belt to bring the next part. If a station is slow, the belt backs up until it catches up. The flow regulates itself.

Goroutines are cheap. Channels are not magic.

Minimal pipeline example

This example builds a pipeline that generates integers, squares them, and prints the results. Each stage runs in its own goroutine. Channels connect the stages.

package main

import "fmt"

// generate sends n integers to the channel c.
func generate(c chan<- int, n int) {
	for i := 0; i < n; i++ {
		c <- i // Send value to channel. Blocks until receiver is ready.
	}
	close(c) // Signal no more values. Prevents goroutine leak.
}

// square reads from in, squares values, sends to out.
func square(in <-chan int, out chan<- int) {
	for v := range in {
		out <- v * v // Compute and forward.
	}
	close(out) // Close output when input is exhausted.
}

// main sets up the pipeline and consumes results.
func main() {
	ch := make(chan int) // Create unbuffered channel.
	out := make(chan int)

	go generate(ch, 5) // Start producer.
	go square(ch, out) // Start processor.

	for v := range out {
		fmt.Println(v) // Consume results.
	}
}

Close the channel. Let the data flow.

Walkthrough: how the runtime moves data

The program starts in main. The call make(chan int) creates an unbuffered channel. No data can pass through yet because there is no receiver.

The statement go generate(ch, 5) starts a new goroutine. The generate function enters the loop and attempts c <- i. Because the channel is unbuffered and no one is receiving, generate blocks. It yields control back to the runtime.

The statement go square(ch, out) starts another goroutine. The square function enters for v := range in. The range loop tries to receive from ch. The runtime sees a sender waiting on ch and a receiver waiting on ch. It matches them. The value 0 moves from generate to square. Both goroutines unblock.

square computes 0 * 0 and attempts out <- 0. The out channel is also unbuffered. square blocks. Meanwhile, main is executing for v := range out. main tries to receive from out. The runtime matches square and main. The value 0 moves to main. main prints 0.

This cycle repeats. generate sends 1. square receives 1, computes 1, sends 1. main receives 1, prints 1. The pipeline flows.

When generate finishes the loop, it calls close(c). This marks the channel as closed. The next time square tries to receive from in, it gets a zero value and the loop condition fails. The range loop exits. square calls close(out). main sees the closed channel on the next iteration and exits its loop. The program terminates cleanly.

Goroutine leaks happen when the goroutine waits on a channel that never gets closed. Always have a cancellation path. In production code, you would pass a context.Context as the first argument to generate and square. Functions that take a context should respect cancellation and deadlines. This lets you shut down the pipeline cleanly when the job is done or fails.

Realistic example: Fibonacci with state exchange

The minimal example shows a linear flow. Real pipelines often need to exchange state between workers. The following example computes Fibonacci numbers using two goroutines that pass values back and forth. It also demonstrates runtime.LockOSThread(), which binds a goroutine to a specific OS thread.

package main

import (
	"fmt"
	"math/big"
	"runtime"
)

// fibber computes Fibonacci numbers in a pipeline.
// It reads from c, writes to out, and updates its internal state.
func fibber(c chan *big.Int, out chan string, n int64) {
	// LockOSThread binds this goroutine to the current OS thread.
	// Use this only when interfacing with C code or debugging thread affinity.
	runtime.LockOSThread()

	i := big.NewInt(n)
	if n == 0 {
		c <- i // Seed the pipeline with the initial value.
	}

	for {
		j := <-c // Wait for the next value from the partner.
		out <- j.String() // Emit the result string.
		i.Add(i, j) // Update state: next = current + previous.
		c <- i // Pass the new value back to the partner.
	}
}

// main launches two fibber workers and drains the output.
func main() {
	c := make(chan *big.Int) // Shared channel for state exchange.
	out := make(chan string) // Output channel for results.

	go fibber(c, out, 0) // Start first worker.
	go fibber(c, out, 1) // Start second worker.

	for i := 0; i < 200; i++ {
		fmt.Println(<-out) // Drain output channel.
	}
}

This code uses big.Int to handle large numbers. The two fibber goroutines share the channel c to swap state. One goroutine holds the current number, the other holds the previous number. They add them and pass the result back. The output channel out streams the string representation to main.

The call runtime.LockOSThread() is unusual. Most Go code never calls this function. It forces the goroutine to stay on the same OS thread for its entire lifetime. This is necessary when calling C code that relies on thread-local storage, or when debugging pthread coordination. In normal Go pipelines, the runtime is free to move goroutines between threads for load balancing. Locking the thread reduces flexibility and should be avoided unless you have a specific reason.

LockOSThread is a sledgehammer. Use it only when the OS thread matters.

Pitfalls and compiler errors

Concurrency introduces subtle bugs. The compiler and runtime help catch many of them, but you need to know what to look for.

If you spawn goroutines inside a loop and capture the loop variable, older versions of Go would share the variable across all goroutines, leading to unexpected values. Go 1.22 changed the loop variable semantics to create a new variable per iteration. If you are maintaining older code or compiling with an older toolchain, the compiler rejects this with loop variable i captured by func literal. Always capture the loop variable explicitly in older code by passing it as an argument to the goroutine.

Channels can deadlock. If a goroutine sends to an unbuffered channel and no one receives, the program hangs. If all goroutines are blocked waiting on channels, the runtime panics with fatal error: all goroutines are asleep - deadlock!. This usually means a channel is never closed, or a select statement is missing a case.

Receiving from a closed channel returns the zero value immediately. If you keep receiving after the sender closes the channel, you get silent zero values. Use range to iterate until the channel closes, or use the two-value receive form v, ok := <-ch to check if the channel is closed.

Sending to a closed channel causes a panic. The runtime stops the program with panic: send on closed channel. Only the sender should close a channel. Never close a channel from the receiver side.

Don't pass a *string. Strings are already cheap to pass by value. Passing a pointer adds indirection without saving memory.

Trust gofmt. Argue logic, not formatting.

Decision: when to use pipelines

Concurrency adds complexity. Use it only when it solves a problem. Pick the pattern that matches your workload.

Use a pipeline when data flows through distinct stages of transformation. Use a fan-out pattern when you can split a workload across identical workers to speed up independent tasks. Use a worker pool when you must limit concurrency to protect a downstream service from overload. Use a mutex when you have shared mutable state that cannot be partitioned, though passing data via channels is usually safer. Use sequential code when the logic is simple and performance is not a bottleneck.

Simple code beats clever code.

Where to go next