Blog

sync, the Race Detector and atomic in Go

Two goroutines that change the same variable without coordination produce a data race and quietly lose updates. Learn how sync.Mutex, RWMutex, atomic and Once prevent it, and how go run -race finds it.

Goroutines that only read their own data are easy. The trouble starts when two of them change the same variable. Nothing crashes and nothing warns you, but some of the changes vanish.

This post shows that bug, then the tools Go gives you to prevent it and to catch it: sync.Mutex, sync.RWMutex, sync/atomic, sync.Once, the race detector and go vet. Every program below was run on Go 1.26, and its output is pasted from the run.

A data race: two goroutines, one counter

A data race happens when two goroutines touch the same memory at the same time, and at least one of them writes. Here are two goroutines, each adding 1 to a shared counter a thousand times:

package main

import (
	"fmt"
	"sync"
)

func main() {
	count := 0
	var wg sync.WaitGroup
	for range 2 {
		wg.Go(func() {
			for range 1000 {
				count++
			}
		})
	}
	wg.Wait()
	fmt.Println("done")
}

Run with go run -race ., it prints a report like this (the ... stands for lines with memory addresses and goroutine numbers that change every run):

WARNING: DATA RACE
...
done
exit status 66

The report names the line, main.go:14, which is count++. It shows one goroutine reading or writing count after another had written it, with nothing coordinating the two. The program still finishes and prints done. Then the race detector makes it exit with status 66, so a test run or a CI job fails instead of passing quietly. (wg.Go starts a goroutine and counts it, and wg.Wait waits for all of them. There’s a short recap further down.)

This program deliberately doesn’t print count. You’d expect 2000, and on a given run you might see it. You might also see less, and a different number next time. A result that changes from run to run can’t be pasted as verified output, and that’s exactly the problem: you can’t trust it.

One thing surprised us when we ran this on Go 1.26. In some runs, the report says one goroutine had already finished when the other touched count. The two never overlapped, and the detector still reported a race. It doesn’t wait to see a collision. It notices that nothing, no lock and no channel, forced one goroutine to go before the other.

Why the count comes out wrong

count++ looks like one step, but the machine does it in three: read the value, add 1, write the result back. Two goroutines can interleave those steps, and when they do, one increment overwrites the other.

G1 count G2 about to count++ read 0 wrote 1 about to count++ locked, read 0 wrote 1, unlocked about to count++ read 0 wrote 1 waiting for the lock locked, read 1 wrote 2, unlocked 0 1 0 1 2 mutex lost update: two increments ran, count is 1 no lost update: count is 2 no lock: each goroutine reads, adds 1, writes back with sync.Mutex: only the holder touches count count is 0, and G1 and G2 each run count++ once G1 reads 0. Before G1 writes, G2 also reads 0 G1 adds 1 to the 0 it read and writes 1 G2 adds 1 to the 0 it read and writes 1: G1's update is lost with a mutex: G1 locks, reads 0, writes 1, unlocks G2 locks, reads 1, writes 2, unlocks: count is 2

Two goroutines each run count++ once on a shared count. Without a lock, both read 0 and both write 1, so one increment is lost. With a mutex, only the goroutine holding the lock can read and write, so the second one sees 1 and writes 2. The unlocked ordering shown is one possible interleaving: others give the right answer, which is why the bug hides.

Here are those steps in words, in case the animation doesn’t play for you:

  1. count is 0. G1 and G2 each run count++ once.
  2. G1 reads 0. Before G1 writes anything, G2 also reads 0.
  3. G1 adds 1 to the 0 it read and writes 1.
  4. G2 adds 1 to the 0 it read and writes 1 as well. Two increments ran, but count is 1. G1’s update is lost.
  5. Now with a mutex. G1 locks, reads 0, writes 1 and unlocks. G2 has to wait for the lock.
  6. G2 locks, reads 1, writes 2 and unlocks. count is 2.

That unlocked ordering is one of many. If G1 finishes before G2 reads, the answer is right. Which orderings happen depends on the scheduler and the machine, so the bug comes and goes.

Explain it like I’m ten

Two kids share one whiteboard with a number on it. Each kid’s job is to add 1.

Kid A looks at the board, sees 5, and starts working out 5 + 1 in their head. Kid B looks at the same moment, also sees 5, and also works out 6. Kid A rubs out the 5 and writes 6. Kid B rubs out that 6 and writes 6. Two kids did their job, and the number only went up by one. Sometimes they smudge each other’s writing so badly you can’t read the number at all.

A mutex is a single marker pen. Only the kid holding the pen may look at the board and write on it. Everyone else waits until the pen is put down. It’s slower, because kids stand around waiting, but the number is always right.

The precise version

