Skip to content

Files

Latest commit

d463076 · Dec 3, 2024

History

History

04_data_race

Folders and files

NameName
Last commit message
Last commit date

parent directory

..
Dec 3, 2024

⚠️ Data Races

💡 What is a Data Race?

A data race occurs when two or more goroutines:

  1. Access the same shared variable or memory location concurrently, and
  2. At least one of the accesses is a write operation,
    without proper synchronization.

This leads to unpredictable behavior, as goroutines may read or write inconsistent or corrupted values.

✅ Techniques to Avoid Data Races

Here are three primary ways to avoid data races in Go:

1. Pass by Value (Avoid Sharing Memory)

By passing copies of data to goroutines instead of references to shared variables, you ensure each goroutine works on its own copy. This approach eliminates the need for synchronization.

Example:

package main

import (
	"fmt"
	"sync"
)

func process(val int, wg *sync.WaitGroup) {
	defer wg.Done()
	val++ // Works on a copy of the value
	fmt.Printf("Processed value: %d\n", val)
}

func main() {
	var wg sync.WaitGroup
	wg.Add(2)

	go process(10, &wg) // Pass a copy of the value
	go process(20, &wg) // Pass a different copy

	wg.Wait()
	fmt.Println("Done")
}

Pros:

  • Simple and eliminates shared state.
  • No need for locks or atomic operations.

Cons:

  • Doesn’t work if you need to update or share the original value.

2. Use sync.Mutex for Synchronization

A mutex is a lock mechanism that ensures only one goroutine can access the critical section of code at a time. It’s useful when you must update a shared variable safely.

Example:

package main

import (
	"fmt"
	"sync"
)

func increment(mu *sync.Mutex, i *int, wg *sync.WaitGroup) {
	defer wg.Done()
	mu.Lock()   // Acquire lock
	*i++
	mu.Unlock() // Release lock
	fmt.Printf("Value: %d\n", *i)
}

func main() {
	var wg sync.WaitGroup
	var mu sync.Mutex
	var counter int

	wg.Add(2)
	go increment(&mu, &counter, &wg)
	go increment(&mu, &counter, &wg)

	wg.Wait()
	fmt.Println("Final Counter:", counter)
}

Pros:

  • Works well for protecting shared state.
  • Easy to understand and use.

Cons:

  • Locks can cause performance overhead.
  • Risk of deadlocks if locks are not used properly.

3. Use sync/atomic for Lightweight Synchronization

The atomic package provides low-level operations for shared variables that are faster than using mutexes. It’s ideal for simple operations like counters or flags.

Example:

package main

import (
	"fmt"
	"sync"
	"sync/atomic"
)

func increment(counter *int32, wg *sync.WaitGroup) {
	defer wg.Done()
	atomic.AddInt32(counter, 1) // Atomic increment
	fmt.Printf("Counter: %d\n", atomic.LoadInt32(counter)) // Atomic read
}

func main() {
	var wg sync.WaitGroup
	var counter int32

	wg.Add(2)
	go increment(&counter, &wg)
	go increment(&counter, &wg)

	wg.Wait()
	fmt.Println("Final Counter:", counter)
}

Pros:

  • Very efficient for simple operations.
  • No explicit locking or unlocking.

Cons:

  • Limited to primitive types like integers and pointers.
  • Complex logic requiring multiple atomic operations can be hard to manage.

🔑 Choosing the Right Approach

Use Case Suggested Approach
Goroutines operate independently Pass by value
Need to protect shared, complex structures sync.Mutex
Need to update simple variables efficiently sync/atomic

Best Practices

  1. Minimize Shared State: Favor designs where goroutines work on independent data as much as possible.
  2. Use Defer for Locks: Always defer mu.Unlock() immediately after acquiring a lock to avoid deadlocks.
  3. Understand Atomic Limitations: While fast, atomic operations don’t work for complex data structures. For those, use a mutex or redesign your program to avoid shared state.

By following these approaches, you can write more robust and race-free concurrent code in Go.