pub struct AuxiliaryTask { /* private fields */ }Expand description
A task that runs a callback on a lower-priority thread when the audio thread asks it to.
This is how a Bela program does work that must not happen in
render: file and network I/O, expensive calculations that would
overrun the block deadline, anything that allocates or blocks.
render calls schedule, which is
real-time safe, and the callback then runs on its own thread.
§Ownership of the task’s state
The callback owns everything it touches: it is a 'static closure,
moved into the task at creation. It cannot borrow from the
application, because it runs on its own thread while the audio
thread is inside render holding &mut self. Share state with
render the way threads normally do — atomics, or a lock-free
queue — and keep the real-time end of it allocation- and lock-free.
§Lifetime
Create tasks in setup (creating one allocates and starts a
thread, so render is the wrong place). They live until the audio
system they were created in is torn down, which deletes all of them
at once: the C API has no way to delete one task, so dropping this
handle does not destroy the task, and the callback’s state stays
allocated for the life of the process.
Because that teardown frees the tasks behind the handles, a handle records which audio system it belongs to, and scheduling it afterwards does nothing — including a handle that outlived one audio system while a later one is running, and including one from an audio system that was initialised but never started.
Tasks cannot be created during a teardown at all: from cleanup,
or from another thread while an audio system is being dropped,
new fails with
Error::TaskCreateWhileStopping rather than handing back a task
that is about to be deleted.
§Shared, but only within a callback
The handle is Send and Sync, because an application is both
and holds its tasks: with more than one render thread, every one of
them reaches the same handle through &self. Scheduling from
several at once is what libbela does itself —
Bela_scheduleAuxiliaryTask takes the task’s mutex and notifies its
condition variable, and a call that cannot take the lock reports the
request as lost, which is already the documented behaviour below.
What keeps that from being a licence to schedule from anywhere is
the context schedule asks for: a
context cannot leave the callback it was handed to, so a handle sent
to an unrelated thread still has nothing to schedule with.
§Example
use core::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use bela::{
AuxiliaryTask, BelaApplication, Priority, RenderContext, SetupContext, ThreadInfo,
rt_println,
};
struct App {
task: Option<AuxiliaryTask>,
blocks: Arc<AtomicU64>,
}
impl BelaApplication for App {
type RenderState = ();
fn setup(&mut self, _context: &SetupContext) -> bool {
let blocks = Arc::clone(&self.blocks);
let priority = Priority::new(50).expect("50 is within Bela's priority range");
self.task = AuxiliaryTask::new("report", priority, move || {
rt_println!("{} blocks so far", blocks.load(Ordering::Relaxed));
})
.ok();
self.task.is_some()
}
fn create_render_state(&mut self, _thread: ThreadInfo, _context: &SetupContext) {}
fn render(&self, _state: &mut (), context: &mut RenderContext) {
let blocks = self.blocks.fetch_add(1, Ordering::Relaxed) + 1;
if blocks % 1000 == 0 {
if let Some(task) = &self.task {
task.schedule(context);
}
}
}
}Implementations§
Source§impl AuxiliaryTask
impl AuxiliaryTask
Sourcepub fn new<F>(
name: &str,
priority: Priority,
callback: F,
) -> Result<Self, Error>
pub fn new<F>( name: &str, priority: Priority, callback: F, ) -> Result<Self, Error>
Creates a task that runs callback each time it is scheduled.
name must be unique across the system (Bela names the
underlying thread with it), and priority should stay below
Priority::AUDIO. Bela permits the full range represented by
Priority, including the audio thread’s own priority and
priorities above it.
The callback runs on a real-time thread of its own, but one that is allowed to miss deadlines: it may allocate, block and make system calls. Doing so still costs the rest of the real-time system, so prefer to keep it modest.
A panic inside the callback crosses a C boundary; binaries
should set panic = "abort" as the crate documentation
recommends.
§Errors
Returns Error::TaskName when name contains a NUL byte,
Error::TaskCreateWhileStopping when an audio system is being
torn down — including from a cleanup callback, which runs
inside that teardown — and Error::TaskCreate when Bela could
not create the task, which is also what happens off-device,
where there is no audio system to create it in.
Examples found in repository?
50 fn setup(&mut self, _context: &SetupContext) -> bool {
51 let runs = Arc::clone(&self.runs);
52 match AuxiliaryTask::new("bela-rs-abandoned", TASK_PRIORITY, move || {
53 runs.fetch_add(1, Ordering::Relaxed);
54 }) {
55 Ok(task) => {
56 // Hand the handle out, as a Send handle could be handed
57 // to any other thread.
58 *self.handle.lock().unwrap_or_else(PoisonError::into_inner) = Some(task);
59 true
60 }
61 Err(error) => {
62 rt_println!("lifecycle: could not create the abandoned task: {error}");
63 false
64 }
65 }
66 }
67
68 fn create_render_state(&mut self, _thread: ThreadInfo, _context: &SetupContext) {}
69
70 fn render(&self, _state: &mut (), _context: &mut RenderContext) {}
71}
72
73/// Runs for real, scheduling both the stale handle and one of its own.
74struct Survivor {
75 stale: Option<AuxiliaryTask>,
76 stale_runs: Arc<AtomicU64>,
77 fresh: Option<AuxiliaryTask>,
78 fresh_runs: Arc<AtomicU64>,
79 blocks: u64,
80 interval: u64,
81}
82
83impl BelaApplication for Survivor {
84 type RenderState = ();
85
86 fn setup(&mut self, context: &SetupContext) -> bool {
87 #[allow(
88 clippy::cast_possible_truncation,
89 clippy::cast_sign_loss,
90 reason = "the sample rate is a small positive number"
91 )]
92 let sample_rate_hz = context.audio_sample_rate() as u64;
93 self.interval = (sample_rate_hz / context.audio_frames().max(1) as u64).max(1);
94
95 let runs = Arc::clone(&self.fresh_runs);
96 self.fresh = AuxiliaryTask::new("bela-rs-survivor", TASK_PRIORITY, move || {
97 runs.fetch_add(1, Ordering::Relaxed);
98 })
99 .ok();
100 self.fresh.is_some()
101 }
102
103 fn create_render_state(&mut self, _thread: ThreadInfo, _context: &SetupContext) {}
104
105 fn render(&self, _state: &mut (), _context: &mut RenderContext) {}
106
107 // Real-time safe: a counter and two schedules, both of which
108 // return immediately. Once per block, on the main audio thread.
109 fn render_post(&mut self, _states: &mut [()], context: &mut BlockContext) {
110 self.blocks += 1;
111 if self.blocks % self.interval != 0 {
112 return;
113 }
114 if let Some(stale) = &self.stale {
115 stale.schedule(context);
116 }
117 if let Some(fresh) = &self.fresh {
118 fresh.schedule(context);
119 }
120 }
121
122 fn cleanup(&mut self, _states: &mut [()], _context: &CleanupContext) {
123 // `cleanup` runs inside the teardown, so this must fail.
124 let created = AuxiliaryTask::new("bela-rs-in-cleanup", TASK_PRIORITY, || {});
125 let cleanup_create = match created {
126 Err(Error::TaskCreateWhileStopping) => "rejected",
127 Err(_) => "failed-otherwise",
128 Ok(_) => "created",
129 };
130 rt_println!(
131 "lifecycle: stale-runs={} fresh-runs={} cleanup-create={}",
132 self.stale_runs.load(Ordering::Relaxed),
133 self.fresh_runs.load(Ordering::Relaxed),
134 cleanup_create
135 );
136 }More examples
71 fn setup(&mut self, context: &SetupContext) -> bool {
72 #[allow(
73 clippy::cast_possible_truncation,
74 clippy::cast_sign_loss,
75 reason = "the sample rate is a small positive number"
76 )]
77 let sample_rate_hz = context.audio_sample_rate() as u64;
78 self.interval = (sample_rate_hz / context.audio_frames().max(1) as u64).max(1);
79
80 // The callback owns everything it touches: it cannot borrow
81 // from the application, which the audio thread is using while
82 // the task runs.
83 let blocks = Arc::clone(&self.blocks);
84 let runs = Arc::clone(&self.runs);
85 let task = AuxiliaryTask::new("bela-rs-report", TASK_PRIORITY, move || {
86 runs.fetch_add(1, Ordering::Relaxed);
87 let count = blocks.load(Ordering::Relaxed);
88 // Allocating here is the point of the exercise: this is a
89 // normal thread, so it may do what the audio thread may not.
90 let bar = "#".repeat((count / 10_000) as usize + 1);
91 rt_println!("task: {count} blocks {bar}");
92 });
93
94 match task {
95 Ok(task) => {
96 self.task = Some(task);
97 rt_println!("setup: reporting every {} blocks", self.interval);
98 true
99 }
100 Err(error) => {
101 rt_println!("setup: could not create the task: {error}");
102 false
103 }
104 }
105 }113 fn setup(&mut self, context: &SetupContext) -> bool {
114 let sample_rate = context.audio_sample_rate();
115 for (index, increment) in self.phase_increments.iter_mut().enumerate() {
116 #[allow(
117 clippy::cast_precision_loss,
118 reason = "the oscillator index is far below f32's exact integer range"
119 )]
120 let harmonic = index as f32 + 1.0;
121 *increment = TAU * BASE_FREQUENCY * harmonic / sample_rate;
122 }
123
124 #[allow(
125 clippy::cast_possible_truncation,
126 clippy::cast_sign_loss,
127 reason = "the sample rate is a small positive number"
128 )]
129 let sample_rate_hz = sample_rate as u64;
130 self.blocks_per_report = (sample_rate_hz / context.audio_frames().max(1) as u64).max(1);
131
132 if context.cpu_usage().is_none() {
133 rt_println!("setup: CPU monitoring is off; run with Settings::cpu_monitoring");
134 return false;
135 }
136
137 // The callback owns everything it touches: the percentages
138 // arrive through the atomics, because the audio thread's
139 // counters cannot be read from this thread.
140 let published = Arc::clone(&self.published);
141 let task = AuxiliaryTask::new("bela-rs-cpu", TASK_PRIORITY, move || {
142 let thread = f32::from_bits(published.thread.load(Ordering::Relaxed));
143 let section = f32::from_bits(published.section.load(Ordering::Relaxed));
144 rt_println!("cpu: audio thread {thread:.1}%; oscillators {section:.1}%");
145 });
146
147 match task {
148 Ok(task) => {
149 self.task = Some(task);
150 rt_println!(
151 "setup: {OSCILLATORS} oscillators over {} render thread(s), \
152 reporting every {} blocks",
153 context.thread_count(),
154 self.blocks_per_report
155 );
156 true
157 }
158 Err(error) => {
159 rt_println!("setup: could not create the task: {error}");
160 false
161 }
162 }
163 }Sourcepub fn schedule(&self, _context: &impl CallbackContext)
pub fn schedule(&self, _context: &impl CallbackContext)
Asks for the callback to run.
Real-time safe: this is the call render makes. It returns
immediately, without waiting for the callback.
§Why it takes a context
The context is a witness that this is a Bela callback — any of
them — which is the only place scheduling is sound. Stopping the
audio system frees every task, and Bela_stopAudio joins the
main audio thread and then every render thread before it does
so, so a schedule made from a callback can never be in flight
while the task behind it is freed. A handle sent to some other
thread (the type is Send, since applications are) has no
context to schedule with, and so cannot race with that teardown.
§Requests can be lost
A request that arrives while the callback is still running is dropped, not queued, and nothing reports it: Bela wakes a condition variable the task is not waiting on, and its return value only says whether that wakeup could be delivered. Measured on the board, a task sleeping 2 ms scheduled from every block ran 1507 times for 9052 requests, with every request reported as successful.
So do not treat one schedule as one run. When it matters, have the callback count its own invocations and compare that with the number of requests.
Once the audio system this task belongs to has stopped, every
task is gone and this does nothing — cleanup runs after that
point.
§First schedule of a task
libbela forces a task’s thread to start the first time the task
is scheduled, by raising and restoring its priority. Two render
threads doing that at the same time can make libbela print
Force starting scheduled thread didn't work on standard error;
nothing else comes of it, and the next schedule finds the thread
started. Scheduling a task once from setup or render_pre
before the render threads share it avoids the message.
Examples found in repository?
More examples
109 fn render_post(&mut self, _states: &mut [()], context: &mut BlockContext) {
110 self.blocks += 1;
111 if self.blocks % self.interval != 0 {
112 return;
113 }
114 if let Some(stale) = &self.stale {
115 stale.schedule(context);
116 }
117 if let Some(fresh) = &self.fresh {
118 fresh.schedule(context);
119 }
120 }221 fn render_post(&mut self, states: &mut [Bank], context: &mut BlockContext) {
222 #[allow(
223 clippy::cast_precision_loss,
224 reason = "a block's frame count is far below f32's exact integer range"
225 )]
226 let frames = context.audio_frames() as f32;
227 for (phase, increment) in self.phases.iter_mut().zip(&self.phase_increments) {
228 *phase = (*phase + frames * increment) % TAU;
229 }
230
231 self.blocks += 1;
232 if self.blocks % self.blocks_per_report != 0 {
233 return;
234 }
235 // Read here, on the main audio thread, and handed to the task
236 // as plain numbers.
237 let thread = context.cpu_usage().map_or(0.0, |usage| usage.percentage());
238 let section = states
239 .first()
240 .map_or(0.0, |bank| bank.timer.usage().percentage());
241 self.published
242 .thread
243 .store(thread.to_bits(), Ordering::Relaxed);
244 self.published
245 .section
246 .store(section.to_bits(), Ordering::Relaxed);
247 if let Some(task) = &self.task {
248 task.schedule(context);
249 }
250 }