Can a future be aborted asynchronusly?

Hi everyone :slight_smile:

Todays question is a continuation of Correct wrapper for buffer filled by other process but this time is async rust.

I'm developing a custom firmware for an ARM based micro controller which (along other things) contains a radio peripheral. Sending and receiving packets with that peripheral is more or less straight forward when doing this in a blocking manner:

  • write the pointer to some buffer into a specific register
  • start the send/receive task (by writing to a specific register)
  • loop until the task finished (can be checked by reading a specific register)

The naive extension into an async version could look like this:

  • write the pointer to some buffer into a specific register
  • enable an interrupt which is set to pending by the peripheral when the task finished
  • start the send/receive task
  • create a future that waits for the interrupt and await its completion
  • disable the interrupt

While probably not necessary to answer my actual question the relevant parts of my code currently look like this (while not complete, I hope the type and variable names are sufficient to infer the meaning of things that are not included):

Code
impl Radio<RadioRegistersInstanceI0> {
    async unsafe fn perform_task_and_wait_for_event(&mut self, task: RadioTask, event: RadioEvent) {
        self.enable_interrupt(&event);
        self.start_task(&task);
        let events = InterruptFuture01 {}.await;
        self.disable_interrupt(&event);
        assert!(events.contains(event));
    }

    pub(crate) async fn packet_receive<const BUFFER_SIZE: usize>(
        &mut self,
        ptr: &mut [u8; BUFFER_SIZE],
    ) -> u8 {
        let ptr: usize = (ptr as *mut [_; BUFFER_SIZE]) as usize;
        unsafe {
            <RadioRegistersInstanceI0 as AnyRadioInstance>::PACKETPTR::write(ptr as u32);
        }
        unsafe {
            self.perform_task_and_wait_for_event(RadioTask::START, RadioEvent::CRCOK)
                .await;
        }
        self.get_rssi_sample()
    }
}


static mut WAKER: Option<Waker> = None;
static mut INTERRUPT_TRIGGERED: Option<RadioEventSet> = None;
struct InterruptFuture01 {}

impl Future for InterruptFuture01 {
    type Output = RadioEventSet;

    fn poll(
        self: core::pin::Pin<&mut Self>,
        cx: &mut core::task::Context<'_>,
    ) -> core::task::Poll<Self::Output> {
        critical_section(|| {
            let event_set = unsafe { &mut INTERRUPT_TRIGGERED };
            let event_set = core::mem::replace(event_set, None);
            match event_set {
                Some(event_set) => core::task::Poll::Ready(event_set),
                None => {
                    let waker = unsafe { &mut WAKER };
                    let new_waker = Some(cx.waker().clone());
                    let _old_waker = core::mem::replace(waker, new_waker);
                    core::task::Poll::Pending
                }
            }
        })
    }
}

// for the instantiation table see https://infocenter.nordicsemi.com/pdf/nRF52840_OPS_v0.5.pdf section 8.4
#[no_mangle]
extern "C" fn Interrupt_01() {
    let mut radio = unsafe { get_radio_handles() };
    let event_set = radio
        .get_enabled_interrupts()
        .iter()
        .filter(|event| radio.check_and_clear_event_is_active(event))
        .collect::<RadioEventSet>();

    let future_result = unsafe { &mut INTERRUPT_TRIGGERED };
    let _previous_future_result = core::mem::replace(future_result, Some(event_set));

    let waker = unsafe { &mut WAKER };
    let waker = core::mem::replace(waker, None);
    match waker {
        Some(waker) => waker.wake(),
        None => {}
    }
}

To my question:
As far as I know, a future might be dropped at any time without polling it to completeness.
Because of this the method packet_receive from my code might cause undefined behavior: If the Future is dropped, the buffer might not be valid anymore when the hardware decides to write to it.

An (obvious?) solution might be to add a Drop implementation to the InterruptFuture01 struct which puts the peripheral into a state in which it will not use the buffer anymore.
The problem I see with this, is that this in itself is also an async task:

  • start the 'stop' task
  • wait for the task to complete

Now you could say that I should just wait in a blocking manner, but since I expect this to not be an uncommon thing to happen and battery is a very precious resource I would like to not waste cpu cycles (and therefore energy) this way.

Therefore my question: is there any way to drop a future in a asynchronus way?

If no other good answers arise from this chat I'm considering to implement something along the following lines:

macro_that_polls_two_furues_until_one_completes_returning_the_unfished_future_and_the_result_of_the_finished_future([future1, future2], 
{
  (result1, future2) => {DropWrapperFuture2(future2).drop();}
  (future1, result2) => {DropWrapperFuture1(future1).drop();}
} 

But I'm a bit concerned about the ergonomics here and how to enforce that the DropWrapper is used on the unfinished future.

Big thank you in advance to all async experts out there :wink:

1 Like

I'm afraid answer wouldn't satisfy you: this is common request and Rust language developers are thinking about how to implement it best.

There are even blog post which explains why thus would be desirable.

But, unfortunately, for now there are support for that in the language. The recommendation is usually to use Tokio::spawn, but I don't think it would work in embedded environment.

1 Like

Your assumption is correct, my executor (at least yet) dies not support spawning arbitrary tasks, and, if possible, i would Luke to keep it that way.

I thought a bit more about issue and i think for my concrete usw case there might be a different solution altogether. I said before that not polling the future to completion would be a common scenario, but what if this would not be the case?
If this wouldn't be the case the drop handler of the future could just panic, taking the whole program with it, preventing any undefined behavior (the unwind cannot be catched since panic in a drop results in a abort).

The scenario in which I expect this to usually happen is:

  • send a packet
  • wait some microseconds for receiving an ack packet back

In a naive implementation the second step consists of awaiting to futures at the same time: receiving a packet and waiting for some microseconds. Resulting in the unfinished future to be dropped.
The hardware however dies support so called "shortcuts" (short: "shorts”). Using these shortcuts it should be possible to tell the hardware to automatically turn of the radio after the timer finished and instead of waiting for the timer have a single future waiting for either the CRCOK event or the STOPED event.

Thus we would only be waiting on a single future all the time.
What I personally don't like that much about the solution is that it doesn't feel as " modular" as waiting for two seperate futures.

Edit: on second thought this solution does not necessarily depend on the existence of shorts, it only depends on the ability to have a single future wait on multiple peripherals, which can also be achieved in other ways.

This is essentially the same problem as why io_uring is difficult to implement in async/await. The standard solution is to have the future have ownership of the buffer so you can keep it alive until the hardware finishes writing even if the future is cancelled before that happens. You may want to look at the API of tokio-uring for inspiration.

I also already thought about letting the future own the buffer, but as far as my imagination goes that would result in either having to leak the memory when the future is dropped or having a static buffer.

The static buffer might be a solution, but, depending on how what the user wants, might result in having to copy buffer contents from one peripheral to the next peripheral where a simple copy of a pointer would have been more than enough.

Now you might argue that this is a kind of premature optimization, but since this is going to be a relevant part of the api I would like to get this as right as possible :slight_smile:

In the case of tokio-uring, the runtime will hold on to the buffer and free it when the IO operation finishes.

The fact that future owns the buffer doesn't mean buffer lives in the future. Box exist for a reason.

Maybe you are trying to create something really tiny, without any allocator at all, but I'm not sure Rust is good fit in that case, you can create primitive allocator in literally few dozen of lines, if you couldn't even afford that then I'm not sure your tasks justifies use of Rust.

I'm sorry, but I don't really see the connection between "doing something tiny" and "justifying the use of rust". Maybe you could explain what exactly you mean by this?

To me, rust shines exactly in this constrained environment because it still allows you to enforce arbitrary restrictions through the type system. The only real other contender at this low level point would be c/c++ which I don't know as well plus I prefer not having memory corruption issues I need to debug painstakingly on an embedded device I might not have easy access to anymore.

Rust add some overhead and requires some runtime to be efficient.

It doesn't add a lot of runtime, sure, but it still adds few kilobytes.

If you couldn't even afford to have very primitive allocator (which takes less then one kilobyte) then it doesn't sound like Rust would be a good fit for your task.

And especially not async Rust. At this level, when you literally need to count every byte and every tick only assembler can deliver.