Hello
I didn’t want to discuss my objections in the RFC, it seemed out of place. But let’s do it here, then.
I agree that the goal of async-await is ergonomics, therefore too much annotation is probably a problem. That can, however, be solved in other ways than pretending that something acts the same (in the syntax) while it doesn’t. One of the things I like about Rust is, it doesn’t paper over what happens. We have explicit heap allocation, for example, while other languages hide this. This makes me feel that I know what is happening in quite a detail and that I can influence it.
The specific points I don’t like about the proposal, and why:
First, I don’t buy the argument of normal function that can do arbitrary bad things. While it is true that in theory, it can ‒ I probably could write a function that moves the current stack into a different OS thread ‒ it is uncommon and you can guess it from the function name. If I have a function called load_from_file(filename)
, I can be quite confident it will not move me into another thread, that it’ll eventually return control to me and that it’ll not try to lock a mutex I haven’t provided to it. On the other hand, if I call scheduler::turn()
, I can guess it just might do something interesting, so I’ll think twice about what state I’m in before the call. If the suspension points disappear, all functions are suspects for doing something bad and just reading the code (when doing code review, for example) is not enough to gain any level of reasonable confidence.
Let’s show an example (I’m not looking the exact interface of the sigprocmask binding into Rust, but it changes the masked signals for the current thread).
let old_mask = sigsetmask(SIG_BLOCK, SIGTERM | SIGQUIT)?; // Wait with the signal until we finish playing with the DB file
do_something_with_db()?;
sigsetmask(SIG_SETMASK, old_mask)?;
If the do_something_with_db
is sync and I further assume that the author of the function was sane not to hide a very complex code to smuggle my stack into another thread (not a big assumption, I guess and if the author had bad intentions, he could crash my program in million other ways), this is OK. However, if it could mean a suspension point, it could set the signal mask for arbitrary other async task and I could restore the mask in a different thread, introducing a very hard to find bug. A hint at the suspension point (maybe just calling it with -()-
instead of just ()
) would be enough to both prevent this from slipping through code review and from compiling and introducing this bug by making that function async sometime in the future.
The same can be said for holding a mutex for a while. Let’s say I have single-threaded executor, but have other threads I want to sync with. If I hold it across a suspension point, I might get a deadlock, because other instance of the same code will run and will try to get the mutex too ‒ and can’t and it won’t let me run. Again, while in theory a function call could also try to lock the mutex, if it’s a library function to load metadata of a movie from file (something that could in theory be async), because I haven’t given it access to the mutex nor to my other tasks.
Both of these is something one might reasonably want to do in systems language like Rust.
Furthermore, the proposal doesn’t seem to look very consistent. OK, async functions have the ::new
function and otherwise await when called directly. I don’t really see how that works, except adding a corner-case somewhere really deep into the language, while some kind of await!
or operator is almost implementable as an extern crate. But what about integration with futures? Lot’s of library functions will return futures (TcpListener::accept
, for example). Is that one awaited automatically? If so, who provides the ::new
for it? Does it appear magically?
Furthermore, if it does auto-await in async function, but returns the future outside of it, then async
is no longer an extention ‒ unsafe
allows some more things to do inside of it, but the code that compiles outside does the same inside of it. This would be a code that does something else inside and outside. That looks confusing.
If you don’t auto-await the future, then you can only auto-await other async fns. Considering a lot of futures need to be implemented in other ways than an async fn, it’ll cover only small part of cases ‒ maybe 50%? Considering I’ll receive some futures by a parameter (and I’ll have to await them explicitly), some will be stored in some data structure, or receive by a channel, this’ll even lower the percentage of cases when it can be used.
Therefore, the result will be half implicit waiting and half explicit, getting disadvantages of both worlds.