count++ compiles to a read of the memory holding count, an addition in a CPU register, and a write back to memory. Those three steps aren’t one indivisible operation. When two goroutines run them without synchronization, the scheduler, and on a multi-core machine the hardware itself, can interleave them in any order. A read-modify-write that overlaps another one overwrites it.

The Go memory model calls this a data race: two goroutines access the same variable concurrently, at least one access is a write, and no synchronization orders them. A program with a data race has no guaranteed result. Lost updates are the mild outcome. On multi-word values, such as a string, a slice header or an interface, a reader can see half of an old value and half of a new one.

Where the analogy breaks: kids can see each other reaching for the board. Goroutines can’t. Nothing in count++ looks for another goroutine, and it only waits if you add a lock. And with the real race, you don’t get a visible smudge. You get a number that looks perfectly normal and is wrong.

sync.Mutex: one goroutine at a time

A sync.Mutex is a lock with two methods. Lock waits until the mutex is free and takes it, and Unlock releases it. Code between the two runs in one goroutine at a time:

package main

import (
	"fmt"
	"sync"
)

func main() {
	count := 0
	var mu sync.Mutex
	var wg sync.WaitGroup
	for range 2 {
		wg.Go(func() {
			for range 1000 {
				mu.Lock()
				count++
				mu.Unlock()
			}
		})
	}
	wg.Wait()
	fmt.Println("count:", count)
}

It prints:

count: 2000

We ran it twenty times, with and without -race, and got count: 2000 every time, with no race report. It’s the animation’s fixed version, a thousand times per goroutine. The zero value of sync.Mutex is unlocked, so var mu sync.Mutex is ready to use.

The code between Lock and Unlock is the critical section. Keep it small. Every other goroutine that wants the lock waits while you hold it, so do slow work, such as a network call or a file read, before you lock.

Protecting a struct’s fields

The usual way to use a mutex is to put it in a struct next to the fields it guards, and do all locking inside the methods:

package main

import (
	"fmt"
	"sync"
)

// Stock counts items by name. It's safe to use from many goroutines.
type Stock struct {
	mu     sync.Mutex // guards counts
	counts map[string]int
}

func NewStock() *Stock {
	return &Stock{counts: make(map[string]int)}
}

func (s *Stock) Add(name string, n int) {
	s.mu.Lock()
	defer s.mu.Unlock()
	s.counts[name] += n
}

func (s *Stock) Get(name string) int {
	s.mu.Lock()
	defer s.mu.Unlock()
	return s.counts[name]
}

func main() {
	s := NewStock()
	var wg sync.WaitGroup
	for range 50 {
		wg.Go(func() {
			s.Add("apples", 2)
			s.Add("pears", 1)
		})
	}
	wg.Wait()
	fmt.Println(s.Get("apples"), s.Get("pears"))
}

It prints:

100 50

Fifty goroutines each added 2 apples and 1 pear, and nothing was lost. Callers never see the mutex. They call Add and Get, and the struct takes care of itself.

defer s.mu.Unlock() right after Lock is the normal pattern. The unlock runs however the method returns, even through a panic, so an early return can’t leave the lock held. The lock is held until the function ends, which is one more reason to keep these methods short.

Two more conventions. Put the mutex directly above the fields it guards, with a comment saying so. And use pointer receivers, func (s *Stock): a value receiver locks a copy of the mutex, which protects nothing, as the go vet section shows. Without the mutex this program could also crash with fatal error: concurrent map writes, a check the runtime makes even without -race.

sync.RWMutex for data that’s read far more than written

A sync.RWMutex lets any number of readers hold it together, but a writer gets it alone. Use it when reads are frequent and writes are rare, such as configuration that a request handler reads on every request and an admin changes once a day:

package main

import (
	"fmt"
	"sync"
)

type Config struct {
	mu       sync.RWMutex
	settings map[string]string
}

func (c *Config) Get(key string) string {
	c.mu.RLock()
	defer c.mu.RUnlock()
	return c.settings[key]
}

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

func main() {
	c := &Config{settings: map[string]string{"mode": "fast"}}

	var wg sync.WaitGroup
	results := make([]string, 8)
	for i := range 8 {
		wg.Go(func() {
			results[i] = c.Get("mode")
		})
	}
	wg.Wait()
	fmt.Println(results)

	c.Set("mode", "safe")
	fmt.Println(c.Get("mode"))
}

It prints:

[fast fast fast fast fast fast fast fast]
safe

RLock and RUnlock take the read side. Eight goroutines can read mode at the same moment, and none of them blocks another. Lock and Unlock take the write side, which waits until every reader has left and keeps new readers out while it writes.

Each goroutine writes into its own element, results[i], so they never share a variable. That’s why the slice itself needs no lock, and it’s also how the output stays in a fixed order.

