Race Detection in Golang
Race conditions occur when multiple goroutines access shared data concurrently without proper synchronization, leading to unpredictable behavior. Go’s race detector is a powerful tool for identifying these issues. This tutorial covers how to use the race detector and fix race conditions.
What is a Race Condition?
A race condition happens when the behavior of a program depends on the relative timing of operations in concurrent goroutines.
package main
import (
"fmt"
"sync"
)
func main() {
var counter int
var wg sync.WaitGroup
// Start 1000 goroutines that increment counter
for i := 0; i < 1000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
counter++ // Race condition!
}()
}
wg.Wait()
fmt.Printf("Final counter: %d (expected: 1000)\n", counter)
}Run with race detection:
go run -race main.goOutput:
==================
WARNING: DATA RACE
Write at 0x00c0000a4010 by goroutine 8:
main.main.func1()
/path/to/main.go:13 +0x3a
Previous read at 0x00c0000a4010 by goroutine 7:
main.main.func1()
/path/to/main.go:13 +0x2e
Goroutine 8 (running) created at:
main.main()
/path/to/main.go:11 +0x88
Goroutine 7 (finished) created at:
main.main()
/path/to/main.go:9 +0x68
==================
Final counter: 987 (expected: 1000)Using the Race Detector
Enabling Race Detection
# Run with race detection
go run -race program.go
# Test with race detection
go test -race ./...
# Build with race detection
go build -race -o program
# Run built binary (race detection included)
./programRace Detector Options
# Set race detector log path
go run -race -log_path=/tmp/race.log main.go
# Set race detector timeout (default 1 second)
go run -race -race_timeout=10s main.goCommon Race Condition Patterns
1. Shared Variables Without Synchronization
type Counter struct {
value int
}
func (c *Counter) Increment() {
c.value++ // Race condition!
}
func (c *Counter) Get() int {
return c.value // Race condition!
}Fixed version:
type Counter struct {
mu sync.Mutex
value int
}
func (c *Counter) Increment() {
c.mu.Lock()
defer c.mu.Unlock()
c.value++
}
func (c *Counter) Get() int {
c.mu.Lock()
defer c.mu.Unlock()
return c.value
}2. Slice and Map Access
var data []int
func addToSlice(value int) {
data = append(data, value) // Race condition!
}
func readSlice() []int {
return data // Race condition!
}Fixed version:
var (
data []int
mu sync.RWMutex
)
func addToSlice(value int) {
mu.Lock()
defer mu.Unlock()
data = append(data, value)
}
func readSlice() []int {
mu.RLock()
defer mu.RUnlock()
// Return a copy to prevent external modification
result := make([]int, len(data))
copy(result, data)
return result
}3. Global Variables
var globalConfig map[string]string
func setConfig(key, value string) {
if globalConfig == nil {
globalConfig = make(map[string]string)
}
globalConfig[key] = value // Race condition!
}Fixed version:
var (
globalConfig map[string]string
configMu sync.RWMutex
)
func setConfig(key, value string) {
configMu.Lock()
defer configMu.Unlock()
if globalConfig == nil {
globalConfig = make(map[string]string)
}
globalConfig[key] = value
}
func getConfig(key string) string {
configMu.RLock()
defer configMu.RUnlock()
return globalConfig[key]
}Synchronization Primitives
Mutex (Mutual Exclusion)
type SafeMap struct {
mu sync.Mutex
data map[string]int
}
func (sm *SafeMap) Set(key string, value int) {
sm.mu.Lock()
defer sm.mu.Unlock()
sm.data[key] = value
}
func (sm *SafeMap) Get(key string) (int, bool) {
sm.mu.Lock()
defer sm.mu.Unlock()
value, exists := sm.data[key]
return value, exists
}RWMutex (Read-Write Mutex)
type SafeMap struct {
mu sync.RWMutex
data map[string]int
}
func (sm *SafeMap) Set(key string, value int) {
sm.mu.Lock() // Exclusive lock for writing
defer sm.mu.Unlock()
sm.data[key] = value
}
func (sm *SafeMap) Get(key string) (int, bool) {
sm.mu.RLock() // Shared lock for reading
defer sm.mu.RUnlock()
value, exists := sm.data[key]
return value, exists
}Atomic Operations
For simple operations, use atomic package:
import "sync/atomic"
type Counter struct {
value int64
}
func (c *Counter) Increment() {
atomic.AddInt64(&c.value, 1)
}
func (c *Counter) Get() int64 {
return atomic.LoadInt64(&c.value)
}Channels for Synchronization
func worker(id int, jobs <-chan int, results chan<- int) {
for job := range jobs {
// Process job
result := job * 2
results <- result
}
}
func main() {
jobs := make(chan int, 100)
results := make(chan int, 100)
// Start workers
for w := 1; w <= 3; w++ {
go worker(w, jobs, results)
}
// Send jobs
for j := 1; j <= 9; j++ {
jobs <- j
}
close(jobs)
// Collect results
for r := 1; r <= 9; r++ {
<-results
}
}Advanced Race Detection
Detecting Race Conditions in Tests
func TestConcurrentAccess(t *testing.T) {
var counter int
var wg sync.WaitGroup
// Run test with race detector
for i := 0; i < 100; i++ {
wg.Add(1)
go func() {
defer wg.Done()
counter++ // Race condition will be detected
}()
}
wg.Wait()
}Run test:
go test -race -vRace Detection in Benchmarks
func BenchmarkConcurrent(b *testing.B) {
var counter int64
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
atomic.AddInt64(&counter, 1) // Safe
// counter++ would cause race condition
}
})
}Race Detection with HTTP Servers
type Server struct {
mu sync.RWMutex
data map[string]string
counter int
}
func (s *Server) handler(w http.ResponseWriter, r *http.Request) {
s.mu.Lock()
s.counter++ // Protected by mutex
s.mu.Unlock()
s.mu.RLock()
value := s.data[r.URL.Query().Get("key")]
s.mu.RUnlock()
fmt.Fprintf(w, "Counter: %d, Value: %s\n", s.counter, value)
}Common Race Condition Fixes
1. Interface Compliance
// Race condition in interface implementation
type Writer interface {
Write(data []byte)
}
type FileWriter struct {
file *os.File
}
func (fw *FileWriter) Write(data []byte) {
fw.file.Write(data) // Race condition if file is shared
}
// Fixed with mutex
type SafeFileWriter struct {
mu sync.Mutex
file *os.File
}
func (sfw *SafeFileWriter) Write(data []byte) {
sfw.mu.Lock()
defer sfw.mu.Unlock()
sfw.file.Write(data)
}2. Singleton Pattern
// Race condition in singleton
var instance *Config
var initialized bool
func GetInstance() *Config {
if !initialized {
instance = &Config{}
initialized = true // Race condition!
}
return instance
}
// Fixed with sync.Once
var instance *Config
var once sync.Once
func GetInstance() *Config {
once.Do(func() {
instance = &Config{}
})
return instance
}3. Lazy Initialization
type LazyStruct struct {
mu sync.Mutex
data map[string]string
loaded bool
}
func (ls *LazyStruct) GetData() map[string]string {
ls.mu.Lock()
defer ls.mu.Unlock()
if !ls.loaded {
ls.data = loadData() // Expensive operation
ls.loaded = true
}
// Return a copy to prevent external modification
result := make(map[string]string)
for k, v := range ls.data {
result[k] = v
}
return result
}Race Detection Best Practices
1. Always Use Race Detector in Tests
# GitHub Actions workflow
name: Test
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-go@v4
with:
go-version: '1.21'
- run: go test -race -v ./...2. Test with Multiple Goroutines
func TestConcurrentOperations(t *testing.T) {
const numGoroutines = 100
const numOperations = 1000
var wg sync.WaitGroup
safeMap := &SafeMap{data: make(map[string]int)}
// Start multiple goroutines
for i := 0; i < numGoroutines; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
for j := 0; j < numOperations; j++ {
key := fmt.Sprintf("key-%d-%d", id, j)
safeMap.Set(key, j)
safeMap.Get(key)
}
}(i)
}
wg.Wait()
}3. Use Stress Testing
func TestStress(t *testing.T) {
if testing.Short() {
t.Skip("Skipping stress test in short mode")
}
var counter int64
var wg sync.WaitGroup
for i := 0; i < 1000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for j := 0; j < 10000; j++ {
atomic.AddInt64(&counter, 1)
}
}()
}
wg.Wait()
expected := int64(1000 * 10000)
if counter != expected {
t.Errorf("Expected %d, got %d", expected, counter)
}
}4. Profile Race Conditions
// Add race detector to benchmarks
func BenchmarkWithRace(b *testing.B) {
// This will detect races during benchmarking
b.RunParallel(func(pb *testing.PB) {
// Benchmark code
})
}Performance Considerations
Race Detector Overhead
The race detector adds significant overhead:
- 2-20x slower execution
- 5-10x more memory usage
- Only use in development/testing
When to Disable Race Detection
// In performance-critical code, disable race detector
//go:build !race
package main
// Code that runs only when race detector is disabled
Selective Race Detection
func init() {
// Skip race detection for this package if needed
if race.Enabled {
// Alternative implementation
}
}Troubleshooting Race Conditions
1. False Positives
Sometimes the race detector reports false positives:
var initialized bool
var mu sync.Mutex
func initOnce() {
mu.Lock()
defer mu.Unlock()
if !initialized {
// Initialization code
initialized = true
}
}The race detector might flag initialized even with mutex protection. Use sync.Once:
var once sync.Once
func initOnce() {
once.Do(func() {
// Initialization code
})
}2. Race Detector Limitations
- Not all races are detected (especially complex scenarios)
- False negatives possible
- Only detects races that occur during execution
3. Common Pitfalls
// Pitfall: Lock in wrong order (deadlock potential)
type AB struct {
muA, muB sync.Mutex
}
func (ab *AB) Method1() {
ab.muA.Lock()
ab.muB.Lock() // Lock A then B
// ...
ab.muB.Unlock()
ab.muA.Unlock()
}
func (ab *AB) Method2() {
ab.muB.Lock()
ab.muA.Lock() // Lock B then A - potential deadlock!
// ...
ab.muA.Unlock()
ab.muB.Unlock()
}Fix: Always lock in the same order
func (ab *AB) Method1() {
ab.muA.Lock()
ab.muB.Lock()
// ...
ab.muB.Unlock()
ab.muA.Unlock()
}
func (ab *AB) Method2() {
ab.muA.Lock() // Always lock A first
ab.muB.Lock()
// ...
ab.muB.Unlock()
ab.muA.Unlock()
}Advanced Topics
Context and Cancellation
func worker(ctx context.Context, jobs <-chan int) {
for {
select {
case job, ok := <-jobs:
if !ok {
return
}
// Process job
case <-ctx.Done():
// Context cancelled
return
}
}
}Race-Free Patterns
Worker Pool Pattern
type WorkerPool struct {
workers int
jobs chan func()
}
func NewWorkerPool(workers int) *WorkerPool {
wp := &WorkerPool{
workers: workers,
jobs: make(chan func(), workers*2),
}
for i := 0; i < workers; i++ {
go wp.worker()
}
return wp
}
func (wp *WorkerPool) worker() {
for job := range wp.jobs {
job() // Execute job
}
}
func (wp *WorkerPool) Submit(job func()) {
wp.jobs <- job
}
func (wp *WorkerPool) Shutdown() {
close(wp.jobs)
}Publish-Subscribe Pattern
type Publisher struct {
mu sync.RWMutex
subs map[string][]chan string
}
func (p *Publisher) Subscribe(topic string) <-chan string {
p.mu.Lock()
defer p.mu.Unlock()
ch := make(chan string, 10)
p.subs[topic] = append(p.subs[topic], ch)
return ch
}
func (p *Publisher) Publish(topic, message string) {
p.mu.RLock()
subs := make([]chan string, len(p.subs[topic]))
copy(subs, p.subs[topic])
p.mu.RUnlock()
// Send to subscribers without holding lock
for _, ch := range subs {
select {
case ch <- message:
default:
// Subscriber slow, skip
}
}
}External Resources:
Related Tutorials: