Race Conditions & sync/atomic
Prerequisites: Concurrency patterns
A data race happens when two goroutines access the same memory at the same time and at least one of them writes. The result is undefined: lost updates, torn reads, or corrupted maps. Go won't stop you from writing one, but it ships a tool that finds them.
A racy counter
package main
import (
"fmt"
"sync"
)
func main() {
var count int
var wg sync.WaitGroup
for i := 0; i < 1000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
count++ // read, add, write — not atomic
}()
}
wg.Wait()
fmt.Println(count)
}
This may print 1000, or 987, or something else — and it may print 1000
on your laptop and fail in production. "It worked when I ran it" proves
nothing about races.
The race detector
Add -race to go run, go build, or go test:
go run -race main.go
go test -race ./...
When it catches a race it prints both stack traces (abbreviated here):
==================
WARNING: DATA RACE
Read at 0x00c000012118 by goroutine 8:
main.main.func1()
/tmp/race/main.go:15 +0x78
Previous write at 0x00c000012118 by goroutine 7:
main.main.func1()
/tmp/race/main.go:15 +0x8a
==================
The detector only reports races that actually happen during that run, so it
is most useful in tests with real concurrency. Run go test -race in CI. It
slows programs down several times over, so don't ship race-enabled binaries.
On some platforms it needs cgo and a C toolchain.
Fix 1: a mutex
Covered in Level 2 — wrap every access to count in mu.Lock() /
mu.Unlock(). This is the right tool when several fields must change
together.
Fix 2: sync/atomic
For a single number, the typed atomics in sync/atomic are simpler and
faster:
package main
import (
"fmt"
"sync"
"sync/atomic"
)
func main() {
var count atomic.Int64
var wg sync.WaitGroup
for i := 0; i < 1000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
count.Add(1)
}()
}
wg.Wait()
fmt.Println(count.Load())
}
Expected output:
1000
atomic.Int64, atomic.Int32, atomic.Uint64, atomic.Bool, and
atomic.Pointer[T] all offer Load, Store, Swap, and
CompareAndSwap; the integer types also have Add. Their zero value is
ready to use.
sync.Once
sync.Once runs a function exactly once, even when many goroutines race to
call it — the standard way to do lazy, thread-safe initialization:
package main
import (
"fmt"
"sync"
)
var (
configOnce sync.Once
config map[string]string
)
func Config() map[string]string {
configOnce.Do(func() {
fmt.Println("loading config")
config = map[string]string{"env": "prod"}
})
return config
}
func main() {
var wg sync.WaitGroup
for i := 0; i < 5; i++ {
wg.Add(1)
go func() {
defer wg.Done()
_ = Config()
}()
}
wg.Wait()
fmt.Println(Config()["env"])
}
Expected output:
loading config
prod
Go 1.21 added sync.OnceValue and sync.OnceValues, which wrap a function
returning a value (or value and error) and cache the result:
var loadConfig = sync.OnceValue(func() map[string]string { ... }).
Common mistakes
- Mixing atomic and plain access to the same variable. If one goroutine uses
count.Addeverywhere, every read must useLoad. - Assuming a map is safe for concurrent reads and writes. It isn't; Go's
runtime may even crash with
concurrent map writes. - Copying a struct that contains a
sync.Mutexor an atomic type (for example, a value receiver).go vetreports this ascopylocks. - Using atomics to coordinate several related fields. Two individually atomic updates are not atomic together — use a mutex.
Practice
This cache is racy when used from several goroutines. Make it safe, allowing concurrent readers.
type Cache struct {
data map[string]string
}
func (c *Cache) Get(k string) (string, bool) { v, ok := c.data[k]; return v, ok }
func (c *Cache) Set(k, v string) { c.data[k] = v }
Show solution
package main
import (
"fmt"
"sync"
)
type Cache struct {
mu sync.RWMutex
data map[string]string
}
func NewCache() *Cache {
return &Cache{data: make(map[string]string)}
}
func (c *Cache) Get(k string) (string, bool) {
c.mu.RLock()
defer c.mu.RUnlock()
v, ok := c.data[k]
return v, ok
}
func (c *Cache) Set(k, v string) {
c.mu.Lock()
defer c.mu.Unlock()
c.data[k] = v
}
func main() {
c := NewCache()
var wg sync.WaitGroup
for i := 0; i < 100; i++ {
wg.Add(1)
go func() {
defer wg.Done()
c.Set("lang", "go")
c.Get("lang")
}()
}
wg.Wait()
fmt.Println(c.Get("lang"))
}
Expected output:
go true
sync.RWMutex lets many readers hold RLock at once while writers get
exclusive access. Running this with -race reports nothing.