DevRef / Go / Channels

Go Channels: Buffered, Unbuffered, and Range

Channel semantics are simple to state and subtle in practice. The difference between buffered and unbuffered channels is not just a capacity parameter — it changes when a goroutine blocks, which changes the synchronisation guarantees your program provides.

Unbuffered channels: rendezvous synchronisation

An unbuffered channel (make(chan T)) requires both a sender and a receiver to be present before either can proceed. The send blocks until a goroutine calls receive; the receive blocks until a goroutine calls send. This is rendezvous synchronisation — both parties arrive at the channel simultaneously.

ch := make(chan int)

go func() {
    ch <- 42 // blocks until someone receives
}()

val := <-ch // unblocks the sender
fmt.Println(val)

The guarantee: the send happens before the receive completes. This is not just a timing observation — it is a memory model guarantee. All writes visible to the goroutine before the send are visible to the goroutine after the receive. Unbuffered channels are a synchronisation primitive as much as a communication one.

Buffered channels: decoupled producer and consumer

A buffered channel (make(chan T, n)) has capacity for n values before any receiver is required. A sender blocks only when the buffer is full. A receiver blocks only when it is empty.

ch := make(chan int, 10)

ch <- 42 // does not block — space in buffer
ch <- 99

val := <-ch // receives 42 (FIFO)
fmt.Println(val)

Buffered channels decouple producer and consumer timing. They are also useful as semaphores: a channel of capacity N limits concurrent access to N slots simultaneously.

sem := make(chan struct{}, 10)

for _, item := range items {
    sem <- struct{}{}        // acquire: blocks when 10 goroutines are running
    go func(item Item) {
        defer func() { <-sem }() // release on exit
        process(item)
    }(item)
}

Closing and ranging over channels

Closing a channel signals to receivers that no more values will be sent. A receive on a closed channel returns the zero value immediately, with an optional second boolean indicating whether the channel was closed.

ch := make(chan int)

go func() {
    for i := 0; i < 5; i++ {
        ch <- i
    }
    close(ch)
}()

for v := range ch { // range exits when ch is closed and drained
    fmt.Println(v)
}

Only the sender should close a channel. Closing a channel from the receiver side, or closing an already-closed channel, panics. If multiple goroutines are sending, coordinate closure with a sync.WaitGroup — a single goroutine waits for all senders to finish, then closes.

Directional channel types

Functions that only send or only receive can express this in their parameter types. chan<-T is send-only; <-chan T is receive-only. Bidirectional channels convert to directional types implicitly; the reverse is a compile error. This is a lightweight API contract enforced by the compiler.

func produce(out chan<- int) { // can only send
    for i := 0; i < 5; i++ {
        out <- i
    }
    close(out)
}

func consume(in <-chan int) { // can only receive
    for v := range in {
        fmt.Println(v)
    }
}