Skip to content
AyoKoding

Concurrency and Goroutines

Why Concurrency Matters

Go's concurrency model based on goroutines and channels enables building high-performance systems that handle thousands of concurrent operations efficiently. Understanding goroutines, channels, select statements, and the context package prevents deadlocks, goroutine leaks, and enables graceful shutdown in production systems.

Core benefits:

  • Scalability: Handle thousands of concurrent requests efficiently
  • Simplicity: Lightweight goroutines simpler than threads
  • Communication: Channels enable safe data sharing
  • Cancellation: Context package provides timeout and deadline control

Problem: Incorrect concurrency causes goroutine leaks (memory exhaustion), deadlocks (hung systems), race conditions (data corruption), and difficulty canceling long-running operations.

Solution: Start with standard library goroutines and channels, understand limitations (no built-in cancellation), then apply context package for production-grade timeout and cancellation handling.

Standard Library: Goroutines and Channels

Goroutines are lightweight threads managed by Go runtime. Channels provide communication between goroutines.

Pattern: Basic Goroutine:

package main
 
import (
    "fmt"
    // => Standard library for output
    "time"
    // => Standard library for timing
)
 
func sayHello(name string) {
    // => Regular function, can run as goroutine
    // => name is parameter passed when launched
 
    fmt.Printf("Hello, %s!\n", name)
    // => Output: Hello, Alice!
    // => Executes in goroutine (concurrent)
}
 
func main() {
    // SYNCHRONOUS: function call blocks
    sayHello("Sync")
    // => Executes immediately in main goroutine
    // => main waits for completion
    // => Output: Hello, Sync!
 
    // ASYNCHRONOUS: goroutine runs concurrently
    go sayHello("Async")
    // => go keyword launches new goroutine
    // => sayHello runs concurrently with main
    // => main continues immediately (doesn't wait)
 
    time.Sleep(100 * time.Millisecond)
    // => Wait for goroutine to complete
    // => HACK: proper solution uses channels or sync.WaitGroup
    // => Without sleep, main exits before goroutine runs
 
    fmt.Println("Done")
    // => Output: Done
    // => main goroutine continues while sayHello runs
}

Pattern: Channels for Communication:

package main
 
import "fmt"
 
func sum(values []int, result chan int) {
    // => result is channel for sending int values
    // => Channel enables communication with goroutine
 
    total := 0
    // => Accumulator for sum
 
    for _, v := range values {
        // => Iterate over values slice
        total += v
        // => Add each value to total
    }
 
    result <- total
    // => SEND total to channel
    // => <- operator sends to channel
    // => Blocks until receiver ready
    // => Communicates result back to main
}
 
func main() {
    values := []int{1, 2, 3, 4, 5}
    // => values to sum
 
    result := make(chan int)
    // => Create unbuffered channel for int
    // => make(chan int) allocates channel
    // => Unbuffered: send blocks until receive
 
    go sum(values, result)
    // => Launch sum in goroutine
    // => Runs concurrently with main
    // => result channel shared between goroutines
 
    total := <-result
    // => RECEIVE from channel
    // => Blocks until value available
    // => Synchronizes goroutines
    // => total is 15 (sum of 1+2+3+4+5)
 
    fmt.Println("Total:", total)
    // => Output: Total: 15
}

Pattern: Buffered Channels:

package main
 
import "fmt"
 
