cd ~/Angga

Concurrency Chaos: A Deep Dive into Race Conditions in Go

February 10, 2025

Go

Concurrency

Synchronization

Have you ever tried to have a conversation with someone while another person was shouting over you? The result is usually chaos—mixed messages, confusion, and nobody gets heard properly. This everyday scenario is surprisingly similar to what happens in your Go programs when you encounter a race condition.

What Exactly is a Race Condition?

A race condition occurs when two or more operations must execute in the correct sequence, but the program doesn't guarantee this order. It's like two people trying to write on the same whiteboard simultaneously without coordination—they'll overwrite each other's work, and the final result will be unpredictable.

In technical terms, a race condition happens when multiple goroutines access shared data concurrently, and at least one of them modifies the data. The outcome depends on the non-deterministic timing of their execution, which can lead to bugs that are frustratingly difficult to reproduce and fix.

When Simple Code Turns Chaotic

Let's look at a practical example where we try to increment a simple counter with multiple goroutines:

package main
 
import (
    "fmt"
    "sync"
)
 
func main() {
    var counter int
    var wg sync.WaitGroup
    
    for i := 0; i < 1000; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            counter++ // This is our critical section
        }()
    }
    
    wg.Wait()
    fmt.Println("final counter:", counter)
}

Run this program a few times, and you'll likely get different results each time. Why? Because counter++ might look like a single operation, but it actually involves three steps: read the value, increment it, and write it back. When multiple goroutines perform these steps simultaneously, they interfere with each other.

Exposing Hidden Race Conditions

Luckily, Go has an excellent built-in tool to help us find these sneaky bugs: the race detector. Simply add the -race flag to your build, run, or test commands:

go run -race main.go

When you run our example program with this flag, Go will output a detailed report showing exactly where the race condition occurs. The race detector works by monitoring memory access patterns at runtime and can catch even subtle timing issues that might slip through during testing.

Solving Concurrency Bugs

The solution to our counter problem is to ensure that only one goroutine can access the critical section of code at a time. We can achieve this using a mutex (short for "mutual exclusion").

package main
 
import (
    "fmt"
    "sync"
)
 
func main() {
    var counter int
    var wg sync.WaitGroup
    var mu sync.Mutex // Create a mutex to protect our counter
    
    for i := 0; i < 1000; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            
            mu.Lock()   // Acquire the lock before accessing shared data
            counter++   // Critical section: safe to modify counter
            mu.Unlock() // Release the lock when done
        }()
    }
    
    wg.Wait()
    fmt.Println("final counter:", counter) // Will always be 1000
}

The mutex acts like a talking stick in a meeting—only the person holding the stick can speak. Similarly, only the goroutine that has acquired the lock can execute the protected code. Other goroutines must wait until the lock is released before they can proceed.

Beyond Basic Mutexes

While sync.Mutex is perfect for many situations, Go offers other synchronization primitives:

The choice between these tools often comes down to your specific use case and the famous Go proverb: "Do not communicate by sharing memory; instead, share memory by communicating."

Wrapping Up The Race

Race conditions are more than just theoretical concerns—they can lead to real-world vulnerabilities, data corruption, and unpredictable application behavior. By understanding how to detect and prevent them using Go's built-in tools, you're not just writing code that works; you're building applications that are safe, reliable, and maintainable.

Keep in mind that whenever you have shared state between goroutines, always think about synchronization. Use the race detector during development and testing, and choose the right synchronization primitive for your specific needs. Your future self (and your teammates) will thank you for the bug-free concurrency!