In the previous tutorial, you learned about channels. Now you will learn how to work with multiple channels at the same time using select, how to cancel operations with context, and some powerful concurrency patterns.

These are the tools that make Go concurrency practical for real applications.

The select Statement

select lets you wait on multiple channel operations at the same time. It blocks until one of the channels is ready:

package main

import (
    "fmt"
    "time"
)

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

    go func() {
        time.Sleep(100 * time.Millisecond)
        ch1 <- "result from ch1"
    }()

    go func() {
        time.Sleep(200 * time.Millisecond)
        ch2 <- "result from ch2"
    }()

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

Output:

Received: result from ch1

The select statement picks whichever channel is ready first. If both are ready at the same time, it picks one randomly.

select with default

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

package main

import "fmt"

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

    select {
    case msg := <-ch:
        fmt.Println("Received:", msg)
    default:
        fmt.Println("No message available")
    }
}

Output:

No message available

This is useful for polling channels without blocking.

Timeouts with select

You can combine select with time.After to set a timeout on channel operations:

package main

import (
    "fmt"
    "time"
)

func slowOperation() <-chan string {
    ch := make(chan string)
    go func() {
        time.Sleep(3 * time.Second) // Simulates slow work
        ch <- "slow result"
    }()
    return ch
}

func main() {
    result := slowOperation()

    select {
    case msg := <-result:
        fmt.Println("Got result:", msg)
    case <-time.After(1 * time.Second):
        fmt.Println("Timeout! Operation took too long")
    }
}

Output:

Timeout! Operation took too long

The operation takes 3 seconds, but we only wait 1 second. This pattern is essential for network calls, database queries, and any operation that might hang.

select in a Loop

You can use select inside a loop to continuously handle multiple channels:

package main

import (
    "fmt"
    "time"
)

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

    done := make(chan bool)
    go func() {
        time.Sleep(2 * time.Second)
        done <- true
    }()

    for {
        select {
        case t := <-ticker.C:
            fmt.Println("Tick at", t.Format("15:04:05.000"))
        case <-done:
            fmt.Println("Done! Stopping.")
            return
        }
    }
}

Output:

Tick at 10:30:00.500
Tick at 10:30:01.000
Tick at 10:30:01.500
Tick at 10:30:02.000
Done! Stopping.

This pattern is common in long-running services. The loop handles periodic tasks (ticker) and stops when signaled (done channel).

context.Context

context.Context is Go’s standard way to handle cancellation, deadlines, and timeouts across goroutines. Almost every Go server and library uses it.

context.WithCancel

Cancel a goroutine from outside:

package main

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

func worker(ctx context.Context, id int) {
    for {
        select {
        case <-ctx.Done():
            fmt.Printf("Worker %d stopped: %v\n", id, ctx.Err())
            return
        default:
            fmt.Printf("Worker %d working...\n", id)
            time.Sleep(300 * time.Millisecond)
        }
    }
}

func main() {
    ctx, cancel := context.WithCancel(context.Background())

    go worker(ctx, 1)
    go worker(ctx, 2)

    time.Sleep(1 * time.Second)
    fmt.Println("Cancelling all workers...")
    cancel() // Signal all goroutines to stop

    time.Sleep(100 * time.Millisecond) // Give workers time to print
    fmt.Println("All workers stopped")
}

Output:

Worker 1 working...
Worker 2 working...
Worker 1 working...
Worker 2 working...
Worker 1 working...
Worker 2 working...
Cancelling all workers...
Worker 1 stopped: context canceled
Worker 2 stopped: context canceled
All workers stopped

When you call cancel(), the ctx.Done() channel closes. Every goroutine watching that channel stops.

context.WithTimeout

Set a deadline that automatically cancels:

package main

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

func fetchData(ctx context.Context) (string, error) {
    // Simulate a slow API call
    ch := make(chan string)
    go func() {
        time.Sleep(2 * time.Second)
        ch <- "data from API"
    }()

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

func main() {
    // 1 second timeout
    ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
    defer cancel() // Always call cancel to release resources

    data, err := fetchData(ctx)
    if err != nil {
        fmt.Println("Error:", err) // context deadline exceeded
        return
    }
    fmt.Println("Data:", data)
}

Output:

Error: context deadline exceeded

The API call takes 2 seconds, but the context times out after 1 second. The function returns the timeout error.

context.WithDeadline

Similar to WithTimeout, but you specify an exact time:

package main

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

func main() {
    deadline := time.Now().Add(500 * time.Millisecond)
    ctx, cancel := context.WithDeadline(context.Background(), deadline)
    defer cancel()

    select {
    case <-time.After(1 * time.Second):
        fmt.Println("Operation completed")
    case <-ctx.Done():
        fmt.Println("Deadline exceeded:", ctx.Err())
    }
}

Output:

Deadline exceeded: context deadline exceeded

Passing Context Through Functions

In real applications, you pass context through the entire call chain. Every function that might block should accept a context.Context as its first parameter:

package main

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

func handleRequest(ctx context.Context, userID int) error {
    fmt.Printf("Handling request for user %d\n", userID)
    data, err := queryDatabase(ctx, userID)
    if err != nil {
        return fmt.Errorf("request failed: %w", err)
    }
    fmt.Println("Got data:", data)
    return nil
}

func queryDatabase(ctx context.Context, userID int) (string, error) {
    ch := make(chan string)
    go func() {
        time.Sleep(500 * time.Millisecond) // Simulate query
        ch <- fmt.Sprintf("user_%d_data", userID)
    }()

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

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

    if err := handleRequest(ctx, 42); err != nil {
        fmt.Println("Error:", err)
        return
    }
    fmt.Println("Request completed")
}

Output:

Handling request for user 42
Got data: user_42_data
Request completed

Rule: Always pass context.Context as the first parameter in functions. This is a Go convention.

Fan-Out Pattern

Fan-out means multiple goroutines read from the same channel. This distributes work across workers:

package main

import (
    "fmt"
    "sync"
)

func fanOut(input <-chan int, workers int) <-chan int {
    output := make(chan int)
    var wg sync.WaitGroup

    for i := 0; i < workers; i++ {
        wg.Add(1)
        go func(id int) {
            defer wg.Done()
            for val := range input {
                result := val * val // Process the value
                fmt.Printf("Worker %d: %d -> %d\n", id, val, result)
                output <- result
            }
        }(i + 1)
    }

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

    return output
}

func main() {
    input := make(chan int)
    go func() {
        for i := 1; i <= 8; i++ {
            input <- i
        }
        close(input)
    }()

    results := fanOut(input, 3)

    var total int
    for val := range results {
        total += val
    }
    fmt.Println("Sum of squares:", total)
}

Three workers process 8 values concurrently. Each worker picks up the next available value from the input channel.

Fan-In Pattern

Fan-in means multiple channels are merged into a single channel:

package main

import (
    "fmt"
    "sync"
)

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 val := range c {
                merged <- val
            }
        }(ch)
    }

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

    return merged
}

