6.8 Common Concurrency Pitfalls

Concurrent programming introduces new types of bugs that don't exist in sequential programs. This section covers common pitfalls and how to avoid them.

Race Conditions

A race condition occurs when multiple goroutines access shared data concurrently, and the outcome depends on the timing of their execution.

Race conditions are dangerous because they cause non-deterministic behavior where the same code produces different results. They create subtle bugs that are hard to reproduce and debug, and can lead to data corruption where shared data becomes inconsistent.

Example of a race condition

package main

import (
    "fmt"
    "sync"
)

// ❌ BAD: Race condition
func raceCondition() {
    var counter int
    var wg sync.WaitGroup

    for i := 0; i < 1000; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            counter++  // Race condition: multiple goroutines modify counter
        }()
    }

    wg.Wait()
    fmt.Printf("Counter: %d (should be 1000)\n", counter)  // May be less than 1000
}

To detect race conditions, use the race detector:

go run -race program.go
go test -race ./...

To fix race conditions, you can:

  1. Use mutexes

    // ✅ GOOD: Protected with mutex
    func fixedWithMutex() {
    var counter int
    var mu sync.Mutex
    var wg sync.WaitGroup
    
    for i := 0; i < 1000; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            mu.Lock()
            counter++
            mu.Unlock()
        }()
    }
    
    wg.Wait()
    fmt.Printf("Counter: %d\n", counter)  // Always 1000
    }
  2. Use atomic operations

    // ✅ GOOD: Using atomic operations
    func fixedWithAtomic() {
    var counter int64
    var wg sync.WaitGroup
    
    for i := 0; i < 1000; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            atomic.AddInt64(&counter, 1)
        }()
    }
    
    wg.Wait()
    fmt.Printf("Counter: %d\n", atomic.LoadInt64(&counter))  // Always 1000
    }
  3. Avoid shared state (use channels)

    // ✅ GOOD: No shared state
    func fixedWithChannels() {
    results := make(chan int, 1000)
    var wg sync.WaitGroup
    
    for i := 0; i < 1000; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            results <- 1  // Send to channel (no shared state)
        }()
    }
    
    go func() {
        wg.Wait()
        close(results)
    }()
    
    counter := 0
    for range results {
        counter++
    }
    fmt.Printf("Counter: %d\n", counter)  // Always 1000
    }

Channels prevent race conditions by eliminating shared state. Each goroutine has its own copy of data, and communication happens through channels, making race conditions impossible.

Deadlocks

A deadlock occurs when goroutines are waiting for each other indefinitely, causing the program to hang.

Deadlocks occur when goroutine A is waiting for resource X (held by B) and goroutine B is waiting for resource Y (held by A), so neither can proceed.

Example of a deadlock

package main

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

// ❌ BAD: Deadlock
func deadlockExample() {
    var mu1, mu2 sync.Mutex
    var wg sync.WaitGroup

    wg.Add(2)

    // Goroutine 1: Lock mu1, then mu2
    go func() {
        defer wg.Done()
        mu1.Lock()
        fmt.Println("Goroutine 1: Locked mu1")
        time.Sleep(100 * time.Millisecond)
        mu2.Lock()  // Waits for mu2 (held by goroutine 2)
        fmt.Println("Goroutine 1: Locked mu2")
        mu2.Unlock()
        mu1.Unlock()
    }()

    // Goroutine 2: Lock mu2, then mu1
    go func() {
        defer wg.Done()
        mu2.Lock()
        fmt.Println("Goroutine 2: Locked mu2")
        time.Sleep(100 * time.Millisecond)
        mu1.Lock()  // Waits for mu1 (held by goroutine 1)
        fmt.Println("Goroutine 2: Locked mu1")
        mu1.Unlock()
        mu2.Unlock()
    }()

    wg.Wait()  // Will hang forever
}

To prevent deadlocks, always acquire locks in the same order:

// ✅ GOOD: Consistent lock ordering
func noDeadlock() {
    var mu1, mu2 sync.Mutex
    var wg sync.WaitGroup

    wg.Add(2)

    // Both goroutines lock mu1, then mu2
    go func() {
        defer wg.Done()
        mu1.Lock()
        defer mu1.Unlock()
        mu2.Lock()
        defer mu2.Unlock()
        // Work
    }()

    go func() {
        defer wg.Done()
        mu1.Lock()
        defer mu1.Unlock()
        mu2.Lock()
        defer mu2.Unlock()
        // Work
    }()

    wg.Wait()
}
  1. Use timeout with channels

    // ✅ GOOD: Timeout prevents indefinite blocking
    func withTimeout() {
    ch := make(chan string)
    
    select {
    case msg := <-ch:
        fmt.Println("Received:", msg)
    case <-time.After(5 * time.Second):
        fmt.Println("Timeout: No message received")
    }
    }
  2. Avoid nested locks when possible

    // ❌ BAD: Nested locks increase deadlock risk
    func nestedLocks() {
    var mu1, mu2 sync.Mutex
    mu1.Lock()
    // ... code ...
    mu2.Lock()  // Dangerous if another goroutine does mu2, then mu1
    // ... code ...
    mu2.Unlock()
    mu1.Unlock()
    }

