6.4 Select Statement

The select statement is Go's way to wait on multiple channel operations simultaneously. It's similar to a switch statement but for channels, allowing you to handle multiple communication paths elegantly.

Select Syntax

Basic syntax

select {
case <-ch1:
    // Handle message from ch1
case msg := <-ch2:
    // Handle message from ch2 and use the value
case ch3 <- value:
    // Successfully sent value to ch3
default:
    // Execute if no channel is ready (optional)
}

Without select, you can only wait on one channel at a time:

// ❌ Can only wait on one channel
msg1 := <-ch1  // Blocks here, can't check ch2
msg2 := <-ch2  // Never reached if ch1 blocks

With select, you can wait on multiple channels:

// ✅ Can wait on multiple channels
select {
case msg1 := <-ch1:
    // Handle ch1
case msg2 := <-ch2:
    // Handle ch2
}

The select statement evaluates all cases to check which channels are ready. If multiple channels have data, one is chosen randomly to prevent starvation. If no channel is ready and there's no default, it blocks until one becomes ready. Only one case executes per select statement.

Basic example

package main

import (
    "fmt"
    "time"
)

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

    go func() {
        time.Sleep(1 * time.Second)
        ch1 <- "Message from ch1"
    }()

    go func() {
        time.Sleep(2 * time.Second)
        ch2 <- "Message from ch2"
    }()

    // Wait for whichever channel is ready first
    select {
    case msg1 := <-ch1:
        fmt.Println("Received from ch1:", msg1)
    case msg2 := <-ch2:
        fmt.Println("Received from ch2:", msg2)
    }

    fmt.Println("Select completed")
}

Output (ch1 will likely be ready first)

Received from ch1: Message from ch1
Select completed

Select picks randomly when multiple channels are ready to prevent channel starvation. If one channel always had priority, other channels might never be serviced. Random selection ensures fairness.

Example demonstrating random selection

package main

import (
    "fmt"
    "time"
)

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

    // Fill both channels
    go func() {
        for i := 0; i < 5; i++ {
            ch1 <- i
        }
    }()

    go func() {
        for i := 10; i < 15; i++ {
            ch2 <- i
        }
    }()

    time.Sleep(100 * time.Millisecond)  // Let channels fill

    // Select will randomly pick between ready channels
    for i := 0; i < 10; i++ {
        select {
        case v1 := <-ch1:
            fmt.Printf("Got from ch1: %d\n", v1)
        case v2 := <-ch2:
            fmt.Printf("Got from ch2: %d\n", v2)
        }
    }
}

Non-Blocking Operations

The default case makes select non-blocking.

Non-blocking operations are useful when you want to check if a channel is ready without blocking:

select {
case msg := <-ch:
    // Handle message
default:
    // Channel not ready, continue without blocking
    fmt.Println("No message available")
}

The default case works by executing immediately if no channel in the select is ready, making the select non-blocking.

Example

package main

import (
    "fmt"
    "time"
)

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

    // Try to receive without blocking
    select {
    case msg := <-ch:
        fmt.Println("Received:", msg)
    default:
        fmt.Println("No message, continuing...")
    }

    // Send a message in background
    go func() {
        time.Sleep(100 * time.Millisecond)
        ch <- "Hello"
    }()

    // Try again after a delay
    time.Sleep(200 * time.Millisecond)
    select {
    case msg := <-ch:
        fmt.Println("Received:", msg)
    default:
        fmt.Println("No message")
    }
}

Non-blocking send

You can also do non-blocking sends:

select {
case ch <- value:
    fmt.Println("Sent successfully")
default:
    fmt.Println("Channel full, couldn't send")
}

Non-blocking operations are useful because they allow you to check channel state to see if a channel is ready without waiting, implement timeouts by combining with timers for timeout logic, and avoid deadlocks by continuing execution even if channels aren't ready.

Default Cases

The default case executes when no other case is ready.

When to use default

  1. Non-blocking operations: Check channel state without waiting
  2. Polling: Periodically check channels while doing other work
  3. Graceful degradation: Continue working even if channels aren't ready

Example: Non-blocking channel check

package main

import (
    "fmt"
    "time"
)

func worker(ch <-chan int, done chan<- bool) {
    for {
        select {
        case val := <-ch:
            fmt.Printf("Worker: Processing %d\n", val)
        default:
            // No work available, do other things
            fmt.Println("Worker: No work, checking again...")
            time.Sleep(100 * time.Millisecond)
        }

        // Check if we should exit
        select {
        case <-done:
            fmt.Println("Worker: Exiting")
            return
        default:
            // Continue working
        }
    }
}

func main() {
    ch := make(chan int)
    done := make(chan bool)

    go worker(ch, done)

    // Send some work
    time.Sleep(200 * time.Millisecond)
    ch <- 1
    ch <- 2

    time.Sleep(500 * time.Millisecond)
    done <- true
    time.Sleep(100 * time.Millisecond)
}

Important: default in loops

When using default in a loop, be careful not to create a busy-wait:

// ⚠️ BAD: Busy-wait (consumes CPU)
for {
    select {
    case msg := <-ch:
        process(msg)
    default:
        // Loops continuously, wasting CPU
    }
}

// ✅ GOOD: Add a small sleep or use blocking select
for {
    select {
    case msg := <-ch:
        process(msg)
    case <-time.After(100 * time.Millisecond):
        // Periodic check, not busy-wait
    }
}

Timeouts

Timeouts are essential for preventing goroutines from waiting indefinitely.

