Async-await experience reports

In my summary post from “A final proposal for await syntax”, I wrote the following:

To date, we’ve had two such reports (I’ll post links below): one from @theduke and one from @steveklabnik. I’d be curious to hear more! So I’m creating a dedicated thread.

Ground rules

This thread is a debate free zone. It’s just a place to hear from people about their experience and ask some questions about it (always with the aim of learning more). It is not a place to propose new ideas or advocate for a particular design – though it is ok to ask people to imagine what might’ve happened with another syntax, if you like. I intend to moderate as needed and would appreciate the help of others towards this goal.

Post structure

When posting an experience report, please include the following information:

  • How long have you been using the .await syntax?
    • If you find your reaction changes over time, in either direction, feel free to post updates or multiple reports.
  • 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?
  • What environment are you editing the code in? Do you have .await syntax highlighting? (The emacs mode, for example, recently landed a PR to highlight the keyword.)
  • Summarize what happened, both the good and the bad. I’m particularly interested in:
    • How the new syntax feels over time.
    • What happened when you were debugging problems (if any).
    • Experiences from teaching others or from people coming from other languages without much Rust experience.
  • Any other background information you think is relevant. For example, @steveklabnik, in his comment about teaching a colleague, mentioned that he had a preference for the “prefix syntax”, which might’ve been expected to color the results (as it was, the colleague seemed to accept the postfix syntax quite readily once it was explained).

To be clear

I want to clarify one thing up front. Just like a poll on an internet forum should be taken with a grain of salt, a thread like this isn’t a real substitute for a “usability study” or anything like that. It’s one input among many. But I hope it is a particularly “high signal” one.

23 Likes

Cross-posting: @theduke posted an experience report here, which has a few follow-up inquiries.

Also, @steveklabnik posted a report from teaching a colleague.

3 Likes

My own modest contribution:

How long have you been using the syntax: A few days

What code are you working on: I re-implemented the “guessing game” using runtime – I basically re-implemented this example on my own.

What environment: emacs, but without await highlighting

What happened: Using .await felt very smooth and easy; I was always using it in conjunction with ? because I was doing I/O. I noticed in particular the fact that .await? just felt like the "async version of ?".

Other background information: I’ve been thinking about async-await syntax altogether too much over the last week. =) I am definitely hyperaware of .await and hence it’s hard to evaluate how it would feel if I were focused on other things.

13 Likes

How long have you been using the .await syntax? One day, I replaced it with own await macro immediately to improve read-ability of my code

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? Re-writing HTTP client that used futures 0.1 to std futures. The functionality is mostly trivial: reading Stream and converting it to various data types, sending HTTP request with redirect/timer support I went for completely replacing futures with async functions Reading body as stream of bytes https://gitlab.com/Douman/yukikaze/blob/1.0/src/extractor/body.rs Sending hyper requests https://gitlab.com/Douman/yukikaze/blob/1.0/src/client/mod.rs#L136-317

What environment are you editing the code in? vim, when I used .await there was no support for syntax highlighting, but since I replaced it with my own macro, not a problem

How the new syntax feels over time. It is unpleasant to use and will be replaced by my own macro in future until a better syntax will appear.

Any other background information you think is relevant. I found it impossible to treat .await as ? because even if I know that it is magical field, I still find it alien as it uses . operator

5 Likes

How long have you been using the syntax: About a week

What code are you working on: embrio-rs procedural macros and some backend RPC services for work

What environment: VSCode + the RLS plugin

What happened:

In the case of embrio, it turned out to be quite easy to parse using syn since currently, it just looks like a field as far as its parser is concerned. This made it pretty simple to apply the proper transformations.

As far as code actually using .await goes, I’ve found that it improved readability pretty significantly. I was originally pretty firmly in the “delimited prefix” camp, but transitioning from await!(...) made things like

await!(some.chain().of()?).methods()?;

much more clear as to where the methods were actually being chained.

Edit with more thoughts:

With the existing macro or a “delimited prefix” approach, I feel like you have to scan back and forth over the expression to figure out what’s going on. The postfix keyword makes things read much more nicely left-to-right.

Other background information: Not much else to tell, I might be a convert :slight_smile:

13 Likes

It sounds like the unpleasantness you experienced is a combination of

  • lack of syntax highlighting
  • readability (could you give an example?)
  • foreign-ness

Do you think these are things that one would just get used to with time? After all, you have been using it for only a day…

3 Likes

How long have you been using the .await syntax?

For the past week or so.

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?

A local rewrite of https://github.com/awslabs/aws-lambda-rust-runtime. The original codebase is about a thousand lines of Rust code, while the rewrite is significantly smaller because we rely more on third-party libraries and support less features. I’ll post a reply to this item when the rewrite is begins to take coherent form.

What environment are you editing the code in?

Visual Studio Code with the a pretty recent (built yesterday evening) release of rust-analyzer. I did not have syntax highlighting nor auto-completion for the keyword.

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

  • How the new syntax feels over time.
  • What happened when you were debugging problems (if any).
  • Experiences from teaching others or from people coming from other languages without much Rust experience.

I’m a big fan of .await. Despite writing Rust code for several years now, I’d still occasionally write a postfix match expression, and I’ve had the same issue with the prefix await! macro. Now, writing async code is far more ergonomic. Reading async code is slightly clearer than reading code with await!. As for explaining .await, I found it helpful to compare it .await to the relationship between the for loop and the .for_each combinator, at which point things clicked pretty quickly.

I’m really excited to use this syntax full-time, and it feels better than the equivalent in Python and TypeScript.

18 Likes

How long have you been using the .await syntax?

I had been using the old await! macro for about 6 month, and .await for a week now.

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?

An implementation of a network protocol, with a goal of multiplexing everything on a minimum amount of threads while still being thread-safe using async/await. And with the additional goal of utilizing zero-dynamic allocations in order to be no-std compatible.

