Concurrent programming introduces new types of bugs that don't exist in sequential programs. This section covers common pitfalls and how to avoid them.
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:
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
}
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
}
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.
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()
}
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")
}
}
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:
select statementsA 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)
}
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
}
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
Goroutine waiting on channel that never closes
// ❌ BAD
go func() {
for val := range ch { // Waits forever if ch never closes
process(val)
}
}()
Goroutine waiting on select with no exit
// ❌ BAD
go func() {
for {
select {
case <-ch1:
// handle
case <-ch2:
// handle
// No way to exit!
}
}
}()
Goroutine blocked on send to unbuffered channel
// ❌ BAD
ch := make(chan int)
go func() {
ch <- 42 // Blocks forever if no receiver
}()
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)
}
}
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)
}
}
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
}
}
}
Best practices
Use channels for communication
Always provide exit mechanisms
done channels or context for cancellationselect statementsRun with race detector
go test -race ./...
go run -race main.go
Use defer for cleanup
defer mu.Unlock()
defer wg.Done()
defer close(ch)
Limit goroutine creation
Test concurrent code
Document synchronization
Debugging concurrent code
Add logging
fmt.Printf("Goroutine %d: Locking mutex\n", id)
mu.Lock()
defer func() {
mu.Unlock()
fmt.Printf("Goroutine %d: Unlocked mutex\n", id)
}()
Use timeouts
select {
case result := <-ch:
return result
case <-time.After(5 * time.Second):
log.Println("Timeout waiting for result")
return nil
}
Monitor goroutines
import "runtime"
fmt.Printf("Goroutines: %d\n", runtime.NumGoroutine())
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.