The article glosses over async Rust and is mostly a rant about how closures are difficult in Rust.
Most of the difficulty comes from Rust not having a GC yet wishing to keep track of object lifetimes precisely: a GC'ed language needs no distinction between an ordinary function pointer and a closure that captures the environment. But Rust being a low-level systems language chose not to have a GC.
Another popular language also doesn't have GC, makes distinctions between ordinary function pointers and closures, and its closures are unnameable types; that language is C++. But instead of using template for closures everywhere (like the STL), you can optionally use std::function<R(Arg)>, a type-erased closure type, which would in principle be similar to Box<dyn Fn(Arg) -> R> in Rust. But such a type doesn't really mention the lifetimes of whatever it captures, so in practice it doesn't work well in Rust (though std::function works well in C++ because the C++ doesn't care about lifetimes).
With that in mind, I see this article as not understanding the goals and tradeoffs of Rust. The author would be happier writing in a higher-level language than Rust.
I think it's not entirely fair to paint problems with rust's async features as an aversion to low-level-ness.
If anything, I think the issue with Rust's async is that it tries to be too high level.
In my experience with async rust, most of the difficulty comes from "spooky errors at a distance". I.e, you're writing some code which feels completely normal, and then suddenly you are hit with a large, obtuse error message about types and traits you didn't even know you were working with. The reason being, something went wrong with all the inference involved in implicitly wrapping everything in futures. And often it's not even related to the line of code you just wrote.
So in these cases, I think the issue is that rust tries very hard to wrap async in a very smooth, sugared syntax. It works fine in a lot of cases, but due to the precise nature of Rust, there are a cases where it doesn't work. And because the system relies on a lot of magic inference, the programmer has to hold a lot of assumptions and rules in their mind to work effectively with async.
I am not an expert, but from my experience it feels like async rust was a bit rushed. It should have been introduced as a more explicit syntax first, with sugar on top once the guard rails had better been figured out through experimentation.
That is under the assumption that future-based async in rust is a good idea at all. Rust's ownership model becomes intuitive very quickly in the single-threaded case, but at times it feels like a square peg in a round hole where async is concerned.
This may be a problem with async generally unless the language is designed specifically around async like Go is, which some profound tradeoffs to make it happen. (not criticizing that I think Go did a great job)
Zig managed to get async correct, if you ask me, but it's because I would call it "the lowest-level primitive to do what you need it to do" (shift the function frame over to a place that "might not be the stack"). If you think of it as a sugared "async" you will probably do it wrong.
After a lot of wrassling with it, once I realized that it was a very shallow abstraction over what the hardware is actually doing, everything clicked.
Having a global switch to switch the program over to async only works if all your concurrency primitives are aware of it: e.g. mutexes. Otherwise you get deadlocks. But Rust is designed to work with raw OS concurrency primitives. So that solution isn't available.
Zig's approach is really nice here, but it's also a notably easier problem to solve than rust has, since it's not trying to solve for parallel/concurrent safety.
Do you think Zig's approach would be feasible in Rust? I guess maybe you could do it, but the sync version would have to have the stricter constraints of the async version.
Not possible for the same reasons why just adding a borrow checker to C++ isn't possible. Mostly this comes down to incompatibility with existing code. Zig is just not memory safe, and the only solution would be to add a GC.
Assuming you mean seL4, that was formally verified by taking a manual specification of the L4 kernel and laboriously proving that the C code implements that specification. That is not at all what the compiler's lifetime/borrow check subsystems do for Rust. They know nothing about what the program is supposed to do; they only check that some relatively-simple rules are followed. That's why Rust requires so much less effort on the part of the programmer than formalisms that aim to prove programs correct.
Nobody is arguing anything on the level of doing a full on Sel4 -- but
Sel4 does have generalized no memory leak, no uaf guarantees as a subset of all specification proofs, so my point "memory safely is possible to bolt onto even C" is still valid.
I don't disagree. After working with Rust's model, which forces you to confront a lot of the tradeoffs and complexity, I'm more inclined to think async is a feature which should really be considered from the ground up when a language is being designed, to avoid painting oneself into a corner in the design space.
Yes, and (to be clear to people who don't click your link) it was removed for exactly the reason that the parent commenter mentioned. Rust's lack of a pervasive GC is not intended to suggest that GC is not generally useful (even in a systems context), only that it's too difficult to satisfy the sort of extreme control that people demand of a low-level language if they are forced to find ways to work around the pervasive GC. Which is to say, you need a way to turn off the GC, and if you can function with the GC off, then maybe you should push that as far as you can get away with, which is what Rust did.
Yeah I have done some work trying to optimize code written in GC'd runtimes, and eventually you will hit a wall.
GC is great for a lot of use-cases: for instance if I am writing a server-less application with mostly lambdas/cloud functions which pull data from a database and serve it to an HTTP response, the productivity gains you get with a nice high-level language will more than make up for a few pennies you might pay for a tracing garbage collector to run, and it's going to be plenty fast enough.
But when you have a case where every cycle/byte of memory counts, the GC is going to get in the way. Of course stop-the-world collectors are a catastrophe for performance, but even with modern generational GC it's essentially a part of your program's execution you didn't write and you have at best arms-reach control over which dictates hugely performance-impacting aspects of execution like memory layout.
Some GC'd languages have escape hatches to manipulate the GC process directly, but even those feel a bit like trying to drive a nail by hammering it with the butt of a screwdriver.
Yeah maybe not like go, but it's not impossible to imagine that you could create a language from the ground up with a rust-like ownership model and some async model - maybe not async await precisely.
The article is also conflating synchronous single-threaded, synchronous multi-threaded and asynchronous programming.
Each have their own usage, and no, a multi-threaded program is not the same as an asynchronous one. For example, using threads and channels instead of async/await is not a design flaw if your workload is mostly about large, blocking computations on a read-only shared state with no I/O. In that situation, lifetime and closures will not pose any issue and won't get in the way because you're not mutating anything and communicate with owned messages.
The async ecosystem is evolving and will be better tomorrow than it is today. Saying that Rust programming as a whole is a mess because async/await is harder to use than necessary, is short-sighted. It's like saying you don't like apples, but you only chewed on the branch of the tree.
its really strange that there are two languages running around together. one which is very opinionated in how to manage memory in a stack discipline and another which just uses reference counts.
they don't quite mix. so you need to be aware of which one you're (implicitly using), and you may need library functions for both colors.
you have to admit this adds some additional mental overhead. but what got me when trying to understand the language was just that there _was_ such a division. I actually tried to apply the lifetime model to asynch objects.
> its really strange that there are two languages running around together.
Mmh, I'm not sure I would agree with that. I've been using Arc & Rc for both async and non-async code, and even in single-threaded code. It's less about what flavor of concurrency you're using, than what program you're writing.
Arcs and references can coexist and are useful for different reasons. It would be a tragic loss for me if the language was (re-)adopting garbage collection or reference counting globally -even optionally. They are made to go along one another.
Edit: for example, tree-like or graph-like structures can use Arc and Weak [see arc documentation] in single-threaded code to enjoy reference-counted, cyclic, heap allocations in order to store a reference to a parent or sibling node.
You can do that with boxes, but Arcs have benefits if you want your code to work in a multithreaded environment.
my perspective is that everything I do is asynch and multithreaded. so the whole hierarchical allocation part of the language is largely useless to me.
once i figured that out, its really not so bad. but anytime a collaborator or a library tells me to use lifetimes I just get all churlish.
IME, using reference counts there is also not a good idea. In practice I think it's better to treat async tasks just like goroutines and then use channels to communicate.
I've found that when I've structured things that way in the past I do have some performance issues if the grain is very small.
more importantly to me is that those machines dont really compose that well over larger scales - that is to add a new component I need to look at the overall scheduling and connectedness of the existing components and need to rework them to order to handle a new control flow.
it is a nice model. i think it isn't appropriate for some things, so I tend not to reach for it by default.
From my very limited expeirience with Rust I noticed that it becomes way more easy and laid back language when you just skip using references and lifetimes nearly completely and just wrap everything in Rc<>. Then you are getting expeirience of fairly high level language with a lot of very cool constructs and features like exhaustive pattern matching and value types with a lot of auto-derived functionality.
`Rc` freezes the value as long as it's shared unless you use interior mutability. It's fine when you want to share immutable data, but if you ever need to mutate its contents you will have to deal with awkward cases and situations, and at runtime!
Wrapping everything in `Rc` is not a good default strategy. Instead figure out an architecture that works well with the borrow checker! This normally means thinking hard about your data structures and the ownership model of your application.
I love Rust precisely because of this. You can leverage the compiler to drive your development and design. If it's hard, then it's probably wrong!
> I love Rust precisely because of this. You can leverage the compiler to drive your development and design. If it's hard, then it's probably wrong!
Yes, I had the same experience writing code in Rust and Haskell. If the design works nicely with the language, then also the design is better, more modular, simpler dependencies and easier to understand and extend.
Are there any good resources for design patterns in Rust? I've gone through Rust by example and am writing mid-size rust programs now but would love to check my work against the experts.
...and someone asked about good code to learn from on Reddit a day or two ago and was advised to read basically any code by dtolnay or burntsushi, two of the big wizards who feel like they've written half the Rust ecosystem at times.
You probably need Arc<> in the context of async, but if it's a normal closure, Rc<> will do.
However, that's not needed always. You largely wanna move stuff into closures in my experience, since the closure ends up owning the data.
Arc comes with a small performance overhead (Rc too, but less so I think?), so you don't want to use it all the time. When using Arc, you'll usually need a Mutex too, which adds its own cost, etc.
There are ways to partially avoid this costs, but they can add complexity (eg channels).
Sometimes you might be able to use an Async-flavor of the synchronization types instead, which come with more acceptable performance compromises in Async contexts.
Rust has advantages over C#, even for high level programming:
* Rust traits are more flexible than C# interfaces, especially when combined with generics (implementing traits for foreign types, associated types, each method can have its own constraints, conditional trait implementation, #derive)
* Rust has much stronger thread safety guarantees (absence of data races, preventing access to a mutex's data without locking)
* Options are cleaner than null (though at least C# supports non-nullable reference types nowadays)
* Cleaner error handling through `Result`s. In C# you either have to use exceptions (discouraged for errors that are expected to happen), or use ad-hoc approaches which don't benefit from syntax sugar like the `?` operator.
* Support for discriminated unions ("enums")
* I like cargo better than msbuild+nuget ("it just works", feature-flags + conditional dependencies, distribution as source instead of binaries)
On the other hand C# has better IDEs and you don't have to deal with the rigors of ownership and the borrow checker. In particular I struggle with LINQ style functional programming in Rust, since the lifetimes of closures and the fields they close over are difficult to reason about.
I haven't used Kotlin, but expect it to be similar to C# in its strengths and weaknesses. Python is not an option for me, since I like static typing.
If you don't need the low-level control of Rust then I think Scala would check all of your boxes (aside from distributing dependencies as code instead of binaries). Specifically, an effect library like ZIO looks a lot like Rust without the complexity of managing lifetimes (because you have a GC).
> Python is not an option for me, since I like static typing.
Have you checked out mypy recently (past few years)? With all the strict flags, it's pretty damn hard to get anything but bulletproof static typed code to pass. The only major downside is it lacks higher kinded types at the moment, and there is a PR in the works to add that. But it has all of the other accoutrements you'd expect from a modern type system: generics, structural subtyping, co/contravariance.
1. No recursive types, so you can't express a lot of basic things like a "Json" type. This ends up coming up a lot more than you might imagine.
2. Not great support for every pep ie: Protocol
3. Generics are extremely confusing in mypy. Generic classes suffer from the lack of recursive types I'd mentioned as well.
4. Implicit 'Any' everywhere unless you use the strictest settings, which I don't believe anyone does on real code because of the above issues. I've never once managed to get a real codebase to typecheck with mypy.
Honestly mypy works as a somewhat fancy linter, not as a type checker.
I don't agree that people should avoid exceptions for expected errors. The idea that they should has twisted the error handling landscape into a pretzel and is responsible for some ugly bits of Rust design.
Exceptions are a mechanism for error handling that comes with control flow changes.
But Rust already has perfectly nice control flow mechanisms (look at std::ops::ControlFlow for example) and perfectly nice error handling. So you can use either, or both, as necessary, you don't need this particular Frankenstein's Monster.
Using Exceptions for things that aren't actually exceptional is perverse.
> Using Exceptions for things that aren't actually exceptional is perverse.
In python using exceptions for some control flow is actually accepted practice if you don't go overboard.
I think it's better that way because it turns them into something familiar instead of pushing them back to some extraordinary circumstances.
The thing that C++ does with exception means that programmers mind is encouraged to stay on happy path because when he'll walk off this path he'll have to deal with this exception handling monster.
So it's better to either not have exceptions and expose unhappy paths to programmer clearly like Rust or Go does, or familiarize exceptions and their handling so programmers can use them easily in all circumstances like in Python.
Just don't do what C++ and similar do. Offer them as a heavy and weird side mechanism reserved only for super exceptional cases.
C++ exception handling is nothing special, it’s just surrounded by a lot of FUD, premature optimization, outdated benchmarks and cargo cult practices. Like you say, limited exposure adds to all this mystery around it.
If you follow “rule of zero” they just work. The problems come when you start implementing your own destructors “to handle exceptions”, which unfortunately seem to be a very common practice in the wild.
- GC pauses, Rc<T> does not, it's simply a deallocation by the last reference holder
- Rc<T> still has predictable memory allocation/deallocation, GC is not guaranteed to run until needed
- More efficient memory use, no need to keep track of allocated objects
- The rest of the Rust language is a pleasure to use (imo)
- Python is slow for certain applications, and not concurrent
- Kotlin is kind of more niche than Rust, it's very popular in the Android community, like C# is popular on Windows
Just my 2 cents, I love progamming in all languages. There are some use-cases where a high level language is absolutely the way to go. For others, Rust provides much more control with a handy escape hatch. Absolutely don't go wrapping everything in Rc<T> like a madman, only when the reduced complexity is more beneficial than dealing with references/lifetimes.
> GC pauses, Rc<T> does not, it's simply a deallocation by the last reference holder
There are pauseless GCs (e.g., Azul for JVM), and Go kicked off a trend of super-low-latency GC. Also, RC deallocation is O(N) while GC is usually O(1) (ignoring pedantry about how RC is a type of GC). Further, RC can't handle cycles automatically.
> Rc<T> still has predictable memory allocation/deallocation, GC is not guaranteed to run until needed
Deterministic GC exists, but admittedly isn't widespread. For most non-critical real time systems (e.g., video games) a low latency GC is probably sufficient.
> More efficient memory use, no need to keep track of allocated objects
I'm not sure if this is true? Presumably each RC has an int for its reference counter? I'm not sure what the bookkeeping overhead is for tracing GCs, but I'm guessing it's not O(N)?
> The rest of the Rust language is a pleasure to use (imo)
Agreed, but the borrow checker affects everything so this is a pretty small consolation in practice.
> I'm not sure what the bookkeeping overhead is for tracing GCs, but I'm guessing it's not O(N)?
I don't know about these days but, historically, the received wisdom was "Take the memory your algorithm should need and double it, to leave room for floating garbage and bookkeeping overhead. Otherwise, your performance will suffer as the GC is forced to run more frequently than is ideal."
("Floating garbage" being the jargon for stuff that's inaccessible but not yet collected.)
> GC pauses, Rc<T> does not, it's simply a deallocation by the last reference holder
While the last bit is true, it may actually end up having to do lots of work. Think large object graphs where the last reference to any of it goes out of scope.
Also, as a matter of terminology: Rc is a form of garbage collection. It's also a pretty bad general GC strategy at that -- which is why one doesn't just slap an Rc on everything.
Naive synchronous reference counting can lead to large pauses as well. What happens when you drop the last reference to the root of a 10,000-node search tree?
You do 10,000 reference deferments and free()s.
Reference counting might feel more incremental than GC, but really is not. There are tricks you can use, but you're better off with a fast, modern, pauseless real GC that comes with tons of other benefits.
Look: modern GCs simply don't have pause time issues. The problem has been solved. We have concurrent marking and concurrent sweeping. Stop living in the 1990s!
> Naive synchronous reference counting can lead to large pauses as well. What happens when you drop the last reference to the root of a 10,000-node search tree?
> You do 10,000 reference deferments and free()s.
You do all that work at the precise point where the last reference was dropped.
What people who complain about GC pauses dislike is the GC causing pauses in completely unrelated threads, including these threads that aren't even allocating or deallocating anything at that moment.
> You do all that work at the precise point where the last reference was dropped.
True, but rarely meaningful in practice. People don't anticipate spikes at implicit deallocation sites. On the contrary, they typically use RCs to share data without worrying about exact destructure time (i.e. same reason as GC), and thus will be caught off guard in either case. In fact so much so that even experts frequently introduce accidental RC cycles (causing hard-to-debug leaks).
In the cases where you actually want destruction to be deterministic (e.g. large allocations, graceful teardown, kernel resources etc), neither a traditional GC nor RCs are a good solution.
The simplicity and elegance of Rusts RAII scoped ownership model largely goes away with async, due to the unavoidable "Arc-hell".
Yes, that is how C++/WinRT handles cascading deletion of COM instances, however when you are going down that route, it is basically a poor man's tracing GC.
> More efficient memory use, no need to keep track of allocated objects
RC uses a traditional malloc/free-style heap, which requires bookkeeping for allocations. It also allocates an _additional_ word to track the reference count, which leads to poor cache behaviour (even in single-threaded programs, but especially in multi-threaded programs). Bumping/copying nursery incurs no bookkeeping overhead.
Because Rust feels even nicer than Python and C# (I don't have any experience with kotlin but from what I've seen it's as decent as Python at least) and even with Rc<> Rust is quite light-weight and fast.
One of its selling points is zero-cost abstractions and pretty powerful abstractions at that.
While I understand that with Graal and .Net you can ostensibly make native, static binaries for Kotlin or C#, I'm very skeptical that it works well in practice. In particular, I'm guessing it will feel like swimming upstream, fighting an ecosystem and build tooling which mostly assume you're running on a VM. And then there's the question of performance...
And of course, with Python your code will run 100x slower than either of the above, your dependency management will suck, you won't be able to statically compile, and to top it all off everyone will give you recommendations for your ailments which take a long time to try out and inevitably will fail miserably for one glaring reason or another. By way of example: "just rewrite the slow bits in C" -> you rewrite the slow bits in C -> your code is now slower because the marshaling costs exceed the gains + your build system is dramatically more complex and you have the sheer joy of debugging segfaults and undefined behavior (yeah, I know Cython exists).
With the modern JVM you actually have to work quite hard to write native code (Rust/C++/C) that outperforms an equivalent Java/Kotlin/Scala implementation. And it is quite easy to perform worse with a native implementation. Of course that is subject to various caveats:
1. Anything running on the JVM will need a 50-500ms of startup time.
2. The JVM implementation will not reach max performance until the runtime has optimized and JIT'd the relevant code paths.
3. There is memory overhead of the VM itself so your runtime will (all else equal) probably require more memory.
If you do need to use Graal to generate native images though it can actually be quite nice. You can run locally (or in benchmarking environments) on the VM and get all the tooling and metrics that come along with that, but build a native binary to actually deploy. I agree that it can be kind of a pain though.
> With the modern JVM you actually have to work quite hard to write native code (Rust/C++/C) that outperforms an equivalent Java/Kotlin/Scala implementation.
For what kind of code? I've never experienced comparable performance between JVM and C++ implementations, and I've had the misfortune of writing a couple parallel implementations in recent years where it was literally an "apples to apples" comparison. C++ is faster by default and isn't particularly close, even if you ignore Java's startup and warmup time. I've never seen a native implementation run slower than a JVM one in my entire career, which has included a lot of Java.
This is the expected outcome and easily explainable in technical terms. Performance in modern systems is dominated by memory handling efficiency, where C++ is very strong and JVM is not.
Those are some very large qualifications, though, right? I think a better way of saying this is something like “you actually have to work quite hard to write native code that outperforms an equivalent Java/Kotlin/Scala implementation for a specific kind of long-running process which can amortize the startup costs and the JIT time.” And that can be true! (Though in my experience naïve Rust often substantially outperforms naïve Java/C#/etc.)
The other thing is that true warmup (not just startup) time to reaching optimization is a lot weirder and less predictable than people realize. See e.g. [1] and [2] for a fascinating analysis (caveat: about a decade old) of the actual warmup characteristics of a number of different VMs (Graal, HHVM, HotSpot, LuaJIT, PyPy, TruffleRuby, and V8). Spoilers: there are bizarre deopt cliffs, scenarios where it appears to optimize and then de-optimizes if you run it long enough, and programs which just never optimize… and this is on benchmark tests!
That's a fair point. But I think that "long-running processes which can amortize the startup cost and JIT time" covers a very large swath of software development.
And you still run into all sorts of ergonomic issues when you inevitably need to dereference your Rc<> pointer. That said, I sympathize with the parent's desire for a proper Rust-lite with a GC: C-family syntax, great tooling, great documentation, great standard library and ecosystem, native+static compilation by default. Of course, someone will ignore those criteria and come in suggesting OCaml/Reason...
> Of course, someone will ignore those criteria and come in suggesting OCaml/Reason...
I hoped Go to be such a language, but it failed to fulfill my needs by throwing away all the PL knowledge that humanity has accumulated for decades. I still mourn for the missed opportunity by Google.
I considered mentioning "Go", and while it would be nice to have a Go with generics + sum types, it's also very nice having Go as it is today. In other words, it would be nice to have a "Go with those things" and a "Go without them" (predictable rebuttal: "But if Go supports those things, you would only have to use them when you wanted to!" <- not true, because in the real world we have to use the libraries available to us).
Frankly though, the type system is absurdly overemphasized. Squabbling over type systems is penny wise and pound foolish if you're missing the fundamentals. Allow me to quote myself from a thread a couple of days ago:
> Agreed. It still surprises me that so many other languages fail at the fundamentals (minimal learning curve, static binaries, fast builds, reproducible dependency management, great tooling, great stdlib + ecosystem, etc) and yet many devotees of those languages have positively hyperventilated about Go's error handling and type system for 12 years. Go is finally getting generics and I'm sort of cautiously excited about it (like I was when Apple added the TouchBar to MacBook Pros), but any net benefit is going to be positively negligible in comparison to the degree in which Go raised the bar on the fundamentals.
Java (and other JVM languages), as well as C# to a certain extent address the points you raise. Secondly, it's not true that golang has a minimal learning curve (I've seen senior engineers write bad golang code when they're onboarded - it takes time to learn the golang way of doing things and its quirks), and it's not even a fundamental goal to have.
I disagree. Firstly, it’s widely agreed upon that Go has quite a lot lower learning curve than just about any other language. Your senior engineer anecdotes sound like outliers.
Even where those languages have nominally improved, it is often so difficult in practice that virtually no one bothers to use the improvements. For example, while Java and .Net technically can do AOT, static compilation, virtually no one does, instead preferring to put up with runtime dependencies (including the runtime itself at a minimum). It’s ticking a box so on paper they compare better to Go (or Rust) but the practical experiences remain leagues apart.
Again, having a lower learning curve is a non-goal for any serious project. All languages have their quirks and approaches to expressing concepts, and golang is no different.
Java already has a low latency GC (ZGC), and with GraalVM being more and more used, more frameworks are starting to allow building native binaries (e.g. quarkus.io and micronaut.io and helidon.io). Once Spring gets on board, then a huge part of the ecosystem will.
Secondly, if the only concern is packaging and deploying a single file, that's been possible for a long time now (uber jars), and made easier recently with jlink and jpackage.
All "serious" golang projects I saw use some sort of testing suite (like testify), because the built in testing library is so verbose it's basically useless for anything but simple use cases.
And a built in HTTP sever is not a big deal. It's literally a one line maven import, and many such options exist now.
My employer is a big user of golang, and for all company projects, we have to use a framework they built to write code (basically Spring or ASP.NET DI reinvented (but poorly), plus handling some of golang's bad design decisions around error handling and propagation - but it's far from perfect there's only so much they could do). Not to mention having to use bazel to build them and resolve dependencies (and they say golang compiles quickly, lol). All serious projects will end up in that state sooner or later, and in Java land, it's all available from the start and extremely mature and battle tested.
You're right that the experiences are leagues apart, but that's in favor of the JVM. golang has nothing close when it comes to monitoring and observability and continuous profiling. Nor the tunability of the JVM to select the best GC based on the application type.
Take a look at Kotlin Native. It compiles to native code, the standard library is pretty good, and the latest versions have introduced a GC so it's now much easier to convert Java to it (Java can be rewritten into Kotlin automatically at the source level).
However I'd just use Graal native image. Most libraries work and it's far easier to write a config or tweak one that doesn't, than not have access to the library at all!
As D is making gc optional, I'm guessing rust will evolve ADT crates that makes it easier to do massive parallell processing via message passing. But like with C, I'm not sure most of that should be "part of the language" - might not be something you need for your bootsector or ABS break system controller...
Yes. It's a kind of a garbage collector for people who don't need garbage collector.
If you need to do a lot of small allocation or have many circular references that you want to drop wholesale then you need a proper GC.
Even in some other cases you might benefit from proper GC. However Rust doesn't have a standard proper GC yet. It might have it as library some day. Some simple ones like https://docs.rs/gc/0.4.1/gc/ already exist.
But Rust is such a great language for many reasons. It would be a shame to not use it just because it doesn't have a great GC yet or because you don't have patience for borrow checker.
You can get those cool features in languages such as OCaml/F#/Scala/Haskell, and then you don't have to worry about managing memory because you have a GC.
From the point of view of somehow that has taught C++ as TA, worked with C and C++ during several years in some heavy contexts, an afternoon won't do it.
That would be circular, but that's not what the parent is saying. Rather, eta's post has one central point - she even bolds it for us:
> Rust is not a language where first-class functions are ergonomic.
And this... I mean, I don't agree with her, but that might be because I've been immersed in Rust for half a decade. But Rust's tradeoffs are based around four things. It is a:
- performant
- reliable (incl. memory safe)
- productive
- systems programming language
"Systems programming language" means no garbage collector. "Reliable" means you can't just pass around references to memory willy-nilly. I'm sure closures and their associated traits (Fn(), "call it as many times as you want", FnMut(), "call it only when certain safety conditions are satisfied", and FnOnce(), "call it once") could have been fine-tuned more, but they _do_ achieve the goal of general, usable (imo) first class functions.
I don't see another design that would have done this.
C++ lambdas are much better than Rust ones, because you can explicitly decide what to copy inside of the closure.
As for the author being happier writing in a higher level language, it kind of proves the point that Rust's main target is the domain where any sort of automatic memory management aren't a viable option.
Pushing Rust outside of this domain is only trying to fit a square peg into a round hole.
C++ lambdas need that feature because they don't have lifetimes or a borrow checker. Rust closures don't need that feature because rust has a borrow checker that ensures you aren't referencing something you shouldn't be.
You can explicitly decide what to copy inside a Rust closure as well, you just use a `move` closure and create references for anything you need referenced outside the closure instead.
Parent is saying that you can declare your closure 'move' and move references rather than values, therefore getting the mixed behavior you described. You don't have to move all values.
If you want to copy in such cases in Rust, you need to copy to dummy variables that are then moved into the closure, a common pattern in Gtk-rs that even deserves a macro to simplify the boilerplate.
Some of the pain inflicted by async Rust is incidental, not
due to this GC choice. Pin is the biggest culprit.
Pin exists so that references captured in Futures may be implemented via raw pointers. This implies that a Future contains pointers to itself, hence Pin.
The cost of Pin is that it forces you to write unsafe code as a matter of course, the so-called pin-projections. [1] Look at the requirements for structural pinning: they are quite complicated.
References captured in Futures probably could have been implemented differently: base + offset, or relocations, or limit what can be captured, or always Box the state for a Future that captures a reference. Those would have avoided a GC and been even safer, since it wouldn't require unsafe code on the part of struct implementors.
I don't think base + offset would have worked because then references would have to be codegen'd differently depending on whether they're in a future or not; this would have impacted separate compilation as you can pass references to other functions (including across FFI). Relocations only work at load time (either dynamic linking or link time), so they wouldn't work. Limiting what can be captured was tried with the old futures crate and it was a real nuisance. Boxed futures violate the zero-cost abstractions principle, which is the reason why many Rust users are using the language in the first place.
Pin is an evil, but it's the least evil of the possible options.
"Relocations" was meant as an analogy. The idea is, each time a future is entered, it checks if its base pointer has changed. If so, it fixes up its own pointers, similar to how a linker fixes up self-pointers in a dynamic library. There's obvious limitations but it would handle common cases efficiently.
Anyways when I first tried async, I encountered structural pinning immediately, and I was surprised to learn that users are just expected to write unsafe code to participate in this system. Maybe it really is the best tradeoff but seems at odds with the rest of the language.
I don't think it's possible for a future to enumerate all the references that belong to it. At the very least it's not obvious to me how that's possible to implement, due to the way lifetime subtyping works.
I don't use Rust but was curious if this was a real or imagined problem--it seems to be the latter. Two points:
1. the example could have made a pure callback function that takes a mut& db param and pass thaf param to the 'do'er function. Why is this not required anyway? i.e. How does Rust know/decide who owns the mut& db if both the closure and the function that creates the closure can reference it?
2. as mentioned, the complaints about async seems to apply to closures in general even if used synchronously, AFAICT.
Best to think of this post as quirks when working with closures in Rust. It would have been far better to list each with workarounds.
> the example could have made a pure callback function that takes a mut& db param and pass thaf param to the 'do'er function.
No. If you did that, you could only pass functions in that took the db as a closure argument, nothing else... you're thinking about this example only, and forgetting the problem is with the general case of capturing whatever is needed for the callback to work.
Ok, so then the problem statement is how to do dependency injection of lifecycle references into closures. This is specific and asking quite a lot of a single entrypoint function. The closure 'at some point' (either at the end, or coordinated give then get calls) would have to transfer ownership. At this point you'd likely be better off making an interface and not a single entry/exit closure.
I agree that this one isn't about async Rust at all. The main comparison is drawn in
> And then fast forward a few years and you have an entire language ecosystem built on top of the idea of making these Future objects that actually have a load of closures inside
And this sentence is wrong. There's next to no `Future` implementations which are built on top of callbacks. And the reason why that's the way it is is exactly the one the author mentions: Callbacks don't work very well with Rusts ownership model.
To be fair withoutboats didn't really address any of the points that people make about the flaws of async Rust (e.g. the cancellation problem).
It may be the case that there's no good way to solve them, but that doesn't mean that they aren't problems and it doesn't really help to say that people who highlight them are totally wrong and don't know what they're talking about.
That said I think your second link does a much better job of explaining the issues than this post does.
withoutboats has explored these trade-offs on their blog, e.g. https://without.boats/blog/poll-drop/ and as part of io-uring investigation. io-uring is the main use-case for async drop, but even that has been mostly solved without language changes: there is tokio-uring now.
Apart from requiring a safe zero-copy io-uring abstraction to use a buffer pool instead of "bare" references, Rust's polling+cancellation model is fantastic. It's incredibly convenient to be able to externally time out and abort any Future, without needing to pass contexts/tokens and/or needing every I/O operation to have an explicit timeout option.
It's annoying when conscious small trade-offs are framed as fatal flaws (like the title of TFA). In the case of the cancellation model the "doesn't work" is that a safe abstraction is not entirely zero-cost. But when you use async in JS, C#, or Golang, you already have unavoidable heap allocations and memcpys (and another set of difficulties with cancellation). Rust's worst-case flaw is comparable to a normal mode of operation in a few other async languages. The scale of the issue is overblown, and its benefits are overlooked.
I’d say both. The author of the post called Rust async/await a disaster, and the top commenter, who withoutboats replied to, agreed with the author’s criticism.
The original article is just as inflammatory as its top comment if you’ve read it:
> In 2017, I said that “asynchronous Rust programming is a disaster and a mess”. In 2021 a lot more of the Rust ecosystem has become asynchronous – such that it might be appropriate to just say that Rust programming is now a disaster and a mess. As someone who used to really love Rust, this makes me quite sad.
I imagine withoutboats read both and was pretty upset.
I'm less comfortable projecting my own opinions into the head of 'withoutboats than you are. The author of the comment they responded to and the author of this post are not the same person.
Oh, thanks for that link. Withoutboats is being quite emotional, but it is understandable to be emotional about their brain child.
I am tempted from time to time to try Rust, but in the end, I don't think it provides enough benefits for me to consider it. I like high-level, and when I am going low-level, I don't want to be hand-cuffed. It seems with Rust, I am paying all the time just for the ability to go into hand-cuffed low-level mode.
I'd say seat belts more than handcuffs.
When going low level it's easy to slip in memory leaks and security vulnerability.
If you care about developer speed and security but don't care about correctness or performance you can rely on someone's else implementation and write higher level code (eg. use node and offload low level security considerations to node).
If you care about developer speed and performance but don't care about correctness or security use a low level unbounded language (eg. write C and triple check your code)
If you care about security, correctness and performance but don't care about developer speed, use Rust.
To be frank I find the point about developer speed in Rust to be a bit exaggerated, it's pretty high level and the tooling is pretty great.
- Compiler speed is a bit hit or miss
- Debugging life cycles definitely take some time, but with time you get the hang of it and you can quickly copy paste previous solutions to the current problem.
- You save a significant amount of developer time by fixing things alerted by the compiler instead of finding errors at runtime
All things considered Rust is definitely the language I feel more productive in (before it used to be Haskell).
Oftentimes considerations about hiring / collaborating with other developers take the precedence over security, correctness and performance.
Right now I'm maintaining a node.js project, 2 Python projects and a Rust project - mainly because node.js and Python worked best with those teams and the Rust one is a solo project.
> To be frank I find the point about developer speed in Rust to be a bit exaggerated, it's pretty high level and the tooling is pretty great.
Rust has a long learning curve that most people won’t make it through. For them, they might feel the language is slow to developenin because they haven’t gotten to the point where they are “thinking” in Rust.
As a Haskeller, you are well suited to learn Rust; due to their similar ML lineage (the original Rust compiler was written in OCaml), Rust and Haskell make some similar design considerations. I’d say one thing Rust does right is take a lot of the things that make Haskell safe and make them fit in an imperative language.
That's an interesting take on Rust. Given that I have written a lot of code in Standard ML, I might give Rust a try after all.
GP's statement "If you care about security, correctness and performance but don't care about developer speed, use Rust." threw me a little off here. Because, Standard ML is very quick and easy to develop in. So should Rust not inherit that property?
Yes, I think Rust does get there. The thing of it is, I think people are measuring "time of development" differently. To some, the time developing a thing is the time spent writing it, and then getting it out the door. They often don't include things like fixing bugs that are discovered down the line and handled maybe by a completely different team. There's a difference between writing something and writing something right.
The Rust compiler and specifically the borrow checker help you write code that works the first time, which is a property Haskell famously exhibits. In order to get there, sometimes an involved conversation with the borrow checker or type checker is needed. What this means is sometimes you might spend a good long while figuring out a particular lifetime and ownership annotation in order to satisfy the borrow rules. Another implication is that sometimes the way you've architected your code is just plain wrong as far as the borrow checker is concerned. Sometimes it's hard to tell when you just need to add a particular annotation to your code to make it work, or if you actually need to rethink everything entirely. For example, if you go writing a doubly linked list in Rust thinking you'll use pointers, that's just the wrong way to go about it in Rust world, so rather than trying to force that style of code, doing things the Rust way (using Rc<RefCell<T>>>) will yield better results.
But once you get your code to compile, things generally work in a sense that the code will never fall victim to a whole class of bugs that frequent code written in other languages (segmentation faults, use after free, memory leaks, dangling pointers, etc.) You just won't see these things in safe, idiomatic Rust code.
And that leads me to developer speed. Once you and the borrow checker are on the same page, it's like night turned to day. Early when I first started writing Rust, I would spend most of my time dealing with borrow checker errors. Now that I know what the borrow checker expects, and I've internalized all the strange syntax and edge cases, the borrow checker fades into the background and I can code in Rust just about as fast as I can in TypeScript.
As a Haskeller, I expect you might approach writing Rust code in a functional style, and I think this would serve you well. The general approach that newbies take in Rust and which finds strong resistance by the Rust compiler is copious use of passing references for mutation, and creating a rat's nest of pointer references. This style of coding doesn't go over well in Rust, and unfortunately a lot of coders practice this because it's permitted by their native language.
Instead, it's better to lean into type checking and actually use the type checker to save you from having to write a lot of code. Take advantage of immutability and referentially transparent function. Make heavy use of pattern matching and Maybe monads cough I mean Option<T>. We don't use that word in Rust. Haskellers get all of this.
I am currently using Swift, and I like it a lot. But you are kind of siloed into the Apple ecosystem. So I am looking for an alternative.
I am thinking TypeScript might give the biggest bang for the buck:
* You have the widest possible reach via web apps & Electron
* You have an eval/Function object for compiling code on the fly
* You can embed low-level code via WebAssembly (and here Rust might be relevant for producing that WebAssembly)
* If you need access to your TypeScript libraries on mobile, you can still do that via embedding a hidden WebView.
Honestly, as a consumer of libraries, I much prefer when they're written in Rust (without gratuitous use of `unsafe`) compared to a language which doesn't make you think about these sorts of things as hard.
For me, writing a program is mostly writing a bunch of libraries that I then put together. So I am consuming my libraries mostly myself. Therefore, for me it is important that both the library writing and the library using can be done efficiently.
I assume that having a Rust library of something is a good thing. The question for me is: How easy and natural and quick is it to express a concept as a library in Rust?
1. Do you like strong type systems? If so, Rust us great! If you really would e itching for some python or JS, it may bother you, even though the Rust type inference really makes it a non-issue.
2. Do you care about allocations? One could be cheeky and rephrase it as "do you care about performance", but caring about allocations is one of the more advanced optimization concerns and GCed languages can have great performance if you dont hit their pathological cases. But the thing about Rust is that you have as much control as possible to avoid allocating. Entire libraries can allocate nothing more than temporary stack variables, with their primary data being lifetime constrained references the user passes in.
2.5 if you dont care about allocations, then does `Rc<Foo>` bother you? There are very easy escape hatches when you _just don't care_ if that thing is shared and don't want to deal with lifetimes. But Rust _lets you choose_.
I see type systems as a necessary evil. Obviously, I like to be able to distinguish a 32 bit integer from a 64 bit integer or a String. And being able to define interfaces is important. But I am not a fan of modelling all sorts of high-level issues as a type system, because that always comes at a prize: When it works, it is great, and otherwise it really really sucks.
So yeah, Rc<Foo> and stuff like that would bother me, because most of the time, these things are rather peripheral to what I am implementing.
I feel that, when performance is really really important, it is more important to be able to generate code on the fly, and I don't think Rust supports that.
But I haven't tried out ownership in Rust. I will have to work with it for a while to see if it bothers me or not.
If you say that "Rust sometimes is guardrails, sometimes it is handcuffs, sometimes it is a rollercoaster", that is already a lot of information. That's exactly what I want to avoid when implementing a concept: I don't want to be dependent on how well the type system fits my concept, because I am dealing with all sorts of concepts.
Ok. Sometimes Rust fits good, sometimes it fits bad. If you don't want to deal with that, that's fine. I don't want to deal with memory leaks and stuff, so I use Rust instead of C/C++ when low-level, and I deal with the bad fits.
I'd still recommend learning Rust though. I find the way (the clean ways, not the ugly ways like Weak/Cell/etc.) it forces you to structure your programs is a very useful pattern when dealing with unrigid code like in C or C++, since its performance is near those languages. Then when coding C/C++ you can just break out of that pattern when you need to (e.g. self-referential structs).
BTW, what languages do you usually work with? I've been eyeing up Zig since it's nearly adjacent to C, but I'm waiting until stabilization.
I am working mostly in Swift these days, and some Metal C for GPU and GPGPU. If Swift and SwiftUI both also ran in the browser, that would meet my needs.
I need to break out of that Apple silo though for an important project of mine, so I am looking for alternatives. I was looking at zig, too! It seems very promising, and comptime seems like a great idea. I am considering both Rust and Zig for generating libraries in WebAssembly.
I think I will mostly go with TypeScript for my project, it seems undogmatic and highly productive, and Turbo Pascal was my second language after Quick Basic, so I like its origins :-)
I am sort of in the same boat. I use C++ for server type applications. I consider modern C++ safe enough. While there are no explicit safety guarantees I am a practical man. My servers work for years without any complaints from clients. No memory leaks, no crashes. That's the end result and I think from that perspective for me using Rust would be ROI negative as I would have to port / replace a lot of code I reuse in my various products.
Things of course would be different should the client insist on implementation done in Rust but so far not a single client of mine ever mentioned using this language.
The point of Rust is that your mediocre C++ developers won't be able to write some kinds of bad code patterns with it, since it won't compile.
The problem is that most likely those mediocre C++ developers won't be able to write any Rust at all.
If you do have good C++ developers, you're also probably better off sticking to well-established and battle-proven C++.
So the use case for Rust remains niche. It's a way to attract the programming language nerd that knows more about Haskell than he does about systems programming. Maybe not exactly the best fit? But worth trying if you want to differentiate yourself from the competition.
Maybe it’s only my take, but from what I understand, the author wants to easily create closures with mutable references and call them from anywhere, asynchronously?
And the complaint is that Rust semantics makes this hard. Well yes, Rust makes it uncomfortable to shoot your own foot, that’s kind of the point.
Maybe it’s only my take, but from what I understand, the author wants to easily create closures with mutable references and call them from anywhere, asynchronously?
I think you are misunderstanding the author's point. I think that they'd probably agree that the (lack of) ergonomics of closures are a necessary result of the safeguards that Rust provides.
They point is more that, given that closures have somewhat frustrating ergonomics, it was probably a bad choice to base Rust's primary concurrency system on closures.
Disclaimer: this is not my opinion, just paraphrasing what I believe the author's point is. I found the post a bit strange, it seems to end rather abruptly. It doesn't seem to address the main question it seems to raise: what does this mean for async Rust and why is this bad?
Rust has stopped depending on closures for async when it moved from experimental futures library to built-in async/await. Rust now easily supports mutable and temporary references in async blocks and across await points.
Rust still doesn't support use-after-free, so you can't reference a temporary object and use it in another thread that will outlive it :)
In TFA the second do_work_and_then() example doesn't compile, because it has a UAF vulnerability that borrow checker has detected and prevented.
mem::forget() is not crashy like free(). It merely prevents destructors from being run (e.g. you could do the same by storing the value in a global variable). It's a safe method, subject to usual safety checks.
You can use an `unsafe {}` block to call literally libc::free(), or dereference a random raw C pointer, or do something else crashy. However, when talking about Rust's safety rules it's usually assumed we're not talking about code bypassing these rules on purpose.
That was my impression too. Rust “makes it hard” for a reason: it’s not safe. If you work with the type system to prove that it is, you can go about and do your thing but Rust is about guarantees. That’s one great thing about it. You can’t ask it to just not guarantee something.
Why would the first example with the database be "shooting your own foot"? I'm not a Rust dev, but in other languages that code makes perfect sense to me.
The `&mut Database` sort of implies that the Database handle doesn't do its own locking or other needed state tracking - it just relies on the exclusivity of `&mut` in Rust to be sure that nobody else is modifying the database handle at the same time.
If the API used `&Database`, then it would have to be more flexible for simultaneous use of the handle. And then the closure would be (a bit) easier to call too, because it's not tracking that exclusive reference.
If the API used a reference counted `Database` handle, then it would release even more restrictions of lifetime management, etc. You can implement all of these in Rust.
In other languages, you usually end up with alternative two or three.
If you try to simultaneously take alternative 1 and use it in a way that's not supported, that would be shooting yourself in the foot. Except Rust says compile error instead.
(Assuming you can just drop the database object, the semantics don’t really matter but the idea is the same.) The OP wants an easy way to do this but this is just not an easy thing to do. How can the closure guarantee that the database object will exist if my_fn decides to sleep for a couple seconds? Most languages do not have this problem because either: they use a garbage collector (Java and it’s cousins, Python, Go) or they are not safe (C, C++). Rust is in a particularly difficult (and ambitious and exciting!) intersection where this problem is tricky.
Ah, I see. So if there was a way to mark the DB as "ephemeral" or so, then it would be fine, but since the compiler can't know if the reference isn't deleted/freed, it doesn't compile? If I got it right, then that makes total sense.
Well, without further ado that's true. But the callback could of course set a result type somewhere (so, behave similar to a future/promise) and then we could know if/when it is finished and if it was successfully, or if it's still running (or broke without an error). But that's not Rust specific, that's just plain language-independent logic no?
> But the callback could of course set a result type somewhere [..] then we could know if/when it is finished
That would be a Mutex<>, the author mentions it in the comments. This would solve the “writing at the same time issue”, but not the lifetimes, so you’d need another change.
Instead of trying the mess of implementing special closures, you could just make your db static.
I write async rust for my day job, and while it is more complicated to write than synchronous rust, the complications generally make sense once you understand how the compiler is managing the remarkably tricky task of async with no GC. Rust remains significantly more pleasurable to write than e.g. typescript for me.
Some patterns around error handling still a bit awkward, but the FuturesExt and TryFuturesExt traits help a lot there.
The author spends the majority of the post saying how synchronous Rust doesn't work. The main problem? Closures have to be passed as traits. And they either don't know about the `f: impl Fn(i32)` syntax or refuse to use it for some reason.
Then the last paragraph just say "Oh and asynchronous Rust is even worse."
But why is Rust so much harder than any other newish programs language.
Dart is like all of my dreams come true at once, Rust still gives me nightmares. I seriously tried to learn it multiple times and failed repeatedly.
I've created several Dart/ Flutter projects for myself and friends. Multiple C#/Unity projects. Python and JavaScript have paid my rent for the better part of a decade.
> But why is Rust so much harder than any other newish programs language.
All the languages you cited are essentially in the same space, they're runtime-heavy GC'd ("managed"), imperative, object-oriented, "applications" languages.
Rust tries to achieve memory-safety without runtime support (as well as low-overhead in general), and to do that it relies on a rather advanced type system with concepts which aren't formal in many commonly used languages (e.g. borrowing and affine types).
It's a different language, and if you approach it with the expectation that it's similar to other languages you know, it's probably not going to work well. Possibly unless the language in question is C++ (as Rust formalises a lot of best practices of C++), but even then the lack of object orientation and strictness of the compiler is going to make the transition difficult.
It's probably easier for people with less expectations that "all languages are similar" and that their knowledge will be easily transferrable, either because they have less experience period, or because they have experience with a much wider range of languages.
> Dart is like all of my dreams come true at once, Rust still gives me nightmares.
Dart is the worst new language I've tried and it should have died back when they (in retrospective rightfully) abandoned DartVM in Chrome plans. (FWIW I used Dart back in the AngularDart betas, before Angular 2.0 was released, when TypeScript didn't even have support for async/await. Back then Dart had some good ideas, the tooling was good and it looked promising. In the meantime TypeScript did everything better while being backwards compatible and JavaScript improved a lot, along with the tooling. Now days Dart is strictly inferior in my view, the object model is closed/static, type system is nominal, so it has none of the scripting language qualities, and the runtime/metaprogramming is limited, with shitty library ecosystem - it's a shittier version of Java.
It's a language designed by VM developers and it shows in every way possible, so much emphasis placed on how the implementation works - for a high level language that isn't that performant in the end anyway and is hardly the bottleneck in it's usage scenarios.
Meta programming is done with compile time code generation and there is no runtime reflection, look at the libraries built for dealing with immutability for example - the ergonomics are Java level bad.
Flutter is a good idea if you need to write cross platform LOB apps (it becomes a bad idea if you need to use native components and render in coordination with them because the async channel native communication introduces visible render lag, eg. if you try to build custom rendering overlays over native maps it will lag frames behind because by the time you receive the map viewport updates and rerender the native map moved forward).
The fact that Flutter is built on top of Dart means I will not touch that framework any time soon, and would recommend anyone who doesn't like writing Java style boilerplate to avoid it as well.
They would need to include serious quality of life features to the language, and these features were requested years ago, but they move at a snails pace and prioritise other stuff.
I'm going to have to disagree, straight up flutter fixes everything wrong with react native. I've wasted countless hours I'll never get back trying to get various Babel configurations to work. I guess typescript tries to fix issues with JavaScript not having real types, but you still have to fight Babel.
Why do I still need a bunch of weird configuration files to just get import working ? JavaScript has treated me very well, it's what allows me to pay my bills. It still has so many fundamental issues that I avoid it when I can.
The vast majority of apps ultimately either CRUD or LOB( thanks for the term). When I or a friend needs something hacked together real quick, Flutter is the answer.
Add in what might be the greatest Firebase integration, and that you can create CRUD apps in hours. Implement login, and authentication flows within minutes.
I don't feel like playing the true Scotsman game when it comes to getting things done, if it works it works.
In fact when I build these tools for my friends, or for myself I don't even build an app. I deploy directly to a website with Firebase
and Flutter web. Google makes us workflow insanely easy, I could probably deploy a new CRUD app with a login system in like an hour.
Ultimately all we need to do is render a list of items, create new items, update them and delete them. That's what the vast majority of apps do.
At least for my personal projects it's Unity for games, and Flutter for anything else.
Then again, I don't have a comp science background and I just love getting things done. I don't really care how Flutter accomplishes what it does. Your allowed to use both Dynamics and Types when defining methods. This really helps when trying to hack something together fast, but latter reffing it.
If a friend needs an app built to track grocery spending or what not, what stack would you pick.
I've found the opposite. Everything around the language is very ergonomic, people have written tools and libs for everything, nothing is hard to integrate.
The language itself seems ok too. If I didn't have the IDE tools to tell me what was wrong I'd be screwed, but once you run into your first few borrow checker issues you'll read up on what's actually going on and you can fix the problem.
Even the async stuff that the guy is writing about, I don't recognize. Normally async causes all sorts of issues, especially ones where you're totally stumped and the errors make no sense.
With Rust it's just been a breeze. Write some code and at some point the compile will heavily hint not to go that way. Especially with shared state type systems, a borrowing this way and that, you'll find that it's smarter to rethink the arch rather than add yet another Arc<RwLock<>>.
All these other languages make things easier at the cost of performance and reliability. Maybe Rust has gotten to a point where it‘s hurt by it‘s own stellar reputation. It‘s so well liked that people pick it up for all kinds of projects. For people who know Rust very well, writing a mobile app or web service in it is probably fun and convenient, because it‘s always great to work with what we know best. For the rest of us, we should maybe stick with languages designed for application programming.
> But why is Rust so much harder than any other newish programs language.
For one, Rust has manual memory management. You are aided by the type system and the compiler, but it's the programmer who has to deal with the mental load of thinking about the lifetime aspects of every variable. Compare to a GCed language, where you just free your mind and can focus on your program.
To be more precise, I consider Rust to have "automatic static memory management", in contrast to C's "manual static memory management", or Java's "automatic dynamic memory management". The static part makes it harder than Java, because you do need to think about how to structure things, but the automatic part makes it easier than C, because the Rust compiler does the nitty-gritty for you.
For me, when learning a new language, there's a war in my brain between learning the thing and being productive. If I'm not productive long enough, I jet. It took me a couple of tries of bouncing off of Rust before it started clicking. I've written a few personal web projects in Rust and I'm still on the fence using it for those. My comparable Go web apps run just as fast with a little more memory usage, but are significantly faster to iterate on (compiler speed) and write, and also to read/understand later. The thing that has been tripping me up the most as an intermediate Rust programmer is that some library authors tend to get Architect Astronaut-y with the type system. It gives me the SimpleBeanFactoryAwareAspectInstanceFactory Java vibes.
Depends on the kind of programs you write, you may not actually need the "features" Rust provides (e.g. no-GC and high performance). In that case writing in a GC managed language certainly removes a lot of mental burden compared to writing in Rust.
However, anyone came from a systems programming background and wrote any non-trivial async network applications in C/C++ will most certainly appreciate the abstraction and safety Rust provides. Productivity grows significantly when writing in Rust because a lot of the low level details are handled by library authors instead of the programmer.
You must be intentional about both coding and systemically learning at the same time how it works. Watch YouTube videos plus build a real world project.
My first attempt to learn rust failed. My second attempt was much better.
> As someone who used to really love Rust, this makes me quite sad.
The current async story is still an MVP and I too dislike it. In the months before async, the ecosystem seemed on halt, waiting for async to land on stable Rust. Since then nothing has changed. The ecosystem "degraded" noticeable and has not recovered since.
Maybe in future async will be great, but right now I try to avoid it.
Sorry to spoil your axe-grinding with hard data, but the Rust async ecosystem has exploded since 2019. It is now about 5-6 times larger than it was before async/await landing:
You're conflating programming models with async runtimes, and weirdly attributing them to stabilization of a syntax sugar.
The split between blocking and non-blocking code existed in Rust before stabilization of async/await (e.g. futures 0.1 existed 3 years before, and mio is older than even Rust 1.0).
The only new thing is async-std, but that's not a stabilized part of Rust. It's just a 3rd party library. It may have been written anyway. It's relatively niche (1/10th of tokio), and you can safely ignore its existence (as far as I can tell every async-std-based crate supports tokio too, or has a direct tokio equivalent.)
I was worried that switch from futures 0.1 to std::future would fragment the ecosystem, but fortunately the whole ecosystem moved quickly, and the switch is completely over now.
I think your comment hits the bullseye better than many others I've seen around this. It very much looks like the language stopped progressing with async.
Seems like they just tried to be everything at once, and reached some sort of a critical mass. It probably didn't help how Mozilla dropped lots of their Rust projects and staff at roughly the same time.
It's still a very good C++ replacement. I think its role as a higher level application language is more of an open question.
I run a team at work building a desktop application in Rust which handles live streaming market data and displays it in an arbitrary layout defined by the user. From our experience (and we have a very good Rust team), async Rust works fantastically well.
Honestly, people want to write high level code in low level languages too often. Both Rust and C++ ought to be relegated to high performance cores of larger, "squishier" programs written in higher level languages. The Emacs model is perfect for most programs running on conventional systems.
The key insight here is that garbage collection gives you access to a variety of patterns that are otherwise fiendishly difficult to implement safely, including non-leaking closures, RCU-like resource sharing, and fast (but safe!) bump-pointer allocation of short-lived objects. Honestly, something like typed Python or Typescript is going to be fast enough for most use-cases, especially if any compute-heavy parts thunk out to optimized C++ or Rust. And if those aren't fast enough, you can write against the JVM or the CLR in a variety of beautiful, expressive languages and still gain the benefit of a GCed, managed environment while retaining the ability to accelerate core kernels in native code.
Writing entire big systems in C++, Rust, etc. just doesn't make much sense to me. Yeah, I understand the benefits of technical uniformity, of having to train people to use only one language, and of easy of debugging when there's only one level of the stack --- but still, I think people use low level systems languages for too much, and these articles about asynchronous work being hard to write in Rust are symptomatic of this fundamental mismatch.
> Both Rust and C++ ought to be relegated to high performance cores of larger, "squishier" programs
So, people from Google have explained about this before. The idea you have assumes that if you speed profile the code, 99% of samples land in this function "A()" and so you just re-write that part in C++ and now it's faster, or if you measure allocations you find 99% of RAM was allocated by "B()" and so you just re-write that part in C++ and now it uses less RAM.
Google already did all that low-hanging fruit. When they run the profile it comes back flat. You should rewrite A, B, D, E, F, J, K, L, M, N ... in other words the way to make the system faster is to just write it in C++
Part of this is also scale. If a system I run once week is a little slow, maybe in some sense that costs 40¢ but I don't account for it. At a mid-size non-IT firm maybe a similar performance cost is $1000 per year. Just about worth somebody enquiring if it can be sped up, but not worth arguing about it if the answer is "No". Maybe a mid-size IT firm where you work spends $1k per month on this problem. You could speed it up by rewriting the whole system, this would take some time - how many days work before it's cheaper to leave the problem than pay you to fix it? However, at Google's scale maybe that problem costs them $1M per week. They can justify assigning a whole team to fix that because of scale.
1) the expressive type system, notably the use of optional type instead of null and the use of lifetimes to make reasoning about references tractable, and
2) the community, with a focus on correctness, documentation, and being welcoming.
"Maybe we could just have kept Rust as it was circa 2016, and let the crazy non-blocking folks write hand-crafted epoll() loops like they do in C++. I honestly don’t know, and think it’s a difficult problem to solve."
I think this is the most underappreciated part of this article. Since when did everything have to be async? There are other ways to represent concurrency that more accurately reflect what the computer is actually doing.
For example, an alternative to async is to represent a workstream as a state machine, where state transitions happen between I/O. Then, your state machine can be a struct, and each state transition can be an impl function on that struct that takes one or more completed I/O requests as input and emits one or more I/O requests as output. This saves you from having to implement everything as a closure, which this article rants about. Your top-level epoll loop merely services I/O requests from state machine instances, and invokes your global application logic to start and stop state machines to carry out business logic tasks.
I realize that many complicated workstreams could have many states due to all the I/O they might do, but the task of converting a high-level workstream into a state machine could be automated by the tooling.
Except doing so introduces a bunch of needless programming difficulties, including but not limited to the ones discussed in this article. For example, if you make the state machines and epoll loop explicit, you don't have the function coloring problem -- all your I/O requests from state machines to the poll loop (which may or may not cross thread boundaries) are explicit synchronization points with your global business logic, which gives you fine-grained control over how your state machines handle things like request timeouts, resource quota limits, cancellations, execution suspend/resume, and so on. As another example, you're not limited to factoring your state transitions into closures. As a third example, your business logic would have global, explicit control over I/O scheduling, which greatly simplifies end-to-end QoS and request prioritization.
> let the crazy non-blocking folks write hand-crafted epoll() loops like they do in C++.
> I think this is the most underappreciated part of this article.
I think it's incredibly silly actually. Abandon all async for a difficult and error prone epoll model?
> Since when did everything have to be async?
It doesn't! No one is forcing anyone to use async. I'm not sure why the author implies that.
But if you do want to use async, Rust is attempting to solve the async problem with the same guarantees it has for blocking code. Turns out that is hard.
>It doesn't! No one is forcing anyone to use async.
Well, hang on there... this isn't entirely fair.
Rust does not force you to be async, but the community in some ways pushes you to be async even where it might not make sense for it to be the default. I can't say for certain (and this is all my opinion, to be clear) but it feels like this started happening when async-fervor hit its peak.
My go-to example is reqwest, which if you want a blocking HTTP call, still just needs all of Tokio in the background. I find it really odd that the blocking API is just a wrapper for a finagled async API; if I'm choosing the blocking one, I probably don't want Tokio in my project.
There are other HTTP request libraries, to be clear - but they're often less battle tested and/or have their own lurking bugs. Reqwest is the de-facto one and it'd be nice to be able to use it without the heaviness it brings in.
Depending on the domain you're programming in, it can often feel like async-by-default is the norm. It can be frustrating in Rust.
(It's nowhere near enough to deter me from using the language, mind you)
I'm not saying that we should go back to hand-rolling our own epoll loops. I'm saying that we can do better than async/await by making both the state machines and event loop explicit. For example, here's an API I'd prefer to use over async/await:
/// A state machine that adds three numbers and uploads them to a web server
struct AddAndUpload {
/// I/O handle to the event loop
io: IOClient,
/// Buffer to store numbers I load
nums: [u64; 3],
/// URL to upload the data to
url: String
}
impl AddAndUpload {
/// Constructor
pub fn new(io: IOClient, url: String) -> AddAndUpload {
AddAndUpload {
io,
nums: [0u64; 3],
url
}
}
/// Entry point to this state machine
pub fn inner_main(&mut self) -> Result<(), IOClient::Error> {
/// go and get the data
let n1_fut : IOClient::Future<u64> = self.io.sql_async("SELECT n1 FROM table1", &[])?;
let n2_fut : IOClient::Future<u64> = self.io.sql_async("SELECT n2 FROM table2", &[])?;
let n3_fut : IOClient::Future<u64> = self.io.sql_async("SELECT n3 FROM table3", &[])?;
// wait for all I/O operations to finish
IOClient::wait_all(&[&n1_fut, &n2_fut, &n3_fut])?;
// extract results
let n1 = n1_fut.into_inner();
let n2 = n2_fut.into_inner();
let n3 = n3_fut.into_inner();
// upload them
let sum = n1 + n2 + n3;
let upload_fut : IOClient::Future<IOClient::HTTPStatus> = self.io.http_post_async(&self.url, &["content-type: application/octet-stream"], &sum.to_be_bytes())?;
let upload_http_status = upload_fut.wait()?.into_inner();
match upload_http_status.as_u16() {
200 => {
Ok(())
}
400..499 => {
Err(IOClient::Error::Custom("client error"))
}
500..599 => {
Err(IOClient::Error::Custom("server error"))
}
x => {
Err(IOClient::Error::Custom("Nonsensical HTTP code"))
}
}
}
}
impl IOClient::StateMachine for AddAndUpload {
type Return = ();
fn main(&mut self) -> Result<(), IOClient::Error> {
self.inner_main()
}
}
/\* somewhere else \*/
fn main() {
let io_server = IOServer::spawn().unwrap();
let io_client = io_server.client().unwrap();
let add_and_upload = AddAndUpload::new(io_client, "http://example.com".to_string());
loop {
io_server.run().unwrap();
match add_and_upload.get_machine_status() {
Ok(IOClient::StateMachine::Finished(result)) => {
eprintln!("add_and_uploaded exited with {:?}", &result);
break;
}
Ok(_) => {},
Err(e) => {
panic!("add_and_upload aborted: {:?}", &e);
}
}
}
io_server.terminate();
}
okay, but that doesnt solve basically the main thing that async paradigms seek to solve: sharing of resources between waiting disjoint processes.
your statemachine blocks the thread. if you had a more complicated state machine, maybe nested machines, theyd block each other because they dont know how to cooperate.
This code is just an example. The state machine can easily run in its own thread, separate from the main thread. As long as the state machine had an IOClient instance that lets it send I/O requests to the IOServer and receive I/O results, you're good. Also, you could imagine an IOClient having an API that takes a StateMachine instance as input, and returns an IOClient::Future that resolved to the machine's main() return value.
I think callbacks do not scale with large projects as they make understanding the flow difficult (at least from my experience of multi million LOC code bases based on callbacks that called callbacks).
Using callbacks in Rust is not idiomatic, at least I haven't seen such code over the last two years writing Rust, and Rust syntax and semantics with life times is not tailored toward that approach.
So the author forces an async style on Rust that it was not build for and then complaints.
The article complains about how hard it is to pass closures with references - which is a valid criticism; I run into that as well and while you can fix it, it's not as straightforward and as documented as it should be.
It doesn't have much to do with async, despite the title. Async works just fine.
It’s a pain with GC as well, coming from a C# background. It’s incredibly easy to write something that intermittently doesn’t work in weird and impossible to debug ways.
Async is bread and butter of JS and you rarely have any larger bits of sync code, so you learn to deal with this.
And then being able to just freely pass bits of data accessing code (closures) around is such wonderful feeling that you'll miss it everywhere else you go.
I recently had a little excursion into Python and had to invent a very nasty hack to concisely keep short bits of code accessible through some constants.
In node.js backends, you also deal with a single thread only; if you want multiple CPUs, you'd need node-cluster, giving you, conceptually speaking, multiple shared-nothing single-thread environments to load-balance requests into. Technically, libuv (the C lib exposing async I/O to node) uses threads but that is hidden away from you. Multithreading in JS can't work anyway since JS doesn't have synchronization primitives, which is both a blessing (because it drastically simplifies the design space for the language ie no JVM-like happens-before constraints, atomic ops, and "synchronized" heisenbugs) and a curse (because most backend/business code doesn't benefit at all from async and its terrible debugging story, and you need to fork out into workers/isolates for even slightly CPU-heavy things).
Forget parallelism I even get race conditions in Typescript frequently because managing state is just hard. Changing state from multiple place became so hard that in one project I just used redux :p. And where redux was not helpful i use async-lock package. May be correct asynchronous programming is harder thing and is arcane knowledge. Not everybody is wizard.
Depends on your scheduling runtime. In Rust you can schedule everything to a single thread or to multiple threads. That could change the correctness of your asynchronous code or at least make certain bugs non-deterministic
"Modern" frontend JS is increasingly async. Yes, it's not true async, but it still has all the problems, but the benefit of not locking up the UI is worth the pain.
Even old frontend JS was async in that events could be triggered by the user at any time, and in any order, and xhr and image loads requests were async as well.
Yeah I know, but I think the gp is thinking of nasty debugging sessions that come from parallelism, not concurrency. Concurrency issues one can step through with a debugger on a single thread.
> intermittently doesn’t work in weird and impossible to debug ways
This is my major reason for using Rust. It's far better to beat your head against a wall when you're writing than when you're debugging. In both c++ and c# it's possible to write subtly wrong code that is basically undebuggable. Often these are intermittent things that show up once every million or more runs. There's no amount of time that will satisfy you after you've run into this, even after you've fixed the problem. It's like having a stalker, you never know if they've stopped stalking you. You can lock your doors, you can buy an alarm system (eg Valgrind suite). But you won't know whether you've really solved the issue.
Of course it's possible there will be other undebuggable issues with Rust, I'm going more by reputation and general impression in blogspace rather than a thorough study. But I thought it was worth a try, and I've been positively impressed thus far.
One of the very particular "undebuggable" issues (safe) Rust solves is data races.
Experience tells us that humans can't successfully reason about non-trivial concurrent programs unless they exhibit Sequential Consistency. In Rust you're promised this is what you get. Maybe what you wrote is stupid and wrong, but it has Sequential Consistency. "Oh," you exclaim during debugging, "A might happened before B and then we're in a pretty pickle" - you found the bug, now you just need to fix it. This promise is delivered by never allowing code to have references to things some other code might change, thus eliminating data races.
In most other languages that offer concurrency this promise only applies if you wrote a program with no data races and the responsibility to ensure that is with you, so you can accidentally write programs that don't have Sequential Consistency and thus... "Wait, so, A happened before B and B happened before A? Huh? I don't even understand the bug".
How so? Obviously you can build an unsafe IPC mechanism with concurrent access, label it "safe" when it isn't, and then say "Look at this horrible mess, I blame Rust" but it seems like it'd be faster to just implement std::ops::Index unsafely and then blame Rust because thing[len+1] blew up even though Rust has "memory safety".
Now I'm going to write an amusing aside. One way you could get into this trouble is if your hardware allows arbitrary foreign memory writes as "IPC". The BBC microcomputer allowed this over the network! You could send a bunch of bytes over Econet (a 1980s network from Acorn Computers available for the BBC, Electron and Archimedes computers), addressed to another BBC micro on the network, asking they be written to a RAM buffer, and the remote hardware would do so. We used this to run a Multi-user Dungeon at school in about 1989 or so. A "server" ran the actual MUD software, and individual users signed in from a computer on the network around the school to play, when they typed a command their command buffer was transmitted over Econet, and then the server wrote remotely to their display RAM to show the result on their screen.
Fortunately your computer is not a BBC Microcomputer, and foreign processes do not (on the whole) get to scribble on your program's memory. So this should not be a problem unless you specifically make this unsafe decision in your Rust program.
But (unless you've got an excerpt that says otherwise) the Rustonomicon is about unsafe Rust. And I was explaining that safe Rust has data race freedom.
The Rustonomicon is not warning you about scary hidden problems in safe Rust, it's warning you about scary problems you need to care about when writing unsafe Rust, so that your unsafe Rust has appropriate safety rails before anybody else touches it.
// Safety: Can't touch this while anybody else might write
This reminds me of the mutex thing. Look at C++ std::mutex. You could implement exactly that in Rust. But, that's not what std::sync::Mutex is at all. Because if you implemented it in Rust, C++ std::mutex is either useless or unsafe and clearly we'd prefer neither.
But do you have some examples of Rust shared memory IPC that you believe are unsafe? It might be instructive to either show why they're actually safe after all or, alternatively, go add the unsafety explanations and work out what a safe wrapper would look like.
You as a user of a crate deemed safe, that underneath uses shmem, mmap, or a database, written without taking the proper care to prevent other processes to change exactly the same underlying data segment, written in what knows what, are in for a surprise and long debugging sessions.
The crate public API surface is safe after all, and unless the user has experience in distributed systems, the answer won't come right away.
But this hypothetical "written without taking the proper care" code is buggy. Like I said it's just the same situation as a bad implementation of Index but more convoluted.
Rust's standard library takes this very seriously. In many languages if I try to sort() things which refuse to abide by common sense rules like "Having a consistent sort order" the algorithm used may blow up arbitrarily. But Rust's sort() is robust against that. You may create an infinite loop (legal in Rust, causes Undefined Behaviour in C++) and the result of sorting things without a meaningful ordering is unlikely to be helpful if it does finish, but it's guaranteed to be safe, you won't get Undefined Behaviour.
Rust's npm like approach to crates and micro approach to standard library make it a real problem, regardless of the quality approach to the standard library.
You would have a point if the standard library was batteries included.
Cargo-geiger and similar tools allow you to audit crates you depend on to discover whether they're definitely safe.
Of course just because some Rust is unsafe does not mean it's wrong, it just means that you're relying on it being correct as you have to with all code in unsafe languages.
Even those tools don't validate data corruption, using perfectly safe Rust accessing a table row from multiple threads without being protected from a transaction block or a table row lock.
Hence why doing blank statements like Rust prevents data races, without the context when that is actually 100% true, does no favours to the language advocacy.
I think what you're imagining is just "What if people use Rust to write bad SQL queries?" which again, not a data race. Stupid perhaps, unlikely to give them the results they expected, but not a data race.
I am thinking that data races in the scenario of multiple threads accessing a global variable is sold too often, and everything else gets ignored.
While a relevant progress versus what other systems languages are capable of, it still leaves too much out of the table, that tends to be ignored when discussing data consistency safety.
Stuff that usually requires formal methods or TLA+ approaches to guarantee everything goes as smooth as possible.
The context here, right up at the top of the thread where perhaps you've forgotten it, is that (safe) Rust gets you Sequential Consistency, since it has Data Race Freedom.
This makes debugging easier, in the important sense that humans don't seem to be equipped to debug non-trivial programs at all unless they exhibit Sequential Consistency. It's easy enough to write a program for modern computers which doesn't have Sequential Consistency, but it hurts your head too much to debug it.
With your C++ hat on, this might seem like a distinction that doesn't make a difference, lack of Data Race Freedom in a C++ program results in Undefined Behaviour, but so does a buffer overrun, null pointer dereference, signed overflow, and so many other trivial mistakes. So many that as I understand it an entire C++ sub-committee is trying to enumerate them. Thus for a C++ programmer of course any mistake can cause mysterious impossible-to-debug problems so Data Race Freedom doesn't seem important.
Try your Java hat. In Java data races can happen but they don't cause Undefined Behaviour. Write a Java program with a data race. It's hard to reason about what it's doing! It can seem as though some variables take on inexplicable values, or program control flow isn't what you wrote. If you introduce such a race into a complex system you should see that it would be impractical to debug it. Most likely you'd just add mitigations and go home. This is loss of Sequential Consistency in its tamest form, and this is what safe Rust promises to avert.
If you are very zealous and use lots of C++17/20 magic, you can prevent lots of runtime bugs with C++ too by doing basically the same stuff you do in Rust (RAII, move semantics, use concepts, etc). Sadly that's not doable in C#.
Move in particular is painful in C++ because to make it work C++ needs to invent these "hollowed out" objects that would be safe to deallocate after there's no longer a real value inside them. Rust doesn't need to do that.
Suppose you've got a local String variable A. You will move A into a data structure which is going to live much longer and then your local function exits.
In C++ when the function exits, A will get destroyed, so when A is moved into the data structure, A needs to be hollowed out so that whatever is left (a String object with no heap storage) can be safely destroyed on function exit.
In Rust the compiler knows you moved A, therefore there is nothing to destroy at the end of the function, no work needed (at runtime).
Hmm, C# is my main language, and I don't think I've had an issue like you describe since back when async/await was new and I was still learning about it. And nowadays, Roslyn analyzers, like those in VS and Rider, will warn you about many problems.
Doing c# for over 9 years. I never ran into async/await issue that everyone seems to encounter. I’ve used it from creating libraries, CLIs, WPF, Winforms and doing web server with ASP.
Yep same. When I had w3wp crashing 50 times a day with half an async stack somewhere inside the .Net framework several continuations after I did something stupid. Sleep well .Net developers :)
Speaking of the four versions of tokio in your build problem: this is definitely an issue with Rust dependencies. Async just magnifies it because there are a lot of async utility crates.
Personally I hate dependencies in general and try to minimize them. Instead of reaching for a utility crate I think “how could I architect this so I don’t need this hack?” There is almost always a way. In the end falling back on Arc<> is still cleaner and probably comes with less overhead than dependency spaghetti.
Rust + Erlang/Elixir is a fantastic combo. Rust is safer than C, so there's a smaller risk that it ends up crashing and taking the entire VM with it. Rustler[0][1] makes the integration a breeze.
I don't have any experience with async Rust (but I stuggled a lot with Rust's closures when I dabbled with Rust a while back so I can at least feel the pain the article tries to convey), but one important reason to not build async-await on top of fibers or threads but instead on code transformation (aka 'compiler magic') is 'weird architectures' like WASM, which doesn't have easy access to threading (locked behind COOP/COEP headers), and where the call stack is inaccessible to code running inside the VM.
I didn't quite get the gist of the article though, isn't the whole point of async-await to get rid of passing callback function pointers around? What do closures have to do with async-await in Rust?
I think the article is just... wrong about async and closures. You're right that async Rust simply doesn't deal with them, because (contrary to the article) they're not used in the async compiler transformation.
I think you can implement fibers and continuations in WASM by converting the whole program into a giant switch statement and heap allocating frames. There are a few scheme-to-C compilers that do that I think.
Mind, it is not going to be fast as it is going to be hard to generate efficient code ...
Emscripten actually has an "ASYNCIFY" feature which does exactly that but (AFAIK) down on the WASM level. It also has surprisingly little performance overhead.
Yes, this is pretty much what I had in mind it seems. From a quick read it is not 100% clear whether it supports stackfull coroutines, but as it is designed to support existing blocking C/C++ code, very likely it does.
> What do closures have to do with async-await in Rust?
Well, space _blocking runs blocking code in an async context. It takes a closure as it's argument and if you're in a mixed async/sync platform you will his this function a lot. To deal with it you end up needing to use move. And to use move you need to clone your pointers to the data and manage all that guff.
The alternatives are Arc, deep copies, or use C++ where you will invariably get it wrong and end up with corrupted data and a very bad week (or you don't notice and reply to this comment saying that you do this in C++ and never had a problem)
This was why I chose F# instead of Rust for the Darklang rewrite [1] - I just couldn't get async to work well, and the hoops and complexity I had already had to get it even close to working were way too much.
The author spills a lot of ink showing that they really didn't take any time at all to understand the problems they complain of.
The title is clickbait, of course aync Rust works, and the article doesn't talk about it anyway. The only mention of async is an observation that it's CPS under that hood, followed by a wandering rant about CPS that fails to account for the design constraints.
In the end, the author suggests forcing every AIO user to manually write out their polling loops, which is simply a silly idea. At least if their recommendation was a completion-based API without language support they would seem serious.
The author ends this with a now apparently trendy Common Lisp yearning, but ironically, Common Lisp's async story is pretty weak, too. On the other hand, at least it didn't infect the whole ecosystem, just the projects that use certain libraries.
Have to day, mixing async and CPU intensive code (I’m using Rust because it is good at CPU intensive code!) has been a frustrating experience, with lots of noise and baggage associated with spawn_blocking to make things run well.
I have had great luck using rayon for distributing CPU intensive work across available cores. If you are doing I/O I can see why async is preferable but rayon is an excellent library for parallel work.
I have found that async or even in Go with goroutines, a large amount of small threads are not faster even if they should be in theory when it comes to CPU intensive work.
I use it to generate a 3D universe (at the atomic level) but store the data as enums.
There are constant efforts to make async easier in all languages. It'll never be as easy as writing synchronous code.
I really don't like futures/promises, I don't know where this abstraction came from.
Callbacks are where it's at. Someone, somewhere has to write callback code; they cannot be got rid of. What works for me is keeping the callback handler as short as possible, this usually means just pushing 'work' onto an async queue that is serviced by one or more threads.
I've found async to be straight forward anytime I've used it. Promise#then is equivalent to callbacks
async/await often requires very little changes compared to synchronous code, whereas reworking a program into callbacks is much more impactful. & the async/await compilation process tends to produce better performance in addition to this. My first async/await work was a few years ago to increase a data importer's performance by an order of magnitude compared to the blocking code (I also used it a couple years earlier for a GUI (PointerWare) where code would await opening new windows, it wasn't really being used for concurrency there, just a nice interface of "show pop up, wait on that view to complete" which made Back buttons easy)
Here's an example where looping made for a callback that recursively called, using async/await I get to use a plain loop:
Callbacks make it difficult to send information up the call chain, as the caller fires it off without a ticket to wait on the result. libxcb is an example of a library which optimized on libx11 by having functions which return cookies which func_reply wait on, these functions could similarly be implemented as an async/await interface
I don't see why people find it so complicated to separate begin-compute & wait-on-compute
> the caller fires it off without a ticket to wait on the result
Exactly! Making a thread block waiting for a result isn't true async in my book. It doesn't matter that the result is going to come from another thread. Futures/Promises make you block a thread.
That's why async/await is about having an event loop driving a state machine so that you aren't blocking a thread, you're having a state machine wait on a result
Performance numbers don't care what's in your book
Can someone explain to me the attraction of async programming?
I don't really do JS, where a lot of this seems to be happening, but the code I have seen with the huge ladders of callbacks doesn't seem so great to work with to me.
Also, although using promises seems better, it seems like it could quickly become spaghetti.
Essentially, the attraction is being able to wait for multiple things at the same time within a single thread. It's useful for things like webservers that want to handle thousands of connections simultaneously, but most of these connections aren't actually doing anything useful, they are waiting on the filesystem or the database connection or some other network service. And you can't really spawn thousands of OS threads because those have non-trivial overhead.
Now, you may think this can be done in C with the `select()` API and many switch statements. And you would be correct. All these "async" languages and framework are wrappers around this that let you write your code in a procedural manner, and take care of the select and switch for you.
Working with Promises and async/await doesn‘t really turn into spaghetti in my experience. Biggest downsides are the function coloring and the lack of cancellation primitives. They usually fit the problems I‘m solving pretty well and play well enough with functional programming styles.
Having never worked with threads heavily, they always seemed like WAY more hassle to me. I assume that‘s the alternative? I think go has something else?
If you mean non-blocking in general, the benefit is that your system can do something else useful while it's waiting for some operation to complete (usually things accessing files or a database or a network service)
If you specifically mean async/await syntax, let me illustrate with a contrived example. It can let you express a sequence of asynchronous operations in a more natural way:
function promised(cache, db, metrics) {
return cache.query(...).then(cachedResult => {
if (cachedResult) {
return cachedResult;
} else {
return db.query(...).then(dbResult => {
return cache.store(dbResult).then(_ => dbResult);
});
}
}).then(finalResult => {
metrics.log(...);
return finalResult;
});
}
async function awaited(cache, db, metrics) {
let result = await cache.query(...);
if (!result) {
result = await db.query(...);
await cache.store(result);
}
metrics.log(...);
return result;
}
It's not that I don't see the benefit of the CPU doing something useful when waiting for I/O, my confusion comes from the fact that people like to express this using promises/await.
Why not just arrange it like this?
function non_awaited(cache, db, metrics) {
let result = cache.query(...);
if (!result) {
result = db.query(...);
cache.store(result);
}
metrics.log(...);
return result;
}
Basically doesn't a good threading library just allow you to make all those 'awaits' implicit?
I mean just because await isn't explicitly there, if I run the function above in a thread, my understanding is that the thread does yield to other threads while waiting for I/O.
It's not as if it busy-loops while waiting or something.
I can't speak authoritatively, but I can think of some good reasons you might not want to automatically and implicitly await every invocation of an async function.
As designed, calling an async function just returns a Promise, and any Promise can be awaited. This means that I can pass that Promise around, and it also means I can use a Promise-based library (of which there are many) easily from within my async code.
An example? What if I want to launch multiple asynchronous tasks in parallel, and then either wait until the first one finishes (a race) or wait until they all finish? Without explicit await, we'd need some syntax to express this. With explicit await, I can store the Promise and then await it when desired, like this:
//start both tasks in parallel
let fileDataPromise = getFileDataAsync();
let netDataPromise = getNetDataAsync();
//wait until both are finished
let fileData = await fileDataPromise;
let netData = await netDataPromise;
Fortunately there are nice standard library functions for transforming collections of Promises, so we can also just write:
let [fileData, netData] = await Promise.all([
getFileDataAsync(),
getNetDataAsync()
]);
Ah. I was coming at this from the perspective of "why would it be implemented this way in JavaScript specifically", as you'd asked about JS land.
More generally, I don't have a good enough overview of programming language trends to speak to how that fits in with other languages' approaches to async.
> Is it common these days to mix promises and threads
I'm not sure. That probably depends a lot on the evolutionary history of a given language and its library ecosystem.
I could see it being done to mix those two things in a world where you are trying to glue together two libraries (or legacy internal modules) built in different ways, and the alternatives either don't exist or are more onerous.
And I think the example is a good one for how doing parallell io can be simple with async tasks/promises:
require "async"
require "open-uri"
Async do |task|
task.async do
URI.open("https://httpbin.org/delay/1.6")
end
task.async do
URI.open("https://httpbin.org/delay/1.6")
end
end
(completes in ~time of slowest request, not sum of requests).
Sure, one could use threads/processes/green threads etc.
In your async example, each await could just be a blocking operation - it’s all serialized. The blocking sleeps, yielding the processor for other work. Like, maybe context switching is a little more expensive, maybe a normal thread’s stack consumes more memory than an async context.
The utility of native async is when you spawn multiple async tasks before awaiting. This is cheaper than spinning up some threads or handing off to a threadpool.
Leveraging multiple threads can allow your program to get more done, and if it's a graphical application you can also reduce blocking in the UI thread to an absolute minimum.
Excellent post. Async ruins everything even in GC languages. It's just making things needlessly hard for programmers in an attempt to save effort from computers. Erlang shows how to do massive scales of slow IO if you really need it (most programs don't).
> It's just making things needlessly hard for programmers in an attempt to save effort from computers.
Well actually it is supposed to make concurrency easier for programmers.
What would you say is an "easy" way to deal with concurrency? Handle threading, memory visibility issues, locking, etc yourself? Doing it "reactive"? I guess personally I too would say something messaging based such as the actor pattern but not everything is a good fit for that model.
Async is intended to be a more generic (ie. fit for everything) and easy way to program. I'm not saying it succeeds in that but the goal wasn't to save the computer the effort.
When async is visible in the type system it means you need to write things like iterating containers twice: once for dealing with non-async values, and a second time for handling async operations.
Well maybe this isn't the best example because you already have iterators for the plain iteration part; however let's say you wanted to make an iterator for a container that needs async to iterate the data, then you find yourself having the same problem that the existing facilities making use of the old "non-async" iterators become useless.
Alternatively you let async leak into _everything_, even if they are not actually using async themselves.
There's nothing wrong with runtime-driven schedulers handling the threads for you, but async isn't the only way to do it. But it is one of the easy ways to do it and a way that is implementable as a library.
In particular single-threaded async (javascript) can be painful because you need to be mindful about not doing anything for "too long" without splitting the work into several parts. In a sense with async we're back in the era of co-operating scheduling. Even in multi-threaded environment is can be based on luck or faith that not all the threads allocated happen to get stuck on long-running jobs when short and fast user-facing interactive async tasks are starving. (A problem solved by over-allocating threads of course.)
Sure, it's possible to parametrize your algorithms.
However, do you need to explicitly parametrize them or does the language do it for you? I'm not aware of any language that would remove the need to consider them and just automatically "work" with async types whereas "normal" threading—with errors handled via exceptions—more or less work out of the box the way you expect.
> However, do you need to explicitly parametrize them or does the language do it for you? I'm not aware of any language that would remove the need to consider them and just automatically "work" with async types whereas "normal" threading
That's correct. What I said is: you can write your algorithm once and apply it to both sync and async datastructures (given certain constraints).
But that doesn't mean that async and sync feels exactly the same. It's simply impossible for this to be true in any meaningful way and has been tried over and over.
> What would you say is an "easy" way to deal with concurrency?
The Beam/Erlang/Elixir model. It's a bit more constraining but it's just so clean and reliable, and processes having individual mailboxes makes it much simpler than having to wonder what synchronisation primitives to use, then decide how to share them (globals? parameters? others?).
Just get a reference to a process and you can send it messages, simple. If you want replies, send it your address. Also simple. And matches the real world very, very well (at least the real world of a few years back when you'd send letters through the mail, electronic communication channels tend to be more full-duplex).
> The Beam/Erlang/Elixir model. It's a bit more constraining but it's just so clean and reliable [..]
I agree. But I think that only works if you embed it in the language like Erlang did. If you make it optional you get libraries which use different models, people still making concurrency mistakes, etc.
But for more generic languages then Erlang such as Rust (or Java, C#, etc.) this simply isn't an option. Because the actor pattern is too opinionated to be a fix for all.
We know how to do interiperable DSLs for IO, see eg IO monads in Haskell, or ebpf in Linux. And Erlang could adapted for embedding, it's already designed to isolate its processes in a vm out of the box, so wouldn't leak back to the host language...
> What would you say is an "easy" way to deal with concurrency? Handle threading, memory visibility issues, locking, etc yourself?
Message queues, with workers atomically reading messages of a single queue, and placing responses on another queue.
Sure, it's not as flexible as starting threads[1], but 99 times out of a 100 you can use message queues and there's literally no race condition to happen, no memory visibility issues, no locking, etc.
[1] Some solutions, such as those that games employ, are rarely able to implement concurrency with just message queues, but to be honest if your problem is such that you can only employ solutions that require global state to be modified by several threads, then Rust, and similar 'safe' languages aren't usable there either.
How does async prevent anything about memory visibility and locking issues? Afaik only immutability and borrow-checker-like solutions can prevent data races (but not race conditions!).
It's really bad in Python, because the baseline awaitable of Python is a coroutine — same as Rust but without compiler support.
The issue of coroutine-based async is that a coroutine does not do anything until it's awaited (and the chain goes up to the reactor), so in Python when you create a coroutine nothing happens. This is unlike Javascript or C# (IIRC) where the baseline awaitable is a task, with tasks awaiting is a synchronisation point, but if you create a task and drop it the task will go and do its own thing.
In Rust, this is mitigated by `[must_use]`, so the compiler will warn you when you've created a coroutine (Future) and dropped it on the floor, not so Python, you just get a warning when the runtime shuts down, it's way harder to track missing awaits.
Agreed. Synchronous logic is clearer and more precise. Programming environments should focus on making lightweight green threads, not turning everything into a callback.
So basically, Rust does async right, but the author wishes he didn't have to worry about generics and lifetime management which become increasingly complicated in asynchronous contexts.
"I’d like to make a simple function that does some work in the background, and lets us know when it’s done by running another function with the results of said background work"
This is why he's got a problem, in a nutshell. I suspect the author has a heavy Javascript background and is used to Continuation Passing Style (CPS)[1]. The problem is that in a non-interpreted language that is a horrible way to do things because of scope and references. A better approach is to use messages from your thread. In C++ you could do this with ZeroMQ inproc sockets [2], or other mechanisms. In JAVA you could use a number of methods, such as a ConcurrentLinkedQueue [3]. In Go, you have channels [4]. In Rust, you have..well, also channels. Rust By Example already has a similar example that uses channels [5]. The concept of a Message Passing Interface (MPI) works amazingly well, and there is at least one flavor implemented for all the major languages (and often more).
The only languages I've seen that get callback style interfaces done well are Clojure, Lisp, Erlang, and Elixir.
The only thing I've seen that's better than MPI, in some situations, is Tuple Spaces [6].
Tar and feather me for it, but I miss JINI [7] and JavaSpaces [8] when I have to do a system in JAVA. JINI is now Apache River [9], but the last release was 2016.