Hacker News new | past | comments | ask | show | jobs | submit login

I think Go got it right by inverting the logic around async/await. In Go you have to explicitly state that a function is to run in the background via "go fn(...)". This makes it much clearer that this code will execute concurrently. In the async/await world you can't tell by looking at a function call if it will block until it's done. Forgot an await? No compile error but your program might behave in weird ways. This has bitten me in JS too many times. Haven't done too much async Rust yet but I don't think it solved this issue from what I've seen. Why can't "await" be the default when calling an async function and if you don't need the result right away then call it with "async func(...)"?



> Haven't done too much async Rust yet but I don't think it solved this issue from what I've seen.

In Rust an async function is really just a const fn that synchronously only constructs and returns a state machine struct that implements the Future trait.

So

async fn foo(x: i32) { }

essentially desugars to

const fn foo(x: i32) -> FooFuture { FooFuture { x } }

struct FooFuture { x: i32 } // technically it's an enum modelling the state machine

impl Future for FooFuture { ... }

You have to explicitly spawn that onto a runtime or await it (i.e. combine it into the state machine that your code is already in). So that's actually really cool about how Rust handles async; that an async fn really isn't doing any magic, it just constructs a state machine and never interacts (or spawns) with a runtime at all, so it never starts running in the background, you are always in full control. And by throwing the future away, you are essentially cancelling it, there's no need to interact with any runtime either.


It's not const (const fn has a very specfic meaning in Rust), but other than that you're correct.


It is "const enough", as in, there is nothing preventing it from being called at compile time, in fact with nightly features (and no changes to the async fn), you can call it at compile time just fine. I also put const fn there to emphasize that it really can't do all that much beside constructing the state machine.


You're talking about particular JS implementation problems, not general async/await problems.

> In Go you have to explicitly state that a function is to run in the background via "go fn(...)".

In Rust you have to explicitly `spawn` a task to detach it from the current coroutine and make it run in background. Typically this is much more costly than not spawning and executing async function concurrently as part of the same coroutine's state machine (and Go actually doesn't give you that option at all).

> In the async/await world you can't tell by looking at a function call if it will block until its done.

   foo().await();  <-- blocks
   foo();          <-- doesn't block
> Forgot an await? No compile error

   warning: unused implementer of `futures::Future` that must be used
> Why can't "await" be the default when calling an async function

For similar reasons you don't want `clone()` to be implicit or rethrowing errors to be implicit (like exceptions in Java).

Awaiting implicitly would hide a potentially long and important operation. Await typically means the control is yielded back to the executor and it can switch to another task. You don't want it in a language that wants to give as much control about performance as possible to the developer. Being able to see that "this fragment of code will never be preempted" is a great thing for predictability. Rust is not Go/Java - nobody is going to celebrate achieving sub 1 ms latency here.

Additionally there are certain things you are not allowed to keep across await points, e.g. mutex guards or other stuff that's not safe to switch between threads. E.g. using a thread-local data structure across await points might break, because you could be on a different thread after await. If await was hidden, you'd likely be much more surprised when the compiler would reject some code due to "invisible" await.


> Awaiting implicitly would hide a potentially long and important operation.

but as you point out else thread, you can still hide blocking and potentially expensive operations in any function, so not seeing await give no guarantee that the operation won't block (it only guarantees that the operation won't return to the event loop, assuming that the rust event loop is not reentrant).

Hence await doesn't really protect any useful invariant.


    > foo();          <-- doesn't block
Only if you know that foo is an async function. You can't tell by the function call itelf.

    > warning: unused implementer of `futures::Future` that must be used
Interesting, I haven't seen this warning in the Rust codebase I worked a little with. I'll have to check the compiler settings. Anyways wouldn't it make sense to actually throw an error instead of just a warning?

    > Additionally there are certain things you are not allowed to keep across await points, e.g. mutex guards or other stuff that's not safe to switch between threads. E.g. using a thread-local data structure across await points might break, because you could be on a different thread after await. If await was hidden, you'd likely be much more surprised when the compiler would reject some code due to "invisible" await. 
Why couldn't the compiler clearly state the reason for the error though?


> You can't tell by the function call itelf.

You can't know that in general. Any regular Go function could spawn a goroutine return immediately too. In JS a "blocking" function could call setImmediate(…) and return too. Even in C, a function could spawn a thread and return immediately too.

You never know at the call site whether a function will block or not, in any language.

So I think polled futures actually are closest to knowing this, since the block-or-not decision can be bubbled up to the caller. In Rust the "doesn't block" example would more likely be `runtime.spawn(foo())`, since the executor is not built into the language, so spawning asynchronously is easier when left up to the caller.


> Only if you know that foo is an async function. You can't tell by the function call itelf.

That's fair point, but traditionally you don't use blocking functions in async contexts at all. It is fairly easy to lint for by prohibiting some inherently blocking calls eg.g std::io, although they might sneak in through some third-party dependency.

This doesn't have an easy solution because Rust is a general purpose language that allows different styles of concurrency adapted best to the situation, instead of one-size-fits-all like Golang.

Rust has means to annotate functions so maybe there will be some automation to deal with that in the future, similar to how `#[must_use]` works now. E.g. `#[blocking]` or whatever.

> I'll have to check the compiler settings.

This is with default compiler settings.

> Why couldn't the compiler clearly state the reason for the error though?

Stating the reason is probably solvable problem, but there is another problem: what if 5 layers down the call chain something suddenly introduces a potentially blocking (awaiting) operation? This would mean that some code that previously compiled now has to stop compiling even though it hasn't changed and even though none of the signatures it uses changed. I guess it would break things like separate compilation.

And again, it would be less readable than it is now. Now it is fairly simple - you don't have to look down the call chain to know that something can do await.


You can't change the async/await rules of Rust anymore. I get that. But if it started like I described from the beginning I don't see why that wouldn't work. It's just a question of syntax. Someone adding a blocking call 5 layers down wouldn't be any different than someone adding an "await foo()" right now. Code would still compile fine. As long as everything follows the same rules. Can't mix them obviously.


> wouldn't be any different than someone adding an "await foo()" right now

It would. `.await` works only inside `async` context. So if the method wasn't async at the top level, then adding `.await` somewhere down the call chain would force changing all the signatures up to now become `async`.

So you cannot just freely add `.await` at random places that don't expect it. Which is sometimes a blessing and sometimes a curse. Definitely when trying to hack a quick and dirty prototype this is a slowdown. But it is really good when you aim for low latency and predictability.


It wouldn't work. In Go, it doesn't matter how deeply nested a call to a blocking function is, when you do "go f()" the runtime takes care of things.

With async however, if "await" is the default, then as soon as an async function calls another async function, it would block, completely defeating the point of async in the first place.

I guess you could flip the rules and say that within an async function async is the default and within a regular function await is the default, but actually in most languages a regular function can't call an async function directly because async needs to propagate all the way to the event loop. So you'd just have async as the default again.

My explanation sucks but if you want to go into this rabbit hole look up "stackful vs stackless coroutines".


It would just make things more explicit. Whenever you want to obtain a future you'd have to add "async". The execution of async stuff would work the same just instead of having to explicitly "await" things you'd have to explicitly "async" things. Of course you can't change the way Rust does async/await now without having to rewrite all the async code so not going to happen.


I don't think so, it would only mislead and hide what's really going on.

Creating a future in Rust does not have any side effects like running the future in background. This is not JS. Creating a future is just creating an object representing future (postponed) computation. There is nothing spawned on the executor. There are no special side effects (unless you code them explicitly). It works exactly as any other function returning a value, hence why should it be syntactically different?

If you called something that returned a future but you forgot to use the returned future - how is that different from e.g. opening a file for write and forgetting to write to it or from creating a User object and discarding it immediately, forgetting to save it to a database? There isn't really a difference, and therefore all those cases are handled by `#[must_use]` warning.

Contrary, an `await` is an effectful operation. It can potentialy do a lot - block execution for arbitrary long time, switch threads, do actual computation or I/O... So I really don't understand why you want to hide this one.

Maybe the naming is confusing - because `await` does not really just `await`. It runs the future till completion. You should think about it more as if it was named `run_until_complete` (although it is still not precise, as some part of that "running" might involve waiting).


    > Creating a future in Rust does not have any side effects like running the future in background. This is not JS. Creating a future is just creating an object representing future (postponed) computation. There is nothing spawned on the executor. There are no special side effects (unless you code them explicitly). It works exactly as any other function returning a value, hence why should it be syntactically different?
