Chapter 7
Workers
Some work has no place on the audio thread: allocating or freeing
memory, building a DSP graph, an FFT, resampling, reading a file -
anything that can block or run for an unbounded time. The fix is a
worker thread: a background thread that does the heavy work while
process() stays real-time.
truce deliberately ships no "worker" abstraction. The Rust ecosystem
already has the right primitives - lock-free queues, ring buffers, and
std::thread - and every plugin's worker differs in exactly the parts a
wrapper would have to hide. This chapter shows how to wire one up
directly, using the two workers in the repo as worked examples.
Do you actually need one? Only if the work is both too heavy for one block and can't be pre-computed in
reset().reset()runs off the audio thread, so a one-time buffer allocation or a filter rebuild triggered by a sample-rate change belongs there, done synchronously - no worker needed. Reach for a worker only for continuous heavy computation (spectral analysis) or a rebuild driven by live automation.
#The one rule
The audio thread's side of every handoff must be wait-free: no lock, no allocation, no free, no syscall, no unbounded loop. In practice:
- push into a channel with
force_push/try_push/ drop-on-full, never a blocking send; - pop from a channel with
pop/try_recv, never a blocking receive; - the worker does all the heavy work and all the heavy drops - a big
BoxorVecis never dropped inprocess().
The worker thread can block, allocate, and free freely. That's the whole point of moving the work there.
#The primitives
| Need | Reach for |
|---|---|
| Stream samples audio → worker | ringbuf SPSC (HeapRb::split()) |
| Hand a discrete request or built object between threads | crossbeam-queue ArrayQueue |
| Wake an idle worker on demand | std::thread::park / Thread::unpark |
| Publish results to the GUI | shared atomics (see meters or the analyzer's SpectrumData) |
Add crossbeam-queue / ringbuf to your plugin's Cargo.toml directly;
they're not re-exported by truce, so you control the versions.
#Shape 1: offload construction
Build a heavy object off-thread, hand it to the audio thread, free the
old one off-thread. The
fundsp-reverb-worker
example rebuilds its fundsp graph this way when the user moves the Time
knob (each change reconstructs the reverb's delay-line network, which
allocates).
Three lock-free queues carry the handoff:
struct RebuildChannel {
requests: ArrayQueue<RebuildRequest>, // audio → worker: Copy, capacity 1
ready: ArrayQueue<ReadyGraph>, // worker → audio: capacity 1
discard: ArrayQueue<Box<dyn AudioUnit>>,// audio → worker: free off-thread
shutdown: AtomicBool,
}
Request (audio → worker), coalesced. The audio thread posts the
latest target and wakes the worker. force_push on a Copy request is
safe here - the displaced value drops for free:
self.rebuild.requests.force_push(RebuildRequest { sample_rate, time_s });
self.worker_thread.unpark();
The worker drains to the newest request and ignores the rest, so a knob sweep triggers one rebuild per worker cycle, not one per block:
let mut latest = None;
while let Some(req) = channel.requests.pop() { latest = Some(req); }
if let Some(req) = latest {
let graph = build_graph(req.sample_rate, req.time_s, /* ... */); // allocates
let _ = channel.ready.force_push(ReadyGraph { graph, sample_rate: req.sample_rate });
}
Swap in (worker → audio), with staleness rejection. The audio thread
pops the finished graph and swaps it in - but only if it was built for
the current sample rate. A graph the worker was midway through building
when reset() changed the rate is routed straight to discard:
if let Some(ready) = self.rebuild.ready.pop() {
if ready.sample_rate.to_bits() == self.last_built_sr.to_bits() {
let old = std::mem::replace(&mut self.graph, ready.graph);
let _ = self.rebuild.discard.push(old); // free off-thread
} else {
let _ = self.rebuild.discard.push(ready.graph); // stale: discard
}
}
Free off-thread. discard is padded (capacity 8 vs. at most one
swap per block) so it can't fill and force a free on the audio thread.
The worker drains and drops it at the top of its loop. That
std::mem::replace above never runs drop on the old graph - it moves
it into the queue instead.
reset() short-circuits the worker. reset() is off the audio
thread, so it rebuilds synchronously and drains any in-flight result so
a stale one can't land afterwards:
fn rebuild_now(&mut self, sample_rate: f64, time_s: f32) {
self.graph = build_graph(sample_rate, time_s, /* ... */); // allocate directly
self.last_built_sr = sample_rate;
while self.rebuild.requests.pop().is_some() {} // drop stale request
while self.rebuild.ready.pop().is_some() {} // drop stale in-flight graph
}
#Shape 2: stream and analyze
Stream samples out to the worker; publish results back for the GUI.
The truce-analyzer
plugin taps the audio into a ring buffer, and a worker runs the spectral
analysis and writes the spectrum into shared atomics the editor reads.
Tap (audio → worker), drop on full, frame-aligned. A
ringbuf::HeapRb split into producer / consumer. If the worker stalls,
the producer drops samples rather than block - and rounds the vacant
window down to whole frames so a drop never desyncs L/R:
fn push_stereo_frames(tap_tx: &mut HeapProd<f32>, samples: &[f32]) {
let vacant_aligned = tap_tx.vacant_len() & !1; // whole stereo frames
let push_len = samples.len().min(vacant_aligned);
let _ = tap_tx.push_slice(&samples[..push_len]);
}
Size the buffer for the worst realistic scheduling gap (the analyzer uses 32k frames, ~170 ms at 192 kHz), with drop-on-full as the net beyond that.
Worker loop, poll + short sleep. A streaming worker polls the tap and
sleeps briefly when it's empty. The sleep must be short enough that a
60 Hz UI never sees stale data (the analyzer uses 4 ms), and each
iteration processes a bounded number of frames so shutdown /
reset still get checked at ~kHz under a full buffer:
loop {
if ctl.shutdown.load(Ordering::Acquire) { break; }
let n = tap_rx.pop_slice(&mut scratch); // scratch pre-sized off-thread
if n == 0 { thread::sleep(Duration::from_millis(4)); continue; }
for frame in scratch[..n].chunks_exact(2) { core.process_stereo(frame[0], frame[1]); }
}
Publish results to the GUI. The worker writes into shared atomics -
the same idea as truce's meters, just scaled up. The analyzer
stores each spectrum bin as an AtomicU32 (an f32 bit-punned with
to_bits() / from_bits()); the editor reads them each frame.
Because editor(params) is an associated function over the param
store - it never gets &self, so it can't reach DSP state -
the plugin hands the shared handle to the editor through a #[skip]
field on the params struct:
#[derive(Params)]
pub struct AnalyzerParams {
#[param(/* ... */)] pub gain: FloatParam,
// `#[skip]` = not a parameter. The plugin's `new()` fills it so the
// receiverless `editor(params)` can reach the shared spectrum.
#[skip]
editor_bridge: Arc<OnceLock<EditorBridge>>, // { spectrum, instance_id }
}
new() calls params.editor_bridge.set(...); editor(params) reads it
back and hands the spectrum to the GUI. For a single scalar value,
#[meter] + context.set_meter() already does all of this for you.
#Lifecycle
Spawn the worker in your logic's new(), and join it in Drop:
let handle = thread::Builder::new()
.name("my-plugin-worker".into()) // always name the thread
.spawn(move || { /* worker loop */ })
.expect("spawn worker");
impl Drop for MyPlugin {
fn drop(&mut self) {
self.ctl.shutdown.store(true, Ordering::Release);
self.worker_thread.unpark(); // if you park; wakes it to see shutdown
if let Some(h) = self.handle.take() { let _ = h.join(); }
}
}
Join, don't detach: the worker holds Arcs to shared state, and joining
guarantees it has stopped touching that state before the plugin - and
the state - drop. Under --shell hot-reload each logic
instance owns its own worker; on reload the old instance's Drop joins
the old worker and the new instance spawns a fresh one, so there's
nothing extra to do.
#Real-time checklist
Everything the audio thread touches:
- no lock, no allocation, no free, no syscall, no unbounded loop;
- every channel push is
force_push/try_push/ drop-on-full; - audio → worker requests are coalesced to the latest, capacity 1;
- heavy objects are freed on the worker via a padded discard queue;
- worker scratch buffers are pre-sized off-thread, never grown in
process().
Get those right and the worker is invisible to the host: process()
stays wait-free, and the heavy work happens where it can't hurt the
audio.