Go Concurrency: Goroutines and Channels Explained
Concurrency is the feature people reach for Go to get. Most languages treat concurrency as an advanced topic bolted on through libraries, threads, and careful reading; Go was designed around making concurrent programs approachable from day one, and its two core tools — goroutines and channels — are genuinely elegant once they click. The click matters, though: Go makes concurrency easy to write, which is not the same as easy to get right. This post explains both tools from the ground up, with the practical cautions that tutorials often skip and production eventually teaches.
Concurrency vs parallelism
First, a distinction that clears up a lot of confusion. Concurrency is dealing with many things at once — structuring a program so tasks can make progress independently, interleaving while they wait on each other or on I/O. Parallelism is doing many things at the same physical instant on multiple CPU cores. As Rob Pike's classic talk "Concurrency is not Parallelism" puts it: concurrency is about structure, parallelism is about execution.
Go's tools give you concurrency; the runtime schedules it across cores and gives you parallelism when it's available. The practical upshot: you design for concurrency — "these tasks are independent" — and mostly let the runtime worry about the cores. For a backend, this is exactly the right division of labor, because a server's defining workload is many independent requests, each spending most of its life waiting on databases and networks.
Goroutines: cheap concurrent functions
A goroutine is a function running concurrently with the rest of your program. Starting one is almost absurdly simple — put go in front of a call:
go doWork() // runs concurrently; the caller keeps going
Goroutines are extremely lightweight — they start with a stack of a few kilobytes (an OS thread wants megabytes) and the runtime multiplexes thousands or even millions of them onto a small pool of OS threads, parking any goroutine that's waiting on I/O and running another in its place. This is precisely why Go handles high-concurrency workloads — a server juggling tens of thousands of simultaneous connections — so comfortably: a goroutine per request is not a design smell in Go, it's the intended idiom, and the standard library's HTTP server does exactly that.
The catch: a goroutine runs independently, and go doWork() returns immediately, telling you nothing about when (or whether) the work finished, whether it errored, or how to get its result. Concurrency's real cost isn't starting tasks — it's coordinating them. That's where channels come in.
Channels: safe communication
A channel is a typed pipe that goroutines use to send and receive values. Instead of multiple goroutines poking at the same variable (and corrupting it in ways that depend on scheduling luck), one goroutine sends a value through a channel and another receives it — the data is handed off cleanly, with synchronization built into the handoff:
ch := make(chan int)
go func() {
ch <- 42 // send a value into the channel
}()
result := <-ch // receive it (blocks until a value arrives)
Sending and receiving block until the other side is ready, which naturally synchronizes the two goroutines — no flags, no sleeps, no polling. (Buffered channels, make(chan int, 10), relax this: sends only block when the buffer is full — useful as a queue between a fast producer and a slow consumer.)
This is the idea behind Go's famous proverb: "Don't communicate by sharing memory; share memory by communicating." In thread-and-lock concurrency, correctness depends on every access to shared state being guarded, everywhere, forever. Passing ownership of data through channels sidesteps the whole category: at any moment, one goroutine owns the value, and the race can't be expressed.
Waiting for multiple goroutines
When you launch several goroutines and need to wait for all of them — fan out work across a batch, then continue — a sync.WaitGroup is the standard tool:
var wg sync.WaitGroup
for _, job := range jobs {
wg.Add(1)
go func(j Job) {
defer wg.Done()
process(j)
}(job)
}
wg.Wait() // blocks until every goroutine calls Done()
Note the job is passed in as a parameter rather than captured from the loop (see the loop-variable note below), and Done is deferred so a panic in process can't wedge the count. If the goroutines also produce results or errors, combine the WaitGroup with a channel they send into — or use errgroup from the extended standard library, which packages "run a batch, collect the first error, cancel the rest" into the tool you actually wanted.
Common pitfalls
Concurrency is easier in Go than in most languages, but the sharp edges are real:
- Leaked goroutines. A goroutine blocked forever on a channel that will never deliver doesn't crash — it just sits there holding memory, invisibly, forever. A server that leaks one goroutine per request under some error path is a slow-motion memory leak that looks fine for weeks. Every goroutine you start should have a guaranteed way to finish, including when things go wrong — which usually means cancellation via
context(below). - Race conditions. If two goroutines do touch the same variable without coordination, you have a data race — behavior that's undefined, timing-dependent, and famous for appearing only in production. Prefer channels; when you genuinely need shared state, guard it with a
sync.Mutex. And run the built-in race detector (go test -race) in CI — it catches real races at runtime with precise reports, and finding one in CI versus production is the difference between a commit and an incident. - Deadlocks. If every goroutine is waiting and none can proceed — a send with no receiver, two goroutines each waiting on the other — the program stalls. The Go runtime detects the total deadlock case and panics helpfully; partial deadlocks (a few goroutines stuck while the rest limp on) are leaked goroutines by another name.
- The loop-variable trap (pre-1.22). In older Go, a
forloop reused one variable across iterations, so goroutines that captured it often all saw the final value — the classic "all my workers processed the last job" bug. Go 1.22 (2024) made loop variables per-iteration, fixing this at the language level — but passing the value as a parameter (as above) works on every version and keeps the ownership explicit, so it remains the habit worth having.
When to use what
- Fire off independent work and forget it? A plain goroutine — but be sure "forget it" is really acceptable, including its errors.
- Hand results back or coordinate steps? Channels.
- Wait for a batch to finish? WaitGroup (or errgroup when errors matter — which is usually).
- Guard a small piece of genuinely shared state — a counter, a cache map? A mutex is simpler than ceremony with channels; use the boring tool.
- Cancel or time out concurrent work? The context package — every long-running goroutine should accept a
context.Contextand stop when it's cancelled. In server code this isn't optional polish; it's how request timeouts actually reach your code.
The meta-rule: reach for the simplest tool that expresses the coordination you need, and keep the concurrency at the edges of your program — a function that takes data and returns data is easier to reason about (and test) than one that spawns goroutines internally.
Summary
Go makes concurrency approachable with two ideas: goroutines — concurrent functions so cheap you can start one per request without thinking — and channels, which let goroutines coordinate by passing values instead of sharing memory. Lean on channels for handoffs, WaitGroups and errgroup for batches, mutexes for small shared state, and context for cancellation — and run the race detector in CI as a standing habit, because it converts Go's honest sharp edges into caught bugs. Get comfortable with this toolkit and you can build the high-concurrency backends Go is famous for, with most of the traditional concurrency headaches designed out rather than debugged out.
Further reading
- A Tour of Go: Concurrency — interactive introduction to goroutines and channels.
- Effective Go: Concurrency — the idioms, from the source.
- The Go race detector — how to run it and read its reports.
- Go Concurrency Patterns — pipelines and cancellation, the next level up.