Handbooks  /  Go
Handbook~16 min readConcurrencyworked math + runnable code
The Go Handbook

Share by
communicating.

Concurrency is where most languages get scary: threads, locks, race conditions, bugs that vanish when you look at them. Go's answer is a different mental model — don't communicate by sharing memory; share memory by communicating. You spin up thousands of near-free goroutines and let them pass data through channels, typed pipes that handle the synchronization for you. The whole thing hinges on one small idea: when does a send or receive block? Get that right and concurrency is safe by construction; get it wrong and you deadlock. This handbook is that rule, made runnable.

01

A different model

The traditional way to do concurrency is shared memory plus locks: many threads read and write the same variables, and you guard them with mutexes so they don't clobber each other. It works, but it's notoriously error-prone — forget a lock and you get a race; take locks in the wrong order and you deadlock; and the bugs are non-deterministic, appearing only under load. Go takes a deliberately different stance, summarized in its famous motto: "Don't communicate by sharing memory; share memory by communicating."

Instead of many threads poking at shared state behind locks, Go has independent goroutines that pass data to each other through channels. Ownership of a value moves along the channel from sender to receiver, so at any moment one goroutine is working with it — no simultaneous access to guard. The synchronization is baked into the send and receive operations themselves, so you often need no explicit locks at all. This is Go's whole concurrency philosophy (borrowed from a model called CSP): make the communication the primitive, and safety follows from it. Everything else — how cheap goroutines are, how channels block — serves that idea.

The one-sentence version

Go runs thousands of cheap goroutines that pass data through channels instead of sharing locked memory — and the one rule that makes it safe is when a send or receive blocks: unbuffered blocks until a partner is ready, buffered blocks only when full/empty.

02

Goroutines are cheap

A goroutine is a lightweight thread managed by the Go runtime, not the operating system. You start one by writing go f() — that's the entire syntax — and it runs concurrently with everything else. The magic is how cheap they are: a goroutine begins with a tiny stack of a few kilobytes that grows on demand, and the Go scheduler multiplexes many thousands of goroutines onto a small pool of real OS threads.

That cheapness changes how you design. Spawning an OS thread per task caps you at a few thousand before memory and context-switching overhead crush the machine; goroutines let you run hundreds of thousands comfortably. So in Go you don't ration concurrency — you use it liberally: a goroutine per incoming request, per connection, per background job, per item in a fan-out. The scheduler handles parking a goroutine that's blocked (say, waiting on a channel or I/O) and running another on that thread, so blocked goroutines cost almost nothing. The mental shift is that concurrency is abundant, which is exactly what makes the channel-based style practical: you can afford to give every unit of work its own goroutine and let channels coordinate them.

03

Channels & blocking

A channel is a typed pipe: one goroutine sends a value in, another receives it out. The crucial property — the thing you must internalize — is when an operation blocks, and that depends on the channel's buffer capacity.

When does a channel operation block?
Unbufferedcap 0: a send blocks until a receiver is ready — send & receive meet (a rendezvous).
Bufferedcap N: a send succeeds without blocking until the buffer is full, then it blocks.
ReceiveBlocks when the channel is empty (waits for a value); on a closed channel returns the zero value.
Why it mattersUnbuffered = tight synchronization; buffered = let a producer run ahead by up to N.

An unbuffered channel (capacity 0) has no storage, so a send can't complete until someone is there to receive — the two operations happen together as a rendezvous, which hands you synchronization for free (the sender knows the receiver got it). A buffered channel (capacity N) can hold up to N values, so sends succeed immediately while there's room and only block when the buffer fills; receives block only when it's empty. The choice is a design decision: use unbuffered when you want sender and receiver to march in lockstep, and buffered when you want to decouple them — let a fast producer get ahead of a slower consumer by a bounded amount. Everything about coordinating goroutines reduces to picking capacities and understanding these blocking conditions.

04

The blocking rules

A send blocks exactly when the buffer is full (which, for an unbuffered channel of capacity 0, is always — until a receiver takes the value); a receive blocks when the buffer is empty:

send blocks  ⟺  len(buf) ≥ capacity     receive blocks  ⟺  len(buf) = 0

Unbuffered (cap 0): len ≥ 0 is always true, so a send always waits for a receiver. Buffered (cap 2): first two sends don't block; the third does.

A deadlock is when a blocked operation has no counterpart that can ever unblock it — a send with no receiver, or a receive with no sender:

deadlock  ⟺  (send blocks ∧ no receivers)  ∨  (receive blocks ∧ no senders)

The classic beginner deadlock: send on an unbuffered channel with no goroutine receiving → "all goroutines are asleep - deadlock!". The runnable version below models channels, blocking, and deadlock detection.

RUN IT YOURSELF

Channel blocking, modeled

Go's concurrency safety comes down to one rule: when does a channel operation block? An unbuffered channel (capacity 0) blocks every send until a receiver is ready — a rendezvous that synchronizes the two goroutines. A buffered channel (capacity N) lets sends succeed until the buffer is full, then blocks; receives block when it's empty. And a deadlock is a blocked operation with no partner to unblock it — a send with no receiver is the classic one. Change the capacity, the number of sends, or the waiting senders/receivers and watch which operations block and when it deadlocks.

CPython · WebAssembly
05

Deadlocks

A deadlock is when goroutines are all blocked waiting on each other and none can move — and Go is unusually helpful here: its runtime detects the case where every goroutine is asleep and panics with fatal error: all goroutines are asleep - deadlock! rather than hanging silently. The most common cause is the beginner mistake of sending on an unbuffered channel with nothing set up to receive:

PatternWhat happens
Send, no receiverSend on unbuffered channel with no goroutine receiving → blocks forever → deadlock panic.
Receive, no senderReceiving from an empty channel nobody will send to → blocks forever.
Range over unclosed channelfor v := range ch waits for more values; if the channel is never closed, it blocks at the end.
Circular waitA waits on B while B waits on A → neither proceeds.

The unifying cure is understanding the blocking rules well enough to guarantee every blocking operation has a partner. Send on an unbuffered channel? Make sure a receiver is running (usually in another goroutine). Range over a channel? Make sure someone close()s it when done, so the loop can end. Reading from N workers? Know how many values will arrive. Deadlocks aren't mysterious in Go — they're the direct, mechanical consequence of a blocked operation with no counterpart, which is why internalizing "when does this block?" is the entire skill. The runtime's deadlock detector turns what would be a silent hang in other languages into a loud, immediate error, which is a genuine gift once you learn to read it.

06

Pitfalls

The first is the goroutine leak. Goroutines are so cheap that it's easy to start one that blocks forever — waiting on a channel that never receives another value, say — and never notice. It doesn't crash; it just sits there holding memory and whatever it referenced, and in a long-running server these accumulate until you're out of memory. Every goroutine you start needs a guaranteed way to finish (a closed channel, a context cancellation, a done signal). "Cheap to start" does not mean "free to abandon."

Two more. Assuming buffered means non-blocking: a buffered channel only defers blocking until the buffer fills — size it wrong and you still block, or worse, you paper over a producer/consumer imbalance that then bites under load. And reaching back for shared memory and mutexes out of habit: Go has sync.Mutex and sometimes it's the right tool (protecting a simple counter, say), but defaulting to shared-state-plus-locks throws away the whole point of the channel model and reintroduces exactly the race conditions Go was designed to avoid. Prefer communicating over sharing until you have a concrete reason not to. The payoff for learning the model is large: concurrency, the thing that terrifies people in other languages, becomes a set of small, composable, mechanically-reasoned pieces. The whole handbook is one question you can now always answer — when does this send or receive block? — and from that, safe concurrency follows.

Worth knowing

Give every goroutine a guaranteed exit (closed channel / context cancel) or it leaks; remember buffered channels only defer blocking until full; and prefer channels over shared-memory+mutex unless you have a specific reason. Safe concurrency reduces to one question: when does this send/receive block?

Frequently asked

Quick answers

What is a goroutine?

A lightweight thread managed by the Go runtime — started with "go f()", cheap enough to run hundreds of thousands, multiplexed onto a few OS threads.

What is a channel?

A typed pipe goroutines use to pass values and synchronize — sharing memory by communicating instead of locking shared state.

Buffered vs unbuffered?

Unbuffered (cap 0) blocks a send until a receiver is ready (rendezvous); buffered (cap N) blocks a send only when the buffer is full.

What causes a deadlock?

A blocked operation with no partner — e.g. sending on an unbuffered channel with no receiver. Go's runtime detects and panics instead of hanging.

Finished this one? 0 / 99 Handbooks done

Explore the topic

See this alongside everything else on the same subject — handbooks, system designs, challenges and tools, in one place.