Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support for statically allocating tasks (no_alloc usage) #7

Open
Dirbaio opened this issue Aug 29, 2020 · 5 comments
Open

Support for statically allocating tasks (no_alloc usage) #7

Dirbaio opened this issue Aug 29, 2020 · 5 comments

Comments

@Dirbaio
Copy link

Dirbaio commented Aug 29, 2020

Motivation

It is currently possible to use async_task with no_std, but it requires having alloc available because tasks are dynamically allocated.

In memory-constrained environments, such as embedded microcontroller devices, it's often useful to statically declare everything and not have any dynamic allocator. The main advantage is that you have compile-time guarantees that your program will never run out of RAM (The linker knows how much RAM the target device has, and will error if all the static variables won't fit).

Generally in an embedded device the following kinds of tasks are present:

  • tasks that start up at boot and run forever
  • tasks that are started and stopped, but never run multiple instances concurrently
  • tasks that run a bounded number of concurrent instances (usually small, 4-16 instances)

It is very rare that you want an arbitrary number of tasks of arbitrary types mixed together. You rarely have enough RAM for it, and it tends to cause fragmentation problems and unpredictable out of RAM errors.

It would be a huge boon for embedded if async_task allowed statically pre-allocating tasks.

Aditionally, this would be especially useful combined with #![feature(type_alias_impl_trait)] (rust-lang/rust#63063), which makes it possible to name the future types of async fns. (naming the type is needed so the user can declare the static variable containing the task)

API proposal

The API could be something like this

// maybe R is not needed
pub struct StaticTask<F, R, S, T> { /* storage for a raw task */ } 

impl StaticTask<F, R, S, T>
where
    F: Future<Output = R> + Send + 'static,
    R: Send + 'static,
    S: Fn(Task<T>) + Send + Sync + 'static,
    T: Send + Sync + 'static,
{
    // create a new StaticTask in "free" state
    // The bit pattern of the return value must be only zero bits and uninitialized bits, so
    // StaticTasks can be placed in the .bss section (otherwise they'd go into .data which wastes flash space)
    pub const fn new() -> Self { ... }

    // If self is in "free" state, change it to "used" state and initialize it with the given future, and return the task and joinhandle.
    // if self is in "used" state, return None.
    pub fn spawn<F, R, S, T>(&'static self, future: F, schedule: S, tag: T) -> Option<(Task<T>, JoinHandle<R, T>)> { .. } 
}

This would be used like this

static MY_TASK: StaticTask<MyFuture, MyFuture::Output, ??, ()> = StaticTask::new()

fn main() {
   if let Some(t, j) = MY_TASK.spawn(my_do_something(), |t| { /* schedule t)}, ()) {
      t.schedule()
   } else {
      // spawn failed because the static task is already running, return some error
   }
}

When the task is no longer running (ie when it would be freed if it was dynamically allocated), the StaticTask is returned to "free" state, so it can be used by .spawn() again.

This would make it possible to mix statically-allocated and dynamically-allocated tasks in the same executor.

Having so many generic arguments in StaticTask is somewhat ugly because the user has to manually specify them, but this is something executor libraries could abstract (ie export a newtype so you only have to set F). A higher-level executor API could be like this:

static MY_TASK: my_executor::Task<MyFuture> = my_executor::Task::new()

fn main() {
  // dynamically allocate
  my_executor::spawn(my_do_something());

  // statically allocate
  MY_TASK.spawn(my_do_something());

  my_executor::run()
}

Alternatives

Add an API where the user can specify a custom allocator for spawning, via some trait. Still, th library would still have to export a type so that user code can know what's the size required for a RawTask of a given future, so they can statically allocate buffers of the right size.

@ghost
Copy link

ghost commented Sep 7, 2020

Hmm, this looks like a really really difficult problem to solve. async_task::Task would have to be parametrized over the future type and the schedule function type.

I kind of believe that even if we managed to somehow change the API like this, in the end it would be so restrictive that it wouldn't be all that useful for embedded systems. :/

It might be easier to build a new crate that is similar to async-task, but designed for allocation-less systems... I'm saying this because the whole point of async-task is to make building executors easier, but specifically the kind of executors that erase the future type and spawn a lot of different tasks.

@Dirbaio
Copy link
Author

Dirbaio commented Sep 7, 2020

async_task::Task would have to be parametrized over the future type and the schedule function type.

Not necessarily, what needs to be parametrized is the StaticTask that you statically allocate for storage, not Task.. You call .spawn() on it and get back an instance of the exact same Task type as now (type-erased), except it points to a buffer inside the StaticTask instead of a heap allocation.

Task could have a new bit in state to store whether its buffer is dynamic or static and do the right thing on deallocation.

I've built a proof of concept of the "statically allocate tasks" idea here: https://github.com/Dirbaio/static-executor . My Task type is equivalent to StaticTask in the API sketch above.

Unfortunately it's not as powerful as async_task since there's no Task equivalent for users to "build their own" executors, it's an executor itself. That simplifies the design hoever (for example, allows using an intrusive list for the task queue instead of having to allocate a deque).

Out of curiosity, what's the reason async_task does the Task layout manually instead of using unions? I was going to implement JoinHandles in async_executor as an union of the future and the result, and I'm curious if there's some scary pitfall I'm not seeing.

@ghost
Copy link

ghost commented Sep 7, 2020

I've built a proof of concept of the "statically allocate tasks" idea here: https://github.com/Dirbaio/static-executor . My Task type is equivalent to StaticTask in the API sketch above.

Interesting -- the #[task] attribute looks quite nice! Wow, never thought of that...

Out of curiosity, what's the reason async_task does the Task layout manually instead of using unions? I was going to implement JoinHandles in async_executor as an union of the future and the result, and I'm curious if there's some scary pitfall I'm not seeing.

It's for performance - the idea was to have the best possible performance with as few atomic operations as possible and using as little memory as possible. But, I'm doubting whether it was worth it. I'm considering rewriting async-task in a simpler way with less unsafe code and fewer crazy optimizations.

I don't have much more to add right now... will need to think about this a bit more.

@reevesPAC
Copy link

Ok thank you, @Dirbaio I got it.

@notgull
Copy link
Member

notgull commented May 29, 2024

Probably could be solved via #24

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Development

No branches or pull requests

3 participants