AnyLearn
All lessons
Programmingadvanced

Channels: Share Memory by Communicating

Cheap concurrency without a coordination primitive is a bug generator. Go's answer is a typed conduit that carries values between goroutines, from a 1978 idea: rather than protecting shared state with locks, pass ownership through a channel so there is no sharing to protect. This lesson builds channels, select, and where the model does not fit.

Updated · AI-authored, review-gated · how lessons are made

Not signed in: your progress and quiz score won't be saved.
Progress1 / 8

The inversion

The default model for concurrent programming is shared memory plus locks: several threads reach the same data, and a mutex ensures one at a time.

That model is where the lesson Locks and Synchronization lives, and its difficulties are well known. Which lock protects which data is a convention nobody can check. Acquiring in inconsistent orders deadlocks. Forgetting one is a race. All of it is invisible in the types.

C. A. R. Hoare proposed the alternative in "Communicating Sequential Processes", Communications of the ACM, volume 21, issue 8, 1978, pages 666 to 677: treat input and output as basic primitives, and build programs as sequential processes that communicate by passing values rather than by sharing variables.

Go's proverb states the consequence: do not communicate by sharing memory; instead, share memory by communicating.

The inversion is the point. Under locks, data sits still and access is coordinated. Under channels, data moves, and only one goroutine holds it at a time. There is no shared state to protect, so the whole category of problems does not arise rather than being managed.

Full lesson text

All 8 steps on one page, for reading, reference, and search.

Show

1. The inversion

The default model for concurrent programming is shared memory plus locks: several threads reach the same data, and a mutex ensures one at a time.

That model is where the lesson Locks and Synchronization lives, and its difficulties are well known. Which lock protects which data is a convention nobody can check. Acquiring in inconsistent orders deadlocks. Forgetting one is a race. All of it is invisible in the types.

C. A. R. Hoare proposed the alternative in "Communicating Sequential Processes", Communications of the ACM, volume 21, issue 8, 1978, pages 666 to 677: treat input and output as basic primitives, and build programs as sequential processes that communicate by passing values rather than by sharing variables.

Go's proverb states the consequence: do not communicate by sharing memory; instead, share memory by communicating.

The inversion is the point. Under locks, data sits still and access is coordinated. Under channels, data moves, and only one goroutine holds it at a time. There is no shared state to protect, so the whole category of problems does not arise rather than being managed.

2. A channel is a typed conduit

A channel carries values of one type between goroutines. It is created, sent to, and received from.

ch := make(chan int)      // unbuffered

go func() {
    ch <- 42              // send; blocks until a receiver is ready
}()

v := <-ch                 // receive; blocks until a value arrives

The arrow shows direction, which makes data flow visible at every use site rather than buried in a method name.

An unbuffered channel is a rendezvous. The sender blocks until a receiver arrives and vice versa, so a completed send is proof that a receive happened. That synchronisation is often the reason to choose unbuffered: the transfer itself is the coordination.

A buffered channel holds a fixed number of values. Sends succeed while there is room and block when full; receives succeed while values remain and block when empty.

ch := make(chan int, 100)

The capacity is a deliberate decision rather than a tuning knob. Zero means the sender waits for the receiver, which is the strongest coupling and the clearest. A large buffer decouples them and lets a fast producer run ahead, hiding the fact that the consumer cannot keep up until memory runs out.

3. Backpressure is the default

That blocking behaviour delivers something systems normally have to engineer explicitly.

If a producer outpaces a consumer, the producer blocks once the buffer fills. It cannot run ahead, cannot accumulate an unbounded queue, and cannot exhaust memory. The system self-regulates to the speed of its slowest stage.

func stage(in <-chan int) <-chan int {
    out := make(chan int, 10)
    go func() {
        defer close(out)
        for v := range in {        // ranges until in is closed
            out <- transform(v)     // blocks if the next stage lags
        }
    }()
    return out
}

results := stage(stage(source()))   // a pipeline, with backpressure

