Skip to content
AgileLogics
7 min read

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.Add everywhere, every read must use Load.
  • 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.Mutex or an atomic type (for example, a value receiver). go vet reports this as copylocks.
  • 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.