{} The Go Reference

Building blocks · Concurrency · Beginner

Channels

Typed conduits that synchronize goroutines — direction, buffering, ownership, closing, and the axioms table that explains every behavior.

Building blocks Beginner ⏱ 11 min read Complete

🚇 Analogy

A channel is a conveyor belt between workers. An unbuffered belt is hand-to-hand: you cannot put a part down until someone takes it (a rendezvous). A buffered belt has slots — you keep working until the slots fill up, and the next worker keeps working until they run out. Closing the belt tells everyone downstream “no more parts are coming.” The belt does double duty: it moves the part and it synchronizes the two workers, which is the whole point.

What a channel is

A channel is a typed conduit that you both communicate and synchronize through. One goroutine sends a value, another receives it, and the language guarantees the handoff is safe — no locks, no shared variable to guard. This is the heart of Go’s motto: don’t communicate by sharing memory; share memory by communicating. Instead of putting a mutex around a piece of state and letting many goroutines poke at it, you give the state to one goroutine and let others ask for it over a channel. Ownership moves with the value, and a value that only one goroutine can touch needs no lock.

There are three operations, and that is the entire surface:

ch <- v      // send v into ch
v := <-ch    // receive a value from ch
close(ch)    // close ch: no more values will be sent

A channel is also a first-class value: you can store it in a struct, pass it to a function, return it, and put channels inside channels. That last one — a chan chan T — is how request/reply patterns hand back a private reply channel.

Creating and directing channels

A channel must be made with make before use; the zero value of a channel type is nil, and a nil channel is usable only in the special ways covered below.

ch := make(chan int)        // unbuffered (synchronous)
ch := make(chan int, 4)     // buffered, capacity 4

var recvOnly <-chan int     // receive-only (for consumers)
var sendOnly chan<- int     // send-only (for producers)

The arrow in the type is channel direction. chan<- int is send-only and <-chan int is receive-only; a plain chan int is bidirectional and converts implicitly to either restricted form (but not back). Use direction in function signatures to document and enforce who may send versus receive — the compiler rejects a receive on a send-only channel. It is the same idea as a read-only versus read-write API, encoded in the type.

// The signature alone tells the caller: this function only writes.
func produce(out chan<- int) { /* out <- ...  but never <-out */ }

// And this one only reads. The compiler enforces both.
func consume(in <-chan int) { /* v := <-in  but never in <- v */ }

Unbuffered: a synchronous handoff

An unbuffered channel (make(chan int)) has no room to store a value, so a send cannot complete until a receiver is ready to take it — and a receive cannot complete until a sender shows up. The two operations rendezvous: they happen at the same instant, in lockstep. That makes an unbuffered channel a synchronization primitive as much as a data pipe — when your send returns, you know the other side received the value.

sequenceDiagram
participant S as Sender
participant C as Channel (unbuffered)
participant R as Receiver
S->>C: ch <- 42 (blocks)
Note over S,R: both park until paired
R->>C: v := <-ch
C-->>S: send completes
C-->>R: receives 42
unbuffered-handoff.go — editable & runnable
package main

import "fmt"

func main() {
ch := make(chan string) // unbuffered: a rendezvous point

go func() {
	// This send blocks until main's receive is ready.
	ch <- "ping"
}()

// The receive blocks until the goroutine's send is ready.
// When this line returns, the handoff is GUARANTEED to have happened.
msg := <-ch
fmt.Println("received:", msg)
}

If no goroutine is ever ready to receive, an unbuffered send blocks forever — and if it is the only goroutine left, the runtime detects that everyone is asleep and panics with fatal error: all goroutines are asleep - deadlock!. That deadlock detector is your friend: it turns a hung program into an immediate, explained crash.

Buffered: decouple up to capacity

A buffered channel (make(chan int, 4)) holds up to its capacity without a waiting receiver. A send succeeds immediately while there is room and only blocks when the buffer is full; a receive succeeds while there is a value and only blocks when the buffer is empty. It behaves like a bounded FIFO queue with built-in blocking — a producer can run ahead of a consumer by up to cap(ch) items before back-pressure kicks in.

len(ch) reports how many values are currently buffered; cap(ch) reports the capacity. Both are rarely needed in correct code — racing on len to decide whether to send is a classic mistake, because another goroutine can change it between the check and the send.

buffered-queue.go — editable & runnable
package main

import "fmt"

func main() {
q := make(chan int, 3) // capacity 3: a small bounded queue

// These three sends never block — there is room.
q <- 10
q <- 20
q <- 30
fmt.Println("len:", len(q), "cap:", cap(q)) // len: 3 cap: 3

// A 4th send here WOULD block (buffer full), so we drain instead.
close(q) // we are done sending; values already buffered survive

// Receiving drains the buffer in FIFO order, then ends at close.
for v := range q {
	fmt.Println("drained:", v)
}
}