That is backpressure, and getting it for free is a significant property. In a system built on unbounded queues, a slow consumer causes memory growth and eventual failure far from the cause, which is a classic production incident.

Note the two directional types in the signature. Taking <-chan int and returning <-chan int states in the type that this stage only receives from its input and only sends to its output. The compiler enforces it, so a stage cannot accidentally write to its input.

Also note defer close(out): closing signals completion, and a range over a channel ends when it closes. Ownership of closing belongs to the sender, always.

4. Select: waiting on several things

A goroutine usually needs to wait on more than one channel, and select is the construct for it.

select {
case v := <-work:
    process(v)
case <-time.After(5 * time.Second):
    return errors.New("timed out")
case <-done:
    return nil                       // asked to stop
}

It blocks until one case is ready and runs that one. If several are ready it chooses uniformly at random, which is a deliberate design decision: a fixed priority order would let a busy channel starve a quiet one, and randomisation removes that failure mode by construction.

Adding a default case makes the whole thing non-blocking: if nothing is ready, default runs immediately.

The timeout case above is worth dwelling on. Timeouts are not a separate mechanism bolted onto channels; a timer is just another channel that produces a value later, so waiting for one is the same operation as waiting for work. Cancellation, timeouts, work arrival and shutdown all become cases in the same select.

That uniformity is the strongest argument for the model. One primitive covers what other systems handle with four unrelated APIs.

5. Ownership moves with the value

The model works because sending transfers ownership. Once a value is sent, the sender stops using it and the receiver has exclusive access, so no two goroutines touch it at once and no lock is needed.

The last box is the honest caveat, and it is the difference from the previous cursus. Go does not enforce this. Sending a pointer and continuing to use it through that pointer compiles fine and is a data race.

Rust's ownership model does enforce exactly this: sending a value moves it, and using it afterwards is a compile error. The discipline is identical; only the enforcement differs.

So channels give the shared-memory problem a shape that is easy to reason about and easy to violate. Sending values rather than pointers avoids most of it, since a copy has no aliasing question at all.

That is the practical rule: send values, not pointers into structures you keep, and if a pointer must be sent, treat the send as the end of your access to it.

flowchart LR
A["Goroutine A owns the value"] --> B["Sends on a channel"]
B --> C["Goroutine B receives it"]
C --> D["Goroutine B now owns it"]
D --> E["A must not touch it again"]
E --> F["Convention, not enforced by the compiler"]

6. The rules that prevent panics

Channels have a small set of sharp edges, and all of them come from closing.

Sending on a closed channel panics. Receiving from one does not: it returns the zero value immediately, forever.

Closing a closed channel panics. So does closing a nil channel.

Only the sender should close. This is the rule that prevents the first two. A receiver cannot know whether more sends are coming, so a receiver closing the channel may cause a sender to panic. With multiple senders, none can close safely either, and the answer is a separate done channel or a sync.WaitGroup to determine when all have finished.

Distinguishing a real zero from a closed channel uses the two-value receive:

v, ok := <-ch
if !ok {
    // channel is closed and drained
}

One further oddity is useful rather than dangerous. Operations on a nil channel block forever, which sounds like a bug and is a tool: setting a channel variable to nil inside a select disables that case, which is the standard way to stop listening to an input that is exhausted without restructuring the loop.

7. When a mutex is the right answer

Go provides sync.Mutex and uses it throughout its own standard library, which is a strong hint that channels are not always the answer. The community advice is explicit: use whichever is simpler for the problem.

Use a channel when data is being transferred between goroutines, when ownership passes, when coordinating a pipeline, or when signalling events and cancellation. Anything with a flow.

Use a mutex when goroutines are sharing a piece of state that stays put: a cache, a counter, a connection pool, a map of registered handlers. The state has a home and many goroutines touch it.

The tell is whether the data moves. A channel expresses movement; a mutex expresses a shared location. Forcing a shared cache through a channel means writing a goroutine that owns the map and a request-response protocol to reach it, which is more code, slower, and harder to read than three lines with a mutex.

