Skip to content

The memory arbitrator — interior mutability

A buffer spills when memory is tight, but “memory” is a single budget shared by every operator running at once, each on its own thread, each charging and releasing bytes. They need one shared object that tracks the total and decides who spills. Here Rust’s ownership model seems to fight you: you share a value across threads with Arc, and Arc gives only shared (&) access, never &mut. So how does anyone update the counters? The answer is interior mutability: a type built to mutate through &self, using atomics, a copy-on-write snapshot, and a condition variable. The MemoryArbitrator is the engine’s masterclass in it. We use just enough of the Rust mechanism to read the real type, and The Rust Book, ch. 15 is the canonical treatment of interior mutability and RefCell if you want it from first principles.

  • Explain why an Arc-shared type must mutate through &self, and name interior mutability as the mechanism that allows it.
  • Distinguish the interior-mutable primitives by the access pattern each fits: Cell/RefCell (single-threaded), Atomic*, Mutex, ArcSwap, Condvar.
  • Read the arbitrator’s atomic counters and copy-on-write registry in real engine signatures and say what makes each write safe without a global lock.
  • Write a shared counter that four threads mutate through &self, and predict the compile error when you swap the atomic for a plain field.

New terms in this lesson (each is also expanded inline at the point you first need it):

  • interior mutability: mutating through a & instead of a &mut.
  • Cell / RefCell: the single-threaded form of the same idea.
  • atomic: a lock-free, hardware-safe shared counter or flag.
  • Mutex: a lock that serializes access to whatever it guards.
  • Condvar: parks a thread until another signals, with no CPU spin.
  • arbitrator: the engine’s single shared memory-budget authority.

Share a value across threads and you reach for Arc<T>. But Arc<T> only ever hands out &T, never &mut T, because two threads holding &mut to the same value is precisely the data race the borrow checker forbids. So a shared budget tracker can only offer &self methods. To mutate through them, its fields must be interior-mutable types, ones whose own API turns a & into a safe write. That’s the entire trick behind Atomic*, Mutex, RwLock, and ArcSwap.

The family splits on one axis: does it cross threads?

  • Cell<T> / RefCell<T> are the single-threaded form. Cell swaps whole values through &self; RefCell hands out a borrow checked at run time and panics if you break the one-writer rule. Neither is thread-safe, so the arbitrator can’t use them, but they’re the simplest way to see the idea.
  • Atomic*, Mutex, RwLock, and ArcSwap are the thread-safe forms, the ones a value crossing threads behind an Arc must use.

The arbitrator is Arc-shared across every operator thread, and its fields are exactly these thread-safe ones:

clinker-exec ·memory.rs ·MemoryArbitrator type @19acdcb4
pub struct MemoryArbitrator {
limit: AtomicU64, // the hard limit, in bytes
peak_rss: AtomicU64, // highest process RSS observed
cumulative_spill_bytes: AtomicU64, // total bytes spilled to disk
// copy-on-write registry of operators to poll / pause / spill:
consumers: ArcSwap<Vec<(ConsumerId, Arc<dyn MemoryConsumer>)>>,
policy: Box<dyn ArbitrationPolicy>,
// ... more atomic counters
}

Every counter is an AtomicU64; the operator registry is an ArcSwap (a lock-free, copy-on-write cell). None of these needs &mut to change, and that’s what lets one Arc<MemoryArbitrator> be read and updated by all the threads at once.

thread (sort op) ─┐
thread (agg op) ─┼──▶ Arc<MemoryArbitrator>
thread (join op) ─┤ limit, peak_rss, cumulative_spill : AtomicU64
thread (source) ─┘ consumers : ArcSwap<Vec<…>> (lock-free snapshot)
every thread holds &self; updates go through the atomics — no &mut, no global lock

Watch the signatures: these methods change the arbitrator’s state, yet every one takes &self. The mutation rides an atomic operation:

crates/clinker-exec/src/pipeline/memory.rs
pub fn observe(&self) {
if let Some(rss) = rss_bytes() {
self.peak_rss.fetch_max(rss, Ordering::Relaxed); // raise the high-water mark
}
}

The per-operator byte counter lives on a ConsumerHandle (an Arc-shared handle each operator holds) and charges/releases bytes the same lock-free way:

clinker-exec ·memory.rs ·ConsumerHandle type @19acdcb4
pub fn add_bytes(&self, n: u64) { // &self — charge memory from any thread
let _ = self.bytes.fetch_update(Ordering::Relaxed, Ordering::Relaxed,
|cur| Some(cur.saturating_add(n)));
}
pub fn sub_bytes(&self, n: u64) { // &self — release it again
let _ = self.bytes.fetch_update(Ordering::Relaxed, Ordering::Relaxed,
|cur| Some(cur.saturating_sub(n)));
}

Why atomics rather than wrapping the whole thing in a Mutex? Because charging bytes happens on the hot path, per batch, from many threads. A fetch_add is a single lock-free CPU instruction; a Mutex would serialize every operator through one lock. The operator registry uses ArcSwap for the same reason: the frequent readers (which operator is using what?) load an immutable snapshot with no lock, while the rare register/unregister clones the Vec and atomically swaps it in. Hot path lock-free; rare path copy-on-write.

Not all coordination is a counter. When the arbitrator decides to pause a source for backpressure, the source’s thread has to actually block until resumed, and busy-spinning would burn a core. That’s what a condition variable is for. The PauseSignal pairs an AtomicBool flag with a Mutex + Condvar:

clinker-exec ·memory.rs ·PauseSignal type @19acdcb4
pub struct PauseSignal { paused: AtomicBool, mu: Mutex<()>, cv: Condvar }
impl PauseSignal {
pub fn resume(&self) {
self.paused.store(false, Ordering::Release);
self.cv.notify_all(); // wake every parked waiter
}
pub fn wait_while_paused(&self) {
if !self.is_paused() { return; } // lock-free fast path when not paused
let mut g = self.mu.lock().unwrap();
while self.is_paused() {
g = self.cv.wait(g).unwrap(); // park the thread — no CPU spin
}
}
}

A paused source calls wait_while_paused() and the OS puts its thread to sleep; resume() wakes it with notify_all. The AtomicBool fast path means the common “not paused” case costs nothing, no lock at all. Three interior-mutable primitives, each chosen for its access pattern: atomics for hot counters, ArcSwap for a mostly-read registry, Condvar for genuine blocking.

This pairs with the bounded channel from the worker pool: a full channel throttles a producer implicitly (its send blocks); the PauseSignal is the explicit backpressure the arbitrator reaches for when the budget, not the channel, is the constraint. Same goal, different lever.

You’ve read the arbitrator’s three primitives. Now build the core idea yourself, one shared, interior-mutable counter, in three rungs: a worked program, a one-gap completion, then the whole thing from a skeleton. One new idea per rung; the type never changes, only how much you write.

This is the whole idea in std: an Arc<AtomicU64> mutated by four threads through &self, no &mut and no lock anywhere. Run it and watch the total land on 10000 every time, regardless of how the threads interleave.

rust // editable

Four threads, one counter, each charging through a shared &. The total is always 10000: atomic fetch_add makes the increments race-free without any lock. That is interior mutability in one line: a & that writes.

The arbitrator doesn’t only charge; operators release memory when a batch is freed. Here the charge step is written for you; complete the matching release so the net total is correct. Each thread charges 1000 then releases 400, so four threads should leave 4 * 600 = 2400.

rust // editable
💡 Hint 1

AtomicU64 has a fetch_sub that mirrors fetch_add: same &self, same lock-free atomic, opposite direction. It takes the amount to subtract and the same Ordering::Relaxed.

Show solution
charged.fetch_sub(400, Ordering::Relaxed); // release, through &self

fetch_sub is the release half of the same lock-free pattern add_bytes/sub_bytes use in the real ConsumerHandle. No &mut, no Mutex: charge and release are both single atomic instructions, which is exactly why they can sit on the per-batch hot path without serializing the operators against each other.

Now write the whole thing from a skeleton. Build a one-method type, Budget, that holds an AtomicU64 of used bytes and exposes try_charge(&self, n) -> bool: it adds n only if the total would stay within a fixed LIMIT, returning whether the charge was admitted. Notice the signature is &self: this is a shared object, like the arbitrator. Two threads will race on it.

rust // editable
💡 Hint 1

fetch_update is the read-modify-write that decides atomically. Give it a closure returning Some(new) to commit or None to reject; it returns Ok if your closure committed, Err if it declined. Map that Result to a bool.

Show solution
fn try_charge(&self, n: u64) -> bool {
self.used
.fetch_update(Ordering::Relaxed, Ordering::Relaxed, |cur| {
let next = cur + n;
if next <= LIMIT { Some(next) } else { None } // commit, or decline
})
.is_ok() // Ok = admitted, Err = rejected
}

fetch_update loops internally until its compare-and-swap wins, so the check-then-set is one atomic decision: two threads can’t both see used == 0, both decide 800 fits, and both commit 1600. Exactly one of the racing try_charge(800) calls returns true; the final used is 800, never 1600. This is the arbitrator’s admission logic in miniature: a &self gate on a shared budget, correct under any interleaving, with no lock on the hot path.

The skeleton you just wrote is the shape of the real thing. In clinker-exec, the same fetch_update read-modify-write drives add_bytes/sub_bytes on the ConsumerHandle, and fetch_max drives observe on the arbitrator, every one a &self method on an Arc-shared type.

What the compiler enforces: because the shared fields are AtomicU64 (which is Sync), MemoryArbitrator is Sync, so Arc<MemoryArbitrator> can be moved into every operator thread. Swap one counter for a plain u64 and the &self methods stop compiling. Incrementing a plain u64 needs &mut self, which Arc never hands out, so the shared-and-mutate pattern becomes a build error, not a latent data race.

What a junior might misread: seeing &self on add_bytes and concluding “this method only reads.” &self means shared, not read-only; the atomic field is what makes the write sound. The other misread is reaching for a Mutex to guard a single counter: correct, but it serializes the hot path the atomic keeps lock-free.

Why-bridge: one primitive per access pattern

Section titled “Why-bridge: one primitive per access pattern”

Why three different interior-mutable types instead of wrapping the whole arbitrator in one big Mutex? Because each field has a different access pattern, and the cheapest correct primitive differs for each:

  • Counters (limit, peak_rss, charged bytes) are written per-batch from many threads on the hot path → AtomicU64, a single lock-free instruction.
  • The consumer registry is read constantly (who is using what?) and written rarely (an operator registers once) → ArcSwap, lock-free reads with copy-on-write writes.
  • The pause flag needs a thread to genuinely block until resumed → AtomicBool for the cheap not-paused check, plus Mutex + Condvar for the rare real wait.

One global Mutex would be correct but would funnel every per-batch charge through a single lock, erasing the parallelism the one-thread-per-source design exists to get.

You’ve seen how the arbitrator stores shared state safely. Next: what it decides with it, spill, pause, or abort, and how it refuses an impossible budget before a single record moves.