-
-
Notifications
You must be signed in to change notification settings - Fork 4.2k
[PoC] Add support for async "coroutine" systems #6641
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
Conversation
cc @hanabi1224, if you're interested. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Very interesting.
I think if we can get them to work, the most useful async/generator system would be ones replacing a manual state machine such as this:
#[derive(Default)]
enum State {
#[default]
Initial,
Loading(Handle<Image>),
Loaded(Handle<Image>),
Done,
}
fn run(
mut state: Local<State>,
asset_server: ResMut<AssetServer>,
mut images: ResMut<Assets<Image>>,
) {
match &*state {
State::Initial => {
let handle = asset_server.load("branding/icon.png");
*state = State::Loading(handle);
}
State::Loading(handle) => match asset_server.get_load_state(handle) {
LoadState::Loading => return,
LoadState::Loaded => *state = State::Loaded(handle.clone()),
_ => todo!(),
},
State::Loaded(handle) => {
let image = images.get_mut(&handle).unwrap();
image.sampler_descriptor = ImageSampler::linear();
*state = State::Done;
}
State::Done => {}
};
}
I tried writing this system with the co!
macro and the end result looked like this:
co!(
async move |asset_server: ResMut<AssetServer>, assets: ResMut<Assets<Image>>| loop {
let handle: Handle<Image> =
co_with!(|asset_server, _| asset_server.load("branding/icon.png"));
loop {
let load_state = co_with!(|server, _| server.get_load_state(&handle));
match load_state {
LoadState::Loaded => break,
LoadState::Loading => yield_now().await,
_ => todo!(),
};
}
co_with!(|_, mut images| {
let image = images.get_mut(&handle).unwrap();
image.sampler_descriptor = ImageSampler::linear();
});
// we're done
std::future::pending::<()>().await;
}
)
Some observations:
- why require the
loop
? can't we just do nothing inrun_unsafe
if the future is done? - it's easy to want to write code that doesn't work, for example
let load_state = co_with!(|server, _| server.get_load_state(&handle));
let poll = /* Loading => Pending, Loaded => Ready() */;
std::future::poll_fn(|_| poll).await
This doesn't work of course, because the poll is calculated once, and always stays pending.
More accurate would be
std::future::poll_fn(|_| {
let load_state = co_with!(|server, _| server.get_load_state(&handle));
/* Loading => Pending, Loaded => Ready() */
}).await
but that doesn't work, hence the loop
with yield_now
in the final solution.
- IDE experience is okay-ish, autocomplete works inside
co!
andco_with
, goto definition only works inco!
and suggestions like fill match arms don't work at all
Also worth pointing out is that this can live in a third-party crate if we don't want to maintain this ourselves but someone wants to use it, that way we can see how much usage it gets or if there are any more obstactles to async systems.
// FUTURE: this is the standard hack to poll a future once, but it would | ||
// be more accurate to either provide a "null waker" since we ignore the | ||
// wakeup, or to provide our own waker that allows us to bridge the wake | ||
// to the system's run criteria and only poll once the future is awoken. | ||
// SAFETY: | ||
// - self.pinned.state has no active interior borrows | ||
// - self.pinned.state is valid for get_unchecked_manual within this fn | ||
// given we use the world ref stashed just above (ensured by caller) | ||
if let Some(never) = block_on(poll_once(this.func)) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We have something for the null waker in bevy_utils
: https://github.com/bevyengine/bevy/blob/main/crates/bevy_utils/src/futures.rs#L7
//! # use bevy_ecs::prelude::*; | ||
//! # use bevy_tasks::prelude::*; | ||
//! # use bevy_async::*; | ||
//! fn make_system() -> impl System { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If you return impl System<In = (), Out = ()>
it can be added with app.add_system(make_system())
.
I also tried using the nightly || {
let (asset_server, images) = yield;
// do stuff
let (_asset_server, _images) = yield;
dbg!(&images); // unfortunately still live here
} I thought that the system parameters could be |
Yeah; using It would be theoretically possible to implement an
Mostly, this is in order to be explicit and avoid making a choice. There's a split on (The former is imho a better option for
I don't think If this async model is adopted more, I suspect we'd add something along the lines of loop {
co_with!(|server, _| server.poll_ready(&handle)).await
} If bevy were to adopt async even more (though I probably wouldn't recommend it for bevy), we could provide some sort of async channel to notify readiness with a single
Yeah, it's quite nice that the transformation is simple enough that simple IDE functionality works. It's probably worth throwing together the
Yeah, although the additional public API surface to |
Backlog cleanup: I think if still labelled controversial, and no movement since 2022, it's reasonable to close this one. |
Objective
async
to write systems that span multiple framesSolution
This PR implements a proof-of-concept for "coroutine systems" defined using
async
. Because it is not sound to hold a reference to ECS resources over anawait
point, access to the system parameters is limited to during a sync closure through aco_with!
macro.This abuses the fact that macro names are not hygienic to define the
co_with!
macro used to access system parameters. This scheme allows minimizing the annotation overhead required of the user, importantly allowing full elision of lifetimes just as with normal function systems.I did attempt to skip the need to re-name the system parameters when entering
co_with!
(essentially getting the "magic mutation" syntax foryield
closures), but unfortunately for this use case, naming the parameters within the closure always refers to the outer names, not the newly bound names due to the new semitransparentmacro_rules!
layer.The macro techniques to do so
The reason this doesn't work seems to be that the
co_with!
definition introduces a fresh scope which the expanded idents are attached to, rather than the intent in this case of keeping their originally captured context. I've tried a number of permutations but was unable to get the desired behavior here.This can mostly be implemented out-of-tree, but requires making a few more things accessible from
bevy_ecs
to be able to implementSystem
properly.