func main() {
    // UNBUFFERED: send blocks until receive
    unbuffered := make(chan int)
    // => make(chan int) creates unbuffered channel
    // => Send blocks until receiver ready
 
    // BUFFERED: send blocks only when buffer full
    buffered := make(chan int, 3)
    // => make(chan int, 3) creates channel with buffer size 3
    // => Can send 3 values without blocking
    // => 4th send blocks until receive
 
    buffered <- 1
    // => Send 1 (doesn't block, buffer has space)
    buffered <- 2
    // => Send 2 (doesn't block, buffer has space)
    buffered <- 3
    // => Send 3 (doesn't block, buffer now full)
 
    // buffered <- 4
    // => DEADLOCK: would block (buffer full, no receiver)
    // => Uncommenting causes deadlock
 
    fmt.Println(<-buffered)
    // => Receive 1 (first value sent)
    // => Output: 1
 
    fmt.Println(<-buffered)
    // => Receive 2
    // => Output: 2
 
    buffered <- 4
    // => Send 4 (now space in buffer after receives)
    // => Doesn't block (buffer has room)
 
    fmt.Println(<-buffered)
    // => Receive 3
    // => Output: 3
 
    fmt.Println(<-buffered)
    // => Receive 4
    // => Output: 4
}

Pattern: Select Statement:

package main
 
import (
    "fmt"
    // => Standard library for output
    "time"
    // => Standard library for timing
)
 
func main() {
    ch1 := make(chan string)
    ch2 := make(chan string)
    // => Two unbuffered channels
 
    go func() {
        // => Anonymous goroutine for ch1
        time.Sleep(100 * time.Millisecond)
        // => Simulate work (100ms delay)
        ch1 <- "from ch1"
        // => Send to ch1 after delay
    }()
 
    go func() {
        // => Anonymous goroutine for ch2
        time.Sleep(50 * time.Millisecond)
        // => Faster than ch1 (50ms delay)
        ch2 <- "from ch2"
        // => Send to ch2 first
    }()
 
    // SELECT: receive from first ready channel
    select {
    // => select blocks until one case ready
    // => Receives from first available channel
    // => Non-deterministic if multiple ready
 
    case msg1 := <-ch1:
        // => Receive from ch1
        // => msg1 is value from ch1
        // => Case selected if ch1 has data
 
        fmt.Println(msg1)
        // => This case won't execute (ch2 faster)
 
    case msg2 := <-ch2:
        // => Receive from ch2
        // => msg2 is value from ch2
        // => Case selected if ch2 has data
 
        fmt.Println(msg2)
        // => Output: from ch2
        // => ch2 ready first (50ms < 100ms)
 
    case <-time.After(200 * time.Millisecond):
        // => Timeout case (200ms)
        // => time.After returns channel that sends after duration
        // => Prevents indefinite blocking
 
        fmt.Println("timeout")
        // => Won't execute (ch2 ready before timeout)
    }
}

Limitations for production:

  • No built-in cancellation (goroutines run until completion)
  • No timeout control (must implement manually with time.After)
  • No propagation of cancellation across goroutine hierarchies
  • Manual error propagation (channels carry only one type)
  • Goroutine leaks if not properly managed

Production Framework: Context Package

The context package provides cancellation, deadlines, and request-scoped values for goroutine trees.

Pattern: Context with Timeout:

package main
 
import (
    "context"
    // => Standard library for cancellation and deadlines
    // => Core package for production concurrency
    "fmt"
    "time"
)
 
func performWork(ctx context.Context, id int) {
    // => ctx carries cancellation signal
    // => id identifies this worker
 
    select {
    case <-time.After(2 * time.Second):
        // => Simulate long-running work (2 seconds)
        // => time.After sends after duration
 
        fmt.Printf("Worker %d: completed work\n", id)
        // => Work finished before timeout
 
    case <-ctx.Done():
        // => ctx.Done() channel closed on cancellation/timeout
        // => Signals work should stop
        // => Returns immediately when context cancelled
 
        fmt.Printf("Worker %d: cancelled - %v\n", id, ctx.Err())
        // => ctx.Err() returns cancellation reason
        // => Output: Worker 1: cancelled - context deadline exceeded
        // => Graceful shutdown when context times out
    }
}
 
