Race Conditions and Data Races
Race conditions occur when multiple goroutines access shared data concurrently without proper synchronization. Go provides excellent tools to detect and prevent these bugs. This guide covers race conditions, detection, and prevention strategies.
Understanding Race Conditions
What is a Race Condition?
A race condition occurs when:
- Multiple goroutines access the same variable
- At least one goroutine modifies the variable
- There’s no synchronization between accesses
package main
import (
"fmt"
"sync"
)
var counter = 0
func main() {
var wg sync.WaitGroup
// Start 100 goroutines
for i := 0; i < 100; i++ {
wg.Add(1)
go func() {
defer wg.Done()
counter++ // Race condition!
}()
}
wg.Wait()
fmt.Println("Counter:", counter) // Expected: 100, Actual: varies
}
// Run with: go run -race main.go
// Output will show race condition detected
Why Race Conditions Happen
package main
import "fmt"
// counter++ is not atomic
// It's actually three operations:
// 1. Load counter from memory
// 2. Increment the value
// 3. Store back to memory
// With concurrent access:
// Goroutine 1: Load (0) -> Increment (1) -> Store (1)
// Goroutine 2: Load (0) -> Increment (1) -> Store (1)
// Result: counter = 1 (should be 2)
func main() {
fmt.Println("Race conditions occur due to non-atomic operations")
}
Detecting Race Conditions
Using the Race Detector
# Run tests with race detector
go test -race
# Run program with race detector
go run -race main.go
# Build with race detector
go build -race
# Run benchmarks with race detector
go test -race -bench=.
Example: Detecting a Race
package main
import (
"fmt"
"sync"
)
var counter = 0
func main() {
var wg sync.WaitGroup
for i := 0; i < 10; i++ {
wg.Add(1)
go func() {
defer wg.Done()
counter++
}()
}
wg.Wait()
fmt.Println("Counter:", counter)
}
// Run with: go run -race main.go
// Output:
// ==================
// WARNING: DATA RACE
// Write at 0x00c0001b2000 by goroutine 7:
// main.main.func1()
// /path/to/main.go:15 +0x44
//
// Previous write at 0x00c0001b2000 by goroutine 6:
// main.main.func1()
// /path/to/main.go:15 +0x44
// ==================
Preventing Race Conditions
Using Mutexes
package main
import (
"fmt"
"sync"
)
var (
counter = 0
mu sync.Mutex
)
func main() {
var wg sync.WaitGroup
for i := 0; i < 100; i++ {
wg.Add(1)
go func() {
defer wg.Done()
mu.Lock()
counter++
mu.Unlock()
}()
}
wg.Wait()
fmt.Println("Counter:", counter) // Always 100
}
Using Channels
package main
import (
"fmt"
"sync"
)
func main() {
ch := make(chan int)
var wg sync.WaitGroup
// Sender
for i := 0; i < 100; i++ {
wg.Add(1)
go func() {
defer wg.Done()
ch <- 1
}()
}
// Receiver
go func() {
counter := 0
for val := range ch {
counter += val
}
fmt.Println("Counter:", counter)
}()
wg.Wait()
close(ch)
}
Using Atomic Operations
package main
import (
"fmt"
"sync"
"sync/atomic"
)
func main() {
var counter int64
var wg sync.WaitGroup
for i := 0; i < 100; i++ {
wg.Add(1)
go func() {
defer wg.Done()
atomic.AddInt64(&counter, 1)
}()
}
wg.Wait()
fmt.Println("Counter:", atomic.LoadInt64(&counter)) // Always 100
}
Common Race Condition Patterns
Pattern 1: Unprotected Shared Variable
package main
import (
"fmt"
"sync"
)
type User struct {
Name string
}
var user *User // โ Race condition
func main() {
var wg sync.WaitGroup
// Writer
wg.Add(1)
go func() {
defer wg.Done()
user = &User{Name: "Alice"}
}()
// Reader
wg.Add(1)
go func() {
defer wg.Done()
if user != nil {
fmt.Println(user.Name)
}
}()
wg.Wait()
}
// Run with: go run -race main.go
// Will detect race condition
Fix: Use Mutex
package main
import (
"fmt"
"sync"
)
type User struct {
Name string
}
var (
user *User
mu sync.Mutex
)
func main() {
var wg sync.WaitGroup
// Writer
wg.Add(1)
go func() {
defer wg.Done()
mu.Lock()
user = &User{Name: "Alice"}
mu.Unlock()
}()
// Reader
wg.Add(1)
go func() {
defer wg.Done()
mu.Lock()
if user != nil {
fmt.Println(user.Name)
}
mu.Unlock()
}()
wg.Wait()
}
Pattern 2: Slice Modification
package main
import (
"fmt"
"sync"
)
var items []int // โ Race condition
func main() {
var wg sync.WaitGroup
for i := 0; i < 10; i++ {
wg.Add(1)
go func(val int) {
defer wg.Done()
items = append(items, val)
}(i)
}
wg.Wait()
fmt.Println("Items:", items)
}
// Run with: go run -race main.go
// Will detect race condition
Fix: Use Mutex
package main
import (
"fmt"
"sync"
)
var (
items []int
mu sync.Mutex
)
func main() {
var wg sync.WaitGroup
for i := 0; i < 10; i++ {
wg.Add(1)
go func(val int) {
defer wg.Done()
mu.Lock()
items = append(items, val)
mu.Unlock()
}(i)
}
wg.Wait()
fmt.Println("Items:", items)
}
Pattern 3: Map Access
package main
import (
"fmt"
"sync"
)
var cache = make(map[string]string) // โ Race condition
func main() {
var wg sync.WaitGroup
// Writer
wg.Add(1)
go func() {
defer wg.Done()
cache["key"] = "value"
}()
// Reader
wg.Add(1)
go func() {
defer wg.Done()
fmt.Println(cache["key"])
}()
wg.Wait()
}
// Run with: go run -race main.go
// Will detect race condition
Fix: Use Mutex
package main
import (
"fmt"
"sync"
)
var (
cache = make(map[string]string)
mu sync.Mutex
)
func main() {
var wg sync.WaitGroup
// Writer
wg.Add(1)
go func() {
defer wg.Done()
mu.Lock()
cache["key"] = "value"
mu.Unlock()
}()
// Reader
wg.Add(1)
go func() {
defer wg.Done()
mu.Lock()
fmt.Println(cache["key"])
mu.Unlock()
}()
wg.Wait()
}
Synchronization Primitives
Mutex
package main
import (
"sync"
)
var (
data = 0
mu sync.Mutex
)
func main() {
mu.Lock()
data = 42
mu.Unlock()
}
RWMutex (Read-Write Mutex)
package main
import (
"sync"
)
var (
data = 0
mu sync.RWMutex
)
func read() int {
mu.RLock()
defer mu.RUnlock()
return data
}
func write(val int) {
mu.Lock()
defer mu.Unlock()
data = val
}
Atomic Operations
package main
import (
"sync/atomic"
)
var counter int64
func increment() {
atomic.AddInt64(&counter, 1)
}
func get() int64 {
return atomic.LoadInt64(&counter)
}
Best Practices
โ Good Practices
- Always use race detector -
go test -race - Protect shared data - Use mutexes or channels
- Use channels for communication - Preferred in Go
- Keep critical sections small - Minimize lock time
- Use RWMutex for read-heavy workloads - Better performance
- Use atomic operations for simple counters - Faster than mutexes
- Document synchronization - Explain why locks are needed
- Test with race detector - Catch bugs early
โ Anti-Patterns
// โ Bad: Unprotected shared variable
var counter = 0
go func() { counter++ }()
go func() { counter++ }()
// โ
Good: Protected with mutex
var (
counter = 0
mu sync.Mutex
)
go func() {
mu.Lock()
counter++
mu.Unlock()
}()
// โ Bad: Holding lock too long
mu.Lock()
doExpensiveOperation()
mu.Unlock()
// โ
Good: Minimize critical section
mu.Lock()
data = value
mu.Unlock()
doExpensiveOperation()
// โ Bad: Not using race detector
go test
// โ
Good: Always use race detector
go test -race
Resources and References
Official Documentation
- Race Detector - Official guide
- sync Package - Synchronization primitives
- Effective Go - Concurrency - Best practices
Recommended Reading
- Go Concurrency Patterns - Rob Pike talk (video)
- Advanced Go Concurrency Patterns - Rob Pike talk (video)
- Concurrency is not Parallelism - Rob Pike talk (video)
Tools and Resources
- Race Detector Documentation - Complete guide
- Delve Debugger - Debug goroutines
- Go Playground - Online Go editor
Summary
Race conditions are serious bugs:
- Detect with race detector:
go test -race - Protect shared data with mutexes
- Use channels for communication
- Keep critical sections small
- Use RWMutex for read-heavy workloads
- Use atomic operations for simple counters
- Always test with race detector
Master synchronization for correct concurrent Go programs.
Comments