Rust Async Makes Me Want To Gouge My Eyes Out
One of the most fundamental problems with Rust is the design of Result
. It is a lightweight, standardized error return value, similar to C-style error codes but implemented at a type system level that can contain arbitrary information. They are easy to use and very useful, and the ecosystem encourages you to use them over panic!
whenever possible. Unfortunately, this ends up creating a problem. Result
is not like a C++ exception because it doesn’t contain a stacktrace by default, nor does the compiler have any idea where it was first constructed, unless the error type it contains decides to include that information upon construction by using backtrace.
You can catch an unwinding panic!
, which is implemented much more like a C++ exception, but a panic!
that aborts cannot be caught and it’s impossible to tell if a panic will unwind at compile-time because the behavior can be changed at runtime. Another interesting difference is that the panic handler is invoked before the panic starts unwinding the stack. These implementation details push you towards using the panic handlers purely as uncaught exception handlers, when you can do nothing but perhaps save critical information and dump a stacktrace before exiting. As a result, panic!
is designed for and used almost exclusively for unrecoverable errors.
If you want an error that is recoverable, you use Result
… which doesn’t have a backtrace, unless you and all your dependencies are using some variant of anyhow
(or it’s various forks), which allows you to add backtrace information. If the error is coming from a dependency that doesn’t use anyhow, you’re screwed. There actually is an RFC to fix this, but it’s been open for six years and shows no signs of being merged anytime soon.
“But”, I hear you ask, “what does this have to do with Rust Async?” Well, like most things, Rust Async makes this annoying part of Rust twice as annoying because the default behavior is to silently eat the error and drop the future, unless you have stored the join handle somewhere, and you are in a position where you can access that join handle to find out what the actual error was. The API for making tokio panic when an unhandled panic happens is still unstable, with the interesting comment of “dropping errors silently is definitely the correct default behavior”. Really? In debug mode? In release mode, fine, that’s reasonable, but if I’ve compiled my program in debug mode I’m pretty sure I want to know if random errors are being thrown. Even with this API change, you’ll have to manually opt-in to it, they won’t helpfully default to this behavior when you compile in debug mode.
Until that feature gets stabilized, you basically have to throw all your JoinHandle
’s into a JoinSet
blender so you can tell when something errored out, and unless you are extremely sure you didn’t accidentally drop any JoinHandle
’s on the floor (because Rust does not warn you if you do this), you probably need a timeout function even after your main future has returned, in case there are zombie tasks that are still deadlocked.
Oh, have I mentioned deadlocks? Because that’s what Rust async gives you instead of errors. Did you forget to await something? Deadlock. Did you await things in the wrong order? Deadlock. Did you forget to store the join handle and an error happened? Deadlock. Did you call a syncronous function that invokes the async runtime 5 layers deep in the callstack because it doesn’t know it’s already inside an async call and you forgot it tried to do that? Deadlock. Did you implement a poll() function incorrectly? Deadlock.
For simple deadlocks, something like tokio-console might be able to tell you something useful (“might” is doing a lot of work here). However, any time you forget to await something, or don’t call join on the localset, or add things to the wrong localset, or your poll function isn’t returning the right value, or the async waker was called incorrectly, you’re just screwed, because there is no list of “pending futures that have not been awaited yet” that you can look through, unless you get saved by your IDE noticing you didn’t await the future, which it often doesn’t. It definitely doesn’t tell you about accidentally dropping a JoinHandle
, which is one of the most common issues.
But why would you have to implement a poll function? That’s reserved for advanced users– Nope, nope, actually you have to do that when implementing literally any AsyncRead
/AsyncWrite
trait. Oh, sorry, there’s actually 4 different possible AsyncRead
/AsyncWrite
implementations and they’re all slightly different and completely incompatible with each other, but they’re all equally easy to fuck up. Everything in Rust Async is absurdly easy to fuck up, and your reward is always the same: [your-program].exe has been running for over 60 seconds
.
I haven’t even mentioned how the tokio and futures runtimes are almost the same but have subtle differences between them, and tokio relies on aspects of futures that have been factored out into future-util
, which should be in the standard library but isn’t because the literal only thing they actually standardized on was std::future
itself. All this is ignoring the usual complaints about async function color-coding - I’m complaining about obnoxious implementation footguns on top of all the usual annoyances involved with poll-based async. Trying to use async is like trying to use a hammer made out of hundreds of tiny footguns hot-glued together.
I wish async was just one cursed corner of Rust that had its warts relatively self-contained, but that isn’t the case. Rust async is a microcosm of an endless stream of basic usability problems that the language simply doesn’t fix, and might not ever fix. I’m honestly not sure how they’re going to fix the split-borrow problem because the type system isn’t powerful enough to encode where a particular borrow came from, which is required to implement spatially disjoint borrows, which ends up creating an endless cascade of subtle complications.
For example, there are quite a few cases where serde_json errors are not very helpful. None of these situations would matter if you could open a debugger and go straight to what was throwing the error, but you can’t because this is Rust and serde_json doesn’t implement anyhow
so you can’t inject any errors. format_serde_error was created to solve this exact problem, but it is no longer maintained and is buggy. Also, artifact dependencies still aren’t stabilized, despite the very obvious use-case of needing to test inter-process communication that comes up in basically any process management framework? So this crazy hack exists instead.
Rust’s ecosystem heavily relies on two undebuggable-by-default constructions: macros and async, which makes actually learning how to debug production Rust code about as fun as pulling your own teeth out. I have legitimately had an easier time hunting down memory corruption errors in C++ then trying to figure out where a particular error is being thrown when it is hidden inside a macro inside an error with no stacktrace information, because C++ has mature tooling for hunting down various kinds of memory errors.
Because of last year’s shenanigans, I am no longer confident that any of these problems will be fixed anymore. Rust’s development has slowed to a crawl, and it seems like it’ll take years to stabilize features like variadic generics, which are currently still in the design phase despite all the problems the ecosystem runs into without them. It is extremely frustrating to see comments saying “oh the ecosystem is just immature” when those comments are 5 years old. On the other hand, I am tired of clueless C or C++ fans trying to throw bricks at Rust over these kinds of problems when C++ has far worse sins. Because of this, I will continue building all future projects in Rust, at least until the dependently typed language I’m working on has a real compiler instead of a half-broken interpreter.
Because hey, at least it isn’t C.