13 - Channel Patterns

📋 Jump to Takeaways

Channels are Go's primary coordination tool, and they're more versatile than just "send and receive." This lesson covers classic channel patterns from Rob Pike's concurrency talks — small, elegant building blocks that show up in real systems under different names.

Quit Signal

Before context.Context existed, Go programmers used a dedicated quit channel to tell goroutines to stop. You'll still see this in older codebases and it's worth understanding.

func boring(msg string, quit chan string) <-chan string {
    c := make(chan string)
    go func() {
        for i := 0; ; i++ {
            select {
            case c <- fmt.Sprintf("%s %d", msg, i):
            case <-quit:
                fmt.Println("cleaning up")
                quit <- "done"
                return
            }
            time.Sleep(time.Duration(rand.Intn(500)) * time.Millisecond)
        }
    }()
    return c
}

func main() {
    quit := make(chan string)
    c := boring("worker", quit)

    for i := 0; i < 3; i++ {
        fmt.Println(<-c)
    }

    quit <- "stop"          // signal the goroutine
    fmt.Println(<-quit)     // wait for cleanup confirmation
}

The producer checks quit on every iteration via select. When the consumer sends on quit, the producer cleans up and confirms by sending back. This is a two-way handshake — the consumer knows the goroutine actually stopped.

In modern Go, use context.WithCancel instead. But the quit channel pattern is the foundation that context was built on.

Restore Sequence

When you fan-in multiple channels, messages arrive in whatever order goroutines happen to run. Sometimes you need to enforce turn-taking — goroutine A, then B, then A, then B.

The trick: embed a "wait" channel in each message. The receiver signals when it's ready for the next value.

type Message struct {
    Text string
    Wait chan bool
}

func boring(msg string) <-chan Message {
    c := make(chan Message)
    wait := make(chan bool)
    go func() {
        for i := 0; ; i++ {
            c <- Message{
                Text: fmt.Sprintf("%s %d", msg, i),
                Wait: wait,
            }
            time.Sleep(time.Duration(rand.Intn(500)) * time.Millisecond)
            <-wait // block until receiver says "go"
        }
    }()
    return c
}

func fanIn(ch1, ch2 <-chan Message) <-chan Message {
    c := make(chan Message)
    go func() { for { c <- <-ch1 } }()
    go func() { for { c <- <-ch2 } }()
    return c
}

func main() {
    c := fanIn(boring("Alice"), boring("Bob"))

    for i := 0; i < 5; i++ {
        msg1 := <-c
        fmt.Println(msg1.Text)
        msg2 := <-c
        fmt.Println(msg2.Text)

        msg1.Wait <- true // release Alice
        msg2.Wait <- true // release Bob
    }
}

Each goroutine blocks on <-wait after sending. The receiver reads two messages, processes them in order, then unblocks both. This guarantees alternating output even though fan-in is non-deterministic.

Daisy Chain

Chain N goroutines together, each passing a value to the next. The classic example: 1000 goroutines, each adds 1 to the value it receives.

func f(left, right chan int) {
    left <- 1 + <-right
}

func main() {
    const n = 1000
    leftmost := make(chan int)
    left := leftmost

    for i := 0; i < n; i++ {
        right := make(chan int)
        go f(left, right)
        left = right
    }

    go func() { left <- 1 }() // seed the rightmost channel
    fmt.Println(<-leftmost)   // prints 1001
}

This creates a chain: leftmost ← g1 ← g2 ← ... ← g1000 ← 1. The value 1 enters the rightmost end, each goroutine adds 1, and the result pops out the left. It demonstrates that goroutines are cheap — 1000 of them use barely any memory.

This pattern appears in real systems as processing pipelines where each stage does a small transformation.

Ping-Pong

Two goroutines pass a shared value back and forth through a channel, like a ping-pong ball on a table.

type Ball struct{ hits int }

func player(name string, table chan *Ball) {
    for {
        ball := <-table
        ball.hits++
        fmt.Println(name, ball.hits)
        time.Sleep(50 * time.Millisecond)
        table <- ball
    }
}

func main() {
    table := make(chan *Ball)

    go player("ping", table)
    go player("pong", table)

    table <- &Ball{} // toss the ball

    time.Sleep(500 * time.Millisecond)
    <-table // grab the ball — game over
}

The channel acts as a mutex here — whoever holds the ball (reads from the channel) has exclusive access. The other player blocks until the ball comes back. This is a simple model for turn-based coordination between two goroutines.

Ring Buffer Channel

A buffered channel can act as a ring buffer — when it's full, drop the oldest item to make room for the new one.

type RingBuffer struct {
    in  chan int
    out chan int
}

func NewRingBuffer(in, out chan int) *RingBuffer {
    return &RingBuffer{in: in, out: out}
}

func (r *RingBuffer) Run() {
    for v := range r.in {
        select {
        case r.out <- v:
        default:
            <-r.out    // drop oldest
            r.out <- v // push new
        }
    }
    close(r.out)
}

func main() {
    in := make(chan int)
    out := make(chan int, 4)
    rb := NewRingBuffer(in, out)
    go rb.Run()

    // Producer: send 10 items fast
    for i := 0; i < 10; i++ {
        in <- i
    }
    close(in)

    // Consumer: read whatever's left
    for v := range out {
        fmt.Println(v) // prints the last 4 values
    }
}

The select with default is the key. If out has room, send directly. If it's full, pop one item and push the new one. The consumer always gets the most recent values. This is useful for metrics, event streams, or any "latest N" buffer where dropping old data is acceptable.

Subscription Pattern

A subscription wraps a data source and delivers items over a channel. The consumer reads from the channel; the subscription handles fetching, buffering, and cleanup internally.

type Item struct {
    Title string
}

type Subscription interface {
    Updates() <-chan Item
    Close() error
}

type sub struct {
    items   chan Item
    closing chan chan error
}

func Subscribe(fetch func() (Item, error), interval time.Duration) Subscription {
    s := &sub{
        items:   make(chan Item),
        closing: make(chan chan error),
    }
    go s.loop(fetch, interval)
    return s
}

func (s *sub) Updates() <-chan Item { return s.items }

func (s *sub) Close() error {
    errc := make(chan error)
    s.closing <- errc
    return <-errc
}

func (s *sub) loop(fetch func() (Item, error), interval time.Duration) {
    var pending []Item
    var next time.Time
    var err error

    for {
        var delay time.Duration
        if now := time.Now(); next.After(now) {
            delay = next.Sub(now)
        }

        var fetchC <-chan time.Time
        if len(pending) < 10 {
            fetchC = time.After(delay)
        }

        var first Item
        var updates chan Item
        if len(pending) > 0 {
            first = pending[0]
            updates = s.items
        }

        select {
        case <-fetchC:
            var item Item
            item, err = fetch()
            if err != nil {
                next = time.Now().Add(10 * time.Second)
                break
            }
            next = time.Now().Add(interval)
            pending = append(pending, item)

        case updates <- first:
            pending = pending[1:]

        case errc := <-s.closing:
            errc <- err
            close(s.items)
            return
        }
    }
}

The select in loop juggles three concerns in one goroutine:

  • Fetch when the buffer isn't full and enough time has passed
  • Send the next pending item when the consumer is ready
  • Close when the owner is done

The nil channel trick is important here: updates is nil (and thus disabled in select) when there's nothing to send. fetchC is nil when the buffer is full. This is a powerful pattern for building event feeds, polling APIs, or any producer that needs backpressure and clean shutdown.

Key Takeaways

  • Quit signal is the predecessor to context.Cancel — a dedicated channel for shutdown coordination
  • Restore sequence uses a wait channel embedded in messages to enforce ordering across fan-in
  • Daisy chain shows goroutines are cheap — chain thousands together for incremental processing
  • Ping-pong uses a channel as a turn-based lock between two goroutines
  • Ring buffer channels drop the oldest item when full — useful for "latest N" scenarios
  • The subscription pattern combines fetching, buffering, and cancellation in a single select loop using nil channel toggling

📝 Ready to test your knowledge?

Answer the quiz below to mark this lesson complete.

© 2026 ByteLearn.dev. Free courses for developers. · Privacy