Channel Patterns
Channel Patterns
Channels are how goroutines hand off tickets without sharing a slice. The boring patterns are few: rendezvous or queue, close when you will send no more, select when you wait on more than one thing. Everything else is a combination of those.
Mental model
make(chan T) is unbuffered. A send waits for a receive, and a receive waits for a send. That meeting is the whole synchronization.
make(chan T, n) is buffered. Send succeeds immediately until n values sit in the queue. Then send waits, same as unbuffered.
close(ch) means “no more sends.” Receivers still drain what is there. for v := range ch ends after the close and the drain. Sending on a closed channel panics. Closing a nil channel panics. Closing twice panics.
select waits on several channel operations and runs one that is ready. If several are ready, it picks one at random. time.After gives you a channel that receives once when a duration elapses.
A done channel is usually chan struct{}. Close it to tell every waiter “stop.” Closing broadcasts. Sending a single struct{}{} does not.
Worked examples
Case 1: Unbuffered rendezvous
Save as rendezvous.go. The window cannot finish the send until the printer is listening. That is the point: the handoff is a meeting, not a mailbox.
// rendezvous.go
package main
import "fmt"
func main() {
ch := make(chan string)
go func() {
ch <- "ticket 7"
fmt.Println("window: handed off")
}()
fmt.Println("printer:", <-ch)
}Run:
go run rendezvous.goOutput (the two lines can appear in either order after the receive unblocks the send):
printer: ticket 7
window: handed off
or:
window: handed off
printer: ticket 7
The receive always happens. The print order of the two Println calls is the only wobble. Both lines always appear because main does not return until it has received.
Case 2: Buffered queue
Save as buffer.go. Capacity 2 lets the window drop two tickets without a printer standing there.
// buffer.go
package main
import "fmt"
func main() {
ch := make(chan string, 2)
ch <- "ticket 7"
ch <- "ticket 8"
fmt.Println(<-ch)
fmt.Println(<-ch)
}Run:
go run buffer.goOutput:
ticket 7
ticket 8
No goroutine. The buffer is a small, bounded queue. A third send with no receive would block main forever. Bound the buffer to a number you can defend (“two tickets in flight”), not “big enough that we never think about it.”
Case 3: Close and range
Save as close_range.go. The kitchen sends every ticket, then closes. The printer ranges until the channel is empty and closed.
// close_range.go
package main
import "fmt"
func kitchen(out chan<- int) {
for id := range 3 {
out <- id + 7
}
close(out)
}
func main() {
ch := make(chan int)
go kitchen(ch)
for id := range ch {
fmt.Println("print", id)
}
fmt.Println("window closed")
}Run:
go run close_range.goOutput:
print 7
print 8
print 9
window closed
The sender closes. The receiver ranges. Flip that and you hang or panic. range on a channel that is never closed is a loop that never ends.
Case 4: Select and a timeout
Save as select_timeout.go. The printer waits for a ticket or for time.After. Only the timeout is ready.
// select_timeout.go
package main
import (
"fmt"
"time"
)
func main() {
tickets := make(chan int)
select {
case id := <-tickets:
fmt.Println("print", id)
case <-time.After(20 * time.Millisecond):
fmt.Println("no ticket")
}
}Run:
go run select_timeout.goOutput:
no ticket
time.After is a one-shot timer channel. Nobody sends on tickets, so the timeout wins. If a send were ready too, select would pick at random — do not treat select as priority order unless you use a default or nested select.
Case 5: Fan-in
Save as fan_in.go. Two windows send into one printer channel. A WaitGroup closes the output when both inputs are done. Results are sorted so the listing is stable.
// fan_in.go
package main
import (
"fmt"
"slices"
"sync"
)
func window(name string, ids []int, out chan<- string) {
for _, id := range ids {
out <- fmt.Sprintf("%s ticket %d", name, id)
}
}
func main() {
out := make(chan string)
var wg sync.WaitGroup
wg.Go(func() { window("A", []int{7, 8}, out) })
wg.Go(func() { window("B", []int{9}, out) })
go func() {
wg.Wait()
close(out)
}()
var got []string
for line := range out {
got = append(got, line)
}
slices.Sort(got)
for _, line := range got {
fmt.Println(line)
}
}Run:
go run fan_in.goOutput:
A ticket 7
A ticket 8
B ticket 9
Fan-in is “many senders, one channel.” Close the channel from the single place that knows all senders finished — here, the goroutine that Waits. A sender must not close a channel other senders still use.
Case 6: Done channel
Save as done.go. Closing done broadcasts stop. The worker waits on select. Only done is ready, so the result is stable. Wait proves the goroutine returned.
// done.go
package main
import (
"fmt"
"sync"
)
func worker(done <-chan struct{}, tickets <-chan int) {
select {
case <-done:
fmt.Println("stopped")
case id := <-tickets:
fmt.Println("print", id)
}
}
func main() {
done := make(chan struct{})
tickets := make(chan int)
close(done)
var wg sync.WaitGroup
wg.Go(func() { worker(done, tickets) })
wg.Wait()
fmt.Println("desk closed")
}Run:
go run done.goOutput:
stopped
desk closed
Close, do not send, to broadcast. If tickets also had a value ready, select would pick at random — keep tests to one ready case.
The trap
Save as send_closed.go. Closing then sending panics. The recover is only so you can see the message; do not recover this at the desk.
// send_closed.go
package main
import "fmt"
func main() {
ch := make(chan int)
close(ch)
defer func() {
fmt.Println("panic:", recover())
}()
ch <- 7
}Run:
go run send_closed.goOutput:
panic: send on closed channel
The matching hang: for range ch when nobody ever closes ch. That program never prints “window closed.” Close from the sender, or receive a fixed count, or wait on done. Do not hope.
The boring rule
- Unbuffered means “meet me.” Buffered means “queue at most n.”
- The sender closes. One closer. Never send after close.
rangeover a channel until close when the stream is finite.selectfor “whichever happens.”time.Afterfor a deadline on that wait.- Close a
donechannel to broadcast stop. Do not send on it as a broadcast. - Fan-in: many senders, one channel, one close after the last sender finishes.
Try this
- In
buffer.go, add a third send and do not add a receive. Run it. Interrupt when it hangs. Then raise the buffer to 3 and run again. - In
close_range.go, deleteclose(out). Run it. Interrupt when it hangs. Put the close back. - In
select_timeout.go, start a goroutine that sends7onticketsbefore theselect. Run it several times. You may seeprint 7orno ticketdepending on scheduling — then buffer the channel, send inmainbeforeselect, and confirm you always print 7. - Change
done.gosoticketsis buffered and already holds7beforeselect. Run it a few times. Noticeselectcan pick either case. That is why this listing closesdonewith no competing ready send.