Go Concurrency Explained: Goroutines and Channels

Concurrency is the part of Go people fall in love with and the part that later keeps them up at night. The primitives are small and the syntax is friendly, which makes it easy to write something that works on your laptop and leaks goroutines in production. This post walks through what goroutines and channels actually do, when to reach for the sync package instead, and the mistakes that show up in every code review.

Goroutines: cheap by design #

A goroutine is a function running independently of the one that started it. You launch one with the go keyword and Go handles the rest. What makes them different from OS threads is cost. A goroutine starts with a tiny stack (a couple of kilobytes) that grows and shrinks on demand, and the Go runtime multiplexes many of them onto a small pool of OS threads. That scheduling happens in user space, so switching between goroutines skips the kernel round trip a thread context switch pays.

The practical result: spawning thousands of goroutines is normal. A web server handling ten thousand concurrent requests with ten thousand goroutines is unremarkable. You would never do that with raw OS threads.

The catch is that a bare goroutine gives you no way to know when it finished or to collect what it produced. go doWork() fires and forgets. To coordinate, you need a way to communicate.

Channels: communicate to share #

Channels are typed conduits. You send values in one end and receive them out the other, and the channel handles the synchronization for you. The Go proverb is worth memorizing: don't communicate by sharing memory; share memory by communicating. Instead of guarding a shared variable with a lock, you pass ownership of the data through a channel, so only one goroutine touches it at a time.

Here is the pattern that replaces fire-and-forget. A worker computes a result and sends it back:

go.go

func main() {
results := make(chan int)

for i := 1; i <= 3; i++ {
go func(n int) {
results <- n * n // send the square back
}(i)
}

// Receive exactly three results.
for i := 0; i < 3; i++ {
fmt.Println(<-results)
}
}

Note the loop variable is passed as an argument. Capturing it directly was a classic bug for years, though Go 1.22 changed loop scoping to make each iteration its own variable.

Buffered vs unbuffered #

The channel above is unbuffered: a send blocks until another goroutine is ready to receive, and vice versa. That makes an unbuffered channel a synchronization point as much as a data pipe. The sender and receiver rendezvous.

A buffered channel, make(chan int, 10), holds up to its capacity without a receiver waiting. Sends only block when the buffer is full; receives only block when it is empty. Buffers are useful for smoothing bursts and for decoupling producer and consumer speeds. They are not a license to ignore backpressure. If your producer is permanently faster than your consumer, a buffer just delays the moment things fall over.

Rule of thumb: start unbuffered. Add a buffer only when you have measured a reason, and size it to a number you can defend.

Select: waiting on many things #

select lets one goroutine wait on multiple channel operations at once, proceeding with whichever is ready first. It is how you build timeouts, cancellation, and fan-in.

go.go

select {
case msg := <-work:
process(msg)
case <-time.After(2 * time.Second):
return errors.New("timed out waiting for work")
case <-ctx.Done():
return ctx.Err()
}

If several cases are ready, select picks one at random, which keeps any single channel from starving the others. A default case makes the whole thing non-blocking.

When to skip channels: the sync package #

Channels are the right tool for handing data between goroutines. They are the wrong tool for the simplest jobs. If you just need to wait for a batch of goroutines to finish, use a sync.WaitGroup. If you need to protect a counter or a map, a sync.Mutex is clearer and faster than routing every access through a channel.

go.go

var wg sync.WaitGroup
for _, url := range urls {
wg.Add(1)
go func(u string) {
defer wg.Done()
fetch(u)
}(url)
}
wg.Wait() // blocks until all Done calls land

The guideline I use: channels for transferring ownership of data or signaling events, mutexes for protecting a small piece of shared state, WaitGroups for "wait until this group is done." Reaching for a channel to guard a single integer is a smell.

Context: cancellation and deadlines #

Long-running goroutines need a way to be told to stop, whether a request was cancelled or a deadline passed. That is what context.Context is for. You pass a context down through your call tree, and any goroutine can watch ctx.Done() and bail out. context.WithTimeout and context.WithCancel give you a context plus a cancel function you should always defer. Every network call and blocking operation in a server should accept a context, otherwise cancellation stops at your door.

The worker pool #

Unbounded goroutine spawning is one of the most common ways to sink a Go service. A request comes in, you launch a goroutine per item, traffic spikes, and suddenly you have a million goroutines fighting over a database with a connection pool of twenty. The fix is a worker pool: a fixed number of goroutines pulling from a shared jobs channel.

go.go

func pool(jobs <-chan int, results chan<- int, workers int) {
var wg sync.WaitGroup
for w := 0; w < workers; w++ {
wg.Add(1)
go func() {
defer wg.Done()
for j := range jobs { // exits when jobs is closed
results <- j * 2
}
}()
}
wg.Wait()
close(results)
}

The producer closes jobs when it has queued everything. Ranging over a channel ends cleanly on close, so each worker returns, the WaitGroup drains, and results closes. This bounds your concurrency to a number you chose on purpose.

The pitfalls #

Goroutine leaks: a goroutine blocked forever on a channel that will never receive never gets collected. Every launched goroutine needs a guaranteed exit path, usually a closed channel or a cancelled context.

Deadlocks: all goroutines blocked waiting on each other. The runtime detects the total case and panics, but partial deadlocks that hang a subset of your program are on you to find.

Closed-channel panics: sending on a closed channel panics, and closing a channel twice panics. The sender closes, never the receiver, and only one goroutine should own the close.

Data races: two goroutines touching the same memory without synchronization is undefined behavior. Build and test with go test -race. The race detector earns its keep and belongs in your CI pipeline.

The call we'd make #

For most services, keep it boring. Bound your concurrency with worker pools, thread a context through every goroutine, close channels from the sender only, and let sync.WaitGroup handle the joins. Run the race detector in CI and treat any finding as a bug, not a warning.

If you are weighing Go against other options for a concurrent backend, our pillar on Rust vs Go covers the tradeoffs in depth, and Go vs Python performance looks at where Go's scheduler pulls ahead on real workloads.