Go Concurrency Explained: Goroutines and Channels
A practitioner's tour of goroutines, channels, select, the sync package, context, and the pitfalls that leak or deadlock real Go services.
Key takeaways
A practitioner's tour of goroutines, channels, select, the sync package, context, and the pitfalls that leak or deadlock real Go services.
On this page
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:
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.
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.
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.
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.
Get the DevOps Troubleshooting Cheat Sheet
Subscribe and get our free one-page reference for the errors that eat an afternoon — CrashLoopBackOff, OOMKilled, Terraform state locks, and more — plus new guides as we publish them.
Rust vs Go — Which Systems Language Should You Learn?
Both are fast, modern, and compiled, but they were built for different problems. This is the map: where each wins, what each costs, and how to pick for your next service, tool, or Wasm module.
iptables vs nftables: A Practical Linux Firewall Guide
iptables still works, but nftables is what every modern distro ships by default now. Here is how the two actually differ and how to migrate a real ruleset.
More from DevOps
Explore more articles in this category
Best Managed Kubernetes in 2026: EKS vs GKE vs AKS vs DOKS
The control plane fee is the least interesting number. What separates managed Kubernetes providers is upgrade cadence, how much they run for you, and where the node bill lands.
Best Log Management Tools in 2026: What You Actually Pay For
Every log platform looks affordable at proof-of-concept volume and expensive at production volume. The pricing model, not the feature list, decides which one you can live with.
Your CI Runner Is the Target: Hardening Against npm Worms
The keyv compromise reached 444 packages and over two billion monthly installs through preinstall scripts. The controls that actually stop it are boring and mostly free.
You might have missed
Evergreen posts worth revisiting.