arrow_backBACK TO GO FOR BACKEND ENGINEERS — CONCURRENCY, SIMPLICITY AND REAL NUMBERS
Lesson 05Go for Backend Engineers — Concurrency, Simplicity and Real Numbers6 min read

Channels & CSP

October 29, 2026

TL;DR

A channel is a typed pipe you pass values through between goroutines — the Go way to communicate safely without shared variables and locks. ch := make(chan int); send ch <- v; receive v := <-ch. UNBUFFERED (make(chan int)): a send blocks until a receiver is ready and vice-versa — it is both a data transfer AND a synchronisation point, a rendezvous. BUFFERED (make(chan int, 100)): send succeeds until the buffer is full, receive until it's empty — it decouples producer and consumer speeds (measured: 10M messages through one in 814 ms ≈ 12.3M msgs/sec). select waits on several channels at once — take whichever is ready, add a timeout, or a cancellation signal. close(ch) says 'no more values'; for v := range ch drains until closed. The philosophy (CSP): 'Don't communicate by sharing memory; share memory by communicating' — instead of many goroutines locking one variable, ONE goroutine owns the data and others send it messages. The channel carries the value AND the happens-before guarantee, so there's no race to reason about.

Starting a goroutine is one keyword (Lesson 4). The real work of concurrency is getting data between goroutines safely — and this is where Go has a distinctive philosophy. Instead of many goroutines sharing variables behind locks, Go encourages them to communicate by passing values through channels. This lesson is channels end to end, and the idea behind them that gives Go concurrency its particular flavour.

Channels: A Typed Pipe Between Goroutines

A channel is a typed conduit: one goroutine sends values into it, another receives them out. The type is part of it — a chan int carries ints, a chan Order carries Orders.

Channels: share memory by communicating. Goroutine A, the producer, sends ch left-arrow val into a channel of type chan int which is typed and safe, and goroutine B, the consumer, receives v colon-equals left-arrow ch. Unbuffered, a handoff: make(chan int); send BLOCKS until a receiver is ready and vice-versa; it is a synchronisation point AND a data transfer at once, a rendezvous between two goroutines. Buffered, a queue: make(chan int, 100); send succeeds until the buffer is full, receive succeeds until it is empty; decouples producer and consumer speeds; measured 12.3 million messages per second through one. The mental model and select: don't communicate by sharing memory, share memory by communicating; instead of many goroutines poking one variable behind a lock, one goroutine owns the data and others send it messages; the channel carries both the value and the happens-before guarantee so there is no race to reason about; select waits on several channels at once — receive from whichever is ready, add a timeout, or a cancellation signal; close(ch) tells receivers no more values; for v range ch drains it.

The operations are small:

ch := make(chan int)   // create
ch <- 42               // send (arrow points INTO the channel)
v := <-ch              // receive (arrow points OUT)
close(ch)              // no more values will be sent

The arrow direction is the mnemonic: ch <- v sends into the channel, v := <-ch pulls out of it. And the critical property: channels are safe for concurrent use. Many goroutines can send to and receive from the same channel with no locks and no data races — the channel handles the synchronisation internally. That safety is the whole point.

Unbuffered vs Buffered

There are two kinds of channel, and the difference in behaviour is important.

Unbuffered — make(chan int), no capacity. A send blocks until some goroutine is ready to receive, and a receive blocks until some goroutine is ready to send. The send and receive happen together, as a single synchronised moment. This makes an unbuffered channel both a data transfer and a synchronisation point at once — a rendezvous. It is how one goroutine says to another “here is a value, and I know you have it now.” Use unbuffered channels when the handoff itself is meaningful — when you want the sender to wait until the receiver has actually taken the value.

Buffered — make(chan int, 100), capacity 100. A send succeeds immediately as long as the buffer has room (up to 100 queued), and only blocks when the buffer is full. A receive succeeds as long as the buffer has something, and only blocks when it is empty. A buffered channel decouples producer and consumer speeds: the producer can run ahead and queue work without waiting for the consumer on every single item, smoothing out bursts. I measured a buffered channel pushing 10,000,000 messages through it in 814 ms — about 12.3 million messages per second, roughly 81 ns per message, one producer to one consumer. Channels are fast enough to be the backbone of high-throughput pipelines.

The choice matters: an unbuffered channel gives you tight synchronisation (the sender knows the value landed); a buffered one gives you throughput and burst-absorption (the sender doesn’t wait, up to the buffer size). Pick the smallest buffer that smooths your workload — an over-large buffer just hides backpressure problems until memory fills.

select, close, and range

Three more pieces and you have the whole model.

select waits on multiple channel operations at once and proceeds with whichever is ready first. This is how you build real concurrent logic — combine inputs, add timeouts, listen for cancellation:

select {
case v := <-work:
    process(v)
case <-time.After(2 * time.Second):
    return errors.New("timed out")        // a timeout
case <-ctx.Done():
    return ctx.Err()                       // cancellation (Lesson 6)
}

