skip to content

What do sync.Map's LoadOrStore and LoadAndDelete guarantee that a Load followed by a Store cannot?

level: middleimportance: should knowfreq 38%

answer

  1. check-then-act has a window in between
  2. one call, one indivisible decision
  3. the bool says whether it was already there
  4. the delete hands the value to one caller
  5. the stored value is built before the call

basics

~20 s

Each is one atomic step on a single key, where a separate Load then Store can interleave. LoadOrStore returns the existing value or installs yours, reporting which; LoadAndDelete removes the key and hands its value to exactly one caller.

solid answer

~50 s

`LoadOrStore(key, value) (actual, loaded)` either finds the key — returning the existing value with `loaded == true` — or installs your value and returns it with `loaded == false`, and that decision happens as one indivisible operation. A hand-rolled `Load`, `if !ok { Store }` has a window in between: two goroutines registering the same connection id can both see "absent" and both store, so one silently overwrites the other and two objects now believe they own the entry. `LoadAndDelete(key) (value, loaded)` is the mirror image for teardown: exactly one caller gets `loaded == true`, which makes it the natural token for "I am the one who closes this". Plain `Delete` reports nothing, so it cannot give you that. The one trap is that `LoadOrStore`'s value argument is an ordinary argument, evaluated before the call, so the losing goroutine has already built an object it must now discard.

code

go · 8 lines
go
meta := &connMeta{user: user, since: time.Now()}

actual, loaded := conns.LoadOrStore(id, meta)
if loaded {
	// another goroutine registered this id first
	meta = actual.(*connMeta)
}
// meta is now the one entry every goroutine agrees on

go deeper

for a junior

Know the signatures and what the second return value means: LoadOrStore reports whether the key was already there, LoadAndDelete whether it actually removed something.

for a middle

Explain the interleaving that a separate Load-then-Store allows, and show the register-once pattern where the loser adopts the winner's entry. Mention that the stored value is built before the call.

for a senior

Demonstrate using the returned boolean as an ownership token so a resource is released exactly once, and be clear that the atomicity stops at one key — cross-key invariants need your own lock.

for a principal

Own the API judgment: if a package's correctness depends on callers remembering to discard a losing value or honour a teardown token, that contract belongs inside a typed wrapper, not in every call site.

## The race a compound operation removes The interesting operations on a shared map are rarely single reads or writes; they are *compound*: "insert this if it is not already there", "take the value out and remove it". Doing those as two calls on a concurrent map is not safe just because each call is safe on its own. Between your `Load` and your `Store`, any number of other goroutines can run. Concretely, in a registry of live client connections keyed by connection id: ```go if _, ok := conns.Load(id); !ok { conns.Store(id, newMeta) // another goroutine may have stored here already } ``` Two goroutines can both observe `ok == false` and both proceed to `Store`. There is no crash and no error — the map ends up holding whichever wrote last, while the other goroutine walks away believing its own object is the registered one. The bug shows up much later as metadata that does not match the connection, or as a resource that is never released because the object holding it was orphaned. ## LoadOrStore ```go actual, loaded := conns.LoadOrStore(id, meta) ``` The map performs the check and the insert as one indivisible step: - If the key was **present**, nothing is stored; `actual` is the value that was already there and `loaded` is `true`. - If the key was **absent**, your `meta` is stored; `actual` is your `meta` and `loaded` is `false`. Every concurrent caller therefore agrees on one winner, and every loser is handed the winner's value. The pattern is: ```go meta := &connMeta{user: user, since: time.Now()} actual, loaded := conns.LoadOrStore(id, meta) if loaded { meta = actual.(*connMeta) // someone registered first; use theirs } ``` Read `loaded` as "was it already there", not "did my store succeed" — inverting that is the single most common mistake with this method. ### The eager-argument trap `LoadOrStore` takes a *value*, not a function. Go evaluates arguments before the call, so `conns.LoadOrStore(id, expensiveThing())` builds the expensive thing every single time, even when the key already exists and the value is thrown away immediately. If constructing that value is merely a small struct, the waste is a garbage-collected allocation and nobody cares. If it opens a file, dials a connection or starts a goroutine, the loser has just leaked a real resource, and you must explicitly release the object you built when `loaded` comes back `true`. When the construction genuinely must happen only once, store a cheap placeholder that performs the expensive work behind its own one-time initialisation, rather than trying to make `LoadOrStore` lazy. ## LoadAndDelete ```go v, loaded := conns.LoadAndDelete(id) if loaded { // exactly this caller owns the teardown of v } ``` Removal and retrieval happen together. If a client disconnects and, at the same moment, a timeout sweeper decides the same connection is idle, both may call `LoadAndDelete` on that id — and exactly one of them gets `loaded == true`. That boolean is a hand-off token: whoever holds it is responsible for closing the connection, flushing its buffered state, decrementing a gauge, or whatever teardown must happen once and only once. Written with `Load` followed by `Delete`, both goroutines could see the value and both run the teardown; written with plain `Delete`, neither knows whether it was the one that removed anything. ## The conditional variants `Swap(key, value) (previous, loaded)` unconditionally replaces and gives you what was there. `CompareAndSwap(key, old, new) bool` replaces only if the current value equals `old`, and `CompareAndDelete(key, old) bool` deletes only under the same condition — the compare-and-set idiom, applied per key, for when you must not clobber a value that changed since you read it. Both compare with `==`, so the old value must be of a comparable type. ## Where the atomicity stops These guarantees are strictly **per key**. Nothing in `sync.Map` makes a pair of keys move together, so "remove connection A and add connection B as one unit" is not something the type can express; if you need an invariant across keys, you need your own lock around both operations, and at that point you are back to a plain map with a lock — which is often the honest answer.

  • Your LoadOrStore value is expensive to build — what does that cost you?
    The value is an ordinary argument, so it is constructed on every call, including the ones that lose. A wasted struct is harmless, but a dialed connection or a started goroutine is a leak: when `loaded` is true you must release the object you built. For genuinely one-time work, store a cheap holder that does the work behind its own one-time initialisation.
  • How do you make sure exactly one goroutine tears down an entry in a sync.Map?
    Use `LoadAndDelete` and treat its `loaded` boolean as the ownership token — only one concurrent caller can see it true, and that caller runs the cleanup. Plain `Delete` returns nothing, so with it you cannot tell whether you removed a live entry or a key that had already gone.
  • When would you use sync.Map's CompareAndSwap instead of Store?
    When you must not clobber a value that changed since you read it: `CompareAndSwap(key, old, new)` writes only if the current value still equals `old`, and reports whether it did. It compares with `==`, so the old value must be of a comparable type — pointers work well, arbitrary structs with slices in them do not.
  • Do these methods let you keep an invariant across two keys?
    No. Every guarantee is per key: two separate calls can interleave with anything. If "remove A and insert B" must be seen as one change, you need your own lock around both operations — and once you hold a lock anyway, a plain typed map is usually the simpler container.

saying these in an interview costs you the question

  • Reads loaded as did my value get stored
  • Treats Load then Store as effectively atomic
  • Assumes the LoadOrStore value is built lazily
  • Uses Delete when the caller needs to know who removed it
  • Believes two keys can be updated atomically together