A buffer hides back-pressure; it does not remove it. If the producer is persistently faster than the consumer, the buffer fills and you are back to blocking — now with extra memory and latency. Pick a capacity for a reason (a known batch size, the number of workers you want to keep busy), not as a vague “make it faster” knob.

Closing, and for range

close(ch) marks a channel as done: no further sends are allowed, but already-buffered values are still delivered, and only after they drain does a receive yield the zero value. Closing is itself a broadcast — every blocked receiver wakes. The cleanest consumer loop is for range, which receives until the channel is closed and drained, then exits the loop automatically:

for v := range ch {   // receives until ch is closed AND empty
	use(v)
}
// falls through here once ch is closed and drained

This is why the producer closes and the consumer ranges: closing is the signal that terminates the range. Note that you do not have to close every channel — a channel is garbage-collected like any value once unreachable. You close only when a receiver needs to learn that no more values are coming (to end a range, or as a signal).

producer-consumer.go — editable & runnable
package main

import "fmt"

// producer OWNS out: it creates, writes, and closes it,
// and returns it as receive-only so consumers cannot misuse it.
func producer(n int) <-chan int {
out := make(chan int)
go func() {
	defer close(out) // the owner always closes, exactly once
	for i := 1; i <= n; i++ {
		out <- i
	}
}()
return out
}

func main() {
sum := 0
// range exits automatically when producer closes out.
for v := range producer(5) {
	sum += v
}
fmt.Println("sum:", sum) // 15
fmt.Println("channel drained and closed")
}

The comma-ok receive: detecting close

for range is the clean way to drain, but sometimes you receive in a select or a hand-written loop and need to know whether a zero value means “a real zero was sent” or “the channel is closed.” The comma-ok form distinguishes them: v, ok := <-ch sets ok to false exactly when the channel is closed and drained.

comma-ok.go — editable & runnable
package main

import "fmt"

func main() {
ch := make(chan int, 2)
ch <- 0   // a genuine zero value, not "closed"
ch <- 7
close(ch)

for {
	v, ok := <-ch
	if !ok {
		// ok is false ONLY now: closed and fully drained.
		fmt.Println("channel closed")
		break
	}
	fmt.Println("got:", v, "ok:", ok)
}
}

Without comma-ok you could not tell the 0 we sent apart from the 0 a closed channel hands out — ok is what carries that one bit of truth.

The channel axioms — memorize this

Every channel behavior falls out of this table. Most concurrency bugs are a cell you forgot. The states are: the channel is nil, it is open, or it is closed.

Channel stateReceive <-chSend ch <- vClose close(ch)
nilblock foreverblock foreverpanic
open, has room / valuevalue (blocks if empty)succeed (blocks if full / no receiver)ok
closeddrain, then zero value + ok=false, never blockspanicpanic

Read it as a few axioms you can recite:

  • A nil channel blocks forever on send and receive — and panics on close. Never useless: it disables a select case (see select).
  • Send on a closed channel panics. This is why closing is the sender’s job and why you never close from a receiver.
  • Receive from a closed channel never blocks — buffered values drain first, then zero value plus ok=false forever. This is what ends a for range.
  • Closing twice panics, and closing a nil channel panics. Close exactly once, from the owner.

⚠️ The three panics that crash production

close and a closed channel are where teams get paged. Sending on a closed channel panics; closing an already-closed channel panics; closing a nil channel panics. None can be caught with a normal if — they are runtime panics. The discipline that prevents all three is ownership: exactly one goroutine creates, writes, and closes each channel, closes it exactly once, and hands it out as <-chan T. If multiple senders must coordinate a close, do not race to call close; instead close a separate done channel that the senders watch, or use a sync.WaitGroup so a single closer fires close only after all senders have finished. See the sync package for WaitGroup.

Channel ownership

The cleanest designs give each channel a single owner goroutine that

  1. creates it,
  2. writes to it,
  3. closes it, and
  4. exposes it to consumers as receive-only (<-chan T).

Consumers only read. This single rule dissolves most channel bugs at once: there is never a question of who closes (the owner), when (when it is done sending), or whether a receiver might send on a closed channel (it cannot — it holds a receive-only view). The producer example above is the canonical shape; internalize it and most pipeline code writes itself.

Signaling with chan struct{}

When you care that an event happened — stop now, a phase is complete — and not about any value, the idiomatic type is chan struct{}. An empty struct is zero bytes, so the channel carries pure signal. Closing it broadcasts to every receiver at once, because every blocked receive on a closed channel unblocks simultaneously. That makes a closed done channel a one-to-many cancellation broadcast — the foundation of context and the or-done pattern.

broadcast-signal.go — editable & runnable
package main

import (
"fmt"
"sort"
"sync"
)

func main() {
done := make(chan struct{}) // a pure signal: no data, zero-width
var wg sync.WaitGroup
var mu sync.Mutex
woken := []int{}

for i := 0; i < 5; i++ {
	wg.Add(1)
	go func(id int) {
		defer wg.Done()
		<-done // all five block here until the broadcast…
		mu.Lock()
		woken = append(woken, id)
		mu.Unlock()
	}(i)
}

close(done) // …closing wakes ALL of them at once
wg.Wait()

sort.Ints(woken) // collect + sort for deterministic output
fmt.Println("woken:", woken) // [0 1 2 3 4]
}

Note that close(done) rather than done <- struct{}{} is what makes this broadcast. A send delivers to exactly one receiver; a close releases all of them — and it is also safe to “receive” from many times, which a one-shot send is not.

When to reach for what

You want…UseWhy
Confirmed handoff / step orderingunbuffered channelthe rendezvous proves the receiver got it
A bursty producer to run aheadbuffered channelabsorbs short spikes up to cap
Tell consumers “no more values”close the channelends for range, returns ok=false
One-to-many cancellation / done signalclose(chan struct{})a closed channel broadcasts to all
Disable a branch in a selectset the channel var to nila nil channel never becomes ready
Shared counter / tiny critical sectionmutex, not a channela lock is simpler and faster than a channel here

Channels are not always the answer. For a plain shared counter or a one-line critical section, a sync.Mutex or sync/atomic is simpler and faster — passing a value through a channel just to guard it is over-engineering. The rule of thumb: use channels to transfer ownership of data and to orchestrate goroutines; use locks to guard state that stays put.

Internals, briefly

A channel is a heap-allocated hchan struct: a ring buffer (for buffered channels), a send-wait queue, a receive-wait queue, and a mutex that guards them. A send on a full channel parks the goroutine on the send queue and yields to the scheduler; a matching receive dequeues and directly copies the value from sender to receiver (a small optimization that skips the buffer on a rendezvous), then marks the sender runnable. So a channel operation is not free — it touches a lock and may reschedule — but it is far cheaper than an OS-level context switch, and the value is copied (channels move copies, so do not send a struct and then mutate the original expecting the receiver to see it).

Edge cases worth knowing

  • Sending a pointer or slice shares the backing data. Channels copy the element, but copying a pointer or slice header copies the reference — the sender and receiver then alias the same underlying array. Send by value, or stop touching it after sending.
  • A nil channel in a select is silently skipped, never chosen — the basis of the disable-a-case trick.
  • range over a channel never ends if no one closes it. A for range ch on a channel that is written but never closed is a leak: the consumer blocks forever once the producer stops. Always close, or use a done/context to break out.
  • Receiving the zero value is ambiguous without comma-ok. A real 0 and “closed” look identical unless you check ok.
  • cap is fixed at creation. There is no way to resize a channel’s buffer; make a new one if you need a different capacity.

🐞 Fix the bug

An unbuffered send blocks until someone receives — and in this program, nobody ever can. The deadlock detector kills it on the spot. Edit until Run & check matches.

🐞 deadlock.go — fix the bug

The send on line 7 blocks forever: the receive below it can never run because main is stuck on the send. Unblock the handoff.

Expected output
ping
package main

import "fmt"

func main() {
ch := make(chan string)
ch <- "ping"
fmt.Println(<-ch)
}

Next: wait on many channels at once with select.

Check your understanding

Score: 0 / 5

1. What do you get when you receive from a closed channel?

Receives from a closed channel never block: any buffered values drain first, then you get the zero value with comma-ok returning false. That is exactly what lets `for range` over a channel terminate.

2. Who should close a channel?

Only the goroutine that owns and writes the channel closes it. Sending on a closed channel panics and closing an already-closed channel panics — both are risks a receiver cannot rule out. Closing is a send-side signal that says 'no more values'.

3. What is the difference between an unbuffered and a buffered channel?

An unbuffered channel is a synchronization point: send and receive rendezvous, so the sender knows the value was received. A buffered channel decouples them until the buffer fills (send blocks) or empties (receive blocks).

4. What happens if you send on a nil channel?

A nil channel blocks forever on both send and receive — there is no buffer and no peer to rendezvous with. This is not a bug to avoid so much as a tool: setting a channel variable to nil disables its case in a select.

5. Why is `chan struct{}` the idiomatic type for a pure signal?

When you care that something happened — a goroutine should stop, a phase is done — not what value came through, `chan struct{}` makes that intent explicit and the element is zero-width. `close(done)` then broadcasts to every receiver at once.

Comments

Sign in with GitHub to join the discussion.