My complaint about the async rust world isn't so much function coloring, but dependency craziness.
For instance, the postgres driver for rust (which is an excellent driver, by the way), is built in on async primitives. That's a good thing, because it means that it can be used effectively from async code.
Unfortunately, a "hello world" program that uses the client takes 38s to build and downloads 65 dependencies, many of which are at 0.x.y versions and I have no idea what they do (parking_lot_core v0.8.5? slab v0.4.5?).
This is not just an issue of build time, but overall confidence in the quality and security of the code, and knowledge of who the authors even are. Also, it just adds complexity and magic, and sometimes I just want my code to be simple and tight and understandable from beginning to end.
One idea to resolve this is if rust has a standard futures runtime API, and you can just choose one in Cargo.toml or something. By default it would be a built-in single-threaded runtime, but you could choose tokio if you want. If you want to call async code from sync code, there would be an easy way to do it (maybe more syntax?) that would use whatever runtime you chose in Cargo.toml.
And then looking into the biggest results and understanding community sentiment. Ultimately you need to trust the authors. Do this enough and you start seeing the same popular projects, making it easier to trust things.
> sometimes I just want my code to be simple and tight and understandable from beginning to end
I hear ya! This is one reason I wrote my own async runtime, and the output of “cargo build” fits on my screen. :) But I don’t recommend this unless you have a LOT of time.
Yea, i don't really get the distrust of the ecosystem tbh. Yea, i agree the `0.x` stuff is annoying/concerning, but the volume of dependencies feels very.. meh. I use NixOS for example. The number of things i download is _insane_ when i build my system. This isn't a Rust or JavaScript "problem", it's just how natural distribution of work occurs. People build off of the work of their peers. And if you reduce the friction of work distribution, like how NPM and Crates do, you naturally get a lot - sometimes to a comical degree.
However are we going to expect crate authors to repeatedly reinvent wheels? Would we trust them if they did?
I would say that this is why reducing friction this way is not actually a good thing. It has disastrous effects on the entire ecosystem when including a dependency is too easy. It means people will not question whether they should be including a dependency, because it is so easy to do.
I don't think the issue is "too easy" here, but the lack of downstream verification tools.
If you had a clear metric for all the parts that go into a library you wouldn't worry about how many there were. And if the library author likewise cared about their libraries metric, they wouldn't arbitrarily choose low-quality libraries.
Friction isn't useful here imo. Trust is just as bad with high friction, just maybe less moving pieces. Not having verified metrics seem to me the real problem. Some sort of safety rating that bubbles up from all the dependencies. Changes to software owners would also have to cause issues in this system too.
This doesn't ring true to me. I get that you need to have some dependencies. But if you go build Linux From Scratch, you get a total of 68 packages, most of which are part of the GNU project and come from common authors. That gets you an entire operating system. On the other hand, when I was writing a book using mdbook and wanted a plugin that does token replacement, it pulled in 238 dependencies. To do token replacement. Up that to the scale of an entire operating system written in Rust, and now what are you looking at? At least thousands. Supply chain auditing eventually becomes impossible if you have a separate vendor per function.
Put another way, if you go and build Firefox from source, the number of Rust crates and NPM modules it pulls in automatically during its own build is more than the total number of packages in Linux From Scratch and Beyond Linux From Scratch combined. Not every library needs to be the size of glibc, but Rust and Node have gone too far in the opposite direction.
I don't know what you have in your Nix build, but even Linux From Scratch is already bloated because it includes a bunch of build and test tools you don't need to just run an OS. The Arch base system is 27 packages.
Is an operating system really a fair comparison? Most libraries depend on the operating system, and the usage of OS independent libraries would be limited to mostly operating systems. Not a very big market.
> But if you go build Linux From Scratch, you get a total of 68 packages, most of which are part of the GNU project and come from common authors. That gets you an entire operating system.
Sure, because dependency management for C is a joke^H^H extremely cumbersome and so you get coarse-grained packages that bundle lots of functionality and are a pain to upgrade.
Why is number of dependencies something you care about? Bundling 10 functions together and calling them a library doesn't make them any easier to audit than auditing them individually, all it does is bloat programs that only needed one of them. Getting supply chain attestation to scale is a legitimate problem, but a solvable one - e.g. we could have organisations that would vouch for a large range of authors or packages.
> I get that you need to have some dependencies. But if you go build Linux From Scratch, you get a total of 68 packages, most of which are part of the GNU project and come from common authors.
Well to be clear, i'm talking about the dozens of applications primarily that make "my workstation". All the crap and varying duplicated versions, all of it.
Much of my dependencies aren't JavaScript or Rust based, and there are still dozens if not hundreds of dependencies. Is Nix at fault for letting me too easily install things? Maybe each individual app needs to have less dependencies? My workstation is built on leagues of other dependencies, is my workstation less secure because of it? Probably. Would installing less things be more safe? Sure... but throwing away my computer would be too.
The problem isn't the count imo. The problem is how we vet them. To not only judge a library/app, but to automate a way to bubble up this vetting. To let consumers know that the one package/library/etc they chose to use increased their risk by a significant amount. To know when a package updates that it is no longer secure, as it is owned by a different author. etc.
In summary: It's easy for me to install packages on my OS. Most OSs in fact. Making it more difficult to install wouldn't help me make safer choices.
JavaScript had the advantage that is was async first (it started with callback-style APIs, but they can be converted to Promises).
In Rust, this is not the case and leads to fragmentation.
There are a lot of high-quality libraries which cannot be efficiently used in an async context (e.g. Diesel).
And since Rust isn't shipped with a default runtime (tokio, async-std, ...), some crates are incompatible with the rest of your program. I understand the reasoning (there is no "one size fits all") but it makes things more complicated.
At least for diesel that's not true anymore. I've build a async connection implementation that will published alongside the next major release.
See https://github.com/weiznich/diesel_async for details
Tangential but the slab concept is actually really neat, basically a userland allocator for a specific type, or a vector that gives you the key inserted at depending on how you prefer to think. https://docs.rs/slab/.
The parking lot concept is also really neat! The premise is that a program with lots of mutexes doesn't actually need to allocate lots of blocking queues, because the number of simultaneous waiters is bounded by the number of threads.
I don't think it's a coincidence that both of the examples here are actually quite reasonable as standalone crates. They're important abstractions, but just a little bit too niche to justify a place in the standard library. That said, I agree that learning what all these crates mean is daunting, and that finding ways to make that easier somehow would be valuable.
> just a little bit too niche to justify a place in the standard library
parking_lot has actually been considered for inclusion in libstd[0], as a better alternative to the native mutex implementation (parking_lot mutex are smaller, can be const-initialized, can be moved, and tend to perform better - all while not depending on external, non-rust code). The effort is currently on hold, but it's possible that it will resume in the future.
parking_lot is a mutex. slab is a slab allocator. It seems to me that in a C or C++ project you’d have everyone writing their own rather than a shared project that everyone depends on.
Personally I have much more confidence in the community de facto standard implementation of something than I do in the half-baked version that someone writes themselves to avoid pulling in a dependency.
Not sure why a C project would do that from scratch. libc includes that functionality, so you already get it for free in any POSIX system. In Windows, I'm sure the system C library provides the same stuff.
I don't think parking_lot is a mutex. It is more similar to a futex, i.e. an ephemeral wait queue. You can use it to implement a mutex, but that's just one use case.
There's an interesting inversion in people's expectations between compiled programs and source. If it's compiled, it should do one thing and do it well. If it's a source package, it should implement as much as it can and use external packages as little as possible.
Meh. When rust was designed it didn't make a distinction between blocking and non-blocking functions (here, I mean having a syscall which may yield to the OS scheduler, such as `read`). Together with threads, this let users enjoy the traditional kernel-space concurrency model seamlessly.
When async got added, this simplification stood in conflict with the new explicit user-space scheduler (reactor-executor in Rust terms). Today, std is a minefield within an async app — your program may deadlock non-deterministically — without as much as a compiler warning. Every project member needs to worry about intricate details of different types of mutices and which std::fs APIs are kosher, and so on.
I would have preferred a plug-in system where you could use the same application code with different scheduling backends - threaded by default, the M:N model or even the M:1 model if you're feeling web scale. This way, code reuse would have been a breeze.
What we got was a mess of application code where the compatibility matrix (what code is compatible with what runtime) is not clear, enforced or even well documented. Even the proponents of the current model acknowledge that the current ecosystem is fragmented.
This was always true, even prior to async. Rust's thread safety features have never prevented deadlocks. They prevent races, but not deadlocks. RAII makes some patterns that can cause deadlock more avoidable. Nothing in Rust prevents you from spawning a thread that locks a mutex and then never gives it up.
I have several issues with Rust async and prefer to stay as far from it as possible, but "colors" is not one of them. The article correctly mentions that we can view other function properties, like fallibility, as "colors". For a language like Rust it's important to expose such properties (similarly to how it relies on Result instead of using exceptions) since FSM and sequential code are quite different from each other. If anything, I would say that Rust does not go far enough to expose such properties. I would love to have compiler-enforced properties like "can not not panic", "execution takes bounded time", "uses bounded amount of stack space", "does not use allocations", etc. Some of those properties could've been useful for async code as well and the bounded stack usage is in a certain sense already implicitly part of async.
One of my issues is that Rust continues to pile ad hoc solutions, without looking for more fundamental approaches. For example, in the async case, it was rushed in the current form instead of properly solving generators and linear types. Yes, it's probably helped with the language adoption, but it's a short-term win at the expense of the longer-term language health.
Are you the one withoutboats always chimes in here to correct by pointing out that there was actually tons of discussion and alternative designs evaluated? I seem to remember the last time rust async was discussed here the people who actually worked on the feature being pretty peeved by people claiming this.
Always? If you mean https://news.ycombinator.com/item?id=26406989#26407395, then I don't remember repeating this discussion with him on HN or anywhere else. He took it really personally, so I don't see a point in rehashing it. I stand by my words behind the link and I don't think that I stand "corrected". You can read the HN discussion and github and IRLO discussions linked in it to form your own opinion.
I'm one of the people always critiquing the use of function coloring[0] to do async programming in most languages and defending the Go model as The Way.
However, I totally get why Rust chose to go down that path. It's the way to go if you want to have zero-cost abstractions, even if it's a PITA. And since you're programming in Rust, you've already accepted that PITA's in the name of performance and low-level access are occasionally alright.
Seconded. If you're shipping a language with a meaty runtime, then save your users the headache and just include built-in support for green threads; your runtime probably already takes care of things like garbage collection and your users are fine with it, so they're unlikely to complain about not being able to squeeze out every last bit of single-threaded performance. But there are certainly niches for languages where users do expect to be able to squeeze out those last drops of performance, and Rust inhabits that niche. Different intended contexts can call for different approaches.
However rust is so hairy that it’s damn slow to develop in.
You would expect database engine devs would pick rust to get those last drops of performance.
Yet there is no production quality storage engine written in rust, whereas there are several of those in go.
Ergonomics matter. Iteration time too. Rust fails hard there.
I wouldn't call the coloring "a good thing". I would call it "an acceptable compromise". It would be better if you didn't have to label it and it all Just Worked, just like it would be better if you didn't have to annotate lifetimes and it all Just Worked, but for the space Rust is operating in, it is an acceptable compromise. Rust sits high on the "force programmer to put more effort in, give programmer more out in return", on a pretty nice cost/benefit tradeoff place in that space. But the fact you get benefits commensurate with the costs doesn't mean it's not a cost at all, and it's probably not good to get into the habit of conflating costs and benefits. Clarity of view of both sides of the balance is very important for engineers.
What would be "just working" in your mind WRT promise, unwrap and thread context semantics?
Sugar around thread yield, unwrap and context return? That seems like a lot of possible gotchas to simply be hidden and lurking under every benign looking synchronous method.
Remember that the point of async/await is cooperative threading and not pre-emption. Hiding these details makes it much harder to reason about. If you don't know when your execution will be yielded, you're essentially being pre-empted, no?
Sure, you can be preempted by another thread (or even process) even within an async function, so why should it matter? The borrow checker should still prevent anything bad from happening.
I'm not a rust programmer, but personally I think that stackless coroutines are probably a necessary evil in system languages as full stack switching might have too much overhead. But let's stop pretending that it is actually a good thing.
Still I think that even with stackless coroutines await should be the default (the async keyword would be used if you explicitly want the promise), which, in addition to better ergonomics, would allow the async-ness of a function to be inferred, possibly as a function of its type parameters. This at the very least would avoid duplicating a lot of functionality.
>Sure, you can be preempted by another thread (or even process) even within an async function, so why should it matter?
You misunderstand. Its not about whether your thread can be pre-empted, its that your logical execution can hold exclusive access to a thread. There are common design patterns that use exclusive threads as a means of synchronization.
> Sure, you can be preempted by another thread (or even process) even within an async function
That really depends on the platform. Async rust is also being leveraged to build RTOS on embedded platform for instance. If async rust was preemptive by default, that would be impossible. It's not a matter of performance there, but of correctness.
It would actually be very hard to build a RTOS using async functions - and the current use of async/await on embedded Rust is indeed not about RTOS, it's just embedded code.
The reason for that is that the point of a RTOS is providing guarantees: A certain task should be guaranteed to run within a certain time. To provide that guarantees, the RTOS needs to support preemption and prioritization. That allows it to schedule a certain task even if another task is running for too long. I'm not aware about any RTOS which makes use of cooperative multitasking.
> I think that stackless coroutines are probably a necessary evil in system languages as full stack switching might have too much overhead.
I think it's worth noting that the big operating system kernels - perhaps the ultimate target of "system languages" - tend to use stackful coroutines rather than stackless.
Stack switching tends to be similar speed overhead than stackless, despite surface appearances in how the code may appear. Stackless tends to do more memory allocation and freeing, though.
Sure, but the perceived advantage of stackless coroutines is not faster switching time, but lower peak memory usage. OSs do not run 100s of thousands of tasks as threads, and when they do they use hand written state machines.
Green threads, like Go has, is one good answer. You don't run around annotating what is and is not async. It just works.
Even full OS threads are an acceptable answer for the vast majority of applications. It is good and proper for embedded programmers and OS designers and such to have options as powerful as Rust, but that does not mean the average program written in Rust is embedded, an OS, or needs to be able to scale to hundreds of thousands of threads.
(I actually think reaching straight for async/await is a mistake, personally. It's a tool for more extreme situations, not something you reach for when you're writing a program that's going to intermittently consume a fraction of a percent of CPU, or run a quick job where it may need maybe 10 threads if that and terminate. A lot of people badly overestimate the resources they need and jump straight for the extra-mega power tools for problems where they are literally a dozen orders of various magnitudes (CPU, RAM, etc.) away from needing them for their task.)
(It is extra ironic to be terrified of threads when working in a language like Rust that has so thoroughly tamed them!)
Yes, there are compromises, so you get the flip side of what I said above: Just because something has costs doesn't mean it doesn't have benefits. It is a benefit that you don't have to annotate everything yourself and the compiler and runtime just takes care of it.
"Remember that the point of async/await is cooperative threading and not pre-emption."
I disagree. Cooperative threading is a disaster. It doesn't scale. We tried it, very very hard, with an entire major consumer OS for decades. The point of async/await is precisely to get something like pre-emption into something that isn't using full, real threads (as its primary organization). That it functions like cooperative threading is one of the disadvantages that happens to be an acceptable compromise at most scales, not a goal.
>Cooperative threading is a disaster. It doesn't scale
It doesn't need to scale because it solves a different problem. It find extreme success in UI and graphics programming where UI systems are dominated by a single UI thread for synchronization. GPU have been single threaded until recently.
You can argue you do not like it but I can't see how you can argue against it's success.
That said, I'm pretty interested to see a popular multithreaded/pre-emptive UI framework in the wild.
> It would be better if you didn't have to label it and it all Just Worked
However, this is not possible. When you have concurrent code, whether you make it obvious or hide it, you have contention over any shared mutable state.
async/await is meant to have the pieces with contention be cooperatively multitasked, and the yielding of control is explicit so that you can understand that the state going into the call may be different than the state once you come out - any number of unrelated things may have run in between when you awaited and when control returned.
Not having coloring means that you have turned an explicit cooperative system to an implicit or even preemptive system. There are definite advantages and disadvantages to both, with the difficulty in tracking down and debugging concurrency issues in implicit/premptive systems being the reason that there has been so much research into other approaches such as async/await, actors, and structured concurrency.
Contention over mutable state is orthogonal to async/await in Rust. Proof is that they had the contention problem solved prior to async/await. Pure functional languages also solve the contention problem by simply destroying mutable state, without async labelling. A brute force solution, but a solution. async/await doesn't solve the problem of things possibly mutating things when you weren't running; you've turned over the thread of control, if your language permits mutation it can happen without you knowing. Rust solves that in other ways that have nothing to do with async/await, and its solution works with preemptive threading.
Rust has been able to thread since long before async/await was introduced. It was introduced because for some of the use cases of Rust the overhead was unacceptable, not because prior to async you had no options at all to have that sort of functionality.
What I don't understand is why these functions must be declared async at the function declaration site. Why not allow the programmer to run any expression asynchronously, which wraps the return value of the expression in a future or promise or what have you?
let promise = async {something+someother();};
// other computations
let result = await promise;
You'd essentially be creating a very ergonomical shortcut to fork-join multithreading which is infinitely more powerful and expressive than whatever this "async function" stuff is.
ETA: perhaps it would be a good idea to have a thread-unsafe keyword, though I'm not sure if rust needs that since rust has a really stringent resource pointer/reference aliasing guarantee.
ETA2: I misunderstood async semantics for rust, it's not a fork/join but send/poll. In any case, I still think you should be able to declare async at the call site, not the function site. That doesn't seem to clash.
> You'd essentially be creating a very ergonomical shortcut to fork-join multithreading which is infinitely more powerful and expressive than whatever this "async function" stuff is.
But that would require starting other threads, which requires everything to be `Send + Sync`, has the overhead of multithreading, and can't be managed with any sort of scheduler (without platform-specific hacks). Unless you use green threads, and that's discussed in the article.
So what then is async? If there is no scheduling/preemption in executing an "async" function, it's not really "async", but rather synchronous. Is async just a new way to say "instruction reordering"? Or is async really just "*defer* until the last possible moment: await"?
edit to match yours: not sure what a "green" thread is.
In any case, if threads are not an option, an async may always be ignorable, after all, a program may not rely on the order of the execution of its threads/its preemption; that would be a race condition. Textbook definition.
As I see it, the fork/join model is very useful for "shingling" code paths that don't have to happen in sequence, but _could_ happen in parallel.
Final edit: nothing is otherwise stopping the implementer of async to add a pre-started threadpool to the runtime, with whatever configurable parameters one would want.
Post final edit: I think fork/join vs send/sync are just implementation details. But I respect Rust for going with send/sync, just that it doesn't change all that much in terms of the programming interface.
(but note that this doesn't mean busy-loop polling, while that will be used in "my first executor" tutorials, production executors don't call a future until it's ready to have more progress made via an event, often from an epoll/kqueue/iocp or similar mechanism.)
It's "compile this as a state machine such that when other futures are waited on internally by this function, store stack state in an anonymous struct that implements Future + Poll, and allow repumping when that Poll implemention says it's ready. This allows you to reuse the same stack when this async function is blocked"
Wrapping sync in async is usually not so hard but usually an anti-pattern. In C# for example, if that synchronous work is long running, it might consume a thread in the shared thread pool and have side effects (starvation) elsewhere in the application.
I may not follow you - but to expand what _(i believe)_ the GP comment said; any long running code - say a loop that spins for an hour, is bad in the async context, because that async thread is part of the runtime. Better to take those special cases and let the executor move the computation to a dedicated thread.
At my work i mix a lot of compute heavy work in an async context, and if i let this compute heavy stuff run on my async threads i'd potentially starve my runtime. Tasks wouldn't get woken up on time, etc.
That's the point though - anything blocking the async thread is bad. If your loop is going to run for an hour, you'd put arbitrary yield points in it so that the executor can reliably call other tasks
The preferred pattern is to avoid long running tasks in the default thread pool as it's a shared resource. Long running tasks should be executed somewhere else, either some new thread pool or just a new thread or just don't use async and make it your caller's problem. The point is that you don't want to consume the limited shared resource.
In your case, what would happen? Would a new thread be created? Is it ok to use the default pool? Did the synchronous method writer even consider this? It's hard to say.
Now, does this need to be enforced by the language? No. Developers could simply append "_Async" to make it clear a method can be put on the default thread pool or not. That's still a form a coloring though. Even if you consider it a choice by the caller, it's still a color that needs to be determined and you never want to exhaust your default thread pool.
That said, there are other reasons why calling an async method should have different semantics than calling a normal sync method. Just saying that the "can use shared threads" aspect of async methods can probably live as a convention.
async things are (generally) cooperatively executed/scheduled/etc. they choose when to give up control and allow other things to run (`await x`). this is in contrast to threads / processes, which are done preemptively - the OS can say "stop doing that, do this", swap out the memory, and your thread basically can't do anything about it. (which is why there are thread priorities)
normal synchronous code never gives up control until it's done. so it'll block the cooperative/async scheduler until it finishes, whether it's maxing out the CPU or just waiting idly for a network request to respond. if there's anything else happening on that scheduler, it has to wait, which can lead to rather terrible behavior.
and last but not least: you generally cannot move async code between threads. async tends to imply you want same-thread concurrency guarantees, to avoid the cost of cross-thread mutexes and whatnot. so while you could in theory just interrupt an async scheduler and move it to a new thread, in practice that's not usually safe, so it isn't done.
you could build a system that allows jumping between threads though - that's essentially what green threaded languages do under the hood (like Go). which gives you a middle-ground of "nice thread-like semantics" but "nice async performance", but neither quite as good as a pure system in sometimes-important edge cases.
---
all that said, yes, you can just dedicate a thread to a single scheduler, run a synchronous func on it, and it has practically no additional cost over using a normal thread. but you have to choose to do that, it won't happen by default.
> I still think you should be able to declare async at the call site, not the function site
This is the big point. I'd love to see anyone put forth a concrete, valid reason why this couldn't be implemented, because as it stands, it appears that the function coloring problem is exclusively due to poor language design, rather than a technical limitation (where an example of a technical limitation would be escape analysis, which is hard).
Explicit await and promise semantics are useful incase you want to, say, start multiple async calls instead of awaiting one and then the other. But ok, lets say you just want await to be implicit in some cases...
Do you actually want this? Currently, you can assume that a function will not change threads mid execution, and that that thread will also not be used to execute other things. Implicit yielding breaks that assumption. Cooperative multithreading becomes impossible because it's much hard to reason about when a method call will yield execution or not.
No, Rust did not solve escape analysis - it was designed to constrain valid programs to those for which escape analysis is trivial (as evidenced by unsafe Rust, which doesn't have those constraints, and yet has nothing else enabling escape analysis). That's like saying that "C solved type inference" - no, it just constrained you to write your types by hand, and then did some checking of them afterward.
The reason to mark a function as async (in today's Rust) is that you want to `await` something within it.
If a function does this, it must be async- there's no other way to implement it. If a function does not do this, there is no reason to make it async in the first place- it will never suspend. (Things get uglier when it may or may not need to await something, e.g. if it accepts a closure parameter. Rust doesn't really handle this today, but one approach would be to make the function polymorphic over async-ness- this is similar to but not really the same as what it sounds like you're talking about.)
Async functions are for language-level cooperative multitasking, not concurrency per se. The difference between fork/join (what you imagined async to be) and what it actually is, is more than an implementation detail- it's a semantic change to how the program behaves. If you want to make something happen in the background at the call site, there are already ways to do that, they're just not spelled "async fn."
But what's the difference between an async function returning int, and a function returning `impl Future<int>`? If there's no difference why do we need the async keyword?
Other parts of the language are conditioned on the return type. For example the ? operator only works if the return type is Option or Result. It seems like `await` could inspect the return type in the same way.
> But what's the difference between an async function returning int, and a function returning `impl Future<int>`? If there's no difference why do we need the async keyword?
No difference, but while the feature was being explored early users found it simpler if they didn't need to think about lifetimes on the returned future. Sometimes sugar is just sugar; same reason that `for` loops exist instead of just using `while`.
You can't await inside of a function returning impl Future<Int>. (You can inside an async block inside one, but there you go with language-level support again.)
The difference between `await` and `?` is that the former completely transforms the entire function body containing it, while `?` just conditionally returns from it. (There's less of a difference to the caller of the function, where both `async` and `impl Future` give you a `Future` object.)
A normal function returning `impl Future` has normal synchronous control flow, cannot ever use `await`, and then eventually returns a value whose type implements the Future trait. You can't just return an int, because ints don't implement `Future`- you have to return something that is `poll`-able.
On the other hand, an `async fn` merely looks like it has normal synchronous control flow, with some `await`s sprinkled in. But when you call it, none of that code runs- instead it immediately constructs and returns a value (of anonymous type, similar to a closure) whose type implements `Future`. The code written in the body has its control flow ripped apart at `await` points, much like a CPS transform, and it goes in the `poll` method of that value's `Future` impl.
It's technically possible that the language could have been designed the way you describe- if an `await` is present, that transformation happens, otherwise it doesn't. But because of the nature of the `await` operator, this is much messier than `?`. For example, when the transformation happens, you need to be able to return an `int`, and when it doesn't you must return an `impl Future`, unlike `?` where you always have to return a `Result` or `Option` regardless of your use of `?`. And you wouldn't be able to tell at a glance whether the function body runs immediately when it's called, or whether it waits for the Future to be `poll`ed. Plus, the language also has `async { .. }` blocks that do the same transformation in an expression context, so it's kind of nice to be consistent with that.
The answer is actually pretty dumb: functions are declared async at the declaration site because that's how C# supported legacy code.
Introducing a new keyword is a breaking change because existing code may have a variable with the same name. So `await` is a conditional keyword in C#, conditioned on whether the function is marked `async`. And other languages have since just copied that choice.
That may have been C#'s rationale, but it wasn't Rust's rationale. Rust doesn't even strictly require the `async` keyword in a signature to define functions that work with `await`; `await` works by polling a future, and `async fn` is just sugar for defining a function that returns a future. In fact Rust does precisely what the parent suggests, and also offers `async {}` blocks as sugar for defining inline futures:
fn bar() -> impl Future<Output = u8> {
async {
let x: u8 = foo().await;
x + 5
}
}
I think it's obvious that Rust chose `async fn` for familiarity with other languages. As you say `async` is just sugar on the return type, so logically it should decorate that type, not the fn keyword. "async fn" is even misleading since the function itself returns synchronously (and immediately): it's the returned value where the async-ness lies.
So I think Rust's use and placement of `async` is for familiarity, which traces back to C#'s original choice. Not to say that's a bad rationale.
Admittedly I didn't follow any of the Rust async design discussions so I could be completely wrong about all of it.
> "async fn" is even misleading since the function itself returns synchronously (and immediately): it's the returned value where the async-ness lies.
You've got the causality backwards- it's the async-ness that causes it to return immediately. Async is primarily about the execution model of the entire function, and the return type is only changed in service of that model.
The course of the design discussions started without async or await, using library-based Future combinators. Async was introduced as a way to desugar control flow and move knowledge of Futures into the compiler for borrow checking reasons, not out of any intention to mimic C#.
I think at this point the metaphor of "function coloring" has been overused, and we should use the real name: monadic async. The problem is obvious when you look at what a monad is: you have lots of ways in, but no way out. So every monad has to offer their specific way out, if they can. With a Option<T>, you can extract the value inside your function with unwrap. Your function will still be a regular function. With async, you can do the same with await. However, await can only be used inside async functions. That's the trap right there. The way to get out of the async monad is only usable inside the async monad.
I don't agree with the article that the idea of colored functions is tied to dynamic types. I think it applies equally well to Dart, TypeScript, C#, and any other language considering async await. It's mostly a question of usability and the ability to encapsulate use of asynchronous operations.
But for Rust, I think having colored functions is probably the right call. Asynchronous operations are fundamentally different at the OS/machine level. Since Rust is a language where code deliberately has very high affinity to what the underlying system is doing, it makes sense to make that distinction visible to users even if it means that it's harder to write and reuse asynchronous code.
This is somewhat covered by other comments, but it's not made explicit enough.
Is async-style the best possible tradeoff to achieve the best result when programming with blocking calls?
The fact that you should not be applying the same approach to a very similar problem (blocking CPU-bound code) kinda sucks, which means that you need to be employing multiple concurrency approaches in the same program.
I am all for syntactic sugar to make concurrency easier and less error prone, but I'd prefer it if the same approach trivially worked for all sorts of concurrent functions without heavy penalties to execution.
It depends on what you mean with „best result“. Async functions in Rust are not about improving ergonomics compared to classical threading. They are strictly worse. They are a tool which can be used to lower the amount of OS threads used by a n application- by multiplexing multiple logical tasks in one thread. Callbacks and state machines are other options for that - but those are even harder to get right. If your application requires that from an efficiency point of view async programming might be a good option. If not threads are fine.
Sure, the definition of the "best result" is the question.
In short, "the best approach" to me is one that:
1. Is suitable for all sorts of concurrency
2. Has a low memory overhead and low switching costs like the async functions today
3. Is the most "ergonomic" (leading to fewest bugs, easiest debugging and easiest maintenance): yeah, this one is not too well defined either, which is why I would measure developer satisfaction and things like defect rates to establish which one is it, not that this makes it any easier to define :)
4. Provides "enough" safety and encapsulation without impacting performance (much)
If 4 has a huge cost, perhaps go with special-cased "async isolated" concurrency primitives when you need added safety in addition to "async", and if the cost is low, allow not paying it anyway with the inverse ("async unsafe"): basically, different defaults depending on what's realistically achievable and nature of the language.
I think that ideal lies somewhere near the "non-cooperative multitasking" async programming approach, but I am not sure, and I haven't played with enough recent developments in programming languages either to know if any one of them gets close.
But having thought through it a bit with this comment, I think my main gripe is how your (developer's) decision of the concurrency tool (threads, async/await, callbacks, green threads...) affects your syntax simultaneously (eg. why should awaiting a compute-intensive task be problematic? that's an implementation detail a developer should not have to worry about if all else is equal).
Seems like a bit of a stretch to say that anything that's part of a method signature is the same as a "color" as it was described in the original article. If a method takes something by value, that doesn't necessarily mean that attribute needs to propagate up the call chain until the literal origin of the process. In fact, in my experience, it _very_ rarely does. Async does, however, always. That's the whole point.
The key part with the "monad" bit at the end is we have monad polymoirphism allready, e.g..
foo :: Monad m => m ()
in Haskell. If that goes to rust, maybe we can have "async polymorphism". This, and the far more easier "mut polymorphism" (no more as as_mut boilerplate) means we get best of both worlds: color and code reuse!
The biggest problem with Rust async is the dependency mess. All the different runtimes, with some projects dragging in one and some dragging in another, is crazy. If you aren’t careful you end up with multiple runtimes in the same binary. That is what needs to be fixed more than anything else.
As far as the complexity goes: it’s a systems language without a true “fat” runtime like C# or Go. You can’t do the sorts of pretty abstractions you can do in those languages. You can’t hide the details very much. Rust async is syntactic sugar around what would be much more verbose nested callback patterns. It hides that nastiness but you still need to have a mental model of what is actually going on.
I'm not a software engineer, but I recall that in the days before abstraction layers there was a tactic called a vectored interrupt. When some device needed attention, it tapped the CPU on the shoulder and got it. I don't understand why nowadays with 4- or more core machines, servicing an interrupt isn't easier. I'm naive enough to think that sounds like an architecture problem.
I think there’s a good argument that async is decent for performance critical languages, e.g C++ and Rust, and for languages looking to model effects, e.g Haskell and arguably Rust.
I don’t see a good reason for it in mainstream languages like Java, JavaScript and C#.
I think Java’s approach with Loom is going to be a big win over C# there, as someone that just wants to get stuff done and is a fan of both.
I'm not sure if Loom will be successful. It does not fit the ecosystem. Thus a lot of java async stuff needs a lot of rewriting, this can either disrupt the ecosystem or split it. at least on the library level.
I actually feel it would be successful because it exactly fits the ecosystem. A lot of Java code is classical threaded code. E.g. the majority of Java servlet code, and older web frameworks. Those would all immediately benefit from Loom in terms of resource utilization and scalability.
Of course Java also has some frameworks like Netty and things built on top of it - which won't benefit. But I feel like even though those are great from a performance point of view, they are actually more niche in the overall java world.
Here's my (oversimplified) understanding of async: It's functions with yield points, at which other code can be run until execution returns to the function. Then, isn't it almost trivial to call an async function from a synchronous context? Just skip over the yield points?
It depends on the context of the yield but assuming the caller and callee contexts can be the same, most async/await implementations will not actually yield until some kind of thread sleep or explicit yield is hit. So yes, this happens in most languages I'm aware of.
Call me weird, but I'll take callbacks any day over async await.
If it goes too many levels deep, we have to ask what we are trying to achieve and perhaps consider a better approach (a specialized state machine? threads?)
interrupt-like things are used to schedule the next poll of the function. Polling doesn't happen randomly or within certain time intervals, but when an external event (via a Waker interface) tells the runtime to schedule the function again for polling.
My complaint about the async rust world isn't so much function coloring, but dependency craziness.
For instance, the postgres driver for rust (which is an excellent driver, by the way), is built in on async primitives. That's a good thing, because it means that it can be used effectively from async code.
Unfortunately, a "hello world" program that uses the client takes 38s to build and downloads 65 dependencies, many of which are at 0.x.y versions and I have no idea what they do (parking_lot_core v0.8.5? slab v0.4.5?).
This is not just an issue of build time, but overall confidence in the quality and security of the code, and knowledge of who the authors even are. Also, it just adds complexity and magic, and sometimes I just want my code to be simple and tight and understandable from beginning to end.
One idea to resolve this is if rust has a standard futures runtime API, and you can just choose one in Cargo.toml or something. By default it would be a built-in single-threaded runtime, but you could choose tokio if you want. If you want to call async code from sync code, there would be an easy way to do it (maybe more syntax?) that would use whatever runtime you chose in Cargo.toml.
I typically vet dependencies by doing reverse lookups, e.g.:
https://crates.io/crates/slab/reverse_dependencies
And then looking into the biggest results and understanding community sentiment. Ultimately you need to trust the authors. Do this enough and you start seeing the same popular projects, making it easier to trust things.
> sometimes I just want my code to be simple and tight and understandable from beginning to end
I hear ya! This is one reason I wrote my own async runtime, and the output of “cargo build” fits on my screen. :) But I don’t recommend this unless you have a LOT of time.
Yea, i don't really get the distrust of the ecosystem tbh. Yea, i agree the `0.x` stuff is annoying/concerning, but the volume of dependencies feels very.. meh. I use NixOS for example. The number of things i download is _insane_ when i build my system. This isn't a Rust or JavaScript "problem", it's just how natural distribution of work occurs. People build off of the work of their peers. And if you reduce the friction of work distribution, like how NPM and Crates do, you naturally get a lot - sometimes to a comical degree.
However are we going to expect crate authors to repeatedly reinvent wheels? Would we trust them if they did?
I would say that this is why reducing friction this way is not actually a good thing. It has disastrous effects on the entire ecosystem when including a dependency is too easy. It means people will not question whether they should be including a dependency, because it is so easy to do.
I don't think the issue is "too easy" here, but the lack of downstream verification tools.
If you had a clear metric for all the parts that go into a library you wouldn't worry about how many there were. And if the library author likewise cared about their libraries metric, they wouldn't arbitrarily choose low-quality libraries.
Friction isn't useful here imo. Trust is just as bad with high friction, just maybe less moving pieces. Not having verified metrics seem to me the real problem. Some sort of safety rating that bubbles up from all the dependencies. Changes to software owners would also have to cause issues in this system too.
This doesn't ring true to me. I get that you need to have some dependencies. But if you go build Linux From Scratch, you get a total of 68 packages, most of which are part of the GNU project and come from common authors. That gets you an entire operating system. On the other hand, when I was writing a book using mdbook and wanted a plugin that does token replacement, it pulled in 238 dependencies. To do token replacement. Up that to the scale of an entire operating system written in Rust, and now what are you looking at? At least thousands. Supply chain auditing eventually becomes impossible if you have a separate vendor per function.
Put another way, if you go and build Firefox from source, the number of Rust crates and NPM modules it pulls in automatically during its own build is more than the total number of packages in Linux From Scratch and Beyond Linux From Scratch combined. Not every library needs to be the size of glibc, but Rust and Node have gone too far in the opposite direction.
I don't know what you have in your Nix build, but even Linux From Scratch is already bloated because it includes a bunch of build and test tools you don't need to just run an OS. The Arch base system is 27 packages.
Is an operating system really a fair comparison? Most libraries depend on the operating system, and the usage of OS independent libraries would be limited to mostly operating systems. Not a very big market.
> But if you go build Linux From Scratch, you get a total of 68 packages, most of which are part of the GNU project and come from common authors. That gets you an entire operating system.
Sure, because dependency management for C is a joke^H^H extremely cumbersome and so you get coarse-grained packages that bundle lots of functionality and are a pain to upgrade.
Why is number of dependencies something you care about? Bundling 10 functions together and calling them a library doesn't make them any easier to audit than auditing them individually, all it does is bloat programs that only needed one of them. Getting supply chain attestation to scale is a legitimate problem, but a solvable one - e.g. we could have organisations that would vouch for a large range of authors or packages.
> I get that you need to have some dependencies. But if you go build Linux From Scratch, you get a total of 68 packages, most of which are part of the GNU project and come from common authors.
Well to be clear, i'm talking about the dozens of applications primarily that make "my workstation". All the crap and varying duplicated versions, all of it.
Much of my dependencies aren't JavaScript or Rust based, and there are still dozens if not hundreds of dependencies. Is Nix at fault for letting me too easily install things? Maybe each individual app needs to have less dependencies? My workstation is built on leagues of other dependencies, is my workstation less secure because of it? Probably. Would installing less things be more safe? Sure... but throwing away my computer would be too.
The problem isn't the count imo. The problem is how we vet them. To not only judge a library/app, but to automate a way to bubble up this vetting. To let consumers know that the one package/library/etc they chose to use increased their risk by a significant amount. To know when a package updates that it is no longer secure, as it is owned by a different author. etc.
In summary: It's easy for me to install packages on my OS. Most OSs in fact. Making it more difficult to install wouldn't help me make safer choices.
It would be nice to subscribe to any change in that dependency relationship. If `proxy-auditor-crate` upgrades, then probably I should to.
And probably more importantly, if `proxy-auditor-crate` drops a dependency because they no longer trust it, then I definitely want to be notified.
JavaScript had the advantage that is was async first (it started with callback-style APIs, but they can be converted to Promises).
In Rust, this is not the case and leads to fragmentation.
There are a lot of high-quality libraries which cannot be efficiently used in an async context (e.g. Diesel). And since Rust isn't shipped with a default runtime (tokio, async-std, ...), some crates are incompatible with the rest of your program. I understand the reasoning (there is no "one size fits all") but it makes things more complicated.
At least for diesel that's not true anymore. I've build a async connection implementation that will published alongside the next major release. See https://github.com/weiznich/diesel_async for details
That's great news, thanks for the info and contribution, weiznich!
Rust does have a standard futures runtime API (edit: thinking over, maybe you meant there should be a runtime in std)
This then opens up alternative runtimes to tokio (async-std, smol, glommio, monoio)
Then your suggestion of a single threaded executor exists already: https://github.com/enlightware/simple-async-local-executor
Granted, in practice many libraries end up building a hard dependency on tokio
Tangential but the slab concept is actually really neat, basically a userland allocator for a specific type, or a vector that gives you the key inserted at depending on how you prefer to think. https://docs.rs/slab/.
The parking lot concept is also really neat! The premise is that a program with lots of mutexes doesn't actually need to allocate lots of blocking queues, because the number of simultaneous waiters is bounded by the number of threads.
I don't think it's a coincidence that both of the examples here are actually quite reasonable as standalone crates. They're important abstractions, but just a little bit too niche to justify a place in the standard library. That said, I agree that learning what all these crates mean is daunting, and that finding ways to make that easier somehow would be valuable.
> just a little bit too niche to justify a place in the standard library
parking_lot has actually been considered for inclusion in libstd[0], as a better alternative to the native mutex implementation (parking_lot mutex are smaller, can be const-initialized, can be moved, and tend to perform better - all while not depending on external, non-rust code). The effort is currently on hold, but it's possible that it will resume in the future.
[0]: https://github.com/rust-lang/rust/pull/56410
parking_lot is a mutex. slab is a slab allocator. It seems to me that in a C or C++ project you’d have everyone writing their own rather than a shared project that everyone depends on.
Personally I have much more confidence in the community de facto standard implementation of something than I do in the half-baked version that someone writes themselves to avoid pulling in a dependency.
Personally I think it's a good thing when my hello world program is vulnerable to 65 different supply chain attacks.
Not sure why a C project would do that from scratch. libc includes that functionality, so you already get it for free in any POSIX system. In Windows, I'm sure the system C library provides the same stuff.
Perhaps not those things, but I see lots of C projects with custom hash map implementations, etc.
I don't think parking_lot is a mutex. It is more similar to a futex, i.e. an ephemeral wait queue. You can use it to implement a mutex, but that's just one use case.
There's an interesting inversion in people's expectations between compiled programs and source. If it's compiled, it should do one thing and do it well. If it's a source package, it should implement as much as it can and use external packages as little as possible.
All of this because the c++ devs who wrote rust do not realize that having a big and well conceived stdlib helps a ton (go is a great example).
Meh. When rust was designed it didn't make a distinction between blocking and non-blocking functions (here, I mean having a syscall which may yield to the OS scheduler, such as `read`). Together with threads, this let users enjoy the traditional kernel-space concurrency model seamlessly.
When async got added, this simplification stood in conflict with the new explicit user-space scheduler (reactor-executor in Rust terms). Today, std is a minefield within an async app — your program may deadlock non-deterministically — without as much as a compiler warning. Every project member needs to worry about intricate details of different types of mutices and which std::fs APIs are kosher, and so on.
I would have preferred a plug-in system where you could use the same application code with different scheduling backends - threaded by default, the M:N model or even the M:1 model if you're feeling web scale. This way, code reuse would have been a breeze.
What we got was a mess of application code where the compatibility matrix (what code is compatible with what runtime) is not clear, enforced or even well documented. Even the proponents of the current model acknowledge that the current ecosystem is fragmented.
> your program may deadlock non-deterministically
This was always true, even prior to async. Rust's thread safety features have never prevented deadlocks. They prevent races, but not deadlocks. RAII makes some patterns that can cause deadlock more avoidable. Nothing in Rust prevents you from spawning a thread that locks a mutex and then never gives it up.
Ah, yes, absolutely! I should have been clear that async caused a fire sale of many more footguns, that didn't exist previously.
I have several issues with Rust async and prefer to stay as far from it as possible, but "colors" is not one of them. The article correctly mentions that we can view other function properties, like fallibility, as "colors". For a language like Rust it's important to expose such properties (similarly to how it relies on Result instead of using exceptions) since FSM and sequential code are quite different from each other. If anything, I would say that Rust does not go far enough to expose such properties. I would love to have compiler-enforced properties like "can not not panic", "execution takes bounded time", "uses bounded amount of stack space", "does not use allocations", etc. Some of those properties could've been useful for async code as well and the bounded stack usage is in a certain sense already implicitly part of async.
One of my issues is that Rust continues to pile ad hoc solutions, without looking for more fundamental approaches. For example, in the async case, it was rushed in the current form instead of properly solving generators and linear types. Yes, it's probably helped with the language adoption, but it's a short-term win at the expense of the longer-term language health.
> it's a short-term win at the expense of the longer-term language health.
this is how we get the mess that is C++
> it was rushed in the current form
Are you the one withoutboats always chimes in here to correct by pointing out that there was actually tons of discussion and alternative designs evaluated? I seem to remember the last time rust async was discussed here the people who actually worked on the feature being pretty peeved by people claiming this.
Always? If you mean https://news.ycombinator.com/item?id=26406989#26407395, then I don't remember repeating this discussion with him on HN or anywhere else. He took it really personally, so I don't see a point in rehashing it. I stand by my words behind the link and I don't think that I stand "corrected". You can read the HN discussion and github and IRLO discussions linked in it to form your own opinion.
I'm one of the people always critiquing the use of function coloring[0] to do async programming in most languages and defending the Go model as The Way.
However, I totally get why Rust chose to go down that path. It's the way to go if you want to have zero-cost abstractions, even if it's a PITA. And since you're programming in Rust, you've already accepted that PITA's in the name of performance and low-level access are occasionally alright.
[0]:https://news.ycombinator.com/item?id=27545031
Seconded. If you're shipping a language with a meaty runtime, then save your users the headache and just include built-in support for green threads; your runtime probably already takes care of things like garbage collection and your users are fine with it, so they're unlikely to complain about not being able to squeeze out every last bit of single-threaded performance. But there are certainly niches for languages where users do expect to be able to squeeze out those last drops of performance, and Rust inhabits that niche. Different intended contexts can call for different approaches.
However rust is so hairy that it’s damn slow to develop in. You would expect database engine devs would pick rust to get those last drops of performance.
Yet there is no production quality storage engine written in rust, whereas there are several of those in go.
Ergonomics matter. Iteration time too. Rust fails hard there.
I wouldn't call the coloring "a good thing". I would call it "an acceptable compromise". It would be better if you didn't have to label it and it all Just Worked, just like it would be better if you didn't have to annotate lifetimes and it all Just Worked, but for the space Rust is operating in, it is an acceptable compromise. Rust sits high on the "force programmer to put more effort in, give programmer more out in return", on a pretty nice cost/benefit tradeoff place in that space. But the fact you get benefits commensurate with the costs doesn't mean it's not a cost at all, and it's probably not good to get into the habit of conflating costs and benefits. Clarity of view of both sides of the balance is very important for engineers.
What would be "just working" in your mind WRT promise, unwrap and thread context semantics?
Sugar around thread yield, unwrap and context return? That seems like a lot of possible gotchas to simply be hidden and lurking under every benign looking synchronous method.
Remember that the point of async/await is cooperative threading and not pre-emption. Hiding these details makes it much harder to reason about. If you don't know when your execution will be yielded, you're essentially being pre-empted, no?
Sure, you can be preempted by another thread (or even process) even within an async function, so why should it matter? The borrow checker should still prevent anything bad from happening.
I'm not a rust programmer, but personally I think that stackless coroutines are probably a necessary evil in system languages as full stack switching might have too much overhead. But let's stop pretending that it is actually a good thing.
Still I think that even with stackless coroutines await should be the default (the async keyword would be used if you explicitly want the promise), which, in addition to better ergonomics, would allow the async-ness of a function to be inferred, possibly as a function of its type parameters. This at the very least would avoid duplicating a lot of functionality.
>Sure, you can be preempted by another thread (or even process) even within an async function, so why should it matter?
You misunderstand. Its not about whether your thread can be pre-empted, its that your logical execution can hold exclusive access to a thread. There are common design patterns that use exclusive threads as a means of synchronization.
I understand. What I'm saying is that marking async points explicitly is neither necessary nor sufficient to guarantee exclusive use of the thread.
The borrow checker or some other explicit critical section markers are a better soluiton.
> Sure, you can be preempted by another thread (or even process) even within an async function
That really depends on the platform. Async rust is also being leveraged to build RTOS on embedded platform for instance. If async rust was preemptive by default, that would be impossible. It's not a matter of performance there, but of correctness.
It would actually be very hard to build a RTOS using async functions - and the current use of async/await on embedded Rust is indeed not about RTOS, it's just embedded code.
The reason for that is that the point of a RTOS is providing guarantees: A certain task should be guaranteed to run within a certain time. To provide that guarantees, the RTOS needs to support preemption and prioritization. That allows it to schedule a certain task even if another task is running for too long. I'm not aware about any RTOS which makes use of cooperative multitasking.
> I think that stackless coroutines are probably a necessary evil in system languages as full stack switching might have too much overhead.
I think it's worth noting that the big operating system kernels - perhaps the ultimate target of "system languages" - tend to use stackful coroutines rather than stackless.
Stack switching tends to be similar speed overhead than stackless, despite surface appearances in how the code may appear. Stackless tends to do more memory allocation and freeing, though.
Sure, but the perceived advantage of stackless coroutines is not faster switching time, but lower peak memory usage. OSs do not run 100s of thousands of tasks as threads, and when they do they use hand written state machines.
Green threads, like Go has, is one good answer. You don't run around annotating what is and is not async. It just works.
Even full OS threads are an acceptable answer for the vast majority of applications. It is good and proper for embedded programmers and OS designers and such to have options as powerful as Rust, but that does not mean the average program written in Rust is embedded, an OS, or needs to be able to scale to hundreds of thousands of threads.
(I actually think reaching straight for async/await is a mistake, personally. It's a tool for more extreme situations, not something you reach for when you're writing a program that's going to intermittently consume a fraction of a percent of CPU, or run a quick job where it may need maybe 10 threads if that and terminate. A lot of people badly overestimate the resources they need and jump straight for the extra-mega power tools for problems where they are literally a dozen orders of various magnitudes (CPU, RAM, etc.) away from needing them for their task.)
(It is extra ironic to be terrified of threads when working in a language like Rust that has so thoroughly tamed them!)
Yes, there are compromises, so you get the flip side of what I said above: Just because something has costs doesn't mean it doesn't have benefits. It is a benefit that you don't have to annotate everything yourself and the compiler and runtime just takes care of it.
"Remember that the point of async/await is cooperative threading and not pre-emption."
I disagree. Cooperative threading is a disaster. It doesn't scale. We tried it, very very hard, with an entire major consumer OS for decades. The point of async/await is precisely to get something like pre-emption into something that isn't using full, real threads (as its primary organization). That it functions like cooperative threading is one of the disadvantages that happens to be an acceptable compromise at most scales, not a goal.
>Cooperative threading is a disaster. It doesn't scale
It doesn't need to scale because it solves a different problem. It find extreme success in UI and graphics programming where UI systems are dominated by a single UI thread for synchronization. GPU have been single threaded until recently.
You can argue you do not like it but I can't see how you can argue against it's success.
That said, I'm pretty interested to see a popular multithreaded/pre-emptive UI framework in the wild.
> It would be better if you didn't have to label it and it all Just Worked
However, this is not possible. When you have concurrent code, whether you make it obvious or hide it, you have contention over any shared mutable state.
async/await is meant to have the pieces with contention be cooperatively multitasked, and the yielding of control is explicit so that you can understand that the state going into the call may be different than the state once you come out - any number of unrelated things may have run in between when you awaited and when control returned.
Not having coloring means that you have turned an explicit cooperative system to an implicit or even preemptive system. There are definite advantages and disadvantages to both, with the difficulty in tracking down and debugging concurrency issues in implicit/premptive systems being the reason that there has been so much research into other approaches such as async/await, actors, and structured concurrency.
Contention over mutable state is orthogonal to async/await in Rust. Proof is that they had the contention problem solved prior to async/await. Pure functional languages also solve the contention problem by simply destroying mutable state, without async labelling. A brute force solution, but a solution. async/await doesn't solve the problem of things possibly mutating things when you weren't running; you've turned over the thread of control, if your language permits mutation it can happen without you knowing. Rust solves that in other ways that have nothing to do with async/await, and its solution works with preemptive threading.
Rust has been able to thread since long before async/await was introduced. It was introduced because for some of the use cases of Rust the overhead was unacceptable, not because prior to async you had no options at all to have that sort of functionality.
Couldn't agree more with everything you have written.
What I don't understand is why these functions must be declared async at the function declaration site. Why not allow the programmer to run any expression asynchronously, which wraps the return value of the expression in a future or promise or what have you?
You'd essentially be creating a very ergonomical shortcut to fork-join multithreading which is infinitely more powerful and expressive than whatever this "async function" stuff is.
ETA: perhaps it would be a good idea to have a thread-unsafe keyword, though I'm not sure if rust needs that since rust has a really stringent resource pointer/reference aliasing guarantee.
ETA2: I misunderstood async semantics for rust, it's not a fork/join but send/poll. In any case, I still think you should be able to declare async at the call site, not the function site. That doesn't seem to clash.
> You'd essentially be creating a very ergonomical shortcut to fork-join multithreading which is infinitely more powerful and expressive than whatever this "async function" stuff is.
But that would require starting other threads, which requires everything to be `Send + Sync`, has the overhead of multithreading, and can't be managed with any sort of scheduler (without platform-specific hacks). Unless you use green threads, and that's discussed in the article.
So what then is async? If there is no scheduling/preemption in executing an "async" function, it's not really "async", but rather synchronous. Is async just a new way to say "instruction reordering"? Or is async really just "*defer* until the last possible moment: await"?
edit to match yours: not sure what a "green" thread is.
In any case, if threads are not an option, an async may always be ignorable, after all, a program may not rely on the order of the execution of its threads/its preemption; that would be a race condition. Textbook definition.
As I see it, the fork/join model is very useful for "shingling" code paths that don't have to happen in sequence, but _could_ happen in parallel.
Final edit: nothing is otherwise stopping the implementer of async to add a pre-started threadpool to the runtime, with whatever configurable parameters one would want.
Post final edit: I think fork/join vs send/sync are just implementation details. But I respect Rust for going with send/sync, just that it doesn't change all that much in terms of the programming interface.
Async is running many tasks on many less threads, using polling.
See https://rust-lang.github.io/async-book/01_getting_started/02...
(but note that this doesn't mean busy-loop polling, while that will be used in "my first executor" tutorials, production executors don't call a future until it's ready to have more progress made via an event, often from an epoll/kqueue/iocp or similar mechanism.)
this sounds like a lot of overhead, though
Rather, Rust just makes this overhead explicit. Go, Node, etc do the same thing, but just hide what actually happens.
It is, relative to not doing it. But it's quite a lot less overhead than a native thread.
As opposed to what?
It's "compile this as a state machine such that when other futures are waited on internally by this function, store stack state in an anonymous struct that implements Future + Poll, and allow repumping when that Poll implemention says it's ready. This allows you to reuse the same stack when this async function is blocked"
Wrapping sync in async is usually not so hard but usually an anti-pattern. In C# for example, if that synchronous work is long running, it might consume a thread in the shared thread pool and have side effects (starvation) elsewhere in the application.
I don't understand what you mean. What is to say that an async-fn is not long running? It can just as well consume a thread, right?
I may not follow you - but to expand what _(i believe)_ the GP comment said; any long running code - say a loop that spins for an hour, is bad in the async context, because that async thread is part of the runtime. Better to take those special cases and let the executor move the computation to a dedicated thread.
At my work i mix a lot of compute heavy work in an async context, and if i let this compute heavy stuff run on my async threads i'd potentially starve my runtime. Tasks wouldn't get woken up on time, etc.
right, but this isn't an argument against allowing arbitrary async expressions. I can make an async-fn that runs an infinite loop just as easy.
That's the point though - anything blocking the async thread is bad. If your loop is going to run for an hour, you'd put arbitrary yield points in it so that the executor can reliably call other tasks
Well I can only speak to C# really but...
The preferred pattern is to avoid long running tasks in the default thread pool as it's a shared resource. Long running tasks should be executed somewhere else, either some new thread pool or just a new thread or just don't use async and make it your caller's problem. The point is that you don't want to consume the limited shared resource.
In your case, what would happen? Would a new thread be created? Is it ok to use the default pool? Did the synchronous method writer even consider this? It's hard to say.
Now, does this need to be enforced by the language? No. Developers could simply append "_Async" to make it clear a method can be put on the default thread pool or not. That's still a form a coloring though. Even if you consider it a choice by the caller, it's still a color that needs to be determined and you never want to exhaust your default thread pool.
That said, there are other reasons why calling an async method should have different semantics than calling a normal sync method. Just saying that the "can use shared threads" aspect of async methods can probably live as a convention.
async things are (generally) cooperatively executed/scheduled/etc. they choose when to give up control and allow other things to run (`await x`). this is in contrast to threads / processes, which are done preemptively - the OS can say "stop doing that, do this", swap out the memory, and your thread basically can't do anything about it. (which is why there are thread priorities)
normal synchronous code never gives up control until it's done. so it'll block the cooperative/async scheduler until it finishes, whether it's maxing out the CPU or just waiting idly for a network request to respond. if there's anything else happening on that scheduler, it has to wait, which can lead to rather terrible behavior.
and last but not least: you generally cannot move async code between threads. async tends to imply you want same-thread concurrency guarantees, to avoid the cost of cross-thread mutexes and whatnot. so while you could in theory just interrupt an async scheduler and move it to a new thread, in practice that's not usually safe, so it isn't done.
you could build a system that allows jumping between threads though - that's essentially what green threaded languages do under the hood (like Go). which gives you a middle-ground of "nice thread-like semantics" but "nice async performance", but neither quite as good as a pure system in sometimes-important edge cases.
---
all that said, yes, you can just dedicate a thread to a single scheduler, run a synchronous func on it, and it has practically no additional cost over using a normal thread. but you have to choose to do that, it won't happen by default.
> I still think you should be able to declare async at the call site, not the function site
This is the big point. I'd love to see anyone put forth a concrete, valid reason why this couldn't be implemented, because as it stands, it appears that the function coloring problem is exclusively due to poor language design, rather than a technical limitation (where an example of a technical limitation would be escape analysis, which is hard).
I think the CPS attempt in Nim could do this
https://github.com/nim-works/cps
Explicit await and promise semantics are useful incase you want to, say, start multiple async calls instead of awaiting one and then the other. But ok, lets say you just want await to be implicit in some cases...
Do you actually want this? Currently, you can assume that a function will not change threads mid execution, and that that thread will also not be used to execute other things. Implicit yielding breaks that assumption. Cooperative multithreading becomes impossible because it's much hard to reason about when a method call will yield execution or not.
I wasn't proposing implicit async/await. I was proposing declaring those at the call site, instead of per function declaration.
Ironically escape analysis is the one thing Rust went above and beyond to solve in a novel way.
No, Rust did not solve escape analysis - it was designed to constrain valid programs to those for which escape analysis is trivial (as evidenced by unsafe Rust, which doesn't have those constraints, and yet has nothing else enabling escape analysis). That's like saying that "C solved type inference" - no, it just constrained you to write your types by hand, and then did some checking of them afterward.
This is possible pretty much with the syntax you wrote: https://play.rust-lang.org/?version=stable&mode=debug&editio...
Of course you need a runtime to drive that future forward, so it's more:
[edit: or if main() is async, I'm not sure if that's what you want to avoid: `let result = future.await;` https://play.rust-lang.org/?version=stable&mode=debug&editio...]
The reason to mark a function as async (in today's Rust) is that you want to `await` something within it.
If a function does this, it must be async- there's no other way to implement it. If a function does not do this, there is no reason to make it async in the first place- it will never suspend. (Things get uglier when it may or may not need to await something, e.g. if it accepts a closure parameter. Rust doesn't really handle this today, but one approach would be to make the function polymorphic over async-ness- this is similar to but not really the same as what it sounds like you're talking about.)
Async functions are for language-level cooperative multitasking, not concurrency per se. The difference between fork/join (what you imagined async to be) and what it actually is, is more than an implementation detail- it's a semantic change to how the program behaves. If you want to make something happen in the background at the call site, there are already ways to do that, they're just not spelled "async fn."
But what's the difference between an async function returning int, and a function returning `impl Future<int>`? If there's no difference why do we need the async keyword?
Other parts of the language are conditioned on the return type. For example the ? operator only works if the return type is Option or Result. It seems like `await` could inspect the return type in the same way.
> But what's the difference between an async function returning int, and a function returning `impl Future<int>`? If there's no difference why do we need the async keyword?
No difference, but while the feature was being explored early users found it simpler if they didn't need to think about lifetimes on the returned future. Sometimes sugar is just sugar; same reason that `for` loops exist instead of just using `while`.
You can't await inside of a function returning impl Future<Int>. (You can inside an async block inside one, but there you go with language-level support again.)
You need the collaboration with the compiler to make it all work well. And that means keywords and language-level infrastructure. We tried no sugar, and it was rough: http://aturon.github.io/tech/2018/04/24/async-borrowing/
(And a proc macro didn't work out either)
The difference between `await` and `?` is that the former completely transforms the entire function body containing it, while `?` just conditionally returns from it. (There's less of a difference to the caller of the function, where both `async` and `impl Future` give you a `Future` object.)
A normal function returning `impl Future` has normal synchronous control flow, cannot ever use `await`, and then eventually returns a value whose type implements the Future trait. You can't just return an int, because ints don't implement `Future`- you have to return something that is `poll`-able.
On the other hand, an `async fn` merely looks like it has normal synchronous control flow, with some `await`s sprinkled in. But when you call it, none of that code runs- instead it immediately constructs and returns a value (of anonymous type, similar to a closure) whose type implements `Future`. The code written in the body has its control flow ripped apart at `await` points, much like a CPS transform, and it goes in the `poll` method of that value's `Future` impl.
It's technically possible that the language could have been designed the way you describe- if an `await` is present, that transformation happens, otherwise it doesn't. But because of the nature of the `await` operator, this is much messier than `?`. For example, when the transformation happens, you need to be able to return an `int`, and when it doesn't you must return an `impl Future`, unlike `?` where you always have to return a `Result` or `Option` regardless of your use of `?`. And you wouldn't be able to tell at a glance whether the function body runs immediately when it's called, or whether it waits for the Future to be `poll`ed. Plus, the language also has `async { .. }` blocks that do the same transformation in an expression context, so it's kind of nice to be consistent with that.
The answer is actually pretty dumb: functions are declared async at the declaration site because that's how C# supported legacy code.
Introducing a new keyword is a breaking change because existing code may have a variable with the same name. So `await` is a conditional keyword in C#, conditioned on whether the function is marked `async`. And other languages have since just copied that choice.
https://dotnetfiddle.net/JUE0Sy
That may have been C#'s rationale, but it wasn't Rust's rationale. Rust doesn't even strictly require the `async` keyword in a signature to define functions that work with `await`; `await` works by polling a future, and `async fn` is just sugar for defining a function that returns a future. In fact Rust does precisely what the parent suggests, and also offers `async {}` blocks as sugar for defining inline futures:
Neat, I didn't know about the `async {}` feature.
I think it's obvious that Rust chose `async fn` for familiarity with other languages. As you say `async` is just sugar on the return type, so logically it should decorate that type, not the fn keyword. "async fn" is even misleading since the function itself returns synchronously (and immediately): it's the returned value where the async-ness lies.
So I think Rust's use and placement of `async` is for familiarity, which traces back to C#'s original choice. Not to say that's a bad rationale.
Admittedly I didn't follow any of the Rust async design discussions so I could be completely wrong about all of it.
> "async fn" is even misleading since the function itself returns synchronously (and immediately): it's the returned value where the async-ness lies.
You've got the causality backwards- it's the async-ness that causes it to return immediately. Async is primarily about the execution model of the entire function, and the return type is only changed in service of that model.
The course of the design discussions started without async or await, using library-based Future combinators. Async was introduced as a way to desugar control flow and move knowledge of Futures into the compiler for borrow checking reasons, not out of any intention to mimic C#.
You're quite correct, I hadn't thought of the fact that the transform and the return type were distinct. My mistake.
I think at this point the metaphor of "function coloring" has been overused, and we should use the real name: monadic async. The problem is obvious when you look at what a monad is: you have lots of ways in, but no way out. So every monad has to offer their specific way out, if they can. With a Option<T>, you can extract the value inside your function with unwrap. Your function will still be a regular function. With async, you can do the same with await. However, await can only be used inside async functions. That's the trap right there. The way to get out of the async monad is only usable inside the async monad.
> In both these cases, it’s like the meaning of having one statement come after another has changed: ; itself has been overriden.
I don't agree with this analogy. With Result, it's ?; – with async, it's .await; . That's not overriding or overloading; it's explicit.
I don't agree with the article that the idea of colored functions is tied to dynamic types. I think it applies equally well to Dart, TypeScript, C#, and any other language considering async await. It's mostly a question of usability and the ability to encapsulate use of asynchronous operations.
But for Rust, I think having colored functions is probably the right call. Asynchronous operations are fundamentally different at the OS/machine level. Since Rust is a language where code deliberately has very high affinity to what the underlying system is doing, it makes sense to make that distinction visible to users even if it means that it's harder to write and reuse asynchronous code.
This is somewhat covered by other comments, but it's not made explicit enough.
Is async-style the best possible tradeoff to achieve the best result when programming with blocking calls?
The fact that you should not be applying the same approach to a very similar problem (blocking CPU-bound code) kinda sucks, which means that you need to be employing multiple concurrency approaches in the same program.
I am all for syntactic sugar to make concurrency easier and less error prone, but I'd prefer it if the same approach trivially worked for all sorts of concurrent functions without heavy penalties to execution.
It depends on what you mean with „best result“. Async functions in Rust are not about improving ergonomics compared to classical threading. They are strictly worse. They are a tool which can be used to lower the amount of OS threads used by a n application- by multiplexing multiple logical tasks in one thread. Callbacks and state machines are other options for that - but those are even harder to get right. If your application requires that from an efficiency point of view async programming might be a good option. If not threads are fine.
Sure, the definition of the "best result" is the question.
In short, "the best approach" to me is one that:
1. Is suitable for all sorts of concurrency
2. Has a low memory overhead and low switching costs like the async functions today
3. Is the most "ergonomic" (leading to fewest bugs, easiest debugging and easiest maintenance): yeah, this one is not too well defined either, which is why I would measure developer satisfaction and things like defect rates to establish which one is it, not that this makes it any easier to define :)
4. Provides "enough" safety and encapsulation without impacting performance (much)
If 4 has a huge cost, perhaps go with special-cased "async isolated" concurrency primitives when you need added safety in addition to "async", and if the cost is low, allow not paying it anyway with the inverse ("async unsafe"): basically, different defaults depending on what's realistically achievable and nature of the language.
I think that ideal lies somewhere near the "non-cooperative multitasking" async programming approach, but I am not sure, and I haven't played with enough recent developments in programming languages either to know if any one of them gets close.
But having thought through it a bit with this comment, I think my main gripe is how your (developer's) decision of the concurrency tool (threads, async/await, callbacks, green threads...) affects your syntax simultaneously (eg. why should awaiting a compute-intensive task be problematic? that's an implementation detail a developer should not have to worry about if all else is equal).
Seems like a bit of a stretch to say that anything that's part of a method signature is the same as a "color" as it was described in the original article. If a method takes something by value, that doesn't necessarily mean that attribute needs to propagate up the call chain until the literal origin of the process. In fact, in my experience, it _very_ rarely does. Async does, however, always. That's the whole point.
The key part with the "monad" bit at the end is we have monad polymoirphism allready, e.g..
foo :: Monad m => m ()
in Haskell. If that goes to rust, maybe we can have "async polymorphism". This, and the far more easier "mut polymorphism" (no more as as_mut boilerplate) means we get best of both worlds: color and code reuse!
The biggest problem with Rust async is the dependency mess. All the different runtimes, with some projects dragging in one and some dragging in another, is crazy. If you aren’t careful you end up with multiple runtimes in the same binary. That is what needs to be fixed more than anything else.
As far as the complexity goes: it’s a systems language without a true “fat” runtime like C# or Go. You can’t do the sorts of pretty abstractions you can do in those languages. You can’t hide the details very much. Rust async is syntactic sugar around what would be much more verbose nested callback patterns. It hides that nastiness but you still need to have a mental model of what is actually going on.
I'm not a software engineer, but I recall that in the days before abstraction layers there was a tactic called a vectored interrupt. When some device needed attention, it tapped the CPU on the shoulder and got it. I don't understand why nowadays with 4- or more core machines, servicing an interrupt isn't easier. I'm naive enough to think that sounds like an architecture problem.
I think there’s a good argument that async is decent for performance critical languages, e.g C++ and Rust, and for languages looking to model effects, e.g Haskell and arguably Rust. I don’t see a good reason for it in mainstream languages like Java, JavaScript and C#.
I think Java’s approach with Loom is going to be a big win over C# there, as someone that just wants to get stuff done and is a fan of both.
I'm not sure if Loom will be successful. It does not fit the ecosystem. Thus a lot of java async stuff needs a lot of rewriting, this can either disrupt the ecosystem or split it. at least on the library level.
I actually feel it would be successful because it exactly fits the ecosystem. A lot of Java code is classical threaded code. E.g. the majority of Java servlet code, and older web frameworks. Those would all immediately benefit from Loom in terms of resource utilization and scalability.
Of course Java also has some frameworks like Netty and things built on top of it - which won't benefit. But I feel like even though those are great from a performance point of view, they are actually more niche in the overall java world.
Here's my (oversimplified) understanding of async: It's functions with yield points, at which other code can be run until execution returns to the function. Then, isn't it almost trivial to call an async function from a synchronous context? Just skip over the yield points?
It depends on the context of the yield but assuming the caller and callee contexts can be the same, most async/await implementations will not actually yield until some kind of thread sleep or explicit yield is hit. So yes, this happens in most languages I'm aware of.
Call me weird, but I'll take callbacks any day over async await.
If it goes too many levels deep, we have to ask what we are trying to achieve and perhaps consider a better approach (a specialized state machine? threads?)
> let's just agree to call this insane malpractice "rusty" and it suddenly becomes okay
You're not making the case here, guys.
tangent: why is the basis of most async functionality a form of polling underneath?
Why are interrupts not used? or are they?
source: epoll, select(bsd at least), isn't kqueue also like a multiplexed epoll?
interrupt-like things are used to schedule the next poll of the function. Polling doesn't happen randomly or within certain time intervals, but when an external event (via a Waker interface) tells the runtime to schedule the function again for polling.