3 min read
On this page

Select & Patterns

The select statement is Go's control structure for working with multiple channels simultaneously. Combined with patterns like fan-out, fan-in, worker pools, and context-based cancellation, it forms the backbone of real concurrent Go programs.

select: Switch for Channels

select blocks until one of its cases can proceed. If multiple cases are ready, one is chosen at random.

select {
case msg := <-ch1:
    fmt.Println("received from ch1:", msg)
case msg := <-ch2:
    fmt.Println("received from ch2:", msg)
case ch3 <- "hello":
    fmt.Println("sent to ch3")
}

select is to channels what switch is to values. But unlike switch, select only works with channel operations (sends and receives).

Timeouts with time.After

One of the most common uses of select is adding timeouts to channel operations.

func fetchWithTimeout(url string, timeout time.Duration) (string, error) {
    result := make(chan string, 1)
    errc := make(chan error, 1)

    go func() {
        resp, err := http.Get(url)
        if err != nil {
            errc <- err
            return
        }
        defer resp.Body.Close()
        body, err := io.ReadAll(resp.Body)
        if err != nil {
            errc <- err
            return
        }
        result <- string(body)
    }()

    select {
    case body := <-result:
        return body, nil
    case err := <-errc:
        return "", err
    case <-time.After(timeout):
        return "", fmt.Errorf("request to %s timed out after %v", url, timeout)
    }
}

time.After returns a channel that receives a value after the specified duration. If neither result nor errc delivers before the timeout, the third case fires.

Non-Blocking Operations with default

Adding a default case makes select non-blocking. If no channel operation is ready, the default case runs immediately.

// Non-blocking receive
select {
case msg := <-ch:
    fmt.Println("got message:", msg)
default:
    fmt.Println("no message ready")
}

// Non-blocking send
select {
case ch <- value:
    fmt.Println("sent")
default:
    fmt.Println("channel full, dropping message")
}

This is useful for polling, draining channels, and implementing try-send patterns where you want to send if possible but move on if the channel is full.

// Drain a channel without blocking
func drain(ch <-chan int) {
    for {
        select {
        case <-ch:
            // Discard
        default:
            return  // Channel is empty
        }
    }
}

Fan-Out: One Channel, Multiple Readers

Fan-out distributes work from one channel across multiple goroutines. Each goroutine reads from the same channel, and the Go runtime ensures each value is delivered to exactly one reader.

func fanOut(jobs <-chan Job, numWorkers int) <-chan Result {
    results := make(chan Result, numWorkers)
    var wg sync.WaitGroup

    for i := 0; i < numWorkers; i++ {
        wg.Add(1)
        go func(workerID int) {
            defer wg.Done()
            for job := range jobs {
                result := process(job)
                results <- result
            }
        }(i)
    }

    go func() {
        wg.Wait()
        close(results)
    }()

    return results
}

When the jobs channel closes, all workers finish their current job, exit the range loop, and call wg.Done(). Once all workers are done, the results channel is closed.

Fan-In: Multiple Channels into One

Fan-in merges multiple input channels into a single output channel.

func fanIn(channels ...<-chan string) <-chan string {
    merged := make(chan string)
    var wg sync.WaitGroup

    for _, ch := range channels {
        wg.Add(1)
        go func(c <-chan string) {
            defer wg.Done()
            for msg := range c {
                merged <- msg
            }
        }(ch)
    }

    go func() {
        wg.Wait()
        close(merged)
    }()

    return merged
}

Worker Pools

A worker pool combines fan-out and fan-in: create a buffered jobs channel, launch N workers that range over it, collect results on a separate channel, and use a WaitGroup to close the results channel when all workers finish. The fan-out example above is essentially a worker pool.

The Done Channel Pattern

Before context.Context was introduced, the done channel was the standard way to signal cancellation to goroutines.

func worker(done <-chan struct{}, jobs <-chan Job) {
    for {
        select {
        case <-done:
            fmt.Println("worker shutting down")
            return
        case job, ok := <-jobs:
            if !ok {
                return  // Jobs channel closed
            }
            process(job)
        }
    }
}

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

    go worker(done, jobs)

    // ... submit jobs ...

    // Signal shutdown
    close(done)  // All workers see the close and exit
}

Closing a channel broadcasts to all receivers. This is why done channels use chan struct{} -- the value does not matter; only the close signal matters.

context.Context for Cancellation

context.Context is the standard way to propagate cancellation, deadlines, and request-scoped values across goroutines. It replaced the done channel pattern for most use cases.

func processItems(ctx context.Context, items []Item) error {
    for _, item := range items {
        select {
        case <-ctx.Done():
            return ctx.Err()  // context.Canceled or context.DeadlineExceeded
        default:
        }

        if err := processItem(ctx, item); err != nil {
            return fmt.Errorf("processing item %s: %w", item.ID, err)
        }
    }
    return nil
}

Creating Contexts

// With cancellation
ctx, cancel := context.WithCancel(context.Background())
defer cancel()

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

// With deadline
deadline := time.Now().Add(30 * time.Second)
ctx, cancel := context.WithDeadline(context.Background(), deadline)
defer cancel()

Always defer the cancel function. Failing to call cancel leaks resources (timers, goroutines).

Context in a Real Service

func (s *Server) handleRequest(w http.ResponseWriter, r *http.Request) {
    // r.Context() is cancelled when the client disconnects
    ctx := r.Context()

    // Add a timeout for the database query
    queryCtx, cancel := context.WithTimeout(ctx, 3*time.Second)
    defer cancel()

    users, err := s.db.ListUsers(queryCtx)
    if err != nil {
        if errors.Is(err, context.DeadlineExceeded) {
            http.Error(w, "query timed out", http.StatusGatewayTimeout)
            return
        }
        if errors.Is(err, context.Canceled) {
            // Client disconnected, no need to respond
            return
        }
        http.Error(w, "internal error", http.StatusInternalServerError)
        return
    }

    json.NewEncoder(w).Encode(users)
}

Common Pitfalls

  • Forgetting to defer cancel on contexts. Every context.WithCancel, WithTimeout, and WithDeadline returns a cancel function. Defer it immediately. Failing to cancel leaks goroutines and timers.
  • Using time.After in a loop. Each call to time.After creates a new timer that is not garbage collected until it fires. In a tight loop, this leaks memory.
// Bad: leaks a timer every iteration
for {
    select {
    case msg := <-ch:
        process(msg)
    case <-time.After(5 * time.Second):
        return
    }
}

Use time.NewTimer and call Reset in the loop instead.

  • Blocking the main goroutine. If main exits, all goroutines are killed. Use WaitGroup, channels, or select{} (blocks forever) to keep main alive.
  • Not handling context cancellation in loops. Long-running loops should check ctx.Done() periodically to respond to cancellation.
  • Assuming select is deterministic. When multiple cases are ready, select picks one at random. Do not rely on priority or ordering.

Key Takeaways

  • select lets you wait on multiple channel operations. It blocks until one case is ready.
  • time.After adds timeouts to channel operations. Use time.NewTimer in loops to avoid memory leaks.
  • The default case makes select non-blocking for try-send and try-receive patterns.
  • Fan-out distributes work across workers. Fan-in merges results from multiple channels.
  • Worker pools combine fan-out, fan-in, and a shared jobs channel for bounded concurrency.
  • context.Context is the standard mechanism for cancellation, timeouts, and deadlines. Always defer the cancel function.
  • Check ctx.Done() in long-running loops to support graceful cancellation.