A counter is the clearest case. Guarding an integer with a mutex, or using an atomic add, is obviously right; routing every increment through a channel to a dedicated goroutine is a well-known way to make simple code slow and elaborate.

The failure to avoid is treating CSP as an ideology. It is a tool that fits transfer, and locks fit sharing.

8. What channels give and cost

PropertyChannelsMutex
ExpressesTransfer of a valueProtection of a location
BackpressureBuilt in, by blockingNone; you build it
Timeouts and cancellationA case in selectA separate mechanism
Direction visible in typesYesNo
Composes with other waitsYes, via selectNo
Cost per operationHigherVery low
Deadlock possibleYesYes

The last row is the one people forget. Channels do not prevent deadlock. Two goroutines each waiting to send to the other, or a receive with no sender, blocks forever exactly as a lock cycle does.

Go's runtime detects the specific case where all goroutines are blocked and panics with a clear message, which is genuinely useful. It cannot detect a partial deadlock where some goroutines still run, and that is the common production case.

Which leads to the gap this lesson leaves. A goroutine blocked on a channel nobody will ever use is not deadlocked in the runtime's sense: the program runs fine, and that goroutine simply never finishes and never gets collected.

That is a goroutine leak, the most common Go-specific bug, and it is the subject of the next lesson.

Check your understanding

The lesson ends with a 5-question quiz. Take it in the player above to see your score.

  1. What is the inversion that CSP proposes relative to shared memory and locks?
    • Data moves between processes rather than sitting still while access is coordinated
    • Locks are acquired in a globally fixed order
    • Each thread gets a private copy of all shared state
    • Synchronisation is performed by the kernel rather than the runtime
  2. What does a completed send on an unbuffered channel prove?
    • That the value was copied into a buffer
    • That a receiver was ready and the handoff occurred
    • That the channel has not been closed
    • That no other goroutine is blocked on the channel
  3. Why does `select` choose uniformly at random among ready cases?
    • To distribute load evenly across CPU cores
    • Because evaluation order is unspecified in the language
    • To make the scheduler's behaviour reproducible
    • Because a fixed priority order would let a busy channel starve a quiet one
  4. Who should close a channel, and why?
    • The receiver, since it knows when it has finished reading
    • Whichever goroutine created it
    • The sender, because a receiver cannot know whether more sends are coming and closing early would panic a sender
    • Neither; channels are closed automatically when unreferenced
  5. When is a mutex the better choice than a channel?
    • When goroutines share state that stays put, such as a cache or counter, rather than transferring data
    • Whenever performance matters at all
    • When more than two goroutines are involved
    • When the data must be protected from the garbage collector

Related lessons

Programming
intermediate

Goroutines: Concurrency Cheap Enough to Stop Counting

A thread costs enough that programs are architected around not having many. A goroutine costs little enough that the constraint disappears, which changes what designs are available. This lesson explains where the saving comes from, what the runtime scheduler does with them, and what the abundance does not fix.

8 steps·~12 min
Programming
advanced

The Design Philosophy: What Go Left Out

Go is unusual for what it refuses to include. Errors are values, not exceptions. Interfaces are satisfied without declaring it. Formatting is not a choice. This lesson works through the omissions, the reasoning behind each, and the real costs, because every one of them trades expressiveness for something else.

8 steps·~12 min
Programming
advanced

Goroutine Leaks: Who Stops This Thing

Starting a goroutine is one keyword; stopping one is a design decision nothing forces you to make. A goroutine blocked forever is never collected and holds everything it references, which is the most common Go-specific bug. This lesson covers how leaks happen, the cancellation mechanism that prevents them, and how to detect the ones already there.

8 steps·~12 min
Programming
advanced

Refs: A Branch Is a File Containing a Hash

Hashes are unusable by hand, so Git names them. A branch is not a copy of anything, not a directory, and not a container for commits: it is a forty-character file. Once that is clear, checkout, reset, detached HEAD and the reflog stop being separate concepts and become one operation on one kind of file.

8 steps·~12 min