A final proposal for await syntax

It is not, unfortunately, and it is quite a complicated code base and system, but I'll try to present a simplified but relatively faithful reproduction.

It looked something like this:

async fn execute(ctx: Context, input: InputValue) -> Result<Value, Error> {
  let input_a_and_b = ctx.get(input.x).build()?;

  // Step A.
  let future_a = async_action1(input_a_and_b.clone());

  let input_b = ctx
    .get(input.y)
    .build()
    .await?;
  let (flag, value_b) = match input_b {
    Enum::A(a) => {
        let modified_a = a
         .map(|x| x.modify())
         .unwrap_or(compute_other_value());
        let (flag, input_3) = async_action2(&ctx, modified).await?;

        // Step B.
        let output_b = async_action3( input_a_and_b, input_3, async_action2(..).await? )
            .await?  
            .method()
            .await;
        (flag, output_b)
     },
    Enum::B => {
      (false, Ok(None))
    },
  };
  
  let future_c = async_action4(flag);
  
  // Step C.
  let output = futures_preview::join!( future_a, future_c);
  match output { 
    // Nothing relevant for this example.
    // ...
  }
}

The context for this code is that it fetches some data, schedules processing in a few systems, and waits for some results. It's complicated by the fact that some part of the scheduling happens in bridged C++ code.

Step A Constructs a future which, during executing, at some point increments a counter. Note that due to Rust future semantics, future_a does not start executing until step C.

Step B First fetches some flag which is needed for future_c and then calls async_action3. The last await at step B is the bug.

Step C joins on the two futures.

The bug is a subtle ordering issue which resulted in the execute method never finishing. async_action3 runs some foreign code that waits until a counter is incremented, which happens during execution of future_a. (note they share some common input). It then schedules some processing, and returns an acknowledgement with a id.

Correct code would, instead of await ing at Step B, would have either done join!(a, b, c) or first the join!(a,c) and then await on the future from Step B.

This also wouldn't have happened in other async/await languages since they start executing immediately, but this is unrelated and just something you have to learn when adopting Rust.

This took me ... a long time to find. ( the real code is also has quite a bit more going on)

Now there are all kinds of ways this could have been prevented, like abstracting the interdependent code into a separate function.

It could also be argued that this bug could easily happen in any case and that the syntax isn't really a differentiator here, and that .await vs @ await, |> await or prefix await wouldn't have made a difference. I could understand that argument.

I guess for me it's partly the fact that appending .await is just so easy to do during in normal code flow that it doesn't provide a mental breakpoint.

With prefix await, you either have to know that you want to await when starting to write an expression, or you have to recognize the need for it, backtrack to the start and insert the await. Personally this definitely causes me to re-evaluate the code and re-consider the await.

Some other construct like postfix pipelining could also provide a similar benefit if it's more associated with control flow. @ await is really not much harder to type than .await. It is a lot noisier though when reading it again! It is also clearly a distinct concept from field access. ( I probably read over the function above dozens of times before figuring out the issue)

This ties into your other question.

I’d like to drill a bit into why it is important for it to stand out in git diffs.

Suspending can have a big impact on how code executes, especially so in Rust because futures need to be actively polled to make progress, which makes ordering your suspension points correctly much more important than in , say JS or Python.

The only way to draw your attention to a .await is syntax highlighting. It's really quite easy to skip over it in longer call chains, or in nested expressions (like the inline .await in my Step B).

To me, relying on syntax highlighting to recognize control flow is definitely a detriment. Plenty of contexts have no syntax highlighting, like some code review systems, git diff, or chat clients. It's also a language design question, should control flow be easily recognizable?

An interesting comparison here is prefix await, because in theory the same could apply there. I think this is improved by two factors:

  • await at the beginning of a expression is usually quite noticeable, since it's the first thing you read and immediately frames the expression in a async context
  • In practice, prefix await rarely is used in nested expressions (* in my experience*). If I had to speculate, I'd say this is because
    • nested awaits get awkward very quickly due to the parentheses required
    • they can obfuscate what's going on and so are naturally avoided.

As mentioned, this is partly improved by .await? likely being a very common pattern. I'd venture that .await? could likely be quite a bit more common than .await. And, to me, .await? is much more attention-drawing due to the extra sigil, and probably because you always watch out for ? anyway when reading Rust.

In conclusion, I appreciate how difficult this decision is, and I'm very happy to not be on the lang team. :wink:

12 Likes