Irbisa · Cheatsheet

sync, Atomics & the Memory Model

Mutexes, sync.Once, atomics, happens-before, the race detector

Middle Developer · 22 questions

  1. 01

    Which synchronisation primitives does the sync package give you, and what is each one for?

    sync has locks (Mutex, RWMutex), coordination tools (WaitGroup, Once, Cond) and two special containers (Map, Pool); sync/atomic adds lock-free operations on single values.

  2. 02

    What is wrong with this WaitGroup code, and how does wg.Go from Go 1.25 rule the bug out?

    func fetchAll(ids []int) []Row {    var wg sync.WaitGroup    rows := make([]Row, len(ids))    for i, id := range ids {        go func() {            wg.Add(1)            defer wg.Done()            rows[i] = load(id)        }()    }    wg.Wait()    return rows}

    wg.Add(1) runs inside the goroutine, so it can run after wg.Wait() has already seen a zero counter. fetchAll then returns rows while the goroutines are still writing it: missing results, and a data race that -race reports.

  3. 03

    Why is defer mu.Unlock() the default right after mu.Lock(), and when should you unlock explicitly instead?

    defer mu.Unlock() covers every exit: the early return, the one someone adds next month, a panic in the middle.

    // wrong: the cache-hit path returns with the mutex still heldfunc (c *Cache) Get(k string) ([]byte, error) {    c.mu.Lock()    if v, ok := c.m[k]; ok {        return v, nil      // never unlocked: every later Get blocks forever    }…
  4. 04

    What is the difference between a deadlock, a livelock and starvation?

    In a deadlock goroutines wait for each other and nothing moves; in a livelock they keep running but keep undoing each other's progress; in starvation the system moves, but one goroutine rarely or never gets the resource.

  5. 05

    Incr calls Get while holding the same mutex. What happens at runtime, and why does Go have no reentrant mutex?

    type Cache struct {    mu sync.Mutex    m  map[string]int}func (c *Cache) Get(k string) int {    c.mu.Lock()    defer c.mu.Unlock()    return c.m[k]} func (c *Cache) Incr(k string) int {    c.mu.Lock()    defer c.mu.Unlock()    c.m[k]++    return c.Get(k)}

    The goroutine deadlocks on itself: sync.Mutex is not reentrant, so the Lock inside Get waits for an Unlock that can only run after it. If nothing else runs, the runtime stops with fatal error: all goroutines are asleep - deadlock!; in a server every caller just hangs.

  6. 06

    Find the bug: after one client stops reading, the whole chat hub freezes; new Subscribe calls hang, and so does every other Publish.

    type Hub struct {    mu   sync.Mutex    subs map[int]chan string} func (h *Hub) Subscribe(id int) <-chan string {    h.mu.Lock()    defer h.mu.Unlock()    ch := make(chan string)    h.subs[id] = ch    return ch} func (h *Hub) Publish(msg string) {    h.mu.Lock()    defer h.mu.Unlock()    for _, ch := range h.subs {        ch <- msg    }}

    Publish holds mu while it does a blocking send on an unbuffered channel, so one subscriber that never reads keeps the mutex forever.

  7. 07

    How do you build a thread-safe lazy singleton in Go, and what does sync.Once guarantee that an if instance == nil check does not?

    Use sync.Once, or since Go 1.21 sync.OnceValue: the init function runs exactly once for all callers, and every caller returns only after it has finished. A plain nil check gives neither guarantee.

    // wrong: two goroutines can both see nil, and the read is a data racevar client *http.Client func Client() *http.Client {    if client == nil {        client = newClient()…
  8. 08

    What is the difference between a data race and a race condition, and why can -race stay green while a mutex-guarded balance goes negative?

    A data race is two goroutines touching the same memory at the same time, at least one writing, with no synchronisation; a race condition is a logic bug where the result depends on timing.

    // wrong: -race is clean, every access is locked, and it is still brokenfunc (a *Account) Withdraw(n int64) error {    if a.Balance() < n {       // RLock ... RUnlock        return ErrInsufficient    }    a.add(-n)                  // Lock ... Unlock: a *different* critical section…
  9. 09

    Why is an unsynchronised bool stop flag a bug, even if "at worst a goroutine reads a stale value" and a single byte cannot tear?

    A data race does not just hand you a stale value: the compiler and the CPU may produce results that no sequence of stale reads can explain.

    // wrong: "it is just a bool"; there is no such thing as a benign racevar ready boolvar payload *Report func producer() { payload = build(); ready = true } // two plain stores, nothing orders them…
  10. 10

    What does sync/atomic give you, and when would you use an atomic instead of a Mutex?

    An atomic makes one read, write or read-modify-write of a single value indivisible and visible to other goroutines, without a lock.

    var hits atomic.Int64var ready atomic.Boolvar cfg atomic.Pointer[Config] hits.Add(1)                  // counter: read-modify-write in one stepready.Store(true)            // flag: visible to every later Load…
  11. 11

    This gauge should keep the highest value seen. Every access is atomic and -race is clean, yet the reported maximum is sometimes too low. Why, and what is the fix?

    type Gauge struct{ max atomic.Int64 } func (g *Gauge) Observe(n int64) {    if g.max.Load() < n {        g.max.Store(n)    }}

    Each operation is atomic; the pair is not. A read-modify-write has to be one step, which means CompareAndSwap in a retry loop.

  12. 12

    What is the difference between sync.Mutex and sync.RWMutex, and why can switching to RWMutex make a read-heavy map slower?

    Mutex lets one goroutine in at a time; RWMutex lets many readers in together and gives writers exclusive access.

  13. 13

    How does sync.Mutex work inside, and what are its normal and starvation modes?

    A Mutex is two fields: a state word (locked, woken and starving bits plus a waiter count) and a semaphore.

  14. 14

    What is sync.Map, and when is it better or worse than a plain map behind a Mutex?

    sync.Map is a concurrent map tuned for the two cases its documentation names: keys written once and read many times, like a cache that only grows, and goroutines working on disjoint sets of keys.

    // right use: written once, read forever, e.g. a registry of compiled codecsvar codecs sync.Map // string -> Codec func codecFor(name string) (Codec, error) {    if v, ok := codecs.Load(name); ok {        return v.(Codec), nil  // every read pays a type assertion…
  15. 15

    How does the race detector work, and what can it not find?

    -race instruments every memory access at compile time and, at run time, tracks which accesses are ordered by locks, channels and atomics.

    // The detector sees only what runs, so make the test run the overlap.func TestLimiterConcurrent(t *testing.T) {    l := NewLimiter(100)    var wg sync.WaitGroup    for range 64 {                   // one goroutine would find nothing        wg.Go(func() {…
  16. 16

    How does sync.Cond work, and why do most Go codebases use a channel instead?

    A Cond lets goroutines sleep until locked state changes: Wait atomically unlocks L and parks, Signal wakes one waiter, Broadcast wakes all, and Wait re-locks before it returns.

    // before: Cond cannot be selected on, so no timeout and no shutdown wake-upfunc (q *CondQueue) Pop() Job {    q.mu.Lock()    defer q.mu.Unlock()    for len(q.jobs) == 0 {   // `for`, never `if`: the predicate may be false        q.cond.Wait()        // again once Wait has re-taken the lock…
  17. 17

    Find the bug: a health checker that ranges over Snapshot() sometimes crashes with concurrent map iteration and map write.

    type Registry struct {    mu    sync.RWMutex    peers map[string]Peer} func (r *Registry) Add(p Peer) {    r.mu.Lock()    defer r.mu.Unlock()    r.peers[p.ID] = p} func (r *Registry) Snapshot() map[string]Peer {    r.mu.RLock()    defer r.mu.RUnlock()    return r.peers}

    Snapshot returns the registry's own map, not a copy, so the read lock protects only the return statement. The caller ranges over the map with no lock held while Add writes to it, and the runtime aborts with concurrent map iteration and map write.

  18. 18

    How does sync.Pool reduce allocations, and when does it hurt instead of helping?

    Pool keeps per-processor free lists of objects you return with Put, so the next Get reuses one instead of allocating; that cuts GC pressure from short-lived objects such as encode buffers.

    var bufs = sync.Pool{New: func() any { return new(bytes.Buffer) }} func render(w io.Writer, v any) error {    b := bufs.Get().(*bytes.Buffer)    b.Reset()                   // reset on the way IN: Get may return a used one…
  19. 19

    What does happens-before mean in the Go memory model, and which operations create it?

    Happens-before is the ordering the memory model guarantees: if write w happens before read r, r sees w or a later write.

    // Each pair is free of data races: the marked operation is the edge.var ready = make(chan struct{}) func publishByChannel() { cfg = load(); close(ready) } // close ...func readByChannel()    { <-ready; use(cfg) }          // ... before this receive…
  20. 20

    Why is double-checked locking on a plain pointer broken in Go, and how do you hot-swap a config that every request reads?

    **The outer cfg == nil check is an unsynchronised read of a pointer another goroutine writes, which is a data race that can expose a half-built struct.

    // wrong: textbook double-checked locking, a data race in Govar cfg *Configvar mu sync.Mutex func Get() *Config {    if cfg == nil {              // unsynchronised read…
  21. 21

    Calls to transfer(a, b) and transfer(b, a) hang forever while the rest of the server keeps working. What is the bug, and how do you make it impossible?

    A lock-order inversion: transfer(x, y) locks x then y while transfer(y, x) locks y then x, so each waits for the lock the other holds. The fix is one fixed order that every acquisition path obeys.

    // wrong: two lock orders form a cycle; transfer(x, y) and transfer(y, x) hangfunc transfer(a, b *Account, n int64) {    a.mu.Lock()    defer a.mu.Unlock()    b.mu.Lock()    defer b.mu.Unlock()…
  22. 22

    Per-shard counters in a []struct{ n atomic.Int64 } get slower per operation as cores are added, though no two goroutines touch the same element. Why?

    False sharing: neighbouring counters sit inside one cache line, and CPU caches stay coherent per line, not per variable. A write to one element invalidates the whole line in every other core's cache, its neighbours included.

    // wrong: 8 bytes each, so eight of these share one 64-byte cache linetype packed struct{ n atomic.Int64 } // right: one line each. 128, not 64: internal/cpu.CacheLinePadSize is 128// on arm64, and sync.Pool pads its per-P struct to 128 for the same reason.type padded struct {…