Async-await experience reports

Interesting. It doesn’t seem that inconsistent, though… Imagine if you were doing this using threads instead of async. Then next_frame() would return a Condvar or similar type, and the syntax would be something like next_frame().wait(), which is the same order as .await.

(In reality it would be a bit more complicated because condvars need an associated mutex and can have spurious wakeups; a better fit would be an “event” type, but Rust’s standard library doesn’t have one of those. I guess you could use a mpsc::Receiver<()>…)

3 Likes

How long have you been using the .await syntax?

About a week.

What code are you working on?

udoprog/setmod, work being done in a separate branch (async-await) but the port now runs which was my initial goal.

Right now it has 122 uses of .await. Project is 17k LOC.

What environment are you editing the code in?

VS Code, no syntax highlighting for .await.

Summarize what happened

The .await postfix syntax had almost no impact in terms of weirdness for me. Interactions with it felt very intuitive after a short period of time (a couple of hours). I was initially in the await!(...) macro camp but this is experience has taught me how nice .await is. The biggest win for me is that there’s no need to break up existing code when you introduce new async functions in a call chain which leads to cleaner patches.

The biggest issue is that async blocks and functions suffer from rather scary diagnostics when something goes wrong. I faced lifetime issues from forgetting to move things appropriately into the block or function. This is the patch that fixes it in my project.

Futures not being Send due to something !Send leaking across awaits is another thing that has unhelpful diagnostics. This is the patch that fixes it in my project.

How to work with 0.1 <-> 0.3 compatibility needs more examples and should be visible on docs.rs. For reference, this is available on the privately hosted documentation.

Background information

Rust user for 3 years. I do a programming stream and users on stream didn’t express confusion with the syntax once explained.

15 Likes

How long have you been using the .await syntax?

On and off playing with it since it became available in nightly, only one real but tiny project.

What code are you working on? How many lines of code? How complex? How old is the codebase?

A small service used in house not facing the internet for some UDP packet processing. Currently only 2100 lines of code. I previously wrote it using “stable” future syntax, now converted to .await

Otherwise just experimenting and for personal stuff.

What environment are you editing the code in? Do you have .await syntax highlighting?

Vim, no highlighting yet, maybe it’s already available, I didn’t bother yet to search.

Summarize what happened, both the good and the bad.

I’m really pleasantly surprised! The code using the .await syntax feels more easy to read and understand, even without syntax highlighting. I added a blank line here and there to catch the attention, but I expect that with highlighting the await points will be quick to spot in any way.

I feel that I very quickly got accustomed to await being a new keyword, and the .await syntax feels all fine, I never stumbled upon a thought like “oh wait, this looks like a field, but is it really?”

Guessing from how quickly I got accustomed with .await I strongly feel that it is the way to go!

Compiler error messages can improve, as always. I can’t say whether the error messages were more or less helpful compared to current “stable-style” futures.

I really enjoy using .await

18 Likes

How long have you been using the .await syntax?

A couple of days ago I converted two of my crates from await!() to .await.

What code are you working on?

webdav-handler and webdav-server

What environment are you editing the code in?

vim

How the new syntax feels over time.

Before I used it I thought that suffix dot-await was a brilliant idea. However it only really shines when chaining. In other places it feels kind of weird.

For example:

-    match await!(dav_if_match(req, fs, ls, path)) {
+    match dav_if_match(req, fs, ls, path).await {

Here, visually, I’d prefer

match await dav_if_match(req, fs, ls, path) {

Similar for while let Some(value) = await stream.next() etc

I will probably get used to it.

15 Likes
1 Like

I noticed that this thread became quiet about 25 days ago. There has been a single report in the last 14 days. Until that point, the reports were from people who had used the new syntax for “about a week” or “a few days”.

Coupled with the post about testing needed for async/await, it sounds to me like the feature should bake for a few releases — enough time so that people can really try it out in practice.

“Bake for a few releases” could be made more concrete by finding one or more big projects that currently use await!(), and then wait until they’ve updated their code to use .await. If they don’t see the claimed benefits of chaining .awaits, well then perhaps the syntax isn’t actually doing what people hoped it would do.

5 Likes

How long have you been using the .await syntax?

This is my first time doing anything more than a single line of code with async/await.

What code are you working on? How many lines of code? How complex? How old is the codebase? Can you post a link to it?

It's really short, 36 lines that I wrote in the last couple of hours.

What environment are you editing the code in?

VS Code with the Rust (rls) extension

Summarize what happened, both the good and the bad.

Sorry, this isn't a summary, kind of the opposite in fact - I just made notes while I was going through the process. Hopefully it's useful feedback. Feel free to ask me questions.

Click to expand
  • I'm using the runtime crate. The name is a little odd, but it's a nice crate and easy to get started with. I particularly like the spawn(async {...}) pattern. Since it looks a lot like spawning a thread, I decided wanted to make a producer/consumer example using a channel.

  • There's no channels in the runtime crate, they are in the futures crate. Ok, so I used futures::channel::mpsc::channel to create a sender and receiver for a bounded channel. How do I send something to the channel? There's methods called try_send and start_send. Neither of them look right. I'm expecting something that returns a future so I can .await it, but neither of them does. Maybe there's something in the Sink trait that does this... OK, not in Sink but in SinkExt, there is a send method. Except, it says that it flushes the sink - does that mean it will wait until the buffer is empty? Oh well, let's just try it. use futures::SinkExt. Sigh... this really should be an inherent method on Sender, or at least the documentation should show you how to actually use it. Here's the code, excluding imports:

    #[runtime::main]
    async fn main() {
        let (mut sender, mut receiver) = channel::<i32>(3);
    
        spawn(async move {
            sender.send(42).await.expect("send failed");
        });
    }
    

    I tried using .await? on the result of the call to send, but it doesn't work because we're in an async block, not a closure, and ? only works on functions. It will be nice if we get async try blocks some day. For now, I'll just let it panic if there's an error.

  • In the producer, I want to wait a random amount of time and then produce a random number. So I add let rng = thread_rng(); and await a runtime::time::Delay with a random number of milliseconds, then produce a value. Here's the body of async fn main so far:

    let (mut sender, mut receiver) = channel::<i32>(3);
    
    spawn(async move {
        let rng = thread_rng();
    
        loop {
            let wait_time = rng.gen_range(0, 3000);
            let wait_time = Duration::from_millis(wait_time);
            Delay::new(wait_time).await;
    
            let value = rng.gen();
            sender.send(value).await.expect("send failed");
        }
    });
    

    Uh oh, this produces an error message on the call to spawn:

    error[E0277]: `*mut rand::rngs::adapter::reseeding::ReseedingRng<rand_hc::hc128::Hc128Core, rand::rngs::entropy::EntropyRng>` cannot be sent between threads safely
    

    Well that's not the best error message, but someone will probably make it better at some point. It's saying that the async block is not Send because rng is the thread-local RNG. But I'm calling thread_rng() from within the async block, and I thought async blocks don't start executing until they are polled, so I'm not creating it in this thread and then passing it to that thread, right? I add futures::ready(()).await; to the top of the async block just to make sure. Still the same error.

  • eventually I realize that these are tasks, not threads, and an async block has multiple states where it's frozen due to an await expression. If at any of the await points there's a value on the "stack" that isn't Send, then that means the async block's type has a field with that type, and therefore the async block isn't Send. When I realize this, I think that is really unfortunate, since the value being passed to spawn really is Send, and once it starts executing it will be on the same thread the whole time and it won't matter that it's doing thread local stuff. But again I remember that these aren't threads, they're tasks, and because of this restriction they can be safely moved between threads, which is pretty cool. And it's probably not that big a deal. In this case I can call thread_rng() directly when I need to generate a random number, instead of assigning it to a local variable. I make that change, and woo-hoo! It compiles! Here's the code so far:

    let (mut sender, mut receiver) = channel::<i32>(3);
    
    spawn(async move {
        loop {
            let wait_time = thread_rng().gen_range(0, 3000);
            let wait_time = Duration::from_millis(wait_time);
            Delay::new(wait_time).await;
    
            let value = thread_rng().gen();
            sender.send(value).await.expect("send failed");
        }
    });
    
  • I'm getting a couple warnings: one is that receiver is unused and doesn't need to be mutable, but we'll get to that. The other is that spawn returns a JoinHandle that must be used. Does it though? I thought that spawn started executing the future. This seems like a false positive — sure, an async block by itself won't do anything until you .await it or spawn it, but the future that is returned by spawn is probably a special case because it is running. Maybe adding a #[must_use(false)] attribute to the JoinHandle type could fix this. Then again, what if the spawned future's output type is a Result? There's probably an issue on GitHub somewhere where they're discussing exactly this. Anyway, I'll just save the join handle to a local variable and .await it later:

    let (mut sender, mut receiver) = channel::<i32>(3);
    
    let join_handle = spawn(async move {
        loop {
            let wait_time = thread_rng().gen_range(0, 3000);
            let wait_time = Duration::from_millis(wait_time);
            Delay::new(wait_time).await;
    
            let value = thread_rng().gen();
            sender.send(value).await.expect("send failed");
        }
    });
    
    join_handle.await;
    
  • OK, let's actually use the receiver and print out the values. I added this code above the join_handle.await:

    receiver.for_each(async move |x| {
        println!("got a number: {}", x);
    });
    

    The move keyword is necessary because async non-move closures apparently aren't available yet. I was surprised that I didn't need to use try_for_each - since send on the sender returns a result, I would have thought that the receiver's next method would do the same.

    This code is nice and neat, but I'm getting an ICE about broken MIR, with the #[runtime::main] macro highlighted. Oh, there's also a warning that the for_each call returns a future and I'm not using it. The compiler's actually right in this case. I add .await to the end of the for_each call. No errors, and the ICE is mysteriously gone. Woo hoo! Let's cargo run this thing:

       Compiling async-stuff v0.1.0 (/Users/mikeyhew/Desktop/async-stuff)
        Finished dev [unoptimized + debuginfo] target(s) in 1.47s
         Running `target/debug/async-stuff`
    got a number: 1045570414
    got a number: -1726706442
    got a number: 413019516
    ^C
    

    It works! That's it for today, I'm happy with this progress. Here's the final code:

    #![feature(async_await)]
    
    use {
        runtime::{spawn, time::{Delay}},
        futures::{
            SinkExt, StreamExt,
            channel::mpsc::channel,
        },
        std::{
            time::Duration,
        },
        rand::{thread_rng, Rng},
    };
    
    #[runtime::main]
    async fn main() {
        let (mut sender, receiver) = channel::<i32>(3);
    
        let producer = spawn(async move {
            loop {
                let wait_time = thread_rng().gen_range(0, 3000);
                let wait_time = Duration::from_millis(wait_time);
                Delay::new(wait_time).await;
    
                let value = thread_rng().gen();
                sender.send(value).await.expect("send failed");
            }
        });
    
        receiver.for_each(async move |x| {
            println!("got a number: {}", x);
        }).await;
    
        producer.await;
    }
    
  • Regarding async/await syntax: I initially didn't like the idea of .await syntax because it was just weird, but I've gotten past that initial weirdness. It turns out .await is great in expressions. Even if you're not calling a method directly on the result, it's nice to be able to tack on .await or .await? in response to a compiler error message. It's a little weird as a statement though, when you just want to wait for something to finish before you proceed. For example:

    Delay::new(Duration::from_millis(500)).await;
    

    Syntax highlighting will probably help with that though.

7 Likes

That's not the case, it can be polled from an arbitrary different thread every time (and in fact, the default runtime-native does exactly that).

2 Likes

Given that async/await will only be released to stable in August, it seems rather early to call for delaying that release. There's plenty of experience yet to be gained in the time frame, and I know some big projects like tokio and hyper are in the process of porting to std::future.

3 Likes

@bill_myers There are some executors which run Tasks on a single thread, and thus they don’t require Send. As an example: LocalPool, LocalSpawn, and current_thread. But you have to opt-in to using them. Hopefully all of this will be covered in the upcoming async book.

Yes, I mean that multi-threaded executors that run Send futures can and do call poll() on the same future from multiple different threads unlike what the poster I replied to was assuming.

1 Like

I know that now - see the sentence after that. The point of that sentence was to show what I thought at the time.

My bigger point was that syntax the decision was made without any real experience. The .await syntax introduces a whole new class of syntax, namely "keywords in a field access position". We have not had this kind of syntax before, and I would say people have argued well for why this isn't a natural extension of the existing syntax. In other words, it's a step away from the tradition Rust comes from.

The step was taken because error handling and chaining was seen as being hugely important. In fact, those aspects were deemed to be so important that the syntax isn't .await(), despite .unwrap() being used daily. I believe the argument boils down to that awaiting isn't a method call (just like it isn't field access), and since both .await and .await() will be "wrong" in this sense, why not pick the shorter since we'll be using this often?

This thread is where people should talk about the experience with the new syntax. 1-2 weeks after it was available on nightly, I would have expected people to have done the search-replace on some 1000+ line projects. They could then come and tell us about how it actually feels to work with the new syntax. Questions to be answered would be:

  • Do you now use longer chains than before?
  • Is error handling easier than before?
  • Has .await caused you to miss synchronization points?

There has been two such reports above.

4 Likes

Quoted unofficially from a Calibra member:

Hey I'm Brandon, an engineer on the Calibra team. In short, async/await and futures 0.3 have been a great productivity boost over futures 0.1. Being able to write straight line code over using combinators or requiring that you hand-write a state machines makes reasoning about the code much easier. We understand that async/await is still currently a nightly feature but we felt like the feature had really good velocity and was likely to become stable (which I think will happen in the next month or so) so it was worth investing in using it now. There were a couple rough spots we hit when we first started using it, mostly around use of multiple lifetimes and the error messages being quite dense, but the devs driving the implementation of async/await have been doing really great work to improve the whole experience and we've already seen great usability improvements.

I do think it would be worth while to write up a more in-depth experience report on our use of Rust and especially async/await. We'll try to do a post on this sometime in the next couple weeks.

Edit: Their use of await in project: https://github.com/libra/libra/search?q=.await&unscoped_q=.await

7 Likes

How long have you been using the .await syntax?

I’m just starting to explore how it works.

What code are you working on? How many lines of code? How complex? How old is the codebase? Can you post a link to it?

I’m experimenting with converting dodrio to use futures 0.3/async await.

What environment are you editing the code in? Do you have .await syntax highlighting?

vim / no (it may do - I haven’t updated my packages for a while).

Summarize what happened, both the good and the bad. I’m particularly interested in:

How the new syntax feels over time.

The syntax feels good.

What happened when you were debugging problems (if any).

I’m having a lot of problems with lifetimes when debugging. The fact that async functions de-sugar to

async fn example(&self)  { /* .. */ }
// becomes
fn example(&'a self) -> impl Future<Output = ()> + 'a

(specifically the + 'a at the end) is a real pain. It means that i often have to write a lot of boilerplate e.g.

fn example(&self) -> impl Future<Output = Result<(), MyError>> {
  // say I have something weak reference counted and I want a handle
  let handle = match self.inner.upgrade() {
    Some(v) => v,
    None => return future::Either::Left(future::ready(Err(MyError)))
  }

  future::Either::Right(async move {
    // here I have some code.
  })

so I need to manually wrap my return in an enum to unify the types.

It may be that on balance the inferred lifetime bounds are worth it, I’m just finding it a pain.

Experiences from teaching others or from people coming from other languages without much Rust experience.

I haven’t taught anyone rust async.

Any other background information you think is relevant.

I’ve been using rust for quite a while, so my experiences will be different from a newcomer.


EDIT

I also find the generated documentation confusing:

here is the documentation for my async function

pub async fn with_component<'_, F, T>(
    &'_ self,
    f: F
) -> Result<T, VdomDroppedError> 

And I have to remember that the lifetime _ is linking self and the return value (which is hidden since we are in an async function).

I think this will be a source of a lot of confusion (at least it was for me).

EDIT2

I’ve thought about this some more, and I think you have to tie the lifetime of the future to self, because none of the body is run until the future is polled. In this case the solution has to be compiler error messages and/or documentation, and suggesting the pattern where you include an async block in a non-async function somewhere prominent.

I would also teach async blocks before async functions. Otherwise, async functions will lull newcomers into a false sense of simplicity, and then confuse them when they start to get lifetime error messages, especially since the real types are hidden from the user in async functions.

1 Like

In fact, thinking about the documentation, for a user of an async function, they really don’t care if it’s async or it returns an impl Future, so maybe in the docs the async function should be desugared to a normal function. This would have helped my confusion. So for example

pub async fn example(&self) { /* .. */ }

would appear in the documentation as

pub fn example<'fut>(&'fut self) -> impl Future<Output = ()> + 'fut

EDIT Prior art for this is how fn(mut T) becomes fn(T) in rustdoc, since whether the value is used mutably inside a function is an implementation detail that does not impact the api surface.

5 Likes

Because of the “complexity” of async lifetime inference, I’m very in favor of rustdoc using async fn whenever it’s written in source.

Specifically, the lifetime inference is “all of them”.

pub async fn fun(&'a A, &'b B, &'c C) -> O;
// becomes
pub fn fun(&'a A, &'b B, &'c C) -> impl Future<Output=O> + 'a + 'b + 'c;

“Absorbs all lifetimes” is a simple rule, like how regular lifetime inference is a simple rule. We don’t display fn foo(&self) -> &O as fn foo(&'a self) -> &'a O, so leaving it to lifetime inference is consistent and a lot less noise.

5 Likes

I think I agree with you, but in this case it’s really important to stress prominently right at the beginning of async function documentation that the output future will borrow all references, including self. Maybe it could include an example as well.

I just think that otherwise this will trip people up.

This topic was automatically closed 90 days after the last reply. New replies are no longer allowed.