Skip to content

Synchronization: sync and atomic

Channels are Go's preferred way to coordinate goroutines, but sometimes you just need to protect a piece of shared state. The sync and sync/atomic packages provide the classic tools: mutexes, one-time initialisation, and lock-free counters.

The problem: data races

When two goroutines touch the same variable and at least one writes, without synchronisation, the result is a data race — undefined behaviour. Imagine a page-hit counter bumped by 1000 concurrent requests — this looks like it reaches 1000, but doesn't reliably:

hits := 0
var wg sync.WaitGroup
for i := 0; i < 1000; i++ {
    wg.Add(1)
    go func() { defer wg.Done(); hits++ }()   // RACE: concurrent writes
}
wg.Wait()
fmt.Println(hits)   // unpredictable: often < 1000

hits++ is read-modify-write — three steps that can interleave and lose updates, so some requests get "counted" on top of each other.

sync.Mutex: mutual exclusion

A Mutex lets only one goroutine into the guarded section at a time. Lock before touching the shared state, Unlock after (usually via defer).

var mu sync.Mutex
hits := 0
var wg sync.WaitGroup

for i := 0; i < 1000; i++ {
    wg.Add(1)
    go func() {
        defer wg.Done()
        mu.Lock()
        hits++          // one request counted at a time
        mu.Unlock()
    }()
}
wg.Wait()
fmt.Println(hits)   // output: 1000

Now the increments are serialised, so every request is counted — always 1000.

sync.RWMutex: many readers or one writer

When reads vastly outnumber writes, an RWMutex lets any number of readers proceed in parallel (RLock/RUnlock) while writes (Lock/Unlock) get exclusive access.

var mu sync.RWMutex
balance := 0
var wg sync.WaitGroup

// one writer
wg.Add(1)
go func() {
    defer wg.Done()
    for i := 0; i < 100; i++ {
        mu.Lock()           // exclusive
        balance++
        mu.Unlock()
    }
}()

// three concurrent readers
for r := 0; r < 3; r++ {
    wg.Add(1)
    go func() {
        defer wg.Done()
        for i := 0; i < 100; i++ {
            mu.RLock()      // shared — readers don't block each other
            _ = balance
            mu.RUnlock()
        }
    }()
}

wg.Wait()
fmt.Println(balance)        // output: 100

The readers run in parallel with each other; only the writer's Lock forces everyone else to wait. Run it with go run -race and it's clean.

sync.Once: run exactly once

Once.Do(f) runs f a single time, no matter how many goroutines call it or how often — the standard way to do lazy, thread-safe initialisation.

var once sync.Once
setup := func() { fmt.Println("init") }

for i := 0; i < 3; i++ {
    once.Do(setup)
}
// output:
// init

sync/atomic: lock-free counters

For a single integer, an atomic type is simpler and faster than a mutex. The typed atomics (atomic.Int64, atomic.Bool, …) carry their own synchronisation:

var hits atomic.Int64
var wg sync.WaitGroup

for i := 0; i < 1000; i++ {
    wg.Add(1)
    go func() { defer wg.Done(); hits.Add(1) }()
}
wg.Wait()
fmt.Println(hits.Load())   // output: 1000

Reach for atomics for simple counters and flags; reach for a mutex when you must keep several values consistent together.

The race detector

Go ships a race detector that instruments memory access and reports races at runtime. Run your program or tests with -race:

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

Running the racy count++ loop from the top of this article with -race reports it:

$ go run -race .
==================
WARNING: DATA RACE
Read at 0x00c0000a0068 by goroutine 8:
  main.main.func1()
Previous write at 0x00c0000a0068 by goroutine 9:
  main.main.func1()
==================

It only catches races that actually occur during the run, so use it with tests that exercise concurrency. It's one of the most valuable tools in Go — make a habit of running tests with -race in CI.

From Python: there's no GIL serialising bytecode, so Go code really does race. The flip side: real parallelism, plus a first-class detector to catch the mistakes the GIL would have hidden.

Quick reference

Tool Use
sync.Mutex (Lock/Unlock) exclusive access to shared state
sync.RWMutex (RLock/RUnlock) many readers or one writer
sync.Once (Do) run an init exactly once
sync.WaitGroup wait for goroutines to finish
atomic.Int64 etc. (Add/Load/Store) lock-free counters & flags
go run -race / go test -race detect data races

Sources