Channel deadlock example

// ❌ BAD: Deadlock with channels
func channelDeadlock() {
    ch := make(chan int)  // Unbuffered

    // This goroutine sends, but no receiver
    go func() {
        ch <- 42  // Blocks forever (no receiver)
    }()

    // Main goroutine never receives
    // Program hangs
}

To detect deadlocks, use the following approaches. Go's runtime can detect some deadlocks and will print a stack trace. However, not all deadlocks are detected automatically. Use:

Goroutine Leaks

A goroutine leak occurs when a goroutine is started but never terminates, consuming memory indefinitely.

Goroutine leaks are problematic because each goroutine consumes memory (starts at 2 KB, can grow), leaked goroutines continue consuming CPU and other resources (resource waste), and over time leaks accumulate and can crash the program (scaling issues).

Example of a goroutine leak

package main

import (
    "fmt"
    "time"
)

// ❌ BAD: Goroutine leak
func goroutineLeak() {
    ch := make(chan int)

    // Goroutine waits forever for channel to be closed
    go func() {
        for {
            select {
            case val := <-ch:
                fmt.Println("Received:", val)
            // No way to exit this goroutine!
            }
        }
    }()

    ch <- 1
    // Goroutine continues running forever
}

To fix goroutine leaks, provide an exit mechanism:

// ✅ GOOD: Exit mechanism
func fixedGoroutine() {
    ch := make(chan int)
    done := make(chan bool)

    go func() {
        for {
            select {
            case val := <-ch:
                fmt.Println("Received:", val)
            case <-done:
                return  // Exit goroutine
            }
        }
    }()

    ch <- 1
    close(done)  // Signal exit
    time.Sleep(100 * time.Millisecond)
}
  1. Use context for cancellation

    // ✅ GOOD: Using context
    func withContext(ctx context.Context) {
    ch := make(chan int)
    
    go func() {
        for {
            select {
            case val := <-ch:
                fmt.Println("Received:", val)
            case <-ctx.Done():
                return  // Exit on cancellation
            }
        }
    }()
    
    ch <- 1
    // Later: cancel() to stop goroutine
    }
  2. Close channels properly

    // ✅ GOOD: Close channel to signal completion
    func withChannelClose() {
    ch := make(chan int)
    
    go func() {
        defer close(ch)
        for i := 0; i < 5; i++ {
            ch <- i
        }
    }()
    
    // Range loop exits when channel closes
    for val := range ch {
        fmt.Println("Received:", val)
    }
    // Goroutine has exited
    }

Common leak scenarios

  1. Goroutine waiting on channel that never closes

    // ❌ BAD
    go func() {
    for val := range ch {  // Waits forever if ch never closes
        process(val)
    }
    }()
  2. Goroutine waiting on select with no exit

    // ❌ BAD
    go func() {
    for {
        select {
        case <-ch1:
            // handle
        case <-ch2:
            // handle
        // No way to exit!
        }
    }
    }()
  3. Goroutine blocked on send to unbuffered channel

    // ❌ BAD
    ch := make(chan int)
    go func() {
    ch <- 42  // Blocks forever if no receiver
    }()

Channel Leaks

Channel leaks occur when channels are created but never closed or properly drained, causing goroutines to wait indefinitely.

Channel leaks occur when channels are created but never closed, receivers wait forever for data that never comes, or senders block forever waiting for receivers.

Example of channel leak

// ❌ BAD: Channel leak
func channelLeak() {
    ch := make(chan int)

    go func() {
        for i := 0; i < 10; i++ {
            ch <- i
        }
        // Forgot to close channel!
    }()

    // This goroutine waits forever
    go func() {
        for val := range ch {  // Waits for close
            fmt.Println(val)
        }
    }()
}

To fix channel leaks, always close channels when done:

// ✅ GOOD: Close channel
func fixedChannelLeak() {
    ch := make(chan int)

    go func() {
        defer close(ch)  // Always close
        for i := 0; i < 10; i++ {
            ch <- i
        }
    }()

    for val := range ch {
        fmt.Println(val)
    }
}
  1. Use buffered channels when appropriate

    // ✅ GOOD: Buffered channel prevents blocking
    func withBufferedChannel() {
    ch := make(chan int, 10)  // Buffered
    
    go func() {
        for i := 0; i < 10; i++ {
            ch <- i  // Doesn't block (buffer has space)
        }
        close(ch)
    }()
    
    for val := range ch {
        fmt.Println(val)
    }
    }
  2. Drain channels properly

    // ✅ GOOD: Drain channel
    func drainChannel(ch <-chan int) {
    for {
        select {
        case val, ok := <-ch:
            if !ok {
                return  // Channel closed
            }
            process(val)
        case <-time.After(1 * time.Second):
            // Timeout: channel might be stuck
            return
        }
    }
    }

