I will say that the notion that you must make your whole project async is largely true (except that you can block on futures with all runtimes), but this is more symptomatic of the fact that Rust has hitched its horse to two wagons that don't have clean overlap. It is effectively two languages trying to converge in the middle.
It's equally desired at "web server and above" and "operating system and below", which probably makes it one of the hardest languages on the planet to design (lets not forget it deliberately has no runtime, which makes things even more difficult). Whether or not this is a good idea remains to be seen over time, but they are in largely uncharted territory so I'm prepared to give them some slack on it while they figure it out.
That said, holy has it taken such a long time to round out the async story to make it feel better. I don't blame users not wanting to wait it out because it has felt like an eternity.
My theory as to why C++ has become as a problematic language as it has is because it is able to do it all. And "all" doesn't fit nicely into a single package or paradigm.
I don't like async (in any language), so your post immediately piqued my interest. I'm not that interested in 'webserver and above' so of course that would be where people find async useful.
It is possible that there is no way to unify these two domains into a single language (at least without becoming c++). Although, then again, it might be that if you think about it long enough then the unification mechanism becomes apparent.
- attempts to solve too many problems,
- has significant papercuts that have to be considered,
- has significant runtime cost,
- interacts poorly with other features, or at least leaves significant edge cases open,
- has non-uniform compiler support (although in OSS land only GCC and LLVM matter), and
- creates arcane error messages that are anything but fun to analyze. Especially when templates are involved.
Above everything the fundamental problem that the unsafe parts of C are everywhere, and to master C++ you have to become really proficient at diagnosing problems with them because you will run into them. Only then can you start thinking about fun things like software architecture. Always use a linter/static analyzer.
IME the bad compiler messages tend to come more often from the `async-trait` macro than from anything fundamental to asyc itself. Hoping the stabilization of async-trait helps with that. Backtraces with async are definitely a bit nasty though.
The C++ committee basically evaluates features almost entirely in the abstract, writing papers and discussing privately among a small group of people about things instead of working on proof of concept implementations, getting actual real world feedback on it, or having something along the lines of Rust's nightly where experimental features can be tried out.
Sitting on the C++ committee, we actually demand a lot of proof-of-concept implementation of proposals before they'll be accepted.
It's a shame Rust let itself be distracted by it instead of focusing on refining its strengths and developer experience
those people probably may use another language (java, go, c#) if absolute performance is not critical for them.
This isn't true. I'm writing an application right now that is mostly sync, but has a small amount of async code in it. It's easy to spin up a tokio runtime which can run inline on the current thread. Then use it to evaluate a Future.
tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap().block_on(async {
println!("I'm async!");
}); smol::block_on(async {
println!("I'm async!");
});
(I thought tokio had a helper like this too but could only find `tokio::runtime::Runtime::new().unwrap().block_on(async { println!("I'm async!"); });`.)Rust is not a batteries-included language like Python. There are lots of libraries that are very commonly used in most projects (serde, thiserror, and itertools are in almost all of mine), but this is a conscious choice. They say in Python that the stdlib is where projects go to die. I'd rather have the flexiblity of choosing my dependencies, even for stuff I have to use in every project.
So you are stuck with smaller, less battle tested products if you'd rather not pull in 100+ crates of dependencies that are doing nothing but inflating the build times and file sizes (for your particular usecase).
Example: reqwest vs ureq
Increased build times are not great but holy shit the way people talk you'd never know that that's the actual trade-off here, an extra 3 seconds on a clean build.
smol::block_on(async {
println!("I'm async!");
});
if smol is an option.But on the other hand, just read the code. It's not complex.
You drill down into a tokio namespace. You make a builder object. You unwrap it. This is idiomatic Rust. It's verbose, but explicit is better than vague. There's no conditional logic. There's no weird type-fu. No macros. There's not even any parameters to supply, other than the Future to block on.
It's trivial to write a wrapper to go from chained methods a helper call.
- create a builder
- run on the current thread only
- enable all drivers
- create the instance
- ignore errors
- then call some blocking async code
Would look nicer if split out onto multiple lines I imagine:
use tokio::runtime::Builder;
let runtime = Builder::new_current_thread()
.enable_all()
.build()?;
runtime.block_on(async { println!("I'm async!"); });I'd love to see this in Rust, and I know that it initially had something like this, perhaps its not a bad idea for someone to take a second look at this now that more time has passed.
EDIT: to be clear, that is not me saying we need goroutines or we need a Java Loom equivalent in Rust, simply, the DX of these two examples are far superior to the DX of async Rust today, in my opinion
And Java/Goroutines don't have a keyword because they do things implicitly, they have heavy preemptive runtimes.
I didn't say we need goroutines or we need Loom style asynchronous primitives I am however pointing out, that they did a really good job of making async approachable and feel like you're writing the same code as you would in a traditional synchronous model. Thats the real win.
Its not an easy problem to solve, to be absolutely clear, but its a worthy goal, and if that means it adds marginal overhead initially I think its worth the tradeoff (esp. if you can do something similar to how you can use Rust in a `no-std` build, you could do one without a `async runtime` build, spitballing off the top of my head)
Another problem is that generally speaking, async is de facto used a 3rd party lib, and it really should be a first party primitive that everyone feels comfortable using.
To me, this is what matters
While you are right that DX matters, DX is not the only thing that exists. Design is about balancing constraints and tradeoffs. Rust has other design commitments that preclude using this design, before you even get to DX questions.
(Furthermore not everyone agrees that these things are clearly better from a DX perspective, as DX is a subjective topic.)
I will say, that its clear, to me and a good portion of Rust users, that async in Rust needs DX improvements. Its one of the top features of the language that is used alot and people struggle with often[0]
[0]: To be fair, async in any language trips up developers pretty often, though I think Rust can give someone a particularly bad time. Granted, I have not compared it to C / C++ as I don't do any development in either language currently
To be honest, this is why this discussion always gets frustrating: people demand change that is impossible, and then when pushed for how to accomplish the impossible, they throw up their hands. I do not think you or anyone else is doing it maliciously, but for some reason, on this specific topic, it happens endlessly.
Ideally, the runtime could handle anything written using lower level primitives as to not completely kneecap libraries that need to work with `no-async-runtime` (or whatever you want to call it).
This would at least alleviate a common concern I hear around this, which is runtime bloat.
That seems like a step in the right direction to integrate a unified async runtime with an alternative / better syntax[0]
[0]: I want to note, that C# is the only language I ever worked in that supported async in two constructs. There is the traditional async / await, which is by far the most common. Before that though, there was event driven async programming (with support for background workers and other async features) and that is also still supported, and they can interop with each other (with some caveats)
You created a dichotomy - languages with a native construct for async versus languages that provide this as libraries. But that dichotomy does not exist - both language have native concurrency support through their runtime. That was what I was pushing back against.
> async is de facto used a 3rd party lib, and it really should be a first party primitive that everyone feels comfortable using.
I think this really remains to be seen. Rust has always had a "just pull in a crate" mentality and a "be very conservative about what's in std" approach, and I think the community is overwhelmingly in favor of that. Any major stdlib changes should be taken pretty seriously.
I think 4 years worth of a feature being out in the wild and in use is enough time to start having conversations about what went well and what didn't and how to address that. Its very clear to me, at least, that async in Rust is becoming more and more dependent on tokio. Most of the major async supporting crates support tokio and/or only leverage tokio. Just a cursory glance at the crates registry supports this much.
I'm not against a "pull in a crate" mentality mind you (though, careful what you wish for here, see: NPM / Node ecosystem) however, it is worth identifying when something is becoming / has become / is considered to be such a core feature of the ecosystem that it would benefit greatly from stdlib support, and I think this fits that definition based on the evidence I've seen, at least. Though I realize others may not share this sentiment, I think its a viewpoint that has evidentiary backing (see all the talk about async Rust in the communicate, issues etc surrounding it. Its already a pretty big buzz topic relative to other things surrounding the language)
All this is to say, maybe its time to seriously start thinking about what first party support will look if we bring in a first party async / non-blocking I/O platform into the stdlib
Just to be clear, I think the NPM ecosystem is generally great and a massive success. People totally overblow the issues, and none of them are actually because of a small std library or due to the ease of install/publish.
> however, it is worth identifying when something is becoming / has become / is considered to be such a core feature of the ecosystem that it would benefit greatly from stdlib support,
I agree, and I think that there are a few places with regards to async that could work well here. Maybe some kind of Executor trait (hard, but maybe possible?), probably `std::block_on`.
> see all the talk about async Rust in the communicate,
FWIW I think the majority of people are just happily doing async work in Rust and don't get too involved in the discussions. I'm one of those people, except I'm also an internet addict on extended PTO so here I am.
> All this is to say, maybe its time to seriously start thinking about what first party support will look if we bring in a first party async / non-blocking I/O platform into the stdlib
I agree, I think some of this is best done in the language but some should be in std. No question.
"I will forever argue that [...]" -- Why are you forever arguing?
"I feel like I am alone with [...]" -- No, I read it every day on here.
My hope is that by raising these things on HN, someone will take notice in a different way and at least start considering / revisiting alternatives
This is a serious wart on language design and while I can agree that it's likely too late to fix it for Rust, there is a kind of race among new languages to be a successor to C++ in many of the domains C++ is used in, and while I think Rust does hold a lead in that race, the race is not over.
A language that can provide an ergonomic solution to concurrency would absolutely provide a huge boost to any such language, and so to people in that space, listen to these complaints. Async/await is not a good solution to this problem.
You almost always hear people complaining about async/await in every language it's a part of but you rarely hear people complain about how Go manages concurrency.
EDIT: to add a specific example, postgres. I understand from sfackler's perspective why it makes sense to maintain just an async library and a sync wrapper around it. But from the user's perspective there'a absolutely no reason that all of tokio should be required to talk to their db.
Would you also advocate for the kernel to deprecate blocking i/o?
And all you need to do to not deal with async is wrap it in one of the many different block_on implementations. Yes, that's less ideal than not having to pull in these dependencies; if avoiding those dependencies is mission critical for you, maybe yours could be the enterprise that pays for the blocking IO postgres library.
I've never saw anyone discuss using poll(2) style concurrency here which is interesting but also kind of sad.
With the mio crate for example, it uses the operating systems "select/poll" interface. On Linix this is epoll(7), but other OSes have their own interface.
"poll" style concurrency is a lot less complicated for me. The general idea is to register IO handles such as sockets into a data structure and pass it into the kernel. The kernel will emit events whenever a handle is ready to do something. There is the option to go to sleep and block until one of the handles are ready but it's not required.
"async" is an abstraction on top of this. The async runtime is handling the event loop and other stuff for you which is very useful but also a little bit complicated.
I am not in any way against "async" style concurrency but I think a lot of libraries could avoid pulling in a runtime and still be concurrent.
DB drivers, http servers, runtimes and so many other complex components are preferences of high skilled developers and not some objective market choices. Once its developed most application developers have to use it however user unfriendly it maybe.
If there's not enough community interest for there to already exist a solution for your problem, you really only have three options: write it yourself (including opening a PR to add it to an existing implementation), pay someone to write it, or switch languages. If none of these are options, you're just out of luck, and complaining about it is unlikely to do any good. I'm not saying you can't complain of course, but I am saying that you're more likely to get what you want via another path.
But regardless, I don't think it's an odd argument. Indeed, no one is stopping anyone from writing a new, low-level, memory-safe programming language! That's why Rust exists! Turns out the market was huge! Writing a new one now would be easier because of all the great ideas that Rust helped to prove work at scale. We're seeing this already with the success of languages like Zig, which makes different tradeoffs than Rust did and seems to be finding success in slightly different niches.
And sort of disproving your point, we use postgres, and I can think of three different implementations of postgres drivers offhand (sqlx, tokio-postgres, and diesel). I'll also note that the author of tokio-postgres also publishes https://docs.rs/postgres/latest/postgres/, which is not async! It's impressive how many options we have for such a complex thing in such a young ecosystem.
Async, in the sense from the article, and non-blocking aren't synonyms. Not using Async doesn't imply blocking.
(Also, in case it isn't obvious: I wrote the article in question.)
More generally than the concurrency operations I described, are any sort of event-loop or state machine, of which Async is one example.
I eventually rewrote the whole thing with rusqlite, but apart from being non-async, I found the API much less ergonomic to work with. But at least Rayon worked exactly as I expected.
I just wish everything would at least still support blocking IO as a common denominator, so there's at least a baseline that is known to be possible. 99% of percent of the time I do not need benefits of async, because I write small software for relatively small, but still real use-cases. And using Rust ecosystem was easier just a few years ago than it is now for these use-cases, as blocking Rust was not yet relegated to be a second class citizen, replaced by an immature and fractured async.
I used to write Rust web services (professionally) before async and it was definitely not easier for me. It was way harder. I'd end up with accidental hanging because some socket didn't have a timeout set on it properly, it was extremely leaky (why the hell am I talking to raw socket APIs just so that I can read from S3?), and it sucked compared to async. We've had radically different experiences, somehow.
I don't know. https://doc.rust-lang.org/std/net/struct.TcpStream.html#meth... etc. were there for a while. Though I think that deadline-based timeouts would be way better to have. https://www.reddit.com/r/rust/comments/8b5krv/the_case_for_d... . Probably could be built around existing primitives. These things were never built/popularized because the community just jumped on async like some silver bullet.
I agree that building heavy duty networked services got easier with async, but at an expense of fracturing the ecosystem and dragging everything else into an MVP feature, which made things worse (at least in some respect) for other things. I personally don't do much web servers, and when I do I can just spawn lots of threads, terminate TLS with nginx anyway.
Again, I don't mind async on its own. I think it's great for what it is, and is useful when it's useful. But for decades tons of the web was built in Java, Python, RoR without async IO and it worked just fine. And I didn't have to play IO-type sudoku, and could expect that the most basic, native blocking IO is well supported, and not just an afterthought/wrapper.
The specific issue is the context switch. The compiler, with async is able to be much more performant than the context switch that the OS provides.
Depending on the kind of application you're writing, this is either splitting hairs or very, very important. Applications that handle many (hundreds, 1000s,) of concurrent IO will have a noticeable performance improvement using async versus the OS's context switching.
But, there's a more important thing to consider: "async" in a programming language communicates that a method can block. It allows the caller to start the call and do something while it's waiting for the result, without needing to get into the weeds of threading. Purely relying on the OS for context switching means that it's hard to know what methods block.
> without needing to get into the weeds of threading
A thread is the exact concept needed to describe that and preserve “if else then” sequential code.
> 1000s of threads
Linux handles thousands of threads just fine. If you’re using a scripting language like python, context switching is the least of your performance concerns.
I suggest looking at C# and Javascript that implement async very, very well.
The difference is that with conventional threading semantics, when you join a thread, it doesn't return a result. You still need to write some kind of "thing" to get your result from the subthread to whatever's waiting on it. (C# also provides a less-well-known BeginInvoke mechanism which is somewhat cleaner than join.)
In contrast, the promise (Javascript) or task (C#) has a result. Instead of joining a thread, the await keyword gets the result, just like calling a method.
> Linux handles thousands of threads just fine.
Yes... And no...
It doesn't matter what OS you're on, each thread needs its own allocated stack space and has the overhead of context switching. "async" optimizes that by putting data that would normally go into many different stack spaces into the heap and jumping around among concurrent operations without the context switch.
Again, depending on what you're doing, that's either splitting hairs, or really making a tangible improvement. But you can't argue that more allocated stacks, and more context switches, is faster than doing it in process. At that point you're arguing with fact.
(I really struggled with async rust, so I'll admit that I don't remember the name of the type that represents the promise.)
I am familiar with how these are implemented. My opinion is the same. Yes you can implement slightly lighter weight threads in a language itself.
> It doesn't matter what OS you're on, each thread needs its own allocated stack space and has the overhead of context switching
This is a quantitative question. I know what it does, the question is how much slower it is than whatever async construct you want to use.
See e.g. this recent clip from one of the engineers on Project Loom in which they argue that the context switch is relatively low overhead: https://youtu.be/07V08SB1l8c?si=i0v9w90Kb_M0I4gP&t=966
Is this something that's hard to do on Linux?
Both of these APIs are set by you, the user. You can choose how big to set your stack size, it's true. However, what value do you actually set? Too high, and you're still using too much memory, though admittedly less. Too low, and you either need to accept death by stack overflow, or runtime detect this case and fix things up.
With async/await in Rust, the compiler can statically see how large the stack size needs to be. Each "thread" will have a perfectly sized stack. No user intervention required, no fiddling with settings.
This is a solved problem - it's "just" the longest path in a call graph where each node is weighted by its function's stack frame size.
> With async/await in Rust, the compiler can statically see how large the stack size needs to be. Each "thread" will have a perfectly sized stack. No user intervention required, no fiddling with settings.
There is nothing stopping a compiler from doing the exact same analysis for a sync function at compile time (barring exceptions like varargs or deliberate recursion). They just... haven't, for some reason. It's a shame that it took until Zig for it to be addressed.
That being said, I am also a fan of embassy when you have different design constraints and goals, and consider the fact that is is able to exist and be successful is a massive testament to the design of async Rust.
(We also use async Rust heavily further up the stack, and have some issues with it, but they tend to be disjoint from the way that this is talked about online.)
Example from a current side project, since it's not work-encumbered: A wifi-enabled clock light for my 5yo. It has a task that sits there and every hour pings an SNTP server, updating a (mutex-protected) global with the time state. It has another task that listens for a telnet session for various control signals - which also updates a mutex-protected global config state. And it has a task that spins doing LED effects.
With embassy/async, I can write each of those as a separate task, without paying much attention to what gets invoked by interrupt handlers.
(This particular one is an rp2040, but I use it on stm-based systems as well).
I feel like this is kind of analogous to the discussion of threads-vs-events as a mechanism for structuring code vs. threads as a mechanism for achieving parallelism. :-)
Edited to add: Or, perhaps an alternative view of what I'm doing is that I'm using the embassy runtime as a really lightweight alternative to an RTOS, since I mostly haven't met an RTOS I don't want to throw across the room. An argument against what I'm saying here is, "well, use Hubris as your embedded OS and then you can have tasks and they can be synchronous" - which seems entirely fair.
This is also a good design! The primary designer of Hubris also has a project that works like this: https://github.com/cbiffle/lilos
My complaint is I don't find the async ergonomics intuitive; it feels like a layer of misdirection. And, the viral character.
That said, I disagree on the usefulness of async: in my experience it does the job and it's my default setup.
There's a complex project where I ended up just using threads because I needed to squeeze performance out but overall async is great for tasks, sequence of async operations.
Still, it had some rough edges: (it's been a while but) using Async Closures wasn't pleasant and I re architected my app to not use them as a result. There's an argument to be made for this change making my application easier to reason about - but overall it's poor language flexibility.
No.
This is how yo do: you look what executor your dependency is using (most likely tokio) you add it to your cargo.toml (at zero cost, since it's already there in your dep) and then you wrap the async library calls in `block_on` and call it a day. You don't need to change a single other line in your project.
Now if only the other warts were fixed, like the particularly poor compiler errors when there's an issue within an async function...
If it matters to you, you don't have the same priority as your dependency's author anyway and probably shouldn't be using it in the first place, and it has nothing to do with async.
[1]: https://docs.rs/reqwest/latest/reqwest/blocking/index.html
So many people complain about Rust being hard. What on earth are you people on about? This isn't at all a hard language.
I'm so sick of this "async is hard" / "Rust is hard" meme. No, it isn't.
Maybe it's inconvenient if you're used to slapping packages together and calling it a day. But I don't think the majority of us do work like that.
And it's not hard.
I'm sick of people perpetuating this about the Rust ecosystem. It's not a month that goes by that there isn't some article badmouthing the language and its features.
Stop it.
We want our language to gain support in enterprise so we can get paid to write it as our day job. These articles and negative comments are not helping.
The amount of "async rust is impossible to use," "async rust is fundamentally broken," etc. commentary that comes up on HN is absurd. I have trouble squaring my own experience with it, and it makes me think people are either complaining just to complain or that they haven't worked with it in anything other than toy contexts. I have a hard time imagining how difficult it would be to maintain our ~100k line rust codebase (http proxy that processes requests & responses, makes DB queries, and makes http requests to other services) and keep any kind of predictable performance by spawning threads, using channels, etc.
Like, not trying to be elitist, but I feel like people are missing the benefits for the sake of piling on or something.
You didn't make any arguments here at all. You can check my other comment for detailed reasons why async in a language is not a good approach to concurrency. The overview is that is just isn't a holistic solution and the only time it will solve someone's problem is if they have extremely simple concurrency needs in the first place and those never scale or change.
a) They often focus on problems that, at least for me (and many others) are not significant. For example, acting like writing "+ Send + Sync + 'static" is causing your hands to seize in pain. Or they ignore that you can "block_on" a future.
b) They're then often interpreted by people who have very little context on async or rust at all. Look at the initial comment of "Async is a wart", it's idiotic. Look at how stupid the comments section is, how people are not talking about literally anything that boats wrote.
c) They make propositions that aren't very helpful. They mostly say "it was a mistake!" or "we don't need multithreading" or "we want TPC".
Contrast with Boats' post, which is far more contextual, far more historical, and proposes actual solutions and future work to be done.
It's quite frustrating to see the same sorts of complaints over and over again, especially when they're often not super great complaints to begin with.
That's no way to do good work.
Integrating 'async' into a language is not a good approach to concurrency. If you want to run a single function on a different thread it can work. If you want something to run after that, that can work out.
Once you go beyond that, you are building a graph with very crude tools. Then you have lots of problems, including how you handle packaging dependencies when something you want to run depends on data coming from multiple other async sources.
Treating this as a language issue and not a library and tools issue is a huge mistake. Another reason is exactly what you outlined - libraries getting infected with a languages half baked concurrency solution instead of doing what they are supposed to while the user can fit them in where they want.
What actually works is graphs that handle dependencies and data structures for synchronization, but ultimately those need to be done well too.