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

Add a schematic state machine implementing Future #2048

Open
wants to merge 6 commits into
base: main
Choose a base branch
from

Conversation

djmitche
Copy link
Collaborator

@djmitche djmitche commented May 3, 2024

@fw-immunant generated something like this on-the-fly in teaching the course, and I thought it was great. I think having a schematic understanding of what's going on here helps students through some of the pitfalls. Particularly, it motivates Pin, which is where @fw-immunant did this derivation.

@djmitche djmitche requested a review from fw-immunant May 3, 2024 18:51
# Recursion

An async function's future type _contains_ the futures for all functions it
calls. This means a recursive async functions are not allowed.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would clarify this by invoking the analogy with recursive enums: they need an indirection to avoid being infinite-sized types. Prior to Rust 1.77, recursion in async fn was forbidden entirely and code had to forgo the async fn transform in favor of explicitly returning a Box<impl Future<Output=...>> type, but now only "bare" recursion (without an indirection) is forbidden.

Comment on lines +85 to +87
- All local variables are stored in the function's future struct, including an
enum to identify where execution is currently suspended. The real generated
state machine would not initialize `i` to 0.
Copy link
Collaborator

@fw-immunant fw-immunant May 6, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it makes more sense for explanation to first trace the control flow so we can enumerate states and get in our heads something like "we'll have three possible execution states for this async fn, so it makes sense that its future type would be (morally) an enum with three variants". Then we can look at variable liveness at each of the awaits to determine the payload of each variant. This lets us get to "futures store live locals" without having to introduce the notion of liveness explicitly like a compilers course would--these are just the variables whose values we'll need to run the rest of the function.

I think it's better for an example of the state machine transform to use an async fn that doesn't have async in a loop, so that it's easy for readers to enumerate the entire set of states in their head.

So I might suggest an example more like this:

async fn send(s: String) -> usize {
    println!("{}", s);
    s.len()
}

async fn example(x: i32) -> usize {
    let double_x = x*2;
    let mut bytes_written = send(format!("x = {x}")).await;
    bytes_written += send(format!("double_x = {double_x}")).await;
    bytes_written
}

This gives us three states:

  • an initial state holding the argument x
  • a state holding double_x until we return from our first await
  • a state holding bytes_written until we return from our second await

This is, I think, less confusing than a state we return to across iterations of our loop on 1..=count.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Implicit in this suggestion is to use enum variants to represent the liveness of variables. That's great, but does require a bit more mucking about with pins than we want to present at this point. In fact, it uses pin_project, which we don't even talk about in the Pin section. Should I maybe revert this to a flat struct with a fut field, just to avoid this?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think it's so important to make this example have perfect fidelity or be compilable/runnable--pseudocode to capture the transformation would be fine, and would allow us to side-step Pin questions. The sub-future, if we really want to represent it, could just be a std::future::Ready or isomorphic. The most significant thing is not the state of the child future but the fact that at await points we capture live state.

Copy link
Collaborator

@fw-immunant fw-immunant left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some comments inline.

@djmitche djmitche requested a review from fw-immunant May 6, 2024 18:56
@djmitche djmitche self-assigned this Jun 24, 2024
@djmitche
Copy link
Collaborator Author

djmitche commented Jul 4, 2024

@djmitche djmitche removed the request for review from fw-immunant September 4, 2024 16:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants