RCU mutation: build-invisible → publish → reclaim
The safe shape for a structural mutation with concurrent RCU readers is three
phases. Get the phase boundaries right and most correctness questions dissolve.
The three phases
- Build (may fail). Allocate and fully wire the new node cluster. Touch
only new nodes and their fields. The cluster must be reachable by a
concurrent reader from neither direction (see "Observability"). On
allocation failure, free the new nodes immediately (they were never
observable — no grace period) and return; the old structure is untouched, so
there is nothing to roll back.
- Publish (must not fail). With the cluster fully built, perform the
minimal set of stores that link it into the live structure. After the first
such store the cluster is observable, so no failure is permitted past this
point — do all fallible work (allocations) in phase 1.
- Reclaim. Free the old nodes, deferred (call_rcu / synchronize_rcu /
grace period). They were observable, so a reader may still hold a reference.
Observability — the core concept
A node is observable the moment a concurrent reader can reach it from the
roots. Enumerate every channel readers traverse; in a doubly-linked structure
there are usually two:
- Forward: a reachable node's child/next slot points to it (the descent).
- Backward: a reachable node's parent/prev back-pointer points to it, if
readers follow back-pointers (up-walks, ordered traversal, lazy node
recovery from a compressed/skip encoding).
- Sideways: a secondary reader-traversable index — an ordered sibling/cell
list, a hash chain, a cached min/max endpoint — that reaches the node
independently of the tree. Each such index is its own channel: a node is
observable while any channel can reach it, and "unreachable through the
tree" proves nothing about the index.
A write publishes (crosses into observable) the instant it links a new node
into any channel of an already-reachable node. Writes that only touch
a brand-new node's own fields, or links between new nodes, are internal /
non-observable — do as many of those as you like in phase 1.
The classic trap: a line that looks like "set up the new node" is actually a
publish because its target is a reachable node. E.g. set_parent(old_child, new_node) publishes new_node through the back channel, because old_child is
still reachable via the old structure.
Rules that fall out
- Never reclaim a node that became observable without a grace period. And
never make a node observable during phase 1 only to free it on the error path
— that needs a grace period and leans on fragile reader-side consistency
heuristics during the window. Keep the build invisible instead.
- In phase 1, wire back-pointers directly from the pointers you hold. Do NOT
recover a cluster node via the read-side recovery machinery (the same code a
reader uses to chase a back-pointer / decode a compressed pointer). During the
build that machinery follows a back-pointer that still points at the old
structure, so it returns the wrong node and you corrupt it. The mutator holds
every new node directly — use that, not recovery.
- Track / free cluster nodes by an identity that does NOT need recovery.
This is the same rule applied to the abort path. If your scheme has an
encoding that resolves a node via a back-pointer (a skip/indirect pointer
recovered through its child's parent link), do not record cluster nodes in
that encoded form: the abort/free walk will resolve each tracked node to free
it, and that back-pointer is still deferred (stale) → it resolves to the
wrong, live node and frees it. Track the direct/plain form; if the published
slot wants the encoded form, write the encoded value into the (still-private)
slot just before publish, but keep the tracking/free/back-pointer-record in
the plain form. (Builder helpers should therefore return the plain flag and
let the caller re-encode the slot, not return the encoded one.)
- Publish ordering matters for reader consistency (not for failure). Publish
the channel that stops readers from reaching about-to-be-freed nodes first.
(In a re-parent: set the surviving child's new back-pointer before swinging
the top forward slot, so an up-walk from that child enters the new cluster
before the new cluster becomes forward-reachable and the old node is freed.)
The brief 2-store window between is the normal mutation window your readers'
retry/validate logic already handles.
- When several children's back-pointers are deferred together, wire them
fresh-before-live. A cluster-leaf (see the realloc rule below) defers all
its children's back-pointers to publish, and those children are often a mix of
fresh (new, reachable only through the cluster) and live (re-parented from
the old structure). Setting a live child's back-pointer is itself the
back-channel publish: the instant it lands, a reader up-walking from that
still-reachable child enters the cluster and can scan the cluster's other
slots — including fresh siblings. So wire the cluster's own upward link and
every fresh child's back-pointer first, and the live re-parent edge
last (immediately before the forward publish). Backwards, a reader enters
via the live edge and reaches a fresh sibling whose parent is not yet set —
and this is not a transient window the reader retries past: the reader's
data-dependency (consume) chain is anchored at the live back-pointer it
loaded, so a fresh-parent store sequenced after that load has no
release-consume edge to the reader. It observes the stale (often NULL) parent
at any later wall-clock time. (Diagnosing exactly this — a parent set
microseconds earlier yet read NULL — is the worked example in the
lttng-tracing-root-cause-analysis skill. The single-surviving-child re-parent
in the rule above is the degenerate case with no fresh siblings to strand.)
- Commit every reader channel in one publish — a secondary index is a
channel. If readers also traverse a secondary index (ordered cell list,
hash chain), the structural publish and the index splice/unsplice are ONE
logical publication. Publishing the structure first opens a window where a
reader finds the new node by exact lookup, then steps through its
not-yet-spliced index entry — NULL links read as end-of-list, which is
neither the pre- nor the post-state. Symmetrically, freeing an index entry
whose neighbours' stale links still reference it is a use-after-free even
when the node itself is unreachable through the tree (the tree is not the
only channel). Either fold the index edges into the same atomic commit (one
flip covering forward edge + index links), or make the reader fast path
detect a not-yet-spliced entry (e.g. NULL link but not the cached tail) and
fall back to the structural walk. A guard on the writer's own fast path
does not protect concurrent readers.
- Every reader-visible publish goes through the release-store primitive
(rcu_assign_pointer) — including out-param helpers. A helper that returns
its result through a caller-supplied slot pointer (
*slotp = new) performs
an unordered publish whenever a caller passes a LIVE slot (a child slot of a
published parent, the root) instead of a local. Either use
rcu_assign_pointer unconditionally in the helper (harmless when the slot is
a local), or forbid live slots in the helper's contract and make every
caller re-publish with the release store. Audit out-param helpers by call
site: the one caller that passes a live slot turns a correct helper into a
plain-store publication with no ordering against the node-body stores.
- The old nodes stay allocated and coherent through phases 1 and 2. They are
serving readers the whole time. Free only in phase 3.
- An "exclusive / no-readers" mode flips deferred reclaim to synchronous —
re-check every safety argument built on the grace period. Code whose
correctness argument is "the old copy stays allocated until a grace period
elapses" (a relocation pass navigating from old copies, an undo walk back
through possibly-freed ancestors, draining a detached subtree) silently
becomes a use-after-free when the structure is in exclusive mode and frees
happen immediately. The dual obligation: never mark a structure exclusive —
or return it to a caller as exclusive — on a path that skipped the reader
drain; every path that can leave a parked reader inside must synchronize
first, not just the common one.
- A node that may be reallocated mid-build defers ALL its children's
back-pointers — no per-child exception. If your structure grows/shrinks a
node by reallocating it (resize, recompact, rebalance creates a new copy and
re-parents the children it copied), then a live child whose back-pointer you
set "directly" still gets re-published to each successive copy by that
re-parent step — and left dangling if a later allocation frees the copy. So at
a cluster-leaf (a new node at the cluster's lower boundary, whose children
include live nodes), set no child's back-pointer during the build, not even
the children that look new/safe; wire them all at publish, using the node's
final identity (track it across reallocations — the caller always gets the
new flag back). Selective deferral is the trap: inserting a sibling child
reallocates the node and re-parents the one you thought you'd deferred. (A
boolean "this target is a cluster-leaf, skip its whole re-parent step" is
cleaner and order-independent than a per-child "defer this one" flag, which
would force you to add the deferred child last.)
- Clear a freshly-built node's parent/back-link metadata before you grow
(reallocate) it. If you build a new node and then add another child that
triggers a realloc, the realloc copies the old node's parent (and any
back-link slot it derives from it) into the new copy — and may write through
that inherited parent to update its forward slot. A fresh node from a recycled
allocation can carry stale, non-NULL parent metadata, so that inherited write
lands on an unrelated live node. The new node has no parent until you wire it
at publish; zero its parent/back-link fields before the growing step.
Anti-patterns (and why)
- Mutate-in-place then roll back on error. Tempting and localized, but if the
mutated pointer was observable, rollback alone is a use-after-free (a reader
grabbed the transient target); you must add a grace period before freeing, and
the in-window correctness depends on a reader-side heuristic (e.g. "the lengths
won't match so the reader retries") that is an emergent, non-local invariant,
not a guarantee. Prefer build-invisible: there is no window and nothing to roll
back.
- Using the published-tree insert/link API to wire an unpublished cluster.
Those APIs set back-pointers via read-side recovery (see the rule above) and
assume the slot is already consistent. Wire the cluster with direct field
stores.
Composing build-invisible steps into a transaction
A build-invisible step is only as safe as the transaction around it.
- Propagate the step's failure; don't let a dispatch layer swallow it. When a
descent/dispatch routine calls your mutation and treats its allocation failure
like an ordinary "stop" (e.g. returns the same "done" signal on both success
and OOM), the caller proceeds on un-built / stale state and corrupts the
structure anyway — the build-invisible step's clean OOM is wasted. Thread the
failure out and abort the whole transaction before any irreversible publish
(before the point of no return, so there is nothing to roll back). A
(void)-cast or ignored return on a fallible mutation is a red flag.
- Never publish an incomplete intermediate that a later, fallible step
completes. A transaction that publishes a deliberately-partial structure
(e.g. a branch holding one of its eventual two children, meant to be finished
by a subsequent allocating step) is not atomic: an OOM in the later step
leaves the partial structure live — often a verify-invalid / non-canonical node
rather than a dangling pointer, so it's a subtler corruption that exact lookups
miss. Either build the whole cluster (every step's output) invisibly and
publish once, or accept that the later step's failure must roll back the
earlier publish — usually impractical once the replaced node is freed. If you
can only fix the first step now, say so explicitly and scope the
transaction-atomicity of the rest as separate work.
- Error paths must report failure faithfully and reset out-params. Mapping
an allocation failure to a benign status (NOT_FOUND, or a "duplicate found"
success) tells the caller the operation didn't happen — or worse, that it
did. And if an out-param was set optimistically before the fallible step
(e.g.
*result = removed chain, under a contract of "caller reclaims it
after a grace period"), the error path MUST reset it to NULL: a caller that
keys reclamation off the non-NULL out-param frees live data. Decide each
error exit's (status, out-params, structure state) triple together; an error
status paired with a success-shaped out-param is as dangerous as the
reverse. Where a distinct out-of-memory status exists, use it — the caller's
retry decision depends on distinguishing "absent" from "failed".
Worked example (userspace-rcu fractal trie compressed-split)
Splitting compressed node cn("ABCDE", child C) under parent P on insert of
"ABXYZ": build cluster P→(skip "AB")→branch{ 'C'→sfx"DE"→C , 'X'→nb"YZ"→leaf }.
- Phase 1: alloc sfx/nb/branch/prefix; wire forward slots and set every new
node's back-pointer directly; set the
branch→sfx slot to its final skip
value even though it only becomes recoverable once C->parent flips — no
reader sees it yet, and the mutator never recovers through it. Never touch C
or P's slot. (Install sfx with its plain flag so the API recovers it
directly, then overwrite the slot with the skip value — never install the skip
flag, which would recover sfx through C->parent = still cn.)
- OOM in phase 1: free sfx/nb/branch/prefix immediately;
C, cn, P untouched.
- Phase 2:
C->parent = sfx (back), then P.slot = skip(branch,2) (forward).
- Phase 3: free
cn deferred.
"Skip-encoded" is a publication property: a skip pointer encodes the
compressed node's child + length and recovers the node via that child's
back-pointer, so it only resolves once that back-pointer is published. Setting
the value early in an unobserved slot is fine; resolving it is a reader concern.
1---2name: rcu-mutation3description: Correct discipline for mutating an RCU / lock-free pointer-based data structure (trie, tree, list, graph) that has concurrent readers — build a new node cluster invisibly, publish it, then reclaim the old nodes after a grace period. Use when writing or reviewing any mutator on a structure read concurrently under RCU (or any publish/consume scheme), especially one with allocation-failure paths.4---56# RCU mutation: build-invisible → publish → reclaim78The safe shape for a structural mutation with concurrent RCU readers is three9phases. Get the phase boundaries right and most correctness questions dissolve.1011## The three phases12131. **Build (may fail).** Allocate and fully wire the *new* node cluster. Touch14 only new nodes and their fields. The cluster must be reachable by a15 concurrent reader from **neither** direction (see "Observability"). On16 allocation failure, free the new nodes **immediately** (they were never17 observable — no grace period) and return; the old structure is untouched, so18 there is **nothing to roll back**.192. **Publish (must not fail).** With the cluster fully built, perform the20 minimal set of stores that link it into the live structure. After the first21 such store the cluster is observable, so no failure is permitted past this22 point — do all fallible work (allocations) in phase 1.233. **Reclaim.** Free the *old* nodes, **deferred** (call_rcu / synchronize_rcu /24 grace period). They were observable, so a reader may still hold a reference.2526## Observability — the core concept2728A node is **observable** the moment a concurrent reader can reach it from the29roots. Enumerate *every* channel readers traverse; in a doubly-linked structure30there are usually two:3132- **Forward**: a reachable node's child/next slot points to it (the descent).33- **Backward**: a reachable node's parent/prev back-pointer points to it, *if34 readers follow back-pointers* (up-walks, ordered traversal, lazy node35 recovery from a compressed/skip encoding).36- **Sideways**: a secondary reader-traversable index — an ordered sibling/cell37 list, a hash chain, a cached min/max endpoint — that reaches the node38 independently of the tree. Each such index is its own channel: a node is39 observable while **any** channel can reach it, and "unreachable through the40 tree" proves nothing about the index.4142A write **publishes** (crosses into observable) the instant it links a new node43into **any** channel of an **already-reachable** node. Writes that only touch44a brand-new node's own fields, or links *between* new nodes, are **internal /45non-observable** — do as many of those as you like in phase 1.4647The classic trap: a line that looks like "set up the new node" is actually a48publish because its target is a reachable node. E.g. `set_parent(old_child,49new_node)` publishes `new_node` through the back channel, because `old_child` is50still reachable via the old structure.5152## Rules that fall out5354- **Never reclaim a node that became observable without a grace period.** And55 never make a node observable during phase 1 only to free it on the error path56 — that needs a grace period *and* leans on fragile reader-side consistency57 heuristics during the window. Keep the build invisible instead.58- **In phase 1, wire back-pointers directly from the pointers you hold.** Do NOT59 recover a cluster node via the *read-side* recovery machinery (the same code a60 reader uses to chase a back-pointer / decode a compressed pointer). During the61 build that machinery follows a back-pointer that still points at the *old*62 structure, so it returns the wrong node and you corrupt it. The mutator holds63 every new node directly — use that, not recovery.64- **Track / free cluster nodes by an identity that does NOT need recovery.**65 This is the same rule applied to the abort path. If your scheme has an66 encoding that resolves a node *via a back-pointer* (a skip/indirect pointer67 recovered through its child's parent link), do not record cluster nodes in68 that encoded form: the abort/free walk will resolve each tracked node to free69 it, and that back-pointer is still deferred (stale) → it resolves to the70 *wrong, live* node and frees it. Track the direct/plain form; if the published71 slot wants the encoded form, write the encoded value into the (still-private)72 slot just before publish, but keep the tracking/free/back-pointer-record in73 the plain form. (Builder helpers should therefore *return* the plain flag and74 let the caller re-encode the slot, not return the encoded one.)75- **Publish ordering matters for reader consistency** (not for failure). Publish76 the channel that stops readers from reaching about-to-be-freed nodes *first*.77 (In a re-parent: set the surviving child's new back-pointer before swinging78 the top forward slot, so an up-walk from that child enters the new cluster79 before the new cluster becomes forward-reachable and the old node is freed.)80 The brief 2-store window between is the normal mutation window your readers'81 retry/validate logic already handles.82- **When several children's back-pointers are deferred together, wire them83 fresh-before-live.** A cluster-leaf (see the realloc rule below) defers *all*84 its children's back-pointers to publish, and those children are often a mix of85 *fresh* (new, reachable only through the cluster) and *live* (re-parented from86 the old structure). Setting a **live** child's back-pointer *is itself the87 back-channel publish*: the instant it lands, a reader up-walking from that88 still-reachable child enters the cluster and can scan the cluster's *other*89 slots — including fresh siblings. So wire the cluster's own upward link and90 every **fresh** child's back-pointer **first**, and the **live** re-parent edge91 **last** (immediately before the forward publish). Backwards, a reader enters92 via the live edge and reaches a fresh sibling whose parent is not yet set —93 and this is **not** a transient window the reader retries past: the reader's94 data-dependency (consume) chain is *anchored* at the live back-pointer it95 loaded, so a fresh-parent store sequenced *after* that load has **no96 release-consume edge** to the reader. It observes the stale (often NULL) parent97 at *any* later wall-clock time. (Diagnosing exactly this — a parent set98 microseconds earlier yet read NULL — is the worked example in the99 lttng-tracing-root-cause-analysis skill. The single-surviving-child re-parent100 in the rule above is the degenerate case with no fresh siblings to strand.)101- **Commit every reader channel in one publish — a secondary index is a102 channel.** If readers also traverse a secondary index (ordered cell list,103 hash chain), the structural publish and the index splice/unsplice are ONE104 logical publication. Publishing the structure first opens a window where a105 reader finds the new node by exact lookup, then steps through its106 not-yet-spliced index entry — NULL links read as end-of-list, which is107 neither the pre- nor the post-state. Symmetrically, freeing an index entry108 whose *neighbours'* stale links still reference it is a use-after-free even109 when the node itself is unreachable through the tree (the tree is not the110 only channel). Either fold the index edges into the same atomic commit (one111 flip covering forward edge + index links), or make the reader fast path112 detect a not-yet-spliced entry (e.g. NULL link but not the cached tail) and113 fall back to the structural walk. A guard on the *writer's* own fast path114 does not protect concurrent readers.115- **Every reader-visible publish goes through the release-store primitive116 (rcu_assign_pointer) — including out-param helpers.** A helper that returns117 its result through a caller-supplied slot pointer (`*slotp = new`) performs118 an unordered publish whenever a caller passes a LIVE slot (a child slot of a119 published parent, the root) instead of a local. Either use120 rcu_assign_pointer unconditionally in the helper (harmless when the slot is121 a local), or forbid live slots in the helper's contract and make every122 caller re-publish with the release store. Audit out-param helpers by call123 site: the one caller that passes a live slot turns a correct helper into a124 plain-store publication with no ordering against the node-body stores.125- **The old nodes stay allocated and coherent through phases 1 and 2.** They are126 serving readers the whole time. Free only in phase 3.127- **An "exclusive / no-readers" mode flips deferred reclaim to synchronous —128 re-check every safety argument built on the grace period.** Code whose129 correctness argument is "the old copy stays allocated until a grace period130 elapses" (a relocation pass navigating from old copies, an undo walk back131 through possibly-freed ancestors, draining a detached subtree) silently132 becomes a use-after-free when the structure is in exclusive mode and frees133 happen immediately. The dual obligation: never mark a structure exclusive —134 or return it to a caller as exclusive — on a path that skipped the reader135 drain; every path that can leave a parked reader inside must synchronize136 first, not just the common one.137- **A node that may be reallocated mid-build defers ALL its children's138 back-pointers — no per-child exception.** If your structure grows/shrinks a139 node by *reallocating* it (resize, recompact, rebalance creates a new copy and140 re-parents the children it copied), then a live child whose back-pointer you141 set "directly" still gets re-published to each successive copy by that142 re-parent step — and left dangling if a later allocation frees the copy. So at143 a *cluster-leaf* (a new node at the cluster's lower boundary, whose children144 include live nodes), set **no** child's back-pointer during the build, not even145 the children that look new/safe; wire them all at publish, using the node's146 **final** identity (track it across reallocations — the caller always gets the147 new flag back). Selective deferral is the trap: inserting a *sibling* child148 reallocates the node and re-parents the one you thought you'd deferred. (A149 boolean "this target is a cluster-leaf, skip its whole re-parent step" is150 cleaner and order-independent than a per-child "defer this one" flag, which151 would force you to add the deferred child last.)152- **Clear a freshly-built node's parent/back-link metadata before you grow153 (reallocate) it.** If you build a new node and then add another child that154 triggers a realloc, the realloc copies the *old* node's parent (and any155 back-link slot it derives from it) into the new copy — and may write *through*156 that inherited parent to update its forward slot. A fresh node from a recycled157 allocation can carry stale, non-NULL parent metadata, so that inherited write158 lands on an unrelated live node. The new node has no parent until you wire it159 at publish; zero its parent/back-link fields before the growing step.160161## Anti-patterns (and why)162163- **Mutate-in-place then roll back on error.** Tempting and localized, but if the164 mutated pointer was observable, rollback alone is a use-after-free (a reader165 grabbed the transient target); you must add a grace period before freeing, and166 the in-window correctness depends on a reader-side heuristic (e.g. "the lengths167 won't match so the reader retries") that is an emergent, non-local invariant,168 not a guarantee. Prefer build-invisible: there is no window and nothing to roll169 back.170- **Using the published-tree insert/link API to wire an unpublished cluster.**171 Those APIs set back-pointers via read-side recovery (see the rule above) and172 assume the slot is already consistent. Wire the cluster with direct field173 stores.174175## Composing build-invisible steps into a transaction176177A build-invisible step is only as safe as the transaction around it.178179- **Propagate the step's failure; don't let a dispatch layer swallow it.** When a180 descent/dispatch routine calls your mutation and treats its allocation failure181 like an ordinary "stop" (e.g. returns the same "done" signal on both success182 and OOM), the caller proceeds on un-built / stale state and corrupts the183 structure anyway — the build-invisible step's clean OOM is wasted. Thread the184 failure out and abort the whole transaction *before any irreversible publish*185 (before the point of no return, so there is nothing to roll back). A186 `(void)`-cast or ignored return on a fallible mutation is a red flag.187- **Never publish an incomplete intermediate that a later, fallible step188 completes.** A transaction that publishes a deliberately-partial structure189 (e.g. a branch holding one of its eventual two children, meant to be finished190 by a subsequent *allocating* step) is **not atomic**: an OOM in the later step191 leaves the partial structure live — often a verify-invalid / non-canonical node192 rather than a dangling pointer, so it's a subtler corruption that exact lookups193 miss. Either build the *whole* cluster (every step's output) invisibly and194 publish once, or accept that the later step's failure must roll back the195 earlier publish — usually impractical once the replaced node is freed. If you196 can only fix the first step now, say so explicitly and scope the197 transaction-atomicity of the rest as separate work.198- **Error paths must report failure faithfully and reset out-params.** Mapping199 an allocation failure to a benign status (NOT_FOUND, or a "duplicate found"200 success) tells the caller the operation didn't happen — or worse, that it201 did. And if an out-param was set optimistically *before* the fallible step202 (e.g. `*result = removed chain`, under a contract of "caller reclaims it203 after a grace period"), the error path MUST reset it to NULL: a caller that204 keys reclamation off the non-NULL out-param frees live data. Decide each205 error exit's (status, out-params, structure state) triple together; an error206 status paired with a success-shaped out-param is as dangerous as the207 reverse. Where a distinct out-of-memory status exists, use it — the caller's208 retry decision depends on distinguishing "absent" from "failed".209210## Worked example (userspace-rcu fractal trie compressed-split)211212Splitting compressed node `cn`("ABCDE", child `C`) under parent `P` on insert of213"ABXYZ": build cluster `P→(skip "AB")→branch{ 'C'→sfx"DE"→C , 'X'→nb"YZ"→leaf }`.214215- Phase 1: alloc sfx/nb/branch/prefix; wire forward slots and set every *new*216 node's back-pointer **directly**; set the `branch→sfx` slot to its final skip217 value even though it only becomes *recoverable* once `C->parent` flips — no218 reader sees it yet, and the mutator never recovers through it. Never touch `C`219 or `P`'s slot. (Install sfx with its *plain* flag so the API recovers it220 directly, then overwrite the slot with the skip value — never install the skip221 flag, which would recover sfx through `C->parent` = still `cn`.)222- OOM in phase 1: free sfx/nb/branch/prefix immediately; `C`, `cn`, `P` untouched.223- Phase 2: `C->parent = sfx` (back), then `P.slot = skip(branch,2)` (forward).224- Phase 3: free `cn` deferred.225226"Skip-encoded" is a *publication* property: a skip pointer encodes the227compressed node's *child* + length and recovers the node via that child's228back-pointer, so it only resolves once that back-pointer is published. Setting229the value early in an unobserved slot is fine; resolving it is a reader concern.