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. For simple shared counters or caches, a mutex is often clearer.