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.
| Java | Go |
|---|---|
synchronized / ReentrantLock | sync.Mutex (not reentrant!) |
ReentrantReadWriteLock | sync.RWMutex |
CountDownLatch | sync.WaitGroup |
| Lazy holder idiom / double-checked locking | sync.Once, sync.OnceValue |
AtomicLong, AtomicBoolean, AtomicReference | atomic.Int64, atomic.Bool, atomic.Pointer[T] |
ConcurrentHashMap | map + sync.RWMutex, or sync.Map |
volatile | Use atomics or a mutex; there is no volatile |
sync.Mutex: exclusive access
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();
}
}
}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.
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
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
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
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
}
}// 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
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
// 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
})