In a lock-free data structure written without garbage collection, why is it unsafe for the thread that unlinks a node to free that node immediately, and what problem must any safe-memory-reclamation scheme solve?
answer
- unlink != unreachable by existing readers
- lock-free readers announce nothing
- retire now, free later
- hazard pointers: per-pointer publish, bounded garbage
- epochs/RCU: cheap reads, unbounded garbage if a reader stalls
basics
~20 sOther threads may still hold pointers they read before the unlink, since lock-free readers never announce themselves by taking a lock. Freeing at once causes use-after-free and enables address reuse, which brings back ABA. Reclamation schemes must determine when no thread can still reference a removed node, then free it.
solid answer
~60 sUnlinking makes a node unreachable **from now on**. It says nothing about threads that loaded a pointer to it a moment earlier — and in a lock-free structure readers hold no lock, so the remover cannot see them. Freeing immediately gives concurrent readers a dangling pointer: they may read torn or reallocated bytes, follow a wild `next`, or crash. It also lets the allocator hand the same address back, recreating the exact value recurrence ABA needs. So every scheme answers one question: **when is it provably impossible for any thread to still reach this node?** They differ in how readers publish their interest: - **Hazard pointers** — a reader publishes each pointer it is about to use in a per-thread slot; a reclaimer scans all slots and frees only unhazarded nodes. Per-read cost, bounded garbage. - **Epoch-based reclamation** — readers announce entry into a global epoch; a node retired in epoch e is freed once every thread has been seen past e. Near-zero read cost, unbounded garbage if a reader stalls. - **Read-copy-update** — same shape: publish new versions, defer freeing old ones until a grace period covering all pre-existing readers has elapsed.
code
text · 14 linesprotect(slot, &location):
loop:
p = location.load()
hazard[me][slot] = p # publish intent
fence()
if location.load() == p: # verify it did not get retired meanwhile
return p
# else retry: p may already be retired
retire(node):
deferred.add(node)
if deferred.size > threshold:
live = union of all hazard[*][*]
free every node in deferred not in livego deeper
Know the one-liner: another thread may still hold a pointer it read earlier, so freeing right away is a use-after-free.
Explain the retire-then-free split and that readers must publish something for the reclaimer to check.
Contrast hazard pointers and epoch schemes on read cost versus garbage bound, and name the stalled-reader failure mode explicitly.
Weigh hand-rolled lock-free plus a reclamation scheme against a lock, per-thread ownership, or a vetted library, and state what testing and memory-accounting you would require before shipping it.
## The core difficulty In a lock-based structure, deletion is easy: a remover holds the lock, so no reader can be inside the structure, and freeing under the lock is safe. Lock-free structures buy their progress guarantee by removing exactly that rendezvous. A reader traverses by loading pointers with plain atomic loads and announces nothing. So when a writer unlinks node N with a compare-and-swap, the only fact established is *no future traversal starting now will reach N*. Threads that loaded a pointer to N microseconds earlier are still holding it and may dereference it at any later moment — after a preemption, a page fault, or a scheduler quantum boundary that can be milliseconds long. Freeing N immediately produces two distinct defects: 1. **Use-after-free.** A concurrent reader dereferences memory returned to the allocator. It may read a reused object's bytes as if they were the node's fields, follow a garbage `next` pointer into unmapped memory, or write into an object now owned by someone else. In manual-memory languages this is undefined behaviour, and the observable symptom (a crash, a corrupted unrelated structure) is usually far from the cause. 2. **ABA re-entry.** A freed address is prime material for reuse. When the allocator hands the same address back and it is pushed into the structure again, an old pointer value recurs — the precondition for a stale compare-and-swap succeeding. The two are related but separable: version tags fix (2) and not (1); reclamation schemes fix (1) and, by suppressing premature reuse, largely defuse (2) as well. ## The shared shape of every solution Every scheme splits deletion into **retire** and **free**. `retire(N)` removes N from the structure and puts it on a deferred list. `free(N)` happens only once the scheme can prove no thread holds a reference. The proof always comes from readers publishing *something* cheap: **Hazard pointers.** Each thread owns a small set of single-writer, multi-reader slots. Before dereferencing a pointer it writes that pointer into a slot, then re-validates that the pointer is still reachable in the structure (the publish-then-verify step is essential — without it the node could be retired between the load and the publication). A reclaimer collects retired nodes and scans all threads' slots; anything not listed is unreachable and can be freed. Properties: **bounded** garbage (at most a fixed number of protected nodes per thread plus the retire batch), but a store plus a memory fence plus a re-check on every pointer traversal, which is real cost in read-heavy traversals. **Epoch-based reclamation.** A global epoch counter advances periodically. Each thread, on entering a critical region, publishes the current epoch in a per-thread slot and clears it on exit. A node retired during epoch `e` is safe to free once every thread has been observed either inactive or in an epoch later than `e` — that guarantees every reader that could have seen the node has finished. Properties: reads are almost free (one store, no per-pointer bookkeeping), but garbage is **unbounded**: a single thread that stalls inside a critical region — preempted, blocked on I/O, or hit by a page fault — pins the epoch and the deferred list grows without limit. This is a memory-exhaustion failure mode, not a correctness one. **Read-copy-update.** The same deferred-free discipline exposed as a read side that is essentially free and a writer side that publishes a new version and waits for a *grace period* — an interval after which every reader that existed at retire time has finished its read-side critical section. It shines where reads vastly outnumber writes and where the runtime can detect quiescence cheaply (in kernels, by observing context switches). **Reference counting** is the obvious alternative and mostly a trap here: incrementing a count requires already holding a safe reference, which is the very thing in question, so naive per-node reference counting has a race at the acquire step and needs split counters or deferred techniques anyway — and it makes every read a contended atomic write on shared cache lines. ## Practical framing The honest senior answer is that safe reclamation, not the algorithm itself, is the hard and expensive part of hand-written lock-free code. Most production systems avoid the whole category: use structures the runtime or a vetted library already implements, keep removals rare, use per-thread ownership so nothing is shared, or accept a lock. Choosing to hand-roll a lock-free structure with manual memory means signing up for a reclamation scheme, its failure mode, and the test infrastructure to trust it.
- Compare hazard pointers and epoch-based reclamation on cost and failure mode.Hazard pointers pay on every protected pointer read — a store plus a fence plus a re-validation — but bound the amount of unreclaimed memory to roughly the number of threads times slots plus the retire batch. Epoch-based reclamation makes reads nearly free by publishing only at region entry, but a thread stalled inside a critical region blocks the epoch from advancing, so deferred garbage grows without bound. In short: hazard pointers cost throughput, epochs risk memory.
- Why does per-node atomic reference counting not straightforwardly solve this?To increment a node's reference count you must first dereference the node, but that dereference is only safe if you already hold a reference — a circularity that leaves a window where the node is freed between your load of the pointer and your increment. Solving it needs split reference counts, deferred increments, or an underlying protection scheme, so you end up back at hazard pointers or epochs. Reference counting also turns every read into a contended write on a shared cache line, which destroys read scalability.
Demolishing a building the moment it is removed from the city map. The map no longer lists it, but people already inside are still walking its corridors. You need a way to know the last visitor has left.
saying these in an interview costs you the question
- Assuming that after a successful unlink no other thread can hold the node
- Believing a version tag alone makes immediate freeing safe
- Proposing naive per-node atomic reference counting without addressing the acquire race
- Calling epoch-based reclamation strictly better without naming the stalled-reader memory blowup
- Treating 'nobody will still be reading it by then' as an argument instead of a proof