select blocks until one case can proceed, then runs that case. Add a default: and it becomes non-blocking (do something else if nothing is ready). select is to channels what switch is to values, and it is where channel code gets its power — timeouts and cancellation fall out naturally.

close(ch) signals that no more values will ever be sent. Receivers can detect it: v, ok := <-ch sets ok to false once the channel is closed and drained. The sender closes a channel to say “I’m done”; receivers never close it. (Sending on a closed channel panics — closing is a one-way, sender-side signal.)

for v := range ch receives values until the channel is closed and drained, then ends the loop. This is the clean way to consume a stream of values from a channel — it handles the “is it closed yet?” check for you:

for v := range ch {   // loops until ch is closed and empty
    process(v)
}

Together: a producer ranges over its inputs, sends results on a channel, and closes it when done; a consumer ranges over that channel and stops automatically when the producer closes it. Clean, race-free streaming.

The Philosophy: Share Memory by Communicating

Behind the mechanics is a design philosophy that distinguishes Go, borrowed from Tony Hoare’s Communicating Sequential Processes (CSP). The Go proverb captures it:

“Don’t communicate by sharing memory; share memory by communicating.”

The usual concurrency model is shared memory with locks: several threads all read and write the same variables, and you wrap every access in a mutex to prevent races. It works, but reasoning about which lock protects what, and avoiding deadlocks, is famously error-prone.

Go’s preferred model inverts it. Instead of many goroutines poking at one shared variable behind a lock, one goroutine owns a piece of data, and other goroutines interact with it by sending messages through a channel. The data has a single owner at any moment; you pass ownership along the channel rather than sharing access to it. Because the channel send/receive carries a happens-before guarantee (the receive sees everything the sender did before the send), there is simply no race to reason about — the synchronisation is built into the communication.

This does not mean you never use locks — Go has mutexes and atomics and they are the right tool for some jobs (Lesson 7, with measurements). The guidance is about your default: reach for a channel to coordinate goroutines and move data between them, and fall back to a mutex when a channel would be awkward (guarding a shared map, a simple counter). “Share memory by communicating” is the mindset that makes most Go concurrency clear: think about who owns this data and how it flows, not which lock guards this variable.

In the next lesson we turn these primitives — goroutines and channels — into the handful of patterns you will actually reach for: worker pools, fan-in/fan-out, pipelines, and cancellation with context.

Pitfalls to Avoid

  1. Deadlock on an unbuffered channel. Sending on an unbuffered channel with no goroutine ready to receive (or receiving with no sender) blocks forever; if it’s the only goroutine, the runtime panics with “all goroutines are asleep - deadlock.” Make sure a receiver exists (often: start it in a separate goroutine before you send).
  2. Closing a channel from the receiver, or twice. Only the sender closes, exactly once. Closing a closed channel, or sending on a closed one, panics. If multiple senders exist, coordinate who closes (often: a separate “done” signal, or a sync.WaitGroup then one close).
  3. Leaking goroutines blocked on channels. A goroutine stuck on <-ch for a value that never comes leaks forever. Give it an escape via select with ctx.Done() (Lesson 6), or ensure the channel is always eventually closed/sent to.
  4. Over-sized buffers hiding backpressure. A huge buffer makes sends never block, which feels fast but just defers the problem — work piles up in memory and you lose the natural backpressure that tells a producer to slow down. Size buffers deliberately and small.
  5. Using channels where a mutex is simpler. A channel to guard a single shared counter or map is often more convoluted (and slower) than a sync.Mutex. Channels shine for moving data and ownership; for guarding a small piece of shared state, a mutex may be clearer (Lesson 7).
  6. Forgetting select default makes it non-blocking. Adding a default: case turns select into a non-blocking poll, which in a tight loop becomes a busy-wait burning CPU. Only add default when you genuinely want “do something else if nothing’s ready,” not as a habit.

Quick Recap

  • A channel is a typed, concurrency-safe pipe between goroutines: ch <- v sends, v := <-ch receives — no locks needed, the channel synchronises internally.
  • Unbuffered (make(chan int)) is a rendezvous: send and receive happen together — a handoff and a sync point. Buffered (make(chan int, n)) decouples speeds up to n queued items (measured: ~12.3M msgs/sec).
  • select waits on many channels at once (and enables timeouts and cancellation); close(ch) signals “no more values” (sender only, once); for v := range ch drains until closed.
  • The philosophy is CSP: “don’t communicate by sharing memory; share memory by communicating.” One goroutine owns the data, others send it messages — the channel carries the value and the happens-before guarantee, so there’s no race. Use channels to coordinate by default; fall back to mutexes (Lesson 7) when simpler.

Next lesson: assembling goroutines and channels into the concurrency patterns you’ll actually use — worker pools, fan-in/out, pipelines, and context cancellation.