Timeouts matter because without them, a goroutine might wait forever if a channel never receives data, causing deadlocks where the program hangs waiting, resource leaks where goroutines never terminate, and poor user experience where applications become unresponsive.

To implement timeouts, use time.After to create a timeout channel:

select {
case msg := <-ch:
    // Handle message
case <-time.After(5 * time.Second):
    // Timeout after 5 seconds
    fmt.Println("Operation timed out")
}

Example

package main

import (
    "fmt"
    "time"
)

func fetchData(ch chan<- string) {
    // Simulate slow operation
    time.Sleep(3 * time.Second)
    ch <- "Data received"
}

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

    go fetchData(ch)

    select {
    case data := <-ch:
        fmt.Println("Success:", data)
    case <-time.After(2 * time.Second):
        fmt.Println("Timeout: Operation took too long")
    }
}

Output

Timeout: Operation took too long

time.After(duration) works by returning a channel that receives a value after the specified duration. This channel can be used in select to implement timeouts.

Reusable timeout pattern

package main

import (
    "fmt"
    "time"
)

func operationWithTimeout(ch <-chan string, timeout time.Duration) (string, error) {
    select {
    case result := <-ch:
        return result, nil
    case <-time.After(timeout):
        return "", fmt.Errorf("operation timed out after %v", timeout)
    }
}

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

    go func() {
        time.Sleep(2 * time.Second)
        ch <- "Result"
    }()

    result, err := operationWithTimeout(ch, 1*time.Second)
    if err != nil {
        fmt.Println("Error:", err)
    } else {
        fmt.Println("Result:", result)
    }
}

Context-based timeouts

For more sophisticated timeout handling, use context (covered in section 6.5: Patterns and Best Practices):

ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()

select {
case result := <-ch:
    return result
case <-ctx.Done():
    return "", ctx.Err()
}

Common Patterns

The select statement enables several powerful concurrency patterns.

1. Fan-in pattern (multiple inputs, one output)

package main

import (
    "fmt"
    "sync"
)

func merge(ch1, ch2 <-chan int) <-chan int {
    out := make(chan int)
    var wg sync.WaitGroup

    wg.Add(2)

    // Forward from ch1
    go func() {
        defer wg.Done()
        for val := range ch1 {
            out <- val
        }
    }()

    // Forward from ch2
    go func() {
        defer wg.Done()
        for val := range ch2 {
            out <- val
        }
    }()

    // Close output when both inputs are done
    go func() {
        wg.Wait()
        close(out)
    }()

    return out
}

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

    // Send to both channels
    go func() {
        defer close(ch1)
        for i := 0; i < 5; i++ {
            ch1 <- i
        }
    }()

    go func() {
        defer close(ch2)
        for i := 10; i < 15; i++ {
            ch2 <- i
        }
    }()

    // Merge and receive
    for val := range merge(ch1, ch2) {
        fmt.Println("Received:", val)
    }
}

2. Priority channel pattern

package main

import (
    "fmt"
    "time"
)

func main() {
    highPriority := make(chan string)
    lowPriority := make(chan string)

    go func() {
        for i := 0; i < 3; i++ {
            lowPriority <- fmt.Sprintf("Low %d", i)
            time.Sleep(200 * time.Millisecond)
        }
    }()

    go func() {
        time.Sleep(500 * time.Millisecond)
        highPriority <- "High priority message"
    }()

    // Check high priority first, then low priority
    for i := 0; i < 4; i++ {
        select {
        case msg := <-highPriority:
            fmt.Println("HIGH:", msg)
        case msg := <-lowPriority:
            fmt.Println("LOW:", msg)
        }
    }
}

3. Graceful shutdown pattern

package main

import (
    "fmt"
    "time"
)

func worker(work <-chan int, shutdown <-chan bool) {
    for {
        select {
        case task := <-work:
            fmt.Printf("Processing task %d\n", task)
        case <-shutdown:
            fmt.Println("Shutdown signal received")
            return
        }
    }
}

func main() {
    work := make(chan int)
    shutdown := make(chan bool)

    go worker(work, shutdown)

    // Send some work
    for i := 0; i < 3; i++ {
        work <- i
        time.Sleep(100 * time.Millisecond)
    }

    // Shutdown
    shutdown <- true
    time.Sleep(100 * time.Millisecond)
}

4. Ticker pattern (periodic operations)

package main

import (
    "fmt"
    "time"
)

func main() {
    ticker := time.NewTicker(500 * time.Millisecond)
    defer ticker.Stop()

    ch := make(chan string)

    go func() {
        time.Sleep(2 * time.Second)
        ch <- "Important message"
    }()

    for {
        select {
        case msg := <-ch:
            fmt.Println("Received:", msg)
            return
        case <-ticker.C:
            fmt.Println("Tick: Still waiting...")
        }
    }
}

5. Multiple operations with timeout

package main

import (
    "fmt"
    "time"
)

func operation1(ch chan<- string) {
    time.Sleep(1 * time.Second)
    ch <- "Operation 1 complete"
}

func operation2(ch chan<- string) {
    time.Sleep(2 * time.Second)
    ch <- "Operation 2 complete"
}

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

    go operation1(ch1)
    go operation2(ch2)

    timeout := time.After(3 * time.Second)

    for i := 0; i < 2; i++ {
        select {
        case result1 := <-ch1:
            fmt.Println(result1)
        case result2 := <-ch2:
            fmt.Println(result2)
        case <-timeout:
            fmt.Println("Overall timeout")
            return
        }
    }
}

The select statement is one of Go's most powerful concurrency primitives. It enables elegant handling of multiple channels, timeouts, and non-blocking operations, making it essential for writing robust concurrent programs.