Curious if there's a nicer way to (ab)use async as co-routines without Box

I'm trying to figure out if I can simplify my emulator logic by representing each bit of hardware with an async function that can just cycles(3).await rather than building a state machine.

Messing around a bit it's easy enough to do with Box::pin() and I expect I'll end up just using that, but I was wondering how far I could push it without any indirection at all, and I came up with this, err, gem:

#![feature(type_alias_impl_trait)]

use std::cell::Cell;
use std::future::Future;
use std::pin::{pin, Pin};
use std::time::Duration;

fn main() {
    let mut vm = pin!(Vm::new());

    loop {
        vm.as_mut().step();
        std::thread::sleep(Duration::from_millis(500));
    }
}

pin_project_lite::pin_project! {
    struct Vm {
        cycles: u32,
        #[pin]
        foo: Foo,
        #[pin]
        bar: Bar,
    }
}

impl Vm {
    fn new() -> Self {
        Self {
            cycles: 0,
            foo: foo(),
            bar: bar(),
        }
    }

    fn step(self: Pin<&mut Self>) {
        let mut this = self.project();
        *this.cycles = this.cycles.wrapping_add(1);
        CYCLES.set(*this.cycles);

        let waker = std::task::Waker::noop();
        let mut cx = std::task::Context::from_waker(waker);
        _ = this.foo.as_mut().poll(&mut cx);
        _ = this.bar.as_mut().poll(&mut cx);
    }
}

thread_local! {
    static CYCLES: Cell<u32> = const { Cell::new(0) };
}

async fn cycles(count: u32) {
    let deadline = CYCLES.get().wrapping_add(count);
    let fut = std::future::poll_fn(move |_cx| {
        if CYCLES.get() < deadline {
            std::task::Poll::Pending
        } else {
            std::task::Poll::Ready(())
        }
    });
    fut.await
}

type Foo = impl Future;

#[define_opaque(Foo)]
fn foo() -> Foo {
    async {
        loop {
            cycles(3).await;
            println!("foo");
        }
    }
}

type Bar = impl Future;

#[define_opaque(Bar)]
fn bar() -> Bar {
    async {
        loop {
            cycles(5).await;
            println!("bar");
        }
    }
}

(Playground)

Is this about where the state of the art is here, am I missing any nicer ways to do this, or should I be looking into other nightly features (generators?) if I'm trying to pull this?

If you want to avoid allocations for unnameable types then using TAITs is indeed the way to go.

(Ab)using async to do this however is mostly orthogonal to avoiding allocations. Rust has had both generators and more general coroutines as unstable features for quite some time now.

Yeah, specifically the weird async fn syntax required (for now?) seems like I'm missing something, and I'm not familiar enough with the generator or coroutines stuff to know if it's worth checking out - in particular if they don't confuse RustRover as much as this stuff seems to.

Hmm. Seems like the best fit, if I wanted to pass in my VM state is co-routines, but here's as good as I can figure:

Which fails with:

error: implementation of `Coroutine` is not general enough
  --> src/lib.rs:71:5
   |
71 | /     Actor::<Foo>::new(#[coroutine] |vm: &mut Vm| {
72 | |         vm.act("start foo");
73 | |         loop {
74 | |             let vm = yield 3;
...  |
77 | |     })
   | |______^ implementation of `Coroutine` is not general enough
   |
   = note: `{coroutine@src/lib.rs:71:36: 71:49}` must implement `Coroutine<&'1 mut Vm>`, for any lifetime `'1`...
   = note: ...but it actually implements `Coroutine<&'2 mut Vm>`, for some specific lifetime `'2`

The unstable book of all things describing them as "Coroutines are an extra-unstable feature in the compiler right now." doesn't give me a lot of confidence, let's say!


(I was getting the same error even before using trait_alias if that matters)

This is already tracked as part of this issue Coroutines should be able to implement `for<'a> Coroutine<&'a mut T>` · Issue #68923 · rust-lang/rust · GitHub

Unfortunately for now you can't give coroutines values that are valid only until the next yield point.

1 Like

I'll put this whole thing down to "it'll be nice when it's cooked" then. Not even sure if the boxed async will work if I want to pass a mutable ref to my memory map either, but I'll mess around when I get more working.

Probably your best bet for now is to abuse the unstable Context::ext method to pass a reference to your Vm type (assuming Vm itself is 'static)

do you mean passing arguments to Future when polling, like the resume argument of Coroutine? currently, you cannot pass user defined data from the scheduler to the Future being polled. the task Context was intended to be extended for similar usage, but on stable, it is just a container for the waker.

note, even without using the unstable Context::ext() API, if you are in control of both the scheduler and the tasks/Futures, you can hack your way around to some extent, by abusing the type-erasure nature of Waker, but I won't go into details because it's way too hacky even for my own taste. [1]

also, Futures cannot yield values directly to the scheduler, so you either have to poll the components more frequently than necessary, or you must use a custom Future and some side channel to send the "yielded value" to the scheduler.

but there's one advantage of Futures over Coroutines, that is you can easily compose smaller Futures using the async/await syntax sugar, while coroutines/generators lack similar features.


  1. hint: futures_micro::waker(), Waker::data() ↩︎

1 Like

Yeah basically all this.

Context isn't too bad in abstract but like with thread_local I'm really suspect of trying to shove a reference through there, let alone a mutable one. That linked to a "Generic Futures" issue that may be closer but that seems even further in the mists of RFC speculation.

You can "compose" coroutines at about the same level as await by just iterating them, but you lost most of the value. I think a lot of the third-party machinery like select! and spawn() is really the difference there though.

I guess for good ergonomics really I need to properly implement the VM as an executor and provide the leaf async methods manually: pretty sure at the moment that's only load, store, a couple waits, and interrupt for the emulated hardware itself.

This topic was automatically closed 90 days after the last reply. We invite you to open a new topic if you have further questions or comments.