The library is about 7000LoC at the moment. Especially the concurrency aspect is pretty complicated, there are about 5 async subtasks and a few timers involved that are all synchronized via awaitable primitives (async Events, async Semaphores, async Timers, etc).

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

  • VsCode with Rust Analyzer: No syntax highlighting for .await, but at least it also doesn’t show error squiggles. Surprisingly type resolution sometimes works, even if there is no explicit support for async/await included.
  • IntelliJ: Switching to postfix .await caused error squiggles to show up everywhere :’(

How the new syntax feels over time.

I changed the syntax on one day and haven’t invested further thoughts in it afterwards. Both work sufficiently well. In my codebase however the new syntax doesn’t seem to play out it’s benefits.

Some stats: I have

  • 48 .awaits in total
    • 24 instances of .await;
    • 11 instances of .await (expression form at end of block)
    • 6 instances of .await?;
    • 7 instances of match expr.await {
  • 4 instances of select!
  • 1 instance of join! and 1 instance of try_join!

There is not a single instance where I needed or wanted to chain anything further after that. For most of the expressions I might have had a light preference towards the prefix await, since it helped me to visualize the await points (over which I must not hold synchronous Mutexes) a bit better. But both are ok. If I could only mention the one thing that changed most then I would say it’s the match expr.await version, since it now transformed into an expression which has a keyword on both sides, which looks a bit weird for a long expression.

I think the reason why I get different results than others is that I tried to mostly write low-level code using async/await, which massively makes active use of concurrency. Whereas for others async/await might be more of a tool to write non-inherently concurrent business logic (e.g. HTTP handlers) in a more efficient fashion which multiplexes the request with others on the same OS thread.

Any other background information you think is relevant.

Using async/await is currently a mixture of extremely awesome and extremely frustrating: Awesome because it enables some mechanisms that are not easily possible in other environments, like multiplexing a couple of subtasks on a single thread and safely exchanging information between them without any dynamic allocations. Frustrating because there are quite a few issues that can prevent one from making progress with code and where the following investigation then either yields a complicated solution or points out there exists no solution yet.

Examples for those ordered by priority:

  • How to abstract async code behind interfaces. Without existential type, GATs or boxing everything in an inefficient FutureObj it doesn’t seem possible yet.
  • Lifetimes in async functions can be a lot more confusing and frustrating than they are in the equivalent synchronous code. The reason is obviously the hidden returned Future that inherits the lifetimes. But getting things right can be a really demotivating exercise that involves arbitrary adding and changing lifetimes. It got a lot better since cramertj’s latest update, but async functions that act on structs that also have an associated lifetime are still a challenge.
  • Needing to think about: What happens if the code behind this await is never executed, since the Future might get dropped.Trying to stuff all cleanup code for those cases in deactivatable destructors in order to work around this.
  • Having to use owned buffers and types (compared to just &[u8]) for a variety of things in order to satisfy the always-cancellable requirement (Concurrent tasks or OS APIs can’t hold onto the reference, since the owning task might get cancelled and reference would be dangling then).
  • std::mem::size_of_val(&my_future)
  • Needing to understand Pin, and sprinkling code with pin_mut! in order to get things like select! working.

Syntax is not on this list

29 Likes

Could you explain more about this? Or give examples?

One example: I have a method which allows to send messages, but internally needs to perform rate-limiting in order to guarantee that the given resources are never exhausted. In order to do this, I’m using an (async) semaphore. Now the tricky thing was to release the semaphore reliably when the remaining async operation failed, and keep the permit and release it later if the sending step completes.

Pseudecode:

async fn send_message(&self, content: &str) -> Result<MessageId, Error> {
    self.rate_limiter.acquire(1).await; // Acquire 1 permit of async semaphore
    
    let serialized_message = serializer.serialize(content);

    match writer.enqueue_message(serialized_message).await {
        Ok(id) => {
            // Sending the message was fully successful and the ownership had
            // had been passed. The permit now will get released when a response
            // is received.
            Ok(id)
        },
        Err(e) => {
            // Sending the message failed. Since the permit hasn't been used,
            // release it immediately.
            self.rate_limiter.release(1);
            Err(e)
        }
    }
}

This code has an hard to find bug: If the caller aborts the task (by dropping the returned Future) after self.rate_limiter.acquire(1).await; and before match writer.enqueue_message(serialized_message).await had been fully executed then the semaphore permit will have been permanently lost.

I worked around this particular issue in meantime by letting the Semaphore Future resolve to a RAII type which automatically releases the permit when not otherwise instructed, and disable/mem::forget this method in the success case. That’s an even better solution for this case, but one will only find it if one is aware that this can happen.

Another occurrence: I have a subtask, and at the end of the task I want to signal to some other component that the subtask finished. The first solution was:

async fn task(&self) {
    // other code including .await statements

    other_component.notify_this_task_has_finished();
}

This also doesn’t work, since the method is not guaranteed to be called (but I rely on it). In order to work around this, I then built a workaround defer()/ScopeGuard mechanism:

async fn task(&self) {
    let _guard = ScopeGuard::new(|| {
        other_component.notify_this_task_has_finished();
    });
    // other code including .await statements
}
15 Likes

This is really helpful, thanks. One of the things I'm thinking about is how to prioritize and communicate the next steps beyond async-await, and I think that getting feedback on which problems are coming up is key.

9 Likes

While I don’t have an opportunity to write code that uses .await myself, I have seen one on Twitter. Here’s my impression if a code reader’s experience counts.

To sum it up, I had quite a hard time finding where .await is used, mainly due to .await blending in a forest of method calls.

3 Likes

Thanks for explaining! This does indeed seem like a major footgun that people need to be aware of.

Wouldn't it be better to have the RAII type always release the permit (even in the Ok case)? Then it would look like this:

async fn send_message(&self, content: &str) -> Result<MessageId, Error> {
    let _lock = self.rate_limiter.acquire(1).await; // Acquire 1 permit of async semaphore
    
    let serialized_message = serializer.serialize(content);

    writer.enqueue_message(serialized_message).await
}

Maybe there's something in your architecture that prevents that, but that's what I would do.

This is really interesting. It seems to be an issue with any resumable function, so I assume the same issues will happen with generators as well.

People have a strong intuition that functions run to completion, and async violates that intuition.

Languages like JavaScript don't have that problem because they don't have cancellable Promises (so async always runs to completion).

1 Like

How long have you been using the syntax: Used preawait!() model for about three months, then await!() for a month in tide, using .await now for a few days.

What code are you working on: A game server for a small amount of clients, experimental inhouse and completely private environment. Porting stuff from futures = 0.2 to futures-preview=0.3.0-alpha.16.

What environment: NeoVim, with a slightly outdated set of plugins including RLS. Sometimes updating to the newest versions broke stuff and I’m currently not comfortable nor have the time to experiment which other setup would work.

What happened: There were positives and negatives. Little concerns with the syntax of .await itself, though using .await felt smoother than the macro and also chaining; It mostly improved many error cases such as all async I/O now properly returning Result<usize> although I ignore the count in most cases. This is easily done by chaining .await.map() as to any Result. The error types are much smoother due to concise ? operator instead of chaining into.

Impractical is that the idiomatic future type is hard to name. Multiple times I had stored it in some intermediate structure previously but can no longer do this neatly due to returning an anonymous type. I don’t want to introduce new, unecessary type parameters. So, I now store an intermediate that is not an actual future and has a function for turning it into one. This gets passed to the coroutines that turn it into a proper future and wait on that. It works. May likely switch to IntoFuture if it gets included into stable but not necessary currently.

// Previously implemented `Future`. Now doesn't, Pin made it hard to rework.
struct Send { ... }

// Now
impl Send {
    // Can't name this Future type directly, although it is unique.
    async fn really_send(self) -> Result<usize, Error> { ... }
}

The rework itself was necessary since there are no longer futures for socket.send() which take ownership of the to-be-sent data. While optimized for async fn where borrowing is definitely cheaper, it makes it even harder to name specific futures. In a async fn broadcast function I share allocated bytes data via an Arc<str> to multiple async fn send, without any type parameters. This would definitely become a core data structure in synchronous usage but without being able to name it, it is not currently in asynchronous usage. Though the finalized solution now simply sends from borrows of the data without requiring Arc as a tradeoff, there is not enough available guidance on how one would create such a type manually.

Some more on pinning overall: StreamExt::next requires the stream to be Unpin. While necesary, there is little to no help offered. When possible, stack-pinning e.g. this pin_mut macro is the cheapest way to achive this. It is far from obvious that this is possible and nothing indicates it. Requiring a third-party crate to resolve doubts of it being safe is also suboptimal. I’m also afraid that the compiler error messages are not as helpful as they could be. Especially if the type of the future requiring pinning is itself not named anywhere but purely deduced (so basically anytime the value comes from a async fn) this becomes quickly confusing and is reminiscent of C++ template failures :crazy_face: .

Regarding highlighting—which doesn’t work due to outdated plugin—it doesn’t feel too limiting. The weirdest occurances was when matching an .await. For other fields matching foo.field by ref does not move from foo, so I accidentally stumbled due to the lifetime of the bound temporary. Nothing major, just takes a bit getting used to. The nicer code pattern would be to introduce a temporary, the argument to match is likely never better off being an expression. Field syntax is however fairly suggestive here to move it into the match. It would however help in no way to be prefix await over .await() or similar. Due to the difficulties storing async fn directly without type parameter overheads, I now sometimes (~5%–10% of times) have two operations to the construction of the actual future. Clarity of postfix is better both in this case and the case of mapping or field accessing the result, totalling ~25% of my usage.

In the rework I once blundered by awaiting sequentially instead of using select!, which meant a loop locked up. This was in one of those match of an .await, which I think contributed to my mistake and made it harder to spot. The sending end for a client has both a message queue and a kill switch to kick misbehaving ones without further overhead. However, the kill switch is not always present, so that code now looks similar to:

if let Some(ref mut kill) = self.kill_switch {
    select! {
        msg = self.queue => deal_with(msg),
        killed = kill => match killed { ... },
    };
} else {
    deal_with(self.queue.await)
}

This is far from optimal. I didn’t find a lot on how to improve this; though I suppose a wrapper around Option<&mut impl Future> could provide an infinite block in the None case which would make it possible to select! in all cases of control flow.

Other background information: Not comfortable with sharing source code, sorry. Other than the blunder, rework was fairly uneventful. Even moving between my own reactor, romio, mio (I had my own uds adaption since the other ones don’t support Linux specific abstract sockets–see section Address format.abstract. I no longer use them, since it has gotten hard to maintain).

7 Likes

Yes, it's a little bit more complicated because sometimes the permit needs to be released outside of the scope of this function. This one is only for sending a message, but since I need to limit the amount of transactions in flight I need to wait until the server responds before releasing it.
But that's an implementation detail. And as long as one thinks about it one will come to a suitable solution. My concern here was more that the necessity to handle early cancellation/termination scenarios might not obvious to everyone, and that I might need to expect a certain amount of bugs due to this.

I think for async/await Rust might be the only language with this property. JavaScript, C# and Kotlin are all run-to-completion (expect for the fact that methods might obviously throw exceptions, but I consider this a slightly different story). C++ coroutines are very flexible and configurable. As far as I understood the semantics (e.g. through this response from Lewis Baker) the ecosystem is planning on using the ability to cancel during yield points only for generator-like semantics, which may not resume after a yield (makes a lot of sense). However the coroutines implementations for async tasks are run-to-completion and can only be cooperatively canceled (e.g. through a CancellationToken.

1 Like

Right, I think the situation is different, because in those languages you have try / finally to handle cleanup, which is very intuitive.

The closest thing Rust has to that is specialized RAII types (which you ended up using). So they're not quite the same thing.

The only language I know of with cancellation mechanics (similar to Rust) is Conductance, and they have a try / retract syntax (similar to try / finally) to intuitively handle cancellation.

Majority of the community is focused on trivialities like where does await go, but this is going to be the fundamental "unfamiliar", "easy to get wrong", "hard to debug" and "super frustrating" aspect of async/await in Rust.

Having said that, this code was not panic-safe in the first place, and it was brittle anyway: in case of refactoring etc. it was easy to introduce an early return and make it buggy. RAII version is more idiomatic and just way better.

It is however something to think about - maybe there's some smart way to add some lints, warning, anything that would at least educate users. At very least we will have to be really vocal that async functions can stop executing at any point. I guess it's similar to pitfalls created by iterators being lazy.

14 Likes

Hi there, here is my 50 cents.

How long have you been using the syntax: more than 6 month

What code are you working on: Commercial project - scraping data from different sources(providers).

What environment: vscode (using rls and rust-analyzer both eating almost half of my 16G memory) intellij-idea(no debugging:( )

What happened: I have tried new .await syntax in several places. I don’t feel significant usability increase of that approach, because I don’t has a lot of futures which outputs futures to leverage full power of the postfix syntax (interesting what kind of project may have a lot of futures outputting futures?), in most of the times .map or .and_then is enough IMHO. Here some very typical part of the project:

       let urls_len = await!(self.listing_urls_repo.count(providers.clone(), skip))?;
       let stream = await!(self.listing_urls_repo.list(providers, skip))?;

       let stream = stream
           .map(move |res| {
               let mut sender = sender.clone();

               async move {
                   let res: Result<_, Error> = try {
                       let url = res?;
                       let kind = url.provider.clone().try_into().unwrap();
                       let id = url.provider_id;
                       
                       let new_listing = await!(self.providers.get_listing(kind, id.clone()))
                           .map_err(|err| ErrorKind::FetchFail(format!("provider: {}; id: {}; err: {}", kind, &id, err)))?;

                       let url_update = UpdateListingUrl {
                           id: url.id,
                           status: 1
                       };

                       await!(sender.send((new_listing, url_update))).unwrap();
                   };

                   res       
               }
           })
       .buffer_unordered(100);

       pin_mut!(stream);
       while let Some(_item) = await!(stream.next()) {
           // increment progress bar
       }

And what I would be happy rust have instead of .await:

  1. pattern matching ? - it is most desirable, here is example:
let task1 = async move { ... }
let task2 = async move { ... }

let (val1?, val2?) = await!(futures::join(task1, task2));
// now I has something like:
// let (res1, res2) = await!(futures::join(task1, task2));
// let (val1, val2) = (res1?, res2?);
// in most cases just
// let _ = (res1?, res2?);
  1. more ergonomics with try + async block. One more annoying thing is async move and error type elision:
let task = async move {
   try { // error type not elided :(
       let a = await!(x)?;
       //    ...
   }
}

and I have to write like:

let task = async move {
   let err: Result<_, Error> = try { // error type not elided :(
       let a = await!(x)?;
       //    ...
   };

   err
}

Other background information: initially was based on futures 0.1 with tokio’s compatibility to futures 0.3

now (few month ago) I have got rid of tokio, used futures 0.3’s block_on and withoutboats’s juliex for spawn and now I have futures 0.3 with compat 0.1 in some cases (like when dealing with tokio-postgres)

find -name *.rs | xargs wc -l - 11646

find -name *.rs | xargs rg await! | wc -l - 311

find -name *.rs | xargs rg "async fn" | wc -l - 132

2 Likes

How long have you been using the .await syntax?

Couple of days

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?

Toy programs like a JSONRPC server and a client to consume an existing JSONRPC API. The programs are simple, about 100 LOC. The code is based mainly on the examples from https://github.com/rustasync/runtime.

What environment are you editing the code in? Do you have .await syntax highlighting? (The emacs mode, for example, recently landed a PR to highlight the keyword.)

I used CLion with Rust plugin but was editing the code as a .txt file without syntax highlighting. I wanted to test the hypothesis that the postfix await is harder to spot.

Summarize what happened, both the good and the bad.

The biggest issue was that it was harder to see await without syntax highlighting (in contrast to Python’s prefix await is immediately obvious).

To improve readability I invented my own postfix syntax where .await was replaced with .:await.

To run the program, I used something like:

sed 's/\.:await/\.await/g' main.rs.txt > main.rs && cargo run

For example, I wrote the following:

while let Some(stream) = incoming.next().:await {
    //...
}

and

if let Ok(n) = buf_stream.read_line(&mut line).:await {
    //...
}

I found that .: slightly improved the readability but I would still need to look for it. Also .:await was a bit slower to write compared to .await.

In docs where the await was highlighted, the keyword was immediately spottable.

Background Used Python’s await for some years, for Rust I prefer the postfix await.

6 Likes

Minor experience report:

How long have you used .await

None with a compiler, in examples from around midway through the GitHub thread.

In what environment

Plaintext markdown editing primarily on Discource-based sites.

Experience

I wrote this post about the theoretical potential of (ab)using Future and async fn to write game-style coroutine cooperative multi-frame multitasking.

In the specific case of creating the “empty” future whose only point is to register the wake up later and immediately awaiting it, postfix await feels backwards. This may be able to be mitigated by better naming than my example next_frame().await.

This same feeling of being reversed didn’t occur with examples where actual work was done in the Future; it’s this specific “suspend only” future that feels out of place for the “pipeline operation” version of await.

(Abusing futures to emulate generators abused to get cooperative semicoroutines leads to awkward syntax, who would have thunk.)

2 Likes