The idea in one minute#
A channel is a typed conduit between goroutines: one sends a value, another receives it. An unbuffered channel is a rendezvous — the send completes only when a receiver takes the value, so it synchronizes the two. A buffered channel is a fixed-size queue: sends block only when it is full, receives only when it is empty.
Inside, a channel is a small struct with a lock, a ring buffer, and two queues of waiting goroutines. Knowing that makes every rule — what blocks, what panics, what a closed channel returns — something you can derive rather than memorize.
An analogy#
An unbuffered channel is handing a parcel to someone in person: you both have to be at the door at the same moment, and you leave knowing it was received. A buffered channel is a parcel locker with N compartments: you drop off and leave, unless every compartment is full, in which case you wait. Closing the channel is putting up a “no more deliveries” sign — people can still collect what is inside, and after that they find it empty immediately.
A picture#
flowchart TB
subgraph HCHAN["hchan: what make(chan T, 4) creates on the heap"]
direction TB
LOCK["lock (a mutex)"]
BUF["buf: ring of 4 slots<br/>qcount=2, sendx, recvx"]
RQ["recvq: goroutines waiting to receive"]
SQ["sendq: goroutines waiting to send"]
CL["closed flag"]
end
S["sender G<br/>ch <- v"] -->|"1. waiting receiver? copy v directly to it, wake it<br/>2. else room in buf? copy v in<br/>3. else park in sendq"| HCHAN
HCHAN -->|"1. buf has data? copy out (and move a waiting sender's value in)<br/>2. else waiting sender? take its value directly<br/>3. else park in recvq"| R["receiver G<br/>v := <-ch"]
class S,R compute
class LOCK,CL neutral
class BUF memory
class RQ,SQ queueHow it really works#
Creating and using#
ch := make(chan int) // unbuffered
ch := make(chan int, 64) // buffered, capacity 64
ch <- v // send
v := <-ch // receive
v, ok := <-ch // ok is false if the channel is closed and drained
close(ch) // no more sends
for v := range ch { } // receive until closedA channel variable is a pointer to the hchan struct: copying it or passing it to a function
shares the channel. Directional types document and enforce intent: chan<- T can only be sent
on, <-chan T only received from.
The rules, as a table#
| Operation | nil channel | Open, not ready | Open, ready | Closed |
|---|---|---|---|---|
Send ch <- v | Blocks forever | Blocks | Succeeds | Panics |
Receive <-ch | Blocks forever | Blocks | Succeeds | Returns buffered values, then the zero value with ok == false, immediately |
close(ch) | Panics | Succeeds | Succeeds | Panics |
Two rules for staying out of the panics:
- Only the sender closes, and only when there is exactly one sender (or the senders
coordinate, usually with a
WaitGroup: close after all have finished). - Closing is a broadcast: every receiver, present and future, is released. That makes
close(done)the idiom for “everyone stop” — which is whatcontextdoes (lesson 03).
A nil channel blocking forever is useful, not just a trap: setting a channel variable to nil
inside a select loop disables that case.
What the runtime does#
- A send takes the lock. If a receiver is parked in
recvq, the value is copied directly into that goroutine’s stack and it is made runnable — no buffer involved. Otherwise, if the buffer has room, the value is copied into the ring. Otherwise the sender is added tosendqand parks. - A receive is the mirror image.
- Values are copied. Sending a struct copies the struct; sending a pointer copies the pointer and shares what it points to. Send values or immutable data when you can; after sending a pointer, treat the object as given away.
- An uncontended channel operation costs tens of nanoseconds — more than a mutex, far more than an atomic. A handoff that parks and wakes a goroutine costs a few hundred.
Buffered or unbuffered?#
| Choose | When |
|---|---|
| Unbuffered | You want synchronization: the sender should know the receiver has it. The default |
| Buffer of 1 | A result from a goroutine whose receiver may have given up — the send never blocks, so the goroutine never leaks |
| Buffer of N | You know N: N workers’ results, a semaphore with N permits, a bounded burst |
| Large buffer “to be safe” | Almost never. It hides a slow consumer until the buffer fills, then you have latency and blocking |
A buffer does not make a system faster; it moves the point where backpressure is felt. A bounded queue with an explicit policy when full — block, drop, or reject — is a design decision (Inference Engineering VIII.03).
Common shapes#
done := make(chan struct{}) // a signal carrying no data
sem := make(chan struct{}, 8) // a semaphore: at most 8 at once
sem <- struct{}{}; defer func() { <-sem }()
results := make(chan Result, len(jobs)) // every worker can send without blockingChannels or mutexes?#
“Share memory by communicating” is good advice for passing ownership of data and for coordinating goroutines: pipelines, worker pools, signalling completion. It is poor advice for protecting a piece of state: a counter or a cache guarded by a channel-and-goroutine is slower and more code than a mutex. Use a channel when a value changes hands; use a mutex when several goroutines need the same value (lesson 04).
Deadlock#
If every goroutine is blocked, the runtime reports fatal error: all goroutines are asleep - deadlock!. It only detects the total case. A partial deadlock — a few goroutines stuck
forever while others run — is silent, and is the usual form of a goroutine leak (lesson 06).
Code#
// channels.go — every row of the rules table, demonstrated, plus the cost of an operation.
package main
import (
"fmt"
"sync"
"testing"
"time"
)
// try runs f and reports whether it finished, blocked, or panicked.
func try(f func()) string {
done := make(chan string, 1)
go func() {
defer func() {
if r := recover(); r != nil {
done <- fmt.Sprint("PANIC: ", r)
}
}()
f()
done <- "ok"
}()
select {
case s := <-done:
return s
case <-time.After(30 * time.Millisecond):
return "blocks"
}
}
func main() {
var nilCh chan int
fmt.Println("send on nil channel: ", try(func() { nilCh <- 1 }))
fmt.Println("receive from nil channel: ", try(func() { <-nilCh }))
fmt.Println("close nil channel: ", try(func() { close(nilCh) }))
unbuf := make(chan int)
fmt.Println("send, unbuffered, no reader:", try(func() { unbuf <- 1 }))
buf := make(chan int, 2)
fmt.Println("send, buffered, has room: ", try(func() { buf <- 1 }))
buf <- 2
fmt.Println("send, buffered, full: ", try(func() { buf <- 3 }))
close(buf)
v1, ok1 := <-buf
v2, ok2 := <-buf
v3, ok3 := <-buf
fmt.Printf("receive after close: %d,%v %d,%v %d,%v (drains, then zero value)\n", v1, ok1, v2, ok2, v3, ok3)
fmt.Println("send on closed channel: ", try(func() { buf <- 4 }))
fmt.Println("close twice: ", try(func() { close(buf) }))
// Close is a broadcast: it releases every waiting receiver at once.
start := make(chan struct{})
var wg sync.WaitGroup
for i := 0; i < 5; i++ {
wg.Add(1)
go func() { defer wg.Done(); <-start }()
}
close(start)
wg.Wait()
fmt.Println("close(start) released 5 waiting goroutines")
// Cost: buffered channel vs mutex-protected slot, single goroutine (no parking).
bc := make(chan int, 1)
rch := testing.Benchmark(func(b *testing.B) {
for i := 0; i < b.N; i++ {
bc <- i
<-bc
}
})
var mu sync.Mutex
slot := 0
rmu := testing.Benchmark(func(b *testing.B) {
for i := 0; i < b.N; i++ {
mu.Lock()
slot = i
mu.Unlock()
mu.Lock()
_ = slot
mu.Unlock()
}
})
// Cost with a real handoff between two goroutines.
ping, pong := make(chan int), make(chan int)
go func() {
for v := range ping {
pong <- v
}
}()
rho := testing.Benchmark(func(b *testing.B) {
for i := 0; i < b.N; i++ {
ping <- i
<-pong
}
})
close(ping)
fmt.Printf("\nsend+receive, buffered, same goroutine: %4d ns\n", rch.NsPerOp())
fmt.Printf("two lock/unlock pairs on a mutex: %4d ns\n", rmu.NsPerOp())
fmt.Printf("round trip between two goroutines: %4d ns\n", rho.NsPerOp())
}Remember this#
- A channel is a lock, a ring buffer and two wait queues. Unbuffered means direct handoff.
- nil blocks forever; send on closed panics; receive on closed returns zero values at once.
- Only the sender closes. Close is a broadcast.
- Buffers move backpressure; they do not remove it.
- Channels to transfer ownership and coordinate; mutexes to protect state.
Try it#
- Run
channels.goand match each line to a cell of the rules table. - Write a function that starts a goroutine sending its result on an unbuffered channel, then
returns early without receiving. Show with
runtime.NumGoroutinethat it leaked. Fix it with a buffer of 1. - Build a semaphore from a buffered channel that limits a loop of 100 tasks to 5 at a time, and record the maximum concurrency actually observed.
Check yourself#
- What happens when you receive from a closed channel that still has buffered values?
- Why should only the sender close a channel?
- What does adding a buffer change about backpressure?