Race Conditions in Go: Detecting Them and Fixing with Mutex and Atomics
Chapter 03 of 04

Race Conditions in Go: Detecting Them and Fixing with Mutex and Atomics

See a data race produce 20 different answers, detect it with -race, and fix it with sync.Mutex, RWMutex or sync/atomic.

Sabbir Ahmmed3 min read0
On this page

When two goroutines access the same variable at the same time and at least one of them writes, you have a data race. The result is unpredictable, and the bug may only show up in production under load. This article shows a race happening, how to detect it, and three ways to fix it.

A race you can see

Add the numbers from 1 to 1000, each addition in its own goroutine:

race.go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
func unsafeSum() int {
	var wg sync.WaitGroup
	sum := 0

	for i := 1; i <= 1000; i++ {
		wg.Add(1)
		go func(i int) {
			defer wg.Done()
			sum = sum + i // read, add, write: NOT atomic
		}(i)
	}

	wg.Wait()
	return sum
}

The correct answer is 500500. I ran this 20 times on Go 1.27 and got 20 different wrong answers.

Why? sum = sum + i is three steps: read sum, add i, write sum. Two goroutines can both read the same old value, both add, and both write. One update is lost:

Lost update
1
2
3
4
goroutine A: read sum (100)
goroutine B: read sum (100)
goroutine A: write 100 + 5  -> sum = 105
goroutine B: write 100 + 7  -> sum = 107   (A's +5 is lost)

Detect races with -race

Go ships with a race detector. Use it in tests and during development:

bash
1
2
go run -race .
go test -race ./...
Output (Go 1.27, paths shortened)
1
2
3
4
5
6
7
8
9
10
11
==================
WARNING: DATA RACE
Read at 0x00c0001a4038 by goroutine 9:
  main.main.func1()
      race/main.go:15 +0x84

Previous write at 0x00c0001a4038 by goroutine 8:
  main.main.func1()
      race/main.go:15 +0x9b
==================
Found 2 data race(s)
Tip
Run your test suite with -race in CI. It slows tests down a little, but it catches bugs that are almost impossible to find by reading code.

Fix 1: sync.Mutex

A mutex (mutual exclusion lock) lets only one goroutine at a time execute the code between Lock and Unlock, the critical section.

mutex.go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
func mutexSum() int {
	var wg sync.WaitGroup
	var mu sync.Mutex
	sum := 0

	for i := 1; i <= 1000; i++ {
		wg.Add(1)
		go func(i int) {
			defer wg.Done()

			mu.Lock()
			defer mu.Unlock()
			sum += i // only one goroutine at a time gets here
		}(i)
	}

	wg.Wait()
	return sum // 500500, every time
}

Guidelines:

Keep the critical section small: lock, touch shared data, unlock. Never hold a lock while doing slow I/O.
Use defer mu.Unlock() right after Lock() so an early return or panic cannot leave it locked.
Never copy a mutex. Embed it in a struct that you pass by pointer.
Lock in a consistent order when you need several locks, or you risk deadlock.

Fix 2: sync.RWMutex for read-heavy data

When many goroutines read and few write (a cache, a config, a product list), an RWMutex allows any number of readers at once, but writers get exclusive access:

go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
type Cache struct {
	mu   sync.RWMutex
	data map[string]string
}

func (c *Cache) Get(key string) (string, bool) {
	c.mu.RLock()
	defer c.mu.RUnlock()
	v, ok := c.data[key]
	return v, ok
}

func (c *Cache) Set(key, value string) {
	c.mu.Lock()
	defer c.mu.Unlock()
	c.data[key] = value
}

Fix 3: atomic operations

For a single number such as a counter, sync/atomic is faster and simpler:

go
1
2
3
4
5
6
7
8
var sum atomic.Int64
var wg sync.WaitGroup

for i := 1; i <= 1000; i++ {
	wg.Go(func() { sum.Add(int64(i)) }) // wg.Go: Go 1.25+
}
wg.Wait()
fmt.Println(sum.Load()) // 500500

Atomics only protect a single value. As soon as you need to update two things together (for example, a balance and a transaction log), use a mutex.

Or avoid sharing entirely

The other Go approach is to not share memory at all: give each goroutine its own data and send results over a channel to one goroutine that combines them. For the sum example, each worker could compute a partial sum and send it back. Choose whichever is clearer:

UseWhen
sync.Mutex / RWMutexprotecting shared state (caches, maps, structs)
sync/atomicsingle counters, flags, gauges
channelspassing ownership of data, pipelines, worker pools

Key takeaways

A data race is concurrent access to the same memory with at least one write.
x = x + 1 is not atomic; updates get lost.
Run go test -race routinely to detect races.
Protect shared state with a mutex; use RWMutex for read-heavy data, atomics for single counters.
Keep critical sections small and always pair Lock with defer Unlock.
#Golang#Concurrency#Mutex#Race condition

Last updated on

Discussion0