Skip to main content

AuxiliaryTask

Struct AuxiliaryTask 

Source
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

Source

pub fn new<F>( name: &str, priority: Priority, callback: F, ) -> Result<Self, Error>
where F: FnMut() + Send + 'static,

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?
examples/task_lifecycle.rs (lines 52-54)
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
Hide additional examples
examples/aux_task.rs (lines 85-92)
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    }
examples/cpu.rs (lines 141-145)
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    }
Source

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?
examples/aux_task.rs (line 119)
113    fn render_post(&mut self, _states: &mut [()], context: &mut BlockContext) {
114        let blocks = self.blocks.fetch_add(1, Ordering::Relaxed) + 1;
115        if blocks % self.interval != 0 {
116            return;
117        }
118        if let Some(task) = &self.task {
119            task.schedule(context);
120            self.requests += 1;
121        }
122    }
More examples
Hide additional examples
examples/task_lifecycle.rs (line 115)
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    }
examples/cpu.rs (line 248)
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    }

Trait Implementations§

Source§

impl Debug for AuxiliaryTask

Source§

fn fmt(&self, f: &mut Formatter<'_>) -> Result

Formats the value using the given formatter. Read more
Source§

impl Send for AuxiliaryTask

Source§

impl Sync for AuxiliaryTask

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

Source§

impl<T, U> Into<U> for T
where U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

That is, this conversion is whatever the implementation of From<T> for U chooses to do.

Source§

impl<T, U> TryFrom<U> for T
where U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.