Channel Patterns

Updated

September 13, 2026

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.go

Output (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.go

Output:

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.go

Output:

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.go

Output:

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.go

Output:

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.go

Output:

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.go

Output:

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.
  • range over a channel until close when the stream is finite.
  • select for “whichever happens.” time.After for a deadline on that wait.
  • Close a done channel 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

  1. 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.
  2. In close_range.go, delete close(out). Run it. Interrupt when it hangs. Put the close back.
  3. In select_timeout.go, start a goroutine that sends 7 on tickets before the select. Run it several times. You may see print 7 or no ticket depending on scheduling — then buffer the channel, send in main before select, and confirm you always print 7.
  4. Change done.go so tickets is buffered and already holds 7 before select. Run it a few times. Notice select can pick either case. That is why this listing closes done with no competing ready send.