🚇 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
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.
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).
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.
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 state | Receive <-ch | Send ch <- v | Close close(ch) |
|---|---|---|---|
| nil | block forever | block forever | panic |
| open, has room / value | value (blocks if empty) | succeed (blocks if full / no receiver) | ok |
| closed | drain, then zero value + ok=false, never blocks | panic | panic |
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
selectcase (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=falseforever. This is what ends afor 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
- creates it,
- writes to it,
- closes it, and
- 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.
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… | Use | Why |
|---|---|---|
| Confirmed handoff / step ordering | unbuffered channel | the rendezvous proves the receiver got it |
| A bursty producer to run ahead | buffered channel | absorbs short spikes up to cap |
| Tell consumers “no more values” | close the channel | ends for range, returns ok=false |
| One-to-many cancellation / done signal | close(chan struct{}) | a closed channel broadcasts to all |
Disable a branch in a select | set the channel var to nil | a nil channel never becomes ready |
| Shared counter / tiny critical section | mutex, not a channel | a 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
nilchannel in aselectis silently skipped, never chosen — the basis of the disable-a-case trick. rangeover a channel never ends if no one closes it. Afor range chon a channel that is written but never closed is a leak: the consumer blocks forever once the producer stops. Always close, or use adone/contextto break out.- Receiving the zero value is ambiguous without comma-ok. A real
0and “closed” look identical unless you checkok. capis 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.
The send on line 7 blocks forever: the receive below it can never run because main is stuck on the send. Unblock the handoff.
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.
Related topics
Go's lightweight, runtime-scheduled concurrent functions — the fork-join model, their tiny cost, M:N scheduling, and how to avoid leaks.
building-blocksselectWait on multiple channel operations at once — the basis of timeouts, cancellation, non-blocking I/O, fan-in, and the event loop.
building-blocksThe sync PackageMutex, RWMutex, WaitGroup, Once, Cond and Pool — the lower-level primitives for guarding shared state, plus the copylocks rule and mutex-vs-channel guidance.
Check your understanding
Score: 0 / 51. 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.