Don’t reach for RWMutex by default. It does more bookkeeping than a Mutex, so with mixed reads and writes, or a tiny critical section, it often gains nothing. Start with Mutex, and switch when a profile shows readers queuing.

sync/atomic: lock-free counters

The sync/atomic package has types whose operations are done as single steps the CPU guarantees can’t be interleaved. For a counter, that’s all you need:

package main

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

func main() {
	var count atomic.Int64
	var wg sync.WaitGroup
	for range 4 {
		wg.Go(func() {
			for range 1000 {
				count.Add(1)
			}
		})
	}
	wg.Wait()
	fmt.Println("count:", count.Load())
}

It prints:

count: 4000

count.Add(1) does the read, the add and the write as one indivisible step, so there’s nothing for another goroutine to interleave with. Load reads the current value. The zero value is 0 and ready to use.

The atomic.Int64 type, along with Int32, Uint64, Bool and Pointer[T], arrived in Go 1.19. Older code calls atomic.AddInt64(&n, 1) on a plain int64. The types are safer: you can’t read the value except through Load, and go vet complains if you copy one.

When atomics aren’t enough

Atomics make one operation safe. They don’t make a sequence of operations safe. This function books a seat if any are left, and every call in it is atomic, yet it’s still broken:

func reserveBroken(seats *atomic.Int64) bool {
	if seats.Load() > 0 { // check...
		seats.Add(-1) // ...then act: another goroutine can run in between
		return true
	}
	return false
}

With one seat left, two goroutines can both Load 1, both see it’s greater than 0, and both Add(-1). You’ve sold the last seat twice and seats is -1. The race detector won’t flag it either, because every access went through an atomic. It’s a logic race, not a data race.

The fix is to make the check and the update one step. CompareAndSwap(old, new) sets the new value only if the value is still old, and reports whether it did:

package main

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

// reserve takes one seat if any are left. The check and the update
// happen in a single CompareAndSwap, so no one can slip in between.
func reserve(seats *atomic.Int64) bool {
	for {
		n := seats.Load()
		if n <= 0 {
			return false
		}
		if seats.CompareAndSwap(n, n-1) {
			return true
		}
		// someone else changed seats since we loaded it: try again
	}
}

func main() {
	var seats atomic.Int64
	seats.Store(10)

	var booked atomic.Int64
	var wg sync.WaitGroup
	for range 100 {
		wg.Go(func() {
			if reserve(&seats) {
				booked.Add(1)
			}
		})
	}
	wg.Wait()
	fmt.Println("booked:", booked.Load(), "left:", seats.Load())
}

It prints:

booked: 10 left: 0

A hundred goroutines chased ten seats, and exactly ten got one. That loop works, but it’s already harder to read than a mutex would be. Here’s the practical rule: use atomics for a single number, such as a counter, a flag or a gauge. As soon as you have to keep two values consistent, or check one thing and then change another, use a mutex.

sync.Once and sync.OnceValue: do it exactly once

sync.Once runs a function exactly once, however many goroutines call it and however they overlap. That’s the safe way to do lazy set-up in concurrent code. Go 1.21 added sync.OnceValue, which wraps a function that returns a value and caches the result:

package main

import (
	"fmt"
	"sync"
)

var (
	setupOnce sync.Once
	ready     bool
)

func setup() {
	fmt.Println("setting up")
	ready = true
}

var loadConfig = sync.OnceValue(func() map[string]string {
	fmt.Println("loading config")
	return map[string]string{"region": "eu"}
})

func main() {
	var wg sync.WaitGroup
	regions := make([]string, 5)
	for i := range 5 {
		wg.Go(func() {
			setupOnce.Do(setup)
			regions[i] = loadConfig()["region"]
		})
	}
	wg.Wait()
	fmt.Println(ready, regions)
}

It prints:

setting up
loading config
true [eu eu eu eu eu]

Five goroutines called setupOnce.Do(setup), and setting up printed once. If a second goroutine arrives while setup is still running, Do makes it wait until setup returns. That’s why ready is safe to read afterwards, and why setting up always prints before loading config.

loadConfig is a function returned by sync.OnceValue. The first call runs the wrapped function, and every later call returns the same map without running it again. sync.OnceValues does the same for a function that returns a value and an error.

Hand-written “if it’s nil, create it” code has the seats example’s check-then-act bug. sync.Once is the fix.

sync.WaitGroup, briefly

sync.WaitGroup waits for a set of goroutines to finish, and every program in this post leans on it. The part on goroutines covered it in full. In short: wg.Go(f) starts f in a new goroutine and counts it, and wg.Wait() blocks until every one of them has returned. Before Go 1.25 you wrote wg.Add(1), go func() { defer wg.Done(); ... }() by hand, and you’ll still see that form in most existing code. A WaitGroup only waits. It doesn’t protect any data, so it doesn’t replace a mutex.

