Understanding Race Conditions and Mutexes in Go

Published Date

It's been quite some time since I picked up Go, and I've built a flex-wrap projects with it (a simple blockchain, p2p file storage, and neartalk). With each project, I find myself liking Go more :D - I think the language really does everything right for what it's made for. It's simple and it's great (and quite fast). Yes, concurrency is quite nice, but when multiple goroutines access shared data simultaneously, things can go wrong fast, although no problem as we can solve this with mutexes.

In this blog, I want to share what I have learned about goroutines, channels, race conditions and mutexes.

What is Concurrency?

So concurrency is about dealing with multiple things at once. In Go, we achieve this through goroutines - lightweight threads managed by the Go runtime.

go doSomething()  // runs concurrently

Think of concurrency like a chef managing multiple dishes:

Single Goroutine:          Multiple Goroutines:
    Task A                     Task A    Task B    Task C
    Task B                       |         |         |
    Task C                       v         v         v
      |                       [Running Concurrently]
      v                              
   [Sequential]                  Time Saved!

Concurrency vs Parallelism

Concurrency is NOT the same as parallelism

Concurrency (1 CPU):          Parallelism (2+ CPUs):
    
    Task A  Task B                Task A    Task B
      |       |                      |         |
      v       |                      v         v
      |       v                   [CPU 1]   [CPU 2]
      v       |                      |         |
      |       v                      v         v
   [Switching back and            [Both running simultaneously!]
    forth on single CPU]               
    

Go's runtime scheduler can run goroutines concurrently on a single CPU core, or in parallel across multiple cores. You write concurrent code, and Go handles making it parallel when possible!

Race Conditions

A race condition occurs when multiple goroutines access shared data simultaneously, and at least one of them is writing. The outcome depends on the unpredictable timing of their execution.

e.g., if one thread tries to increase an integer and another thread tries to read it, this will cause a race condition. On the other hand, if the variable is read-only, there won't be a race condition. Let's try to create a race condition. The easiest way to do it is by using multiple goroutines, and at least one of the goroutines must be writing to a shared variable.

Classic e.g: The Bank Account Problem

Imagine two people trying to withdraw money from the same account at the exact same time:

var balance = 100

func withdraw(amount int) {
    if balance >= amount {
        // What if another goroutine runs here?
        balance = balance - amount
    }
}

// Two goroutines run simultaneously
go withdraw(60)
go withdraw(60)

What could go wrong?

Time    Goroutine 1          Goroutine 2          Balance
----    -----------          -----------          -------
t1      Read balance=100                          100
t2                           Read balance=100     100
t3      Check: 100>=60 ✓                          100
t4                           Check: 100>=60 ✓     100
t5      balance = 100-60                          40
t6                           balance = 100-60     40

Result: Balance is 40, but we withdrew 120

(Race Condition):
┌─────────────┐         ┌─────────────┐
│ Goroutine 1 │         │ Goroutine 2 │
└──────┬──────┘         └──────┬──────┘
       │                       │
       │  Read: balance=100    │
       ├───────────────────────┤
       │                       │ Read: balance=100
       │                       ├───────────────────
       │  Write: balance=40    │
       ├───────────────────────┤
       │                       │ Write: balance=40  X
       │                       │
       ▼                       ▼
   [Data Corruption!]

Solution: Mutexes

A So a Mutex (mutual exclusion) is like a lock on a door. Only one goroutine can hold the lock at a time.

Let's see with an example:

import "sync"

var (
    balance = 100
    mu      sync.Mutex  // Our lock
)

func withdraw(amount int) {
    mu.Lock()           // Lock the door
    defer mu.Unlock()   // Unlock when done
    
    if balance >= amount {
        balance = balance - amount
    }
}

How Mutexes Work : Under the hood

With Mutex Protection:
┌─────────────┐         ┌─────────────┐
│ Goroutine 1 │         │ Goroutine 2 │
└──────┬──────┘         └──────┬──────┘
       │                       │
       │  Lock() ✓             │
       ├───────────────────────┤
       │  Read: balance=100    │
       │  Write: balance=40    │ Lock() ⏳ [Waiting...]
       │  Unlock()             │
       ├───────────────────────┤
       │                       │ Lock() ✓
       │                       │ Read: balance=40
       │                       │ Check: 40>=60 ✗
       │                       │ Unlock()
       ▼                       ▼
   [Safe! Balance=40]