How to Avoid Common Pitfalls

Best practices

  1. Use channels for communication

    • Prefer channels over shared memory
    • Channels make data flow explicit
    • Channels prevent many race conditions
  2. Always provide exit mechanisms

    • Use done channels or context for cancellation
    • Close channels when done sending
    • Use timeouts in select statements
  3. Run with race detector

    go test -race ./...
    go run -race main.go
  4. Use defer for cleanup

    defer mu.Unlock()
    defer wg.Done()
    defer close(ch)
  5. Limit goroutine creation

    • Use worker pools instead of unlimited goroutines
    • Monitor goroutine count
    • Set reasonable limits
  6. Test concurrent code

    • Run tests multiple times
    • Use stress testing
    • Test with different goroutine counts
  7. Document synchronization

    • Document which goroutines access shared data
    • Document channel ownership (who closes)
    • Document lock ordering

Debugging concurrent code

  1. Add logging

    fmt.Printf("Goroutine %d: Locking mutex\n", id)
    mu.Lock()
    defer func() {
       mu.Unlock()
       fmt.Printf("Goroutine %d: Unlocked mutex\n", id)
    }()
  2. Use timeouts

    select {
    case result := <-ch:
       return result
    case <-time.After(5 * time.Second):
       log.Println("Timeout waiting for result")
       return nil
    }
  3. Monitor goroutines

    import "runtime"
    
    fmt.Printf("Goroutines: %d\n", runtime.NumGoroutine())

Loop Variable Capture in Closures

Loop variable capture is a subtle issue that affected Go code in versions 1.22 and earlier, particularly when using closures or goroutines inside loops. Understanding this behavior helps you write correct concurrent code and understand why Go 1.23 changed the behavior.

The Problem (Go 1.22 and earlier)

In previous versions, loop variables in for range loops were shared across all iterations, which could lead to subtle bugs. This occurred because the loop variable was declared once before the loop began, and each iteration reused the same variable by reassigning its value. This means there was only one variable instance for the entire loop, not a new variable for each iteration.

When you create a closure (like an anonymous function) inside the loop that references this variable, the closure captures the variable itself, not the value at the time of capture. Think of it this way: all closures created in the loop are pointing to the same memory location where the loop variable lives. As the loop progresses, that memory location gets updated with new values (0, then 1, then 2, then 3). When the loop finishes, the variable contains the final value (3 in this case). Later, when you call any of the closures, they all read from that same memory location, which now contains the final value.

// PROBLEMATIC CODE (Go 1.22 and earlier)
var funcs []func()
for i := 0; i < 3; i++ {
    // Variable 'i' is declared once before the loop
    // Each iteration reuses the SAME variable 'i'
    // Closures capture the variable 'i' itself, not its current value
    funcs = append(funcs, func() {
        fmt.Println(i)  // All closures reference the SAME variable i
    })
    // After this iteration, 'i' is incremented, but all closures
    // still point to the same variable
}

// When called, all functions print 3 (the final value of i)
// because they all read from the same variable that now equals 3
for _, f := range funcs {
    f()  // Prints: 3, 3, 3 (WRONG!)
}

The technical reason

This behavior occurred because of how Go's compiler implemented loop variables. The compiler essentially transformed the loop into something like:

// What the compiler effectively did (Go 1.22 and earlier):
var i int  // Variable declared once
for i = 0; i < 3; i++ {  // Reuse same variable each iteration
    funcs = append(funcs, func() {
        fmt.Println(i)  // All closures capture the same 'i'
    })
}

Since all closures captured the same variable i, they all saw the final value when executed later. This was particularly problematic with goroutines, where the loop might finish before the goroutines even start executing, causing all goroutines to see the final loop value.

The Fix (Go 1.23)

In Go 1.23, each iteration gets its own copy of the loop variable, eliminating this issue. For details on the new behavior, see Range Loops - Important Go 1.23 Behavior Change in Chapter 3.

Workaround for Go 1.22 and earlier

If you're working with older Go versions, you can work around this by explicitly capturing the loop variable in a local variable:

// Workaround: capture loop variable explicitly
for i := 0; i < 3; i++ {
    i := i  // Create local copy for this iteration
    funcs = append(funcs, func() {
        fmt.Println(i)  // Each closure captures its own copy
    })
}

Understanding these common pitfalls and how to avoid them is crucial for writing robust concurrent Go programs. Always test concurrent code thoroughly and use the race detector to catch issues early.