func produce(name string, count int) <-chan string {
    ch := make(chan string)
    go func() {
        for i := 1; i <= count; i++ {
            ch <- fmt.Sprintf("%s-%d", name, i)
        }
        close(ch)
    }()
    return ch
}

func main() {
    ch1 := produce("api", 3)
    ch2 := produce("db", 3)
    ch3 := produce("cache", 3)

    merged := fanIn(ch1, ch2, ch3)

    for val := range merged {
        fmt.Println("Got:", val)
    }
}

Three producers send values to three channels. Fan-in merges them into one channel. The consumer only needs to read from one place.

Worker Pool Pattern

A worker pool limits the number of concurrent operations. This is the most common concurrency pattern in Go:

package main

import (
    "fmt"
    "sync"
    "time"
)

type Job struct {
    ID    int
    Input int
}

type Result struct {
    JobID  int
    Output int
}

func workerPool(jobs <-chan Job, results chan<- Result, wg *sync.WaitGroup, id int) {
    defer wg.Done()
    for job := range jobs {
        // Simulate processing
        time.Sleep(50 * time.Millisecond)
        result := Result{
            JobID:  job.ID,
            Output: job.Input * job.Input,
        }
        fmt.Printf("Worker %d: job %d (%d -> %d)\n", id, job.ID, job.Input, result.Output)
        results <- result
    }
}

func main() {
    fmt.Println("=== GO-13: Worker Pool ===")
    fmt.Println()

    const numWorkers = 3
    const numJobs = 10

    jobs := make(chan Job, numJobs)
    results := make(chan Result, numJobs)

    // Start workers
    var wg sync.WaitGroup
    for i := 1; i <= numWorkers; i++ {
        wg.Add(1)
        go workerPool(jobs, results, &wg, i)
    }

    // Send jobs
    for i := 1; i <= numJobs; i++ {
        jobs <- Job{ID: i, Input: i}
    }
    close(jobs)

    // Wait for workers and close results
    go func() {
        wg.Wait()
        close(results)
    }()

    // Collect results
    var total int
    for r := range results {
        total += r.Output
    }
    fmt.Printf("\nProcessed %d jobs with %d workers\n", numJobs, numWorkers)
    fmt.Printf("Sum of squares: %d\n", total)
}

Output:

=== GO-13: Worker Pool ===

Worker 1: job 1 (1 -> 1)
Worker 2: job 2 (2 -> 4)
Worker 3: job 3 (3 -> 9)
Worker 1: job 4 (4 -> 16)
Worker 2: job 5 (5 -> 25)
Worker 3: job 6 (6 -> 36)
Worker 1: job 7 (7 -> 49)
Worker 2: job 8 (8 -> 64)
Worker 3: job 9 (9 -> 81)
Worker 1: job 10 (10 -> 100)

Processed 10 jobs with 3 workers
Sum of squares: 385

The worker pool pattern has three parts:

  1. Jobs channel — send work to workers
  2. Workers — goroutines that process jobs
  3. Results channel — collect results from workers

This pattern limits concurrency to a fixed number of workers. This is important for database connections, API rate limits, and CPU-bound work.

Common Mistakes

1. select with no cases that can proceed.

select {} // Blocks forever — deadlock!

An empty select blocks forever. This is sometimes used intentionally in long-running programs, but usually it is a bug.

2. Forgetting to call cancel() on contexts.

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

Always call cancel(), even if the context times out on its own. Use defer cancel() right after creating the context to prevent resource leaks.

3. Using context.Background() in production code.

// Bad — no timeout, no cancellation
data, err := fetchData(context.Background())

// Good — with timeout
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
data, err := fetchData(ctx)

context.Background() should only be at the top level. Pass real contexts with timeouts everywhere else.

Source Code

You can find the complete source code for this tutorial on GitHub:

GO-13 Source Code on GitHub

What’s Next?

In the next tutorial, Go Tutorial #14: Error Handling Patterns, you will learn:

  • Error wrapping with fmt.Errorf and %w
  • Checking error chains with errors.Is() and errors.As()
  • Sentinel errors and custom error types
  • The errgroup package for concurrent error handling
  • When to use panic and recover

This is part 13 of the Go Tutorial series. Follow along to learn Go from scratch.