RWMutex: Optimizing Reads

Sometimes many goroutines just want to read data, which is actually safe. RWMutex allows multiple readers OR one writer.

var (
    balance = 100
    mu      sync.RWMutex
)

func getBalance() int {
    mu.RLock()          // Read lock (multiple allowed)
    defer mu.RUnlock()
    return balance
}

func deposit(amount int) {
    mu.Lock()           // Write lock (exclusive)
    defer mu.Unlock()
    balance += amount
}
RWMutex Behavior:

Multiple Readers (Allowed):
┌─────────┐  ┌─────────┐  ┌─────────┐
│ Reader1 │  │ Reader2 │  │ Reader3 │
│ RLock() │  │ RLock() │  │ RLock() │
└────┬────┘  └────┬────┘  └────┬────┘
     └────────┴────────┴────────┘
              All read safely!

One Writer (Exclusive):
┌─────────┐                ┌─────────┐
│ Writer  │                │ Reader  │
│ Lock()  │                │ RLock() │ ⏳ Blocked
└────┬────┘                └─────────┘
     │ Exclusive access
     │ No one else allowed!

Channels

While mutexes protect shared memory, channels let goroutines communicate by passing data.

Basic Channel Usage

ch := make(chan int)

// Sender goroutine
go func() {
    ch <- 42  // Send value to channel
}()

// Receiver
value := <-ch  // Receive value from channel

How Channels Work

Unbuffered Channel (Synchronous):

Sender                    Channel                  Receiver
  │                          │                        │
  │  ch <- 42                │                        │
  ├─────────────────────────>│                        │
  │  [BLOCKED]               │                        │
  │                          │    value := <-ch       │
  │                          │<───────────────────────┤
  │  [UNBLOCKED]             │                        │
  │                          │  value = 42            │
  │                          │                        │
  
Buffered Channel (buffer=2):

Sender                    Channel                  Receiver
  │                       [  ][  ]                    │
  │  ch <- 1                 ↓                          │
  │  ch <- 2               [1][2]                        │
  │  [No blocking yet!]      │                          │
  │  ch <- 3                 │                          │
  │  [BLOCKED - full]      [1][2]                        │
  │                          │    value := <-ch         │
  │                          │    (gets 1)              │
  │  [UNBLOCKED]           [2][ ]                        │
  │  (sent 3)              [2][3]

We use Mutexes when: we need to protect shared data from concurrent access

The code below is an example of how to use a mutex to protect a counter. Check the increment function. If we remove the mutex, the counter will be corrupted (race condition)

package main

import (
    "fmt"
    "sync"
)

// Mutex Example: Protecting a counter
var (
    counter int
    mu      sync.Mutex
)

func increment() {
    mu.Lock()
    counter++
    mu.Unlock()
}

func main() {
    // Multiple goroutines incrementing the counter
    var wg sync.WaitGroup
    for i := 0; i < 1000; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            increment()
        }()
    }
    wg.Wait()
    fmt.Println("Counter:", counter)  // Will always be 1000 (without mutex)
    
}

Detecting Race Conditions

Go has a built-in race detector! Use it during development:

go run -race main.go
go test -race ./...

The race detector will catch issues like:

WARNING: DATA RACE
Write at 0x00c000018090 by goroutine 7:
  main.withdraw()
      /path/to/main.go:15 +0x44

Previous read at 0x00c000018090 by goroutine 6:
  main.withdraw()
      /path/to/main.go:14 +0x3a

Best Practices

From what I have read and learned so far:

1. Always use defer with Unlock

mu.Lock()
defer mu.Unlock()  // Ensures unlock even if panic occurs

2. Keep critical sections small

// Bad: Long critical section
mu.Lock()
doLotsOfWork()
mu.Unlock()

// Good: Minimal critical section
doLotsOfWork()
mu.Lock()
updateSharedState()
mu.Unlock()

3. Avoid nested locks (can cause deadlocks)

// Dangerous!
mu1.Lock()
mu2.Lock()  // What if another goroutine locks in reverse order?
mu2.Unlock()
mu1.Unlock()

So I think that's about it. I wrote this one because I have been working with Go for quite some time, and wanted to write down my understanding of it. I am here to learn, if you feel something's wrong or missing, please let me know at askwhyharsh@gmail.com.