Hacker News new | ask | show | jobs
by littlestymaar 1089 days ago
I really wish people stopped using this concept of “function colors”, especially in the context of Rust because the `async fn`/“regular function” split is strictly equivalent to the `try_something()`/`something()` split (the first one being fallible and returning a `Result` in case of failure). `Result`s and `Option`s are coloring the stack in exactly the same way a `Future` does (and `async` is pure syntactic sugar on top of future).

So, someone may like exceptions and green threads more than `Result` and `async` (and this is a completely valid PoV, even though I personally like the explicitness better), but thinking `async` is somehow special is just a conceptual mistake.

Edit to give an little more substance to the parallelism:

If you want to call a fallible function inside an infallible one, you MUST handle the result. If you want to use `?` then your function MUST return a Result.

Symmetrically, if you want to call an async function from a non-async one, you MUST `spawn` the future. If you want to use `await` then your function MUST be async.

The only practical difference between async functions and functions returning a `Result` is that `Future` is a trait, not a struct like `Result` (and that means that your future may have a lifetime that's not visible in your function definition, which is an endless source of confusion for beginners).

3 comments

While you are technically right, one area where the two are unlike is that you want to be polymorph with regards to async/non-async, while it is less often the case for error handling. That exact same sleep implementation, or db query should be usable both as an async call, as well as a blocking one and it is the caller that wants to decide that. Which is not trivial to do on the calling side from a language perspective as it then has to recursively decide the same for all its subcalls.

The new experimental languages with effect types might be able to give us the best of both as they actually expose what you are talking about as an abstraction at the type level. We will see.

Yes. To make this more concrete: If your non-async function uses a parameter whose type is a dyn trait or a generic bounded by a trait, it can call the methods of that trait and thus potentially call different implementations of that trait.

Currently, none of those trait implementations can be async because that would change the function signature.

So the only option you have in a trait implementation that needs to call a library that happens to use async apis internallt, is to "block_on" the async. Unfortunately iirc blocking on an async like that is executor specific and your impl must pick a concrete async executor which may be different from the one used elsewhere in the program.

> While you are technically right, one area where the two are unlike is that you want to be polymorph with regards to async/non-async, while it is less often the case for error handling.

The reason why we don't want to be polymorphic between fallible and “infallible” functions is that we put a clear social hierarchy between “properly handle error cases” and “panicking”. Using panics instead of `Result` make error handling much less cumbersome in Rust too, but we've clearly internalized that this isn't something you should do for real. Symmetrically, I'd argue that there's no much point to have both async and sync interfaces, if the user just want the quick and dirty approach, `spawn_blocking` is barely more typing effort than `unwrap`. The broad developer community (most of which having programmed well before Futures/Promises went mainstream) disagrees with me on that point, but that's a cultural thing.

Oh, and Result/Options aren't the sole “stack coloring” thing in Rust either: if you have an “owned” variable down the stack, then you need to either change your entire call stack to take the variable “by ownership” instead of “by reference”, or you can `Clone` it.

And you known what's even worse than this: `&mut`, because then you have no quick-and-dirty fallback (cloning and mutably borrowing the clone variable means you're now dealing with a reference that has a much shorter lifetime than the original one and it only works if your reference doesn't leave the current scope).

As a personal anecdote of someone that's been doing Rust full time for 6 years now, I've encountered the `Option`/`Result `/“owned”/`&mut` function coloring problem many times, and exactly zero times the “async/blocking” function coloring problem. Yet for some reason people are obsessed by an old rant about JavaScript's callback hell ¯\_(ツ)_/¯

Let’s not make this discussion so rust-specific — panics are not used that way in rust for a reason. But exceptions (especially checked exceptions like in java) don’t have that problem, and are exact analogues to Result types. The point is, some way or another that parse function can fail and you may want to handle it. The caller can easily decide that from afar.

This is not true of async/blocking — there can be semantic differences between two, otherwise equivalent implementations, and it is a recursive problem as I mentioned — how should an async-block-async call chain work exactly? Java can decide it at runtime, while rust with its tradeoffs meaningfully can’t - but it is a net negative tradeoff in its case.

> Let’s not make this discussion so rust-specific

But this is a discussion about Rust! And my entire point is that async/await is entirely consistent with Rust's overall design.

> But exceptions (especially checked exceptions like in java) don’t have that problem, and are exact analogues to Result types.

Unchecked exceptions don't have this problem (but checked exceptions do), and that's exactly my point. Async/await vs green-thread is exactly the same trade-off than Result vs exceptions: one is “simpler to use”, the other is “simpler to read”. After years of programming, I personally came to the conclusion that we spend more time reading code than writing it (and it's going to be even more true in the near future with LLMs) so I lean on the Result/await side of things, but I don't have fundamental objections against green thread and exceptions.

I do have a fundamental objection against the idea that “async” is somewhat special.

> This is not true of async/blocking — there can be semantic differences between two, otherwise equivalent implementations

Result vs exceptions have also a significant semantic difference: unwinding, and especially the fact that you can trigger unwinding at any point. This is a significant issue when you're dealing with pointers.

> how should an async-block-async call chain work exactly?

I don't really understand this question. An async function is just a regular function that returns a Future, and yes since there's no marker for blocking function you can definitely call one inside an async context, even though it's often a very bad idea from a perf PoV (well it depends, locks are mostly fine, but you need to use them with caution).

In fact, the `async` marker in functions doesn't bring much (again “async function are just regular functions that return a Future”), and it would make much more sense to have a `blocking` stack-contaminating marker on function that call a blocking syscall in order to avoid performance problems due to those, but we can't have nice things because something Path Dependence something…

I’m talking about checked exceptions — they are absolutely analogous to a Result<ReturnType, ExceptionType> in Rust, that is they are part of the type signatures. It is no longer the example you are talking about re panics vs Result.
Checked exceptions are indeed similar to Result, but as such they also have the same issue of “coloring” the call stack, so I don't really see what's the argument you're making here…
The main difference to me is that Async/await tends to permeate your whole codebase. Once one part of the system is Async/await everything is. With Go I can write most of my code synchronously and maybe somewhere down the stack make 3 HTTP calls in parallel without having to change anything about the calling functions.
> make 3 HTTP calls in parallel without having to change anything about the calling functions.

Exception that now you need to bubble up the error condition comming from these functions (if you function didn't have other error already).

And in fact, adding those call do change things about how the calling function is run (yields point are inserted and the function isn't being run sequentially anymore), it's just not visible in the code, exactly like exceptions vs explicit error return values.

> Exception that

Except*, damn autocorrect.

> not a struct like `Result`

Minor nitpick, but it's an enum.

https://github.com/rust-lang/rust/blob/master/library/core/s...

Indeed, thank you. (and since it's now been 2h since I posted this, my stupid mistake will live forever)