func main() {
    // Create context with 1-second timeout
    ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
    // => context.Background() creates root context
    // => WithTimeout wraps with timeout deadline
    // => cancel is function to manually cancel context
    // => CRITICAL: must call cancel to release resources
 
    defer cancel()
    // => ALWAYS defer cancel() to prevent resource leak
    // => Cancels context when main exits
    // => Releases goroutines waiting on ctx.Done()
 
    go performWork(ctx, 1)
    // => Launch worker with context
    // => Worker monitors ctx.Done() for cancellation
 
    time.Sleep(3 * time.Second)
    // => Wait longer than worker runtime
    // => Observe timeout behavior
 
    fmt.Println("Main exiting")
    // => main exits after 3 seconds
    // => Worker cancelled after 1 second (timeout)
}

Pattern: Context Cancellation:

package main
 
import (
    "context"
    // => Standard library for cancellation
    "fmt"
    "time"
)
 
func worker(ctx context.Context, id int) {
    // => Worker respects context cancellation
 
    for {
        // => Infinite loop (exits on cancellation)
 
        select {
        case <-ctx.Done():
            // => Context cancelled
            // => ctx.Done() closed when cancel() called
 
            fmt.Printf("Worker %d: stopping\n", id)
            // => Graceful shutdown message
            // => Output: Worker 1: stopping
 
            return
            // => Exit goroutine (cleanup)
 
        default:
            // => No cancellation yet, continue working
            // => default case prevents blocking
 
            fmt.Printf("Worker %d: working...\n", id)
            // => Simulate work
 
            time.Sleep(500 * time.Millisecond)
            // => Pause between iterations
        }
    }
}
 
func main() {
    // Create cancellable context
    ctx, cancel := context.WithCancel(context.Background())
    // => context.Background() is root context
    // => WithCancel wraps with cancellation capability
    // => cancel is function to trigger cancellation
 
    go worker(ctx, 1)
    go worker(ctx, 2)
    go worker(ctx, 3)
    // => Launch 3 workers sharing same context
    // => All receive cancellation signal when cancel() called
 
    time.Sleep(2 * time.Second)
    // => Let workers run for 2 seconds
    // => Workers print "working..." messages
 
    fmt.Println("Cancelling workers...")
    cancel()
    // => CRITICAL: call cancel() to stop workers
    // => Closes ctx.Done() channel
    // => All workers receive signal simultaneously
    // => Enables graceful shutdown
 
    time.Sleep(1 * time.Second)
    // => Wait for workers to finish cleanup
 
    fmt.Println("Main exiting")
    // => Output: Main exiting
}

Pattern: Context with Values:

package main
 
import (
    "context"
    // => Standard library for request-scoped values
    "fmt"
)
 
// Key type for context values (prevents collisions)
type contextKey string
 
const (
    userIDKey  contextKey = "userID"
    requestIDKey contextKey = "requestID"
)
// => Custom key types prevent conflicts
// => Don't use string directly (collisions possible)
 
func processRequest(ctx context.Context) {
    // => ctx carries request-scoped values
 
    // Retrieve values from context
    userID, ok := ctx.Value(userIDKey).(string)
    // => Type assertion to string
    // => ok is false if key missing or wrong type
 
    if !ok {
        // => Value not found or wrong type
        fmt.Println("User ID not found")
        return
    }
 
    requestID, ok := ctx.Value(requestIDKey).(string)
    if !ok {
        fmt.Println("Request ID not found")
        return
    }
 
    fmt.Printf("Processing request %s for user %s\n", requestID, userID)
    // => Output: Processing request req-123 for user user-456
    // => Values retrieved from context
}
 
func main() {
    // Create context with values
    ctx := context.Background()
    // => Root context (no values)
 
    ctx = context.WithValue(ctx, userIDKey, "user-456")
    // => Add userID to context
    // => Returns new context with value
    // => Original context unchanged (immutable)
 
    ctx = context.WithValue(ctx, requestIDKey, "req-123")
    // => Add requestID to context
    // => Chain WithValue calls
 
    processRequest(ctx)
    // => Pass context with values
    // => processRequest extracts values
}

