Skip to content
← All writing
April 8, 2026·4 min read

Go Concurrency: Goroutines and Channels Explained

Concurrency is the feature people reach for Go to get. The language was designed around making concurrent programs approachable, and its two core tools — goroutines and channels — are genuinely elegant once they click. This post explains both from the ground up, with the practical cautions that tutorials often skip.

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. Parallelism is doing many things at the exact same instant on multiple CPU cores. Go's tools give you concurrency; the runtime may execute it in parallel if cores are available. You design for concurrency; parallelism is a bonus you mostly don't manage by hand.

Goroutines: cheap concurrent functions

A goroutine is a function running concurrently with others. 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 tiny stack and the runtime multiplexes thousands (or millions) of them onto a small number of operating-system threads. This is why Go handles high-concurrency workloads — like a server juggling many simultaneous connections — so comfortably. You can afford to spin up a goroutine per request without a second thought.

The catch: a goroutine runs independently, so you need a way to coordinate — to know when it's done, and to pass data safely. 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 racing), one sends a value through a channel and another receives it — the data is handed off cleanly.

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. This is the idea behind Go's famous proverb: "Don't communicate by sharing memory; share memory by communicating." Passing ownership of data through channels avoids most of the bugs that plague shared-state concurrency.

Waiting for multiple goroutines

When you launch several goroutines and need to wait for all of them, 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)
    }(j)
}
wg.Wait()   // blocks until every goroutine calls Done()

Note the job is passed in as an argument rather than captured directly (see the loop-variable note below).

Common pitfalls

Concurrency is easier in Go than most languages, but it's not magic. Watch for these:

  • Leaked goroutines. A goroutine blocked forever on a channel that never receives is a leak — it never exits and holds memory. Always ensure there's a path for every goroutine to finish.
  • Race conditions. If two goroutines do touch the same variable without coordination, you get a data race — unpredictable, hard-to-reproduce bugs. Prefer channels; when you must share, protect it with a mutex. Run tests with the built-in race detector (go test -race) to catch these.
  • Deadlocks. If everyone is waiting and no one can proceed, the program hangs. Usually it's a send with no receiver, or vice versa.
  • The loop-variable trap (pre-1.22). In Go before 1.22, a for loop reused a single variable across iterations, so a goroutine that captured it could see the final value instead of its own. Go 1.22 (2024) made loop variables per-iteration, so this bug is gone on any current Go — but passing the value in as a parameter (as above) still works everywhere and keeps the intent explicit.

When to use what

  • Fire off independent work and forget it? A plain goroutine.
  • Hand results back or coordinate steps? Channels.
  • Wait for a batch to finish? WaitGroup.
  • Guard a small piece of genuinely shared state? A mutex.
  • Need to cancel or time out concurrent work? Go's context package (worth a follow-up study).

Summary

Go makes concurrency approachable with two ideas: goroutines, which are cheap concurrent functions you start with go, and channels, which let goroutines communicate and synchronize safely by passing values instead of sharing memory. Lean on channels to coordinate, use WaitGroups to wait, guard shared state sparingly with mutexes, and run the race detector. Get comfortable with these and you can build the kind of high-concurrency backends Go is famous for — without the usual concurrency headaches.