Fair point.

    > Contrary, an `await` is an effectful operation. It can potentialy do a lot - block execution for arbitrary long time, switch threads, do actual computation or I/O... So I really don't understand why you want to hide this one.
I disagree here. Any normal function call can do these things. On the other hands an async function returning a future does nearly nothing. It sets up an execution context but doesn't execute (in Rust). But they usually look like a function call that actually performs the action - not so! An explicit "async" in front of it would make the program flow more clear instead of hiding it.

    > Maybe the naming is confusing - because `await` does not really just `await`. It runs the future till completion. You should think about it more as if it was named `run_until_complete` (although it is still not precise, as some part of that "running" might involve waiting). 
That's exactly speaking to my previous point. The program flow is not 100% immediately obvious anymore. One could argue that "await" is fine as is but maybe adding "async" to the call and not just function signature would add clarity.


> Any normal function call can do these things.

A normal function cannot switch threads.

   foo();   // executed on thread 1
   doSomeIO().await; 
   bar();   // possibly continued on thread 2
Now if foo() does some native calls that write some data to thread-local storage and bar() relies on that storage - that can make a huge impact on correctness. Rust is a systems programming language, so details like that matter.


surely lifetimes and the borrow checker are a better way to statically check for these sort of issues than relying on await side effects? What if an await is inadvertently introduced later inside your (implicit) critical section?


The borrow checker does catch those issues. But it it does not do whole-program analysis. It analyzes code locally, by looking at signatures of functions being called.

And also being forced to read distant code to understand if given snippet is correct would be a maintainability nightmare.

I've had enough problems dealing with Java exceptions which are allowed to pop up from anywhere and are not visible in the code.


What I mean is that if preserving invariants across function calls is important enough that an async call can break it, you want the invariant to be enforced statically by the compiler and you do not want to rely on visual inspection of the source to confirm lack of reentrancy.

Once you do that, you do not need a call site annotation that a function can be preempted as the compiler will check it for you.

Rust is uniquely equipped to enforce these guarantees.


> Haven't done too much async Rust yet but I don't think it solved this issue from what I've seen.

If you don't await your variable contains a future - how are you using that like e.g. an int, without a compiler error?


The issue arises if you don't use the returned value. Lets say there's a function "async fn saveToDisk()". You call this function before you exit the program. Now if you forget to use await on it, your program will exit without having saved the data to disk.


In any sensible API, saveToDisk would return an error status (a Result type in Rust). If you don't check for errors, then probably you didn't care of the data was actually saved or not.


Futures in Rust are annotated with the #[must_use] attribute [1], same as the Result type [2].

This means the compiler will emit a warning (can be upgraded to an error) if you forget to await a future even if it doesn't return anything.

[1]: https://doc.rust-lang.org/nightly/src/core/future/future.rs.... [2]: https://doc.rust-lang.org/nightly/src/core/result.rs.html#49...


You don't want the safety of your program to depend on whether the compiler emits a warning or not.

And turning warnings into errors just encourages people to write 'let _ = ...' to get rid of the error.


This has nothing to do with safety, just correctness.

> And turning warnings into errors just encourages people to write 'let _ = ...' to get rid of the error.

No? writing `let _ = make_future()` will clearly not await the future, why would you do it instead of just adding `.await` ?

Using `let _ = ...` is sometimes fine for Result if you really sure you don't care about the potential error you got but it's a no go with futures.


Note that the lint for un-awaited Future doesn't mention the way to silence them by assigning to _:

  warning: unused implementer of `Future` that must be used
   --> src/main.rs:9:5
    |
  9 |     foo();
    |     ^^^^^
    |
    = note: futures do nothing unless you `.await` or poll them
    = note: `#[warn(unused_must_use)]` on by default


It's actually very likely you'll have a compile error. Async functions return a Future (like a Promise in JS) and this isn't a value you can typically use inplace of others. There are also an on-by-default warning if you don't use the Future value at all




Guidelines | FAQ | Lists | API | Security | Legal | Apply to YC | Contact

Search: