On this page

Channels and Select

15 min read TextCh. 4 — Concurrency

Channels: Safe Communication Between Goroutines

Go's philosophy on concurrency is summarized in a phrase by Rob Pike: "Do not communicate by sharing memory; instead, share memory by communicating." Channels are the mechanism to implement this philosophy.

A channel is a typed conduit through which goroutines send and receive values. They are fundamental to Go concurrency: instead of using mutexes to protect shared data, you pass data through channels, eliminating the possibility of concurrent access to the same variable.

Creating and Using Channels

// Create an unbuffered channel for int
ch := make(chan int)

// Create a buffered channel with 5 slots
ch2 := make(chan string, 5)

// Send a value (arrow operator <-)
ch2 <- "hello"

// Receive a value
msg := <-ch2

// Receive and discard the value (synchronization only)
<-ch2

Unbuffered Channels

An unbuffered channel is synchronous: the send blocks until there is a receiver, and the receiver blocks until there is a sender. This synchronization guarantees that both goroutines "meet" at the channel point:

func main() {
    ch := make(chan string)

    go func() {
        fmt.Println("Goroutine: preparing message...")
        ch <- "Hello from the goroutine!"  // blocks until main receives
        fmt.Println("Goroutine: message sent")
    }()

    fmt.Println("Main: waiting for message...")
    msg := <-ch  // blocks until the goroutine sends
    fmt.Println("Main received:", msg)
}
// Output (deterministic):
// Main: waiting for message...
// Goroutine: preparing message...
// Goroutine: message sent
// Main received: Hello from the goroutine!

Unbuffered channels are used for synchronization more than for data transfer.

Buffered Channels

A buffered channel is asynchronous until the buffer is full. The send only blocks when the buffer is full, and the receive only blocks when the buffer is empty:

ch := make(chan int, 3)  // buffer of 3 slots

ch <- 1  // does not block — buffer[0]
ch <- 2  // does not block — buffer[1]
ch <- 3  // does not block — buffer[2]
// ch <- 4  // would block! buffer full

fmt.Println(len(ch))  // 3 — elements in buffer
fmt.Println(cap(ch))  // 3 — buffer capacity

n := <-ch  // 1 — does not block, buffer has elements
fmt.Println(n)
ch <- 4  // now fits — buffer: [2, 3, 4]

Directional Channels

When passing a channel as a parameter, you can specify whether it can only be used for sending or receiving. This documents the intent and the compiler verifies the usage:

// Send-only (producer)
func producer(ch chan<- int) {
    for i := 1; i <= 10; i++ {
        ch <- i
    }
    close(ch)
    // val := <-ch  // compile error: cannot receive from send-only channel
}

// Receive-only (consumer)
func consumer(ch <-chan int) {
    for n := range ch {
        fmt.Println(n)
    }
    // ch <- 42  // compile error: cannot send to receive-only channel
}

func main() {
    ch := make(chan int, 5)  // chan int (bidirectional)
    go producer(ch)          // implicitly converted to chan<- int
    consumer(ch)              // implicitly converted to <-chan int
}

Closing Channels and range

Closing a channel signals that no more values will be sent. Receivers can detect this:

// Detect closed channel with "comma ok"
for {
    val, ok := <-ch
    if !ok {
        fmt.Println("Channel closed")
        break
    }
    fmt.Println(val)
}

// Idiomatic form: range stops automatically when the channel is closed
for val := range ch {
    fmt.Println(val)
}
// The loop ends when ch is closed and drained

Important rule: Only the producer should close the channel. Closing from the consumer side causes bugs. Sending to a closed channel causes a panic.

The `select` Statement

select allows a goroutine to wait on multiple channel operations simultaneously. It is like a switch but for channel operations:

select {
case msg1 := <-ch1:
    fmt.Println("Received from ch1:", msg1)
case msg2 := <-ch2:
    fmt.Println("Received from ch2:", msg2)
case ch3 <- "sent":
    fmt.Println("Sent to ch3")
}

If multiple cases are ready simultaneously, select picks one at random.

`select` with `default`

The default case makes select non-blocking:

// Non-blocking receive
select {
case val := <-ch:
    fmt.Println("Received:", val)
default:
    fmt.Println("Channel empty — no blocking")
}

// Non-blocking send
select {
case ch <- value:
    fmt.Println("Sent")
default:
    fmt.Println("Channel full — no blocking, value discarded")
}

`select` in a Loop — Handling Multiple Sources

func multiplex(ch1, ch2 <-chan string, done <-chan struct{}) {
    for {
        select {
        case msg := <-ch1:
            processMessage("source1", msg)
        case msg := <-ch2:
            processMessage("source2", msg)
        case <-done:
            fmt.Println("Stop signal received")
            return
        }
    }
}

Timeouts with `time.After`

time.After(d) returns a channel that receives a value after duration d. It is perfect for implementing timeouts on channel operations:

func searchWithTimeout(id int, timeout time.Duration) (*Result, error) {
    resultCh := make(chan *Result, 1)

    go func() {
        // potentially slow operation
        result := searchDatabase(id)
        resultCh <- result
    }()

    select {
    case result := <-resultCh:
        return result, nil
    case <-time.After(timeout):
        return nil, fmt.Errorf("timeout after %v searching id=%d", timeout, id)
    }
}

// Usage
result, err := searchWithTimeout(42, 5*time.Second)

Done Channel as Termination Signal

An empty chan struct{} is used to signal termination without transferring data:

func worker(jobs <-chan Job, done <-chan struct{}) {
    for {
        select {
        case job := <-jobs:
            processJob(job)
        case <-done:
            fmt.Println("Worker stopped")
            return
        }
    }
}

func main() {
    jobs := make(chan Job, 10)
    done := make(chan struct{})

    go worker(jobs, done)

    // ... send jobs ...

    // Signal stop
    close(done)  // closing a channel broadcasts to all receivers
}

Pattern: Pipeline with Channels

A pipeline chains processing stages, each in its own goroutine:

// Stage 1: generate numbers
func generate(nums ...int) <-chan int {
    out := make(chan int)
    go func() {
        for _, n := range nums {
            out <- n
        }
        close(out)
    }()
    return out
}

// Stage 2: squares
func square(in <-chan int) <-chan int {
    out := make(chan int)
    go func() {
        for n := range in {
            out <- n * n
        }
        close(out)
    }()
    return out
}

func main() {
    // Chain the pipeline
    naturals := generate(1, 2, 3, 4, 5)
    squares := square(naturals)

    for n := range squares {
        fmt.Println(n)  // 1, 4, 9, 16, 25
    }
}

With channels and select mastered, in the next lesson we will explore more advanced concurrency patterns: worker pools, fan-in/fan-out, and the essential context.Context.

Do not communicate by sharing memory; share memory by communicating
This is Go's concurrency mantra. Channels allow goroutines to communicate by passing data (like messages), rather than sharing variables with locks. This makes code easier to reason about and less prone to data races.
Closing an already-closed channel causes a panic
Only the producer (the sender) should close a channel. Closing a channel twice, or sending to a closed channel, causes a runtime panic. Receiving from a closed channel is safe — it returns the zero value of the type and ok=false. Use val, ok := <-ch to detect if a channel was closed.