Why context package matters:

  • Cancellation propagates through goroutine hierarchies
  • Deadlines prevent operations running indefinitely
  • Request-scoped values (user ID, trace ID) without globals
  • Standard pattern across Go ecosystem (HTTP, DB, gRPC)

Trade-offs: When to Use Each

Comparison table:

ApproachCancellationTimeoutError PropagationUse Case
Channels onlyManualManualManualSimple pipelines
sync.WaitGroupNoNoManualWait for completion only
ContextBuilt-inBuilt-inManualProduction systems (HTTP, DB, gRPC)

When to use channels:

  • Communication between goroutines (producer-consumer)
  • Pipelines (stage 1 → stage 2 → stage 3)
  • Synchronization without shared memory
  • When cancellation not needed

When to use sync.WaitGroup:

  • Wait for multiple goroutines to complete
  • No cancellation required
  • Fire-and-forget parallelism
  • Batch processing

When to use context:

  • HTTP request handling (cancellation on client disconnect)
  • Database queries (timeout after 30 seconds)
  • gRPC calls (deadline propagation)
  • Hierarchical cancellation (parent cancels children)
  • Request-scoped values (trace IDs, user IDs)

When to combine all:

func processItems(ctx context.Context, items []Item) error {
    // Context for cancellation/timeout
    results := make(chan Result, len(items))
    // Channel for communication
    var wg sync.WaitGroup
    // WaitGroup for completion tracking
 
    for _, item := range items {
        wg.Add(1)
        go func(item Item) {
            defer wg.Done()
 
            select {
            case <-ctx.Done():
                return  // Cancelled
            case results <- process(item):
                // Send result
            }
        }(item)
    }
 
    wg.Wait()  // Wait for all goroutines
    close(results)
 
    return ctx.Err()  // Return cancellation error if any
}

Production Best Practices

Always call cancel() to prevent leaks:

// GOOD: defer cancel immediately
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()  // Releases resources
 
// BAD: forget to call cancel
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
// LEAK: context resources never released

Respect context cancellation:

// GOOD: check ctx.Done() in loops
func worker(ctx context.Context) {
    for {
        select {
        case <-ctx.Done():
            return  // Graceful shutdown
        default:
            // Do work
        }
    }
}
 
// BAD: ignore context (goroutine leak)
func worker(ctx context.Context) {
    for {
        // Work forever (never stops)
    }
}

Pass context as first parameter:

// GOOD: ctx as first parameter (convention)
func doWork(ctx context.Context, data Data) error { }
 
// BAD: ctx not first or missing
func doWork(data Data, ctx context.Context) error { }
func doWork(data Data) error { }  // No cancellation

Use buffered channels to prevent goroutine leaks:

// GOOD: buffered channel (goroutine can exit)
results := make(chan Result, 1)
go func() {
    results <- compute()  // Doesn't block if no receiver
}()
 
// BAD: unbuffered channel (goroutine blocks forever)
results := make(chan Result)
go func() {
    results <- compute()  // LEAK if no receiver
}()

Timeout for external calls:

// HTTP request with timeout
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
 
req, _ := http.NewRequestWithContext(ctx, "GET", url, nil)
resp, err := client.Do(req)  // Cancelled after 10s

Summary

Go's concurrency model uses lightweight goroutines and channels for communication. Standard library provides goroutines, channels, and select for basic concurrency. Production systems use context package for cancellation, timeouts, and request-scoped values, preventing goroutine leaks and enabling graceful shutdown.

Key takeaways:

  • Goroutines are lightweight (thousands feasible)
  • Channels communicate between goroutines safely
  • Select multiplexes channel operations with timeout
  • Context provides cancellation, deadlines, request-scoped values
  • Always defer cancel() to prevent resource leaks
  • Pass context as first parameter (convention)

Last updated February 3, 2026

Command Palette

Search for a command to run...