The race detector: -race

The race detector is built into the Go toolchain, and you switch it on with one flag. It works with run, test and build:

go run -race .
go test -race ./...
go build -race -o app .

-race compiles your program with extra instrumentation that records every memory access and every lock, channel and WaitGroup operation. When two goroutines access the same memory with no synchronization ordering them, and one writes, it prints a WARNING: DATA RACE report with both stack traces. At the end it prints Found N data race(s) and exits with status 66.

It finds data races in code that actually runs during that execution. As the first example showed, the two accesses don’t have to collide at the same instant. They only need to be unordered.

What it can’t find:

  • Races in code that didn’t run. If the racy path runs only when a request has a certain header, and your test never sends that header, -race reports nothing. A clean -race run means “no races in what ran”, not “no races”.
  • Logic races. The double-booked seat used only atomic calls, so there’s no data race to report. The bug lives in your logic.
  • Deadlocks. That’s a different failure, and the runtime reports some of them on its own.

It has a real cost. A program under -race uses several times more memory and runs several times slower, so you don’t ship production binaries with it. Run go test -race ./... on every change, locally and in CI. The detector never reports a false positive: if it says there’s a race, there is one.

Copying a mutex is a bug, and go vet catches it

A mutex must not be copied after first use. A copy is a separate lock, so locking it protects nothing. The easy way to copy one by accident is a value receiver:

type Counter struct {
	mu sync.Mutex
	n  int
}

func (c Counter) Inc() {
	c.mu.Lock()
	defer c.mu.Unlock()
	c.n++
}

Inc gets a copy of the whole Counter, mutex included. It locks the copy, increments the copy’s n, and throws both away. Calling c.Inc() on a fresh counter and printing c.n prints 0. It compiles without complaint, but go vet refuses it. In a module named example.com/counter, go vet . prints:

main.go:13:9: Inc passes lock by value: example.com/counter.Counter contains sync.Mutex

and exits with status 1. This check is called copylocks. It also flags passing a struct that contains a mutex by value to a function, assigning one to a new variable, and ranging over a slice of them by value. The fix here is the pointer receiver, func (c *Counter) Inc().

Don’t count on go test to catch this. It runs only a small subset of vet checks, and when we added a test file to this package on Go 1.26, go test passed. Run go vet ./... yourself, and in CI.

Mutex or channel?

Go’s proverb is “don’t communicate by sharing memory; share memory by communicating”, and channels, the subject of the part on channels and select, are the tool for that. It’s a guideline, not a ban on mutexes. The standard library uses both heavily.

A reasonable way to choose:

  • Use a channel when you’re passing ownership of data from one goroutine to another, or coordinating steps: a worker pool, a pipeline, a result handed back, a signal to stop.
  • Use a mutex when several goroutines share a piece of state that stays in one place: a cache, a counter, a map of sessions. A struct with a mutex and a few methods is usually shorter and clearer than a goroutine that owns the map and serves requests over channels.
  • Use an atomic for a single number or flag that many goroutines update.

If the code fights you, try the other one.

sync.Map, and why you usually don’t need it

sync.Map is a map that’s safe for concurrent use without your own lock. It sounds like the obvious choice, but it’s specialised. It’s untyped (keys and values are any), it has no len, and it’s tuned for two cases: keys that are written once and then only read, such as a cache that only grows, and many goroutines each working with their own disjoint set of keys. For everything else, including most maps in a REST API, a plain map with a sync.Mutex or sync.RWMutex beside it is typed, easier to reason about and often faster. Reach for sync.Map only when a profile shows lock contention on exactly that pattern.

What to remember

  • A data race is two goroutines using the same variable at once with at least one write and no synchronization. count++ is read, add, write, and overlapping writes get lost.
  • sync.Mutex lets one goroutine at a time into a critical section. Lock, defer the unlock, keep the section small, and keep the mutex next to the fields it guards.
  • sync.RWMutex allows many readers or one writer. Use it for read-heavy data, not by default.
  • atomic.Int64 and friends make a single operation safe. Check-then-act needs CompareAndSwap or a mutex, and the race detector won’t catch the logic bug.
  • sync.Once and sync.OnceValue run initialisation exactly once, however many goroutines ask.
  • Run go test -race ./... all the time. It finds races only in code that runs, and a report is never a false alarm.
  • Never copy a mutex. Use pointer receivers, and let go vet enforce it.

If two goroutines can touch the same variable and one of them writes, something has to decide who goes first.

How useful was this post?

Click on a heart to rate it!

Average rating 0 / 5. Vote count: 0

No votes so far! Be the first to rate this post.