#requesting feedback for hardware exclusion with no-alloc async task pools

16 messages · Page 1 of 1 (latest)

plush geyser
#

to give you an idea of what the syntax looks like:

// regular async function that waits `ticks` ticks
async fn wait_ticks(ticks: i32) {
    for _ in 0..ticks {
        future::yield_now().await;
    }
}

// PREEMPTIBLE async function that increments an &mut i32
#[preemptible(data)]
async fn increment_n_times(data: &mut i32, times: i32) {
    for i in 0..times {
        assert_eq!(*data, i);
        *data += 1;
        future::yield_now().await;
    }
}

// another preemptible async function that sets a mutable i32
#[preemptible(data)]
async fn set(data: &mut i32, val: i32) {
    *data = val;
}

// this is needed for the test case to pass
#[preemptible(data)]
async fn data_assert(data: &mut i32, cond: fn(i32) -> bool) {
    assert!(cond(*data));
}

// a RevocableCell called `data` is created with the initial value 0
let data = RevocableCell::new(0, "test data");

let wait_5_then_reset = async || {
    wait_ticks(5).await;
    set(&data, 0).await?;
    data_assert(&data, |x| x == 0).await?;
    Ok(())
};

let (a, b) = future::zip(wait_5_then_reset(), increment_n_times(&data, 100)).await;
assert!(a.is_ok()); // a ran to completion
assert!(b.is_err()); // b got cancelled by set after 5 ticks of a

// data got reset to 0 in a
data_assert(&data, |x| x == 0).await.unwrap();

let res = future::try_zip(wait_5_then_reset(), increment_n_times(&data, 100)).await;
assert!(res.is_err()); // a preempted b, cancelling both since they are joined

#

this works pretty well, and is very applicable to hardware control (eg. multiple async tasks controlling the same piece of hardware is unsafe)

#

Note that this is all heap allocation free, since a primary target of this library is bare metal with soft real time requirements. The PreemptibleFuture system is currently runtime agnostic, so it can somewhat run on top of embassy or rtic** (more on this in a bit)

#

The problem i'm struggling with, and would like advice on, is multithreading this

#

Because the invariant of "any incoming tasks immediately revokes ownership of all requirements from all incumbent tasks, which are cancelled next time they're polled," requires all tasks to be stopped while a given one is scheduled, RevocableCell must not be Sync

#

I initially thought the fix for this without spin locking each newly scheduled task would be straightforward, just convert all #[preemptible] annotated tasks into proxy tasks that send their actual future over a channel to the relevant thread and schedule it there, then send the result back over a channel

#

The problem is now you need to store arbitrary Future impls in a queue (eg. Box<&dyn Future>), which isn't possible in no-alloc environments

#

The way embassy and rtic approach this is by having something like a static TaskPool<F: Future, const N: usize> that contains the tasks and TaskRef to do type erasure and act as a &dyn Future for the schedule to poll

#

_ _
This leads to the actual topic I'm working on:

  • a TaskPool has limited storage
  • under the current PreemptibleFuture system, outgoing tasks cancel themselves on their next poll, rather than when an incoming tasks is scheduled. this means the minimum TaskPool size for an async task using swiper is 2, which isn't ideal

Currently, spawn is a fallible operation in embassy, which can fail if the task pool is full. Interop with swiper stealing functionality necessitates changing this to make spawn infallible and cancel an incumbent task instead.. which is essentially the same as the original design

#

requesting feedback for hardware exclusion with no-alloc async task pools

#

One major size optimization that can be gleaned from mutually exclusive preemptible tasks is that multiple tasks could be grouped together as all preemptible, where

#[preemptible_union]
impl ExampleHardwareSystem {
  async fn action1(&mut self) {}
  async fn action2(&mut self) {}
}```

would create a single task pool for an instance of either `action1` or `action2`
#

oh lovely

#

anyway, ignoring current war related events, back to hyperfocusing on rust

plush geyser
# plush geyser _ _ This leads to the actual topic I'm working on: - a `TaskPool` has limited st...
  • in standard embassy code, only the highest level tasks are marked with the #[embassy_executor::task] macro, everything else is handled by the state machines that the compiler generates for the high level tasks

  • the requirement for swiper to be able to transparently await a task from any core and have it execute on the appropriate core adds a potentially unnecessary layer of indirection to the lower level tasks (which would now be type erased references to a task pool), especially in single threaded environments

  • it would be possible to fork or upstream from embassy's TaskPool system to allows task preemption when spawning, but that is redundant with PreemptibleFuture in some cases

  • TaskPool max-task limits apply per instance the same task (or a defined union of tasks), whereas PreemptibleFuture semantics apply per instance of actual hardware object, which is often more appropriate

#

it would be somewhat interesting to try giving a locked down version of Future Sync with a bunch of constraints and just allocating it locally + awaiting it on the other thread with checks to see if it got destructed

#

that is very much getting into the territory of rewriting embassy executor