Logo
Logo

Atharva Pandey/Lesson 15: Select — Waiting on multiple channels at once

Created Fri, 22 Mar 2024 00:00:00 +0000 Modified Fri, 22 Mar 2024 00:00:00 +0000

By now you know how to create goroutines and how to communicate between them using channels. But what happens when a goroutine needs to listen to two channels at the same time? Maybe it’s waiting for work to arrive, but it also needs to stop if a timeout fires. With a single channel you’d be stuck — receiving from one blocks the other.

select solves this. It’s like a switch statement for channels. It waits until one of several channel operations is ready, then executes that case. If multiple are ready at the same time, it picks one at random.

The Basics

Here’s the simplest select:

package main

import "fmt"

func main() {
    ch1 := make(chan string)
    ch2 := make(chan string)

    go func() { ch1 <- "from ch1" }()
    go func() { ch2 <- "from ch2" }()

    // wait for whichever channel sends first
    select {
    case msg := <-ch1:
        fmt.Println("received:", msg)
    case msg := <-ch2:
        fmt.Println("received:", msg)
    }
}

Both goroutines send at roughly the same time. select picks one randomly. Run it a few times — you’ll see both outputs eventually.

Timeouts with time.After

One of the most useful patterns in Go is using select to impose a timeout. time.After(d) returns a channel that receives a value after duration d. Put that in a select case and you have a timeout:

package main

import (
    "fmt"
    "time"
)

func slowOperation(ch chan string) {
    time.Sleep(2 * time.Second)
    ch <- "result"
}

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

    select {
    case result := <-ch:
        fmt.Println("got result:", result)
    case <-time.After(1 * time.Second):
        fmt.Println("timed out — operation took too long")
    }
}

Because the operation takes 2 seconds but the timeout fires after 1 second, you’ll always see “timed out.” Change the sleep to 500ms and the result arrives first. This pattern is everywhere in real Go code — HTTP clients, database calls, any operation that shouldn’t run forever.

The default case

A select with a default case never blocks. If no channel is ready, it runs default immediately:

ch := make(chan int, 1)

select {
case v := <-ch:
    fmt.Println("received:", v)
default:
    fmt.Println("nothing ready, moving on")
}

This is useful for non-blocking channel checks — “give me a value if one is ready, otherwise keep going.” Be careful not to use default when you actually want to wait; it turns your blocking wait into a busy loop.

Using select with context.Done()

The context package is how Go programs signal cancellation. A context.Context has a Done() method that returns a channel. When the context is cancelled, that channel closes. select lets you react to it:

package main

import (
    "context"
    "fmt"
    "time"
)

func doWork(ctx context.Context, ch chan string) {
    for {
        select {
        case <-ctx.Done():
            fmt.Println("work cancelled:", ctx.Err())
            return
        case ch <- "working":
            time.Sleep(300 * time.Millisecond)
        }
    }
}

func main() {
    ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
    defer cancel()

    ch := make(chan string, 1)
    go doWork(ctx, ch)

    for {
        select {
        case msg := <-ch:
            fmt.Println(msg)
        case <-ctx.Done():
            fmt.Println("main: context done")
            return
        }
    }
}

The goroutine keeps sending “working” every 300ms. After 1 second, the context times out, both the goroutine and main detect ctx.Done(), and the program exits cleanly.

The for-select pattern

In real programs, goroutines rarely do one thing and stop. They loop continuously, handling incoming events until told to stop. The for-select pattern combines a for loop with a select to create a goroutine that runs until cancelled:

func worker(jobs <-chan string, done <-chan struct{}) {
    for {
        select {
        case job := <-jobs:
            fmt.Println("processing:", job)
        case <-done:
            fmt.Println("worker shutting down")
            return
        }
    }
}

func main() {
    jobs := make(chan string, 5)
    done := make(chan struct{})

    go worker(jobs, done)

    jobs <- "task 1"
    jobs <- "task 2"
    jobs <- "task 3"

    time.Sleep(100 * time.Millisecond)
    close(done) // signal the worker to stop

    time.Sleep(100 * time.Millisecond) // let it print the shutdown message
}

The done channel carries struct{} — an empty struct. It carries zero bytes. It’s the conventional way to signal “stop” through a channel when you don’t need to send any data, only a signal.

Try It Yourself

Write a program that fans out to two workers and uses a timeout to stop everything if both are too slow:

package main

import (
    "fmt"
    "time"
)

func worker(id int, out chan<- string) {
    delay := time.Duration(id*400) * time.Millisecond
    time.Sleep(delay)
    out <- fmt.Sprintf("worker %d done", id)
}

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

    go worker(1, ch) // finishes in 400ms
    go worker(2, ch) // finishes in 800ms

    timeout := time.After(600 * time.Millisecond)

    for i := 0; i < 2; i++ {
        select {
        case msg := <-ch:
            fmt.Println(msg)
        case <-timeout:
            fmt.Println("timed out waiting for remaining workers")
            return
        }
    }
}

Worker 1 reports in at 400ms. The timeout fires at 600ms before worker 2 finishes. Try changing the delays to see different outcomes.

Common Mistakes

Using select with only one case. If you have only one channel, you don’t need select. Just receive from the channel directly. select is for choosing between multiple channels.

Forgetting that select chooses randomly when multiple cases are ready. If both ch1 and ch2 have values waiting, select doesn’t guarantee which one runs first. Don’t write code that depends on a specific ordering.

Busy-looping with default. This will max out a CPU core:

// WRONG — loops millions of times per second burning CPU
for {
    select {
    case v := <-ch:
        process(v)
    default:
        // nothing ready, try again immediately
    }
}

If you’re waiting for work, block. Remove the default case, or add a small sleep, or use context.Done() as your exit condition.

Not handling context cancellation in a for-select. If your goroutine loops with for { select { ... } } and you don’t have a case <-ctx.Done() or case <-done, the goroutine will run forever after the rest of the program has moved on. Always give your goroutines a way to stop.

Key Takeaway

select is how you write goroutines that respond to multiple things at once — incoming work, timeouts, cancellation signals. The for-select pattern is the foundation of nearly every long-running goroutine you’ll write in production Go. Keep your cases simple, always include a way to stop, and use time.After or context.WithTimeout when an operation must not run forever.


Series: Go from Scratch