Skip to content
JavaGo
Part IV · Concurrency

Sync Primitives

Mutex, RWMutex, Once, atomics and sync.Map: guarding shared state when a channel is the wrong tool, and why Go locks are not reentrant.

Section 16 of 31 6 min read

Channels move data between goroutines. When several goroutines need to read and update the same data in place, a map of sessions, a cache, a counter, the sync and sync/atomic packages are the right tools.

JavaGo
synchronized / ReentrantLocksync.Mutex (not reentrant!)
ReentrantReadWriteLocksync.RWMutex
CountDownLatchsync.WaitGroup
Lazy holder idiom / double-checked lockingsync.Once, sync.OnceValue
AtomicLong, AtomicBoolean, AtomicReferenceatomic.Int64, atomic.Bool, atomic.Pointer[T]
ConcurrentHashMapmap + sync.RWMutex, or sync.Map
volatileUse atomics or a mutex; there is no volatile

sync.Mutex: exclusive access

Java — ReentrantLock
class Registry {
    private final Map<String, Worker> workers = new HashMap<>();
    private final ReentrantLock lock = new ReentrantLock();

    void register(String id, Worker w) {
        lock.lock();
        try {
            workers.put(id, w);
        } finally {
            lock.unlock();
        }
    }
}
Go — sync.Mutex
type Registry struct {
    mu      sync.Mutex // guards workers
    workers map[string]*Worker
}

func (r *Registry) Register(id string, w *Worker) {
    r.mu.Lock()
    defer r.mu.Unlock() // released on every return path

    r.workers[id] = w
}

Go mutexes are not reentrant

In Java a thread that holds a monitor can enter another synchronized method on the same object. In Go, calling Lock on a mutex the same goroutine already holds deadlocks forever. There is no owner tracking.

The idiomatic fix is structural: exported methods take the lock, and they call unexported helpers that assume the lock is already held, often named with a Locked suffix, such as removeLocked.

locked helpers instead of reentrancy
func (r *Registry) Replace(id string, w *Worker) {
    r.mu.Lock()
    defer r.mu.Unlock()
    r.removeLocked(id) // must NOT call r.Remove(): it would re-lock
    r.workers[id] = w
}

func (r *Registry) Remove(id string) {
    r.mu.Lock()
    defer r.mu.Unlock()
    r.removeLocked(id)
}

// removeLocked requires r.mu to be held.
func (r *Registry) removeLocked(id string) {
    if w, ok := r.workers[id]; ok {
        w.Stop()
        delete(r.workers, id)
    }
}

sync.RWMutex: many readers or one writer

RWMutex for read-heavy state
type Router struct {
    mu     sync.RWMutex
    routes map[string]Handler
}

// Writers take the exclusive lock
func (r *Router) Register(topic string, h Handler) {
    r.mu.Lock()
    defer r.mu.Unlock()
    r.routes[topic] = h
}

// Readers share the read lock
func (r *Router) Lookup(topic string) (Handler, bool) {
    r.mu.RLock()
    defer r.mu.RUnlock()
    h, ok := r.routes[topic]
    return h, ok
}

sync.RWMutex: many readers or one writer

sync.Mutex: one holder at a time

Fig. 16.1Time runs left to right. An RWMutex lets readers hold the lock together, but once a writer is waiting, new readers queue behind it, so taking RLock again while holding it can deadlock. A plain Mutex makes every holder take turns.

sync.Once and friends: lazy initialisation

Java — holder class idiom
class Templates {
    private static class Holder {
        static final Map<String, Template> ALL = loadAll();
    }

    static Map<String, Template> get() {
        return Holder.ALL; // loaded on first access, once
    }
}
Go — sync.OnceValue (Go 1.21+)
// Runs loadAll exactly once, on first call, from any goroutine.
// Concurrent callers wait for the first call to finish.
var templates = sync.OnceValue(func() map[string]*Template {
    return loadAll()
})

func render(name string) {
    t := templates()[name]
    ...
}

// sync.OnceValues returns (T, error); sync.OnceFunc for no result.
// The older form: var once sync.Once; once.Do(func() { ... })

sync/atomic: lock-free counters and pointers

typed atomics (Go 1.19+)
type Metrics struct {
    processed atomic.Int64
    failed    atomic.Int64
}

func (m *Metrics) RecordSuccess() { m.processed.Add(1) }
func (m *Metrics) RecordFailure() { m.failed.Add(1) }

// Copy-on-write configuration: readers never block
type Server struct {
    cfg atomic.Pointer[Config]
}

func (s *Server) Config() *Config { return s.cfg.Load() }

func (s *Server) Reload(next *Config) {
    s.cfg.Store(next) // readers see the old or new config, never half of one
}

sync.Map

a concurrent map for two specific patterns
// sync.Map is optimised for:
//   1. keys written once and read many times (caches that only grow)
//   2. goroutines working on disjoint sets of keys
// For everything else, a map + Mutex is clearer and usually as fast.

var sessions sync.Map // zero value ready; keys and values are any

sessions.Store("u-1", &Session{UserID: "1"})

if v, ok := sessions.Load("u-1"); ok {
    s := v.(*Session) // values come back as any
    fmt.Println(s.UserID)
}

actual, loaded := sessions.LoadOrStore("u-2", &Session{UserID: "2"})
// loaded == true: a value already existed and actual is that value

sessions.Range(func(key, value any) bool {
    return true // false stops the iteration
})
↑ ↓ to navigateEnter to openEsc to close