CHAPTER 25 · CONCURRENCY

Channels & select

A channel is a typed conduit through which goroutines send and receive values. Go's concurrency mantra: "Don't communicate by sharing memory; share memory by communicating." Channels are how you do the latter.

Learning objectives

  • Create, send, receive, and close channels.
  • Distinguish buffered from unbuffered channels.
  • Range over a channel until it's closed.
  • Use select to wait on multiple channels and implement timeouts.
  • Diagnose and avoid deadlocks.

Creating a channel

ch := make(chan int)           // unbuffered
buf := make(chan int, 10)      // buffered, capacity 10

The zero value of a channel is nil; sends and receives on a nil channel block forever. Always use make.

Sending & receiving

ch := make(chan string)

go func() {
    ch <- "hello"         // send
}()

msg := <-ch               // receive (blocks until value available)
fmt.Println(msg)

An unbuffered channel synchronizes: the sender blocks until a receiver is ready, and vice versa.

Buffered channels

ch := make(chan int, 3)
ch <- 1
ch <- 2
ch <- 3          // no blocking, buffer has room
// ch <- 4       // THIS would block until a receive happens
fmt.Println(<-ch, <-ch, <-ch)    // 1 2 3

Buffers decouple producer and consumer timing. Use sparingly; a big buffer often indicates a design smell.

Closing

close(ch)

Only the sender should close. Receivers get the zero value and an "ok" of false:

v, ok := <-ch
if !ok { fmt.Println("closed") }

Sending to a closed channel panics. Closing an already-closed channel panics. Closing a nil channel panics. In short: be thoughtful about who closes.

range over a channel

ch := make(chan int)
go func() {
    for i := 1; i <= 3; i++ {
        ch <- i
    }
    close(ch)          // ← signals the range loop to exit
}()

for v := range ch {
    fmt.Println(v)
}

The range loop exits when the channel is closed and drained.

Channel direction

You can constrain a channel parameter to be send-only or receive-only , a powerful safety mechanism:

func producer(out chan<- int)    { out <- 1 }       // send-only
func consumer(in  <-chan int) int{ return <-in }    // receive-only

ch := make(chan int, 1)
go producer(ch)
fmt.Println(consumer(ch))

select

select waits on multiple channel operations, whichever is ready first wins:

select {
case v := <-ch1:
    fmt.Println("from ch1:", v)
case v := <-ch2:
    fmt.Println("from ch2:", v)
case ch3 <- 42:
    fmt.Println("sent to ch3")
default:
    fmt.Println("no channel ready")   // optional; makes select non-blocking
}

Timeouts

select {
case v := <-ch:
    fmt.Println("got:", v)
case <-time.After(2 * time.Second):
    fmt.Println("timed out")
}

time.After returns a channel that fires once after the given duration. Combined with select, you get clean timeouts. (For more sophisticated cancellation, see Chapter 26 on context.)

Fan-in & fan-out

Fan-out: many workers consuming from one channel.

jobs := make(chan int, 100)
var wg sync.WaitGroup

// 3 workers fan-out from jobs
for w := 0; w < 3; w++ {
    wg.Add(1)
    go func(id int) {
        defer wg.Done()
        for j := range jobs {
            fmt.Printf("worker %d: %d\n", id, j)
        }
    }(w)
}

for i := 1; i <= 10; i++ { jobs <- i }
close(jobs)
wg.Wait()

Fan-in: merging multiple input channels into one.

func merge(a, b <-chan int) <-chan int {
    out := make(chan int)
    go func() {
        defer close(out)
        for v := range a { out <- v }
        for v := range b { out <- v }
    }()
    return out
}

Deadlocks

If every goroutine is blocked waiting, Go detects the deadlock and panics with a helpful message. Common causes:

  • Sending to an unbuffered channel with no receiver.
  • Receiving from a channel no one sends on.
  • Forgetting to close before a range.
ch := make(chan int)
ch <- 1   // fatal error: all goroutines are asleep - deadlock!

Check your understanding

Practice exercises

EXERCISE 1

Worker pool

Write a worker pool: N worker goroutines consume from a jobs channel, compute each job's result, and push it to a results channel. The main goroutine feeds jobs and drains results.

Show solution
package main

import (
    "fmt"
    "sync"
)

func worker(id int, jobs <-chan int, results chan<- int, wg *sync.WaitGroup) {
    defer wg.Done()
    for j := range jobs {
        results <- j * j          // square as a placeholder for "work"
    }
}

func main() {
    jobs := make(chan int, 10)
    results := make(chan int, 10)
    var wg sync.WaitGroup

    // start 3 workers
    for w := 1; w <= 3; w++ {
        wg.Add(1)
        go worker(w, jobs, results, &wg)
    }

    // feed
    for j := 1; j <= 5; j++ { jobs <- j }
    close(jobs)

    // close results when all workers finish
    go func() { wg.Wait(); close(results) }()

    for r := range results {
        fmt.Println(r)
    }
}

Further reading

Channels mastered.