13 / 20 · Day 5
Day 5 · Concept 13

Channels

Channels are typed conduits. One goroutine sends; another receives. The famous Go proverb: "Do not communicate by sharing memory; share memory by communicating." A channel is the canonical synchronization primitive — and when used right, it eliminates whole categories of race conditions.


1 · The intuition

A channel is a typed pipe between goroutines. ch <- v sends; v := <-ch receives. The arrow points the direction the value travels. A channel without a buffer is unbuffered — a send blocks until a receiver is ready, and vice versa. That synchronization is the feature.

The proverb. "Do not communicate by sharing memory; share memory by communicating." Instead of one shared variable with a mutex, pass the data through a channel — only one goroutine owns it at a time. The locking is implicit in the channel's send/receive.

2 · Try it — unbuffered

go main.go · the simplest channel
package main

import "fmt"

func main() {
    ch := make(chan int)  // unbuffered

    go func() {
        ch <- 42          // blocks until main is ready to receive
        fmt.Println("sent")
    }()

    v := <-ch              // blocks until someone sends
    fmt.Println("got:", v)
}

An unbuffered send and receive happen at the same moment — they synchronize. The "sent" prints after "got" because the goroutine doesn't continue past the send until main has received.

3 · Buffered channels

go main.go · buffered
package main

import "fmt"

func main() {
    ch := make(chan int, 3)  // buffer of 3

    ch <- 1
    ch <- 2
    ch <- 3
    // ch <- 4  // would BLOCK here — buffer full, no receiver

    fmt.Println(<-ch, <-ch, <-ch)  // drains in FIFO order
}

Buffer size = capacity. The channel acts like a fixed-size FIFO queue. Sends block when the buffer is full; receives block when it's empty. Used for decoupling producers and consumers when you can tolerate some latency.

4 · close + range — clean shutdown

go main.go · the canonical producer/consumer
package main

import "fmt"

func producer(ch chan<- int) {  // send-only direction
    for i := 1; i <= 5; i++ {
        ch <- i * 10
    }
    close(ch)  // signal "no more values"
}

func main() {
    ch := make(chan int, 3)
    go producer(ch)

    // range reads until the channel is closed
    for v := range ch {
        fmt.Println(v)
    }
    fmt.Println("done")
}
Three rules of close. Only the sender should close. Sending on a closed channel panics. Receiving from a closed channel returns the zero value immediately — use the comma-ok form v, ok := <-ch to detect "closed".

5 · Directional channels — the type system as documentation

go main.go · send-only and receive-only
package main

import "fmt"

func send(ch chan<- int) {     // only allowed to send
    ch <- 1
}

func receive(ch <-chan int) {   // only allowed to receive
    fmt.Println(<-ch)
}

func main() {
    ch := make(chan int, 1)
    send(ch)
    receive(ch)
}

Using chan<- T (send-only) and <-chan T (receive-only) in function signatures lets the type checker enforce intent. A bidirectional chan T converts to either direction freely.

6 · Pipelines — composition through channels

go main.go · pipeline stage
package main

import "fmt"

// Each stage takes an input channel, returns an output channel.
func gen(nums ...int) <-chan int {
    out := make(chan int)
    go func() {
        defer close(out)
        for _, n := range nums { out <- n }
    }()
    return out
}

func square(in <-chan int) <-chan int {
    out := make(chan int)
    go func() {
        defer close(out)
        for v := range in { out <- v * v }
    }()
    return out
}

func main() {
    for v := range square(gen(1, 2, 3, 4, 5)) {
        fmt.Println(v)
    }
}

Each stage owns one goroutine that reads from its input and writes to its output. Composing pipelines becomes "feed one stage's output as another's input". This is how high-throughput Go services structure stream processing.

7 · From the wild

go kubernetes · watch event channel (paraphrased)
type Interface interface {
    Stop()
    ResultChan() <-chan Event
}

type Watcher struct {
    result chan Event
    stopCh chan struct{}
}

// Producer goroutine inside the watcher
func (w *Watcher) run() {
    defer close(w.result)
    for {
        select {
        case <-w.stopCh:
            return
        case ev := <-w.upstream:
            w.result <- ev
        }
    }
}

// Consumer side
for ev := range watcher.ResultChan() {
    handle(ev)
}
From the wild: github.com/kubernetes/kubernetes · Apache 2.0

8 · Coming from another language?

If you know…The bridge
ErlangLike message-passing between processes, but bounded by buffer size and typed.
Rusttokio::sync::mpsc or crossbeam::channel. Unbuffered = rendezvous channel.
Pythonqueue.Queue but synchronous-by-default (unbuffered).
JavaSynchronousQueue (unbuffered) or LinkedBlockingQueue (buffered).
JavaScriptNo direct equivalent. Async iterators come closest. Channels don't fit JS's event-loop model naturally.

9 · Common mistakes

  • Sending on a closed channel. Panics. The sender should close, not the receiver, and only after no more sends will happen.
  • Closing twice. Panics. Ensure single ownership.
  • Reading from nil channel. Blocks forever. Useful trick for "disable a case in select" but a footgun if accidental.
  • Goroutine leak via channel. Receiver waiting on a channel nobody will ever send to. Always pair with a cancellation path.
  • Channel-as-queue at scale. Channels are great up to ~10⁵ ops/sec. Beyond that, look at sync.Pool or specialized ring buffers.
  • Choosing channels when a mutex is simpler. Channels are for pipelining; mutexes are for protecting shared state. Don't channel-ify a counter.

10 · Exercises (~15 min)

  1. Ping-pong. Two goroutines bouncing a value between them via two channels. Print "ping", "pong", alternating, 5 times.
  2. Worker pool. One jobs channel, 4 workers, one results channel. Feed 20 jobs in, drain 20 results.
  3. Fan-in. Three producers each feed their own channel. Combine into one output channel using a goroutine per source.
  4. Closed-channel detection. Write a function that drains a channel and reports how many values it read before close.

11 · When it clicks

  • You reach for channels when goroutines need to talk; mutexes when they need to share.
  • You always know which goroutine owns "close" rights on each channel.
  • Function signatures use chan<- T / <-chan T for documented intent.
  • You can sketch a 3-stage pipeline on a napkin and it works the first time.
Found this useful?