Till: Part 1

Writing a customisable Rust Async Executor

November 10 2023

I’ve been reading some of Without Boats’ recent blog posts about the state of async programming in Rust and it’s become a sort of motivator for me to step outside my comfort zone when it comes to programming in async Rust. I write with async Rust daily at work using the Actix-Web HTTP server framework and sometimes just bare Tokio when the situation calls for it. I’ve had a vague idea of what it is that an executor does for a while, but I’ve never considered writing my own until now. This will be a series of posts detailing the development of my async executor, Till.

Goals

Goal 1: My goal isn’t to build a better Tokio, that seems like setting myself up for failure. Instead my goal is to do what Tokio can’t do, run on small embedded systems. I could target the ESP32 since I work with them a lot, but since you can build Rust’s std on top of esp-idf I wouldn’t be surprised if it could just run Tokio anyway. Instead I’ll do my testing on something much smaller, an Arduino Nano. There’s no std support for AVR targets since they’re much too small, and since Tokio normally runs on machines with at least tens of gigabytes of RAM, it’s probably more optimised for that kind of use so I doubt it could fit into the 2.5 kilobytes of RAM of the AtMega328.

Goal 2: Beyond targeting tiny embedded systems without std, I also want to support no-alloc environments too. With so little memory to work with on these systems, the predictability of static and stack only are especially valuable. I want to offer extra features if alloc is available, but it shouldn’t be mandatory.

Goal 3: Finally, although I’ll be testing on an Arduino Nano, the whole thing should be agnostic of what it’s running on. All of the target specific logic should be able to be injected into it as needed by the consuming application.

Design

The way I see it an executor needs a few main parts:

The executor is then just going to be a thin wrapper over these parts. Each of them will get a trait and the executor will take in implementors of these traits in its new method. That way the user can customise aspects of the executor’s function by implementing them on their own types.

The event loop part doesn’t seem like it should be strictly necessary so I’ll leave it out of my proof-of-concept and work on its design later.

Task Manager

Initial requirements for this part are to have some way to iterate over the stored tasks so the executor can poll them, and also to have a way to put tasks to sleep. The executor can’t hold the wake status of each task since it doesn’t know how many there are and would need to allocate at runtime to do so. Goal 2 precludes this requirement so that responsibility has to be pushed onto the implementor of this trait.

Here’s what that trait might look like:

pub trait TaskManager {
    type TaskIterator<'a>: Iterator<
        Item = Pin<&'a mut dyn Future<Output = ()>>
    >
    where
        Self: 'a;
    fn get_task(
        &mut self,
        i: usize,
    ) -> Option<Pin<&mut dyn Future<Output = ()>>>;
    fn sleep_task(&mut self, i: usize);
    fn sleep_all(&mut self);
    fn tasks<'a>(&'a mut self) -> Self::TaskIterator<'a>;
}

I didn’t realise this until after I had written it, but here’s a great use for Rust’s recent MVP support for generic associated types (GATs).

The only thing the jumps out as needing improvement here is the i: usize in get_task and sleep_task. The idea I had was that it should correspond with when in the iterator sequence the task gets returned, but that probably imposes too many restrictions on the implementor. I’ll continue with this for now but it’ll need some more work later.

Context

Looking at the Future trait…

pub trait Future {
    type Output;

    fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output>;
}

…the next thing to figure out is Context<'a> since I’ll need those to be able to poll the tasks. Initially it looks quite good. There’s a lifetime parameter on it so maybe this communication part mentioned earlier will be able to exist on the stack. I’ve looked at this type before and remember that there’s a RawWaker which is sort of like a &dyn WakerTrait except WakerTrait doesn’t exist and instead you have to make the V-table manually. That RawWaker then gets put behind a wrapper, Waker for some reason that I don’t understand.

Looking at Waker I notice that unlike Context<'a>, it doesn’t have a lifetime parameter. I guess if you only ever have a 'a reference to it from a context it’s basically the same thing but then I notice it implements Clone. So much for having the communication part be able to live on the stack, with a Waker that can live forever so too must anything it references also live forever. This means I need 'static lifetimes for the Waker’s references which means static or heap and since I don’t want to require alloc, I’ll have to keep in mind as I design that the communication part will be a static variable. Let’s give this communication part the name…

Marshall

I think I’ve seen this term used in the context of interrupt handlers where you want to go from a function pointer to making a method call on a particular object. This situation of having to go from a 'static to a 'a feels similar to me so I’ll use the same name for it.

Here’s the trait:

pub trait Marshall: Sync + 'static {
    fn wake(&'static self);
    fn waker(&'static self) -> core::task::Waker;
}

The documentation for Waker makes note that it implements Send and Sync. Since I think my waker will basically be a reference to an implementor of Marshall, that implementor will need to be Sync to be safe in multi-threaded environments. Although maybe not strictly necessary, I’ve included a wake method in addition to the waker since it was easier for me to think about it this way.

This trait is kept deliberately very simple, nothing about how it communicates with a task manager is present. This is because I expect the TaskManager implementor and Marshall implementor to be written as a compatible pair and it makes more sense to have the bound be on the TaskManager side, which now becomes:

pub trait TaskManager {
    type Marshall: Marshall;
    type TaskIterator<'a>: Iterator<
        Item = (
            Pin<&'a mut dyn Future<Output = ()>>,
            &'static Self::Marshall,
        ),
    >
    where
        Self: 'a;
    fn get_task(
        &mut self,
        i: usize,
    ) -> Option<(
        Pin<&mut dyn Future<Output = ()>>,
        &'static Self::Marshall,
    )>;
    fn sleep_task(&mut self, i: usize);
    fn sleep_all(&mut self);
    fn tasks<'a>(&'a mut self) -> Self::TaskIterator<'a>;
}

The executor will need to be able to get a reference to the Marshall to be able to construct a Context<'a> to poll with, and depending on implementation each task may need to have it’s own Marshall instance so making it part of the task iterator and getter seems right.

Executor

Now seems like a good point to start writing the Executor wrapper type. Here’s the first version:

pub struct Executor<'a, Tasks: TaskManager> {
    tasks: &'a mut Tasks,
}

For now it’s just a wrapper over a mutable reference to a TaskManager but I expect there to be more in here once the event loop part is built.

Methods on the type look like this:

impl<'a, Tasks: TaskManager> Executor<'a, Tasks> {
    pub fn new(tasks: &'a mut Tasks) -> Self {
        Executor { tasks }
    }

    pub fn run_to_completion(self) {
        while self.tasks.tasks().any(|(task, _)| !task.is_terminated()) {
            for (i, (mut task, marshall)) in
                self.tasks.tasks().enumerate().filter(|(_, (task, _))| {
                    todo!("What goes here?")
                })
            {
                let waker = marshall.waker();
                let mut context: core::task::Context<'_> = core::task::Context::from_waker(&waker);
                self.tasks.sleep_task(i);
                let _ = task.poll(&mut context);
            }
        }
    }
}

Unfortunately, run_to_completion doesn’t compile. We’re borrowing from self.tasks for as long as we have a pinned reference to the task, so we can’t then borrow again to set the sleep status. Also, I want to be able to filter out tasks that are asleep or completed since they don’t need to be polled, but I don’t have the means to check for either. Luckily both of these issues can be solved with the same technique, use a sub-trait of Future for the tasks.

If we depend on the futures we can use its FusedFuture trait so we can check if a task is complete. Similarly we can define our own sub-trait of FusedFuture which stores a wake status too.

pub trait FusedFutureWithWakeStatus: FusedFuture {
    fn status(&self) -> WakeStatus;
    fn set_status(self: Pin<&mut Self>, status: WakeStatus);
}

WakeStatus is just a two-state enum:

pub enum WakeStatus {
    Woken,
    Asleep,
}

set_status needs to be callable when pinned so Pin appears in the self type. Since the status method is immutable it’s not needed.

TaskManager now becomes:

pub trait TaskManager {
    type Marshall: Marshall;
    type TaskIterator<'a>: Iterator<
        Item = (
            Pin<&'a mut dyn FusedFutureWithWakeStatus<Output = ()>>,
            &'static Self::Marshall,
        ),
    >
    where
        Self: 'a;
    fn get_task(
        &mut self,
        i: usize,
    ) -> Option<(
        Pin<&mut dyn FusedFutureWithWakeStatus<Output = ()>>,
        &'static Self::Marshall,
    )>;
    fn sleep_task(&mut self, i: usize);
    fn sleep_all(&mut self);
    fn tasks<'a>(&'a mut self) -> Self::TaskIterator<'a>;
}

And Executor::run_to_completion becomes:

pub fn run_to_completion(self) {
    while self.tasks.tasks().any(|(task, _)| !task.is_terminated()) {
        for (i, (mut task, marshall)) in
            self.tasks.tasks().enumerate().filter(|(_, (task, _))| {
                !task.is_terminated() && matches!(task.status(), WakeStatus::Woken)
            })
        {
            let waker = marshall.waker();
            let mut context: core::task::Context<'_> = core::task::Context::from_waker(&waker);
            task.as_mut().set_status(WakeStatus::Asleep);
            let _ = task.poll(&mut context);
        }
    }
}

Since we’re depending on FusedFuture we can just discard the result of task.poll as it’s implementation will update termination status for us if needed.

Conclusion

That’s it for the first part in this series. We’ve laid down the core ideas and formalised them into some traits. In the next part I’ll be implementing these traits to see how they work in practise.

Recommended Projects

Till: Part 2

Implementing MVP and testing on an Arduino