Channels let goroutines exchange typed values and coordinate without exposing every shared-memory detail.
Reviewer’s note: This lesson treats channels as ownership and synchronization tools, not just message pipes. It covers rendezvous, buffering, close responsibility, directional APIs, and cancellation so each channel has a clear lifecycle.

Send and receive
messages := make(chan string)
go func() {
messages <- "ready"
}()
fmt.Println(<-messages)
An unbuffered send waits for a receiver, and a receive waits for a sender. That rendezvous communicates both a value and synchronization.
Buffer only with a reason
jobs := make(chan Job, 20)
A buffer lets a limited number of sends proceed without an immediate receiver. It can absorb short bursts, but it does not fix a consumer that is permanently slower than its producer.

Closing is the sender’s responsibility
go func() {
defer close(jobs)
for _, job := range input {
jobs <- job
}
}()
for job := range jobs {
process(job)
}
Close means no more values will be sent. Receivers generally should not close a channel, and a channel does not need to be closed merely because it is no longer used.
Express direction in APIs
func produce(out chan<- int) {}
func consume(in <-chan int) {}
Directional types document ownership and let the compiler reject accidental sends or receives.
Avoid goroutine leaks
A sender blocks forever when nobody will receive. Combine channel operations with cancellation when a caller may stop early:
select {
case out <- result:
case <-ctx.Done():
return ctx.Err()
}
Use select when one goroutine must react to several channel operations, and use a worker pool when concurrency must be bounded.
Channels are excellent for transferring ownership, distributing jobs, and broadcasting completion. Once these mechanics are comfortable, Kelly’s advanced channel patterns turns them into reusable stream recipes, while Paul’s concurrency patterns catalogue compares the broader production designs. For simple shared counters or caches, a mutex is often clearer.