8 ms·
Genuine question: can anyone comment on which alternatives to async/await were considered? And if so why they were rejected? It’s proliferation puzzles me: c#,
by spinningslate 7y ago
Genuine question: can anyone comment on which alternatives to async/await were considered? And if so why they were rejected?
It’s proliferation puzzles me: c#, python, node, rust and no doubt others. Yet it seems like a poor abstraction.
1. The cognitive overhead. Whilst the example might be intended to show off power, that function signature is not at all easy to parse mentally. Even allowing for generics it’s still a lot more difficult to think of a function that returns a future that will return the result at some point (as opposed to a function that returns a result synchronously).
2. It’s declared at the call site - not by the caller. As the writer of a function, how am I supposed to know whether the majority of callers will get sufficient benefit to offset the increased cognitive overhead? It can also have viral tendencies: a function that calls an async function is async itself, and so on. (Not sure if that’s mandatory in all language implementations). I’ve seen more than one code base where async was like a virulent rash.
Personally I find Actor model concurrency so much easier to deal with (e.g. Erlang). The rules seem significantly more straightforward and easier to deal with mentally.
Data flow concurrency (e.g. FRP) also seems much easier to reason about.
Async/await by comparison seems like a poor substitute. So I’m puzzled but intrigued by its growing popularity.
I can imagine it’s easier to implement in the language (compiler/runtime), which might be key for rust. I can also see that it’s an incremental step for procedural languages. But then surely it’s a wise investment to build good abstractions in the language - even if it’s (a lot) harder - rather than burden the programmer with more complexity?
What am I missing?
- steveklabnik 7y agoEverything was considered. This has been the most discussed Rust feature to date, but a serious margin. I'll try to give you some answers from my perspective; I am not on the language team, nor have I done any of this work myself, but this is what I remember, and see from the outside. The short answer is: it's the only way to accomplish the task that fits into Rust's requirements. The top relevant ones are speed, lack of required runtime for non-async code, and zero-cost C interoperability. It sounds to me like you're coming at this from a "what is the simplest thing for a programmer to use" angle. That's a totally valid angle, but Rust is focused on ease of use only within the context of other requirements. Notably, Erlang's model, while it has excellent properties, falls down on all three of these other requirements. That's totally okay in the context of Erlang, but is not acceptable in the context of Rust.
- spinningslate 7y agoThanks. I suspected run-time constraints might be a reason so that makes a lot of sense. > Notably, Erlang's model, while it has excellent properties, falls down on all three of these other requirements. Being nitpicky, but I'd argue Erlang only falls down on speed where it's computationally bound. Where it's IO bound (e.g. a prototypical chat server) Erlang has excellent speed characteristics - where speed is measured in concurrency and latency. But I absolutely get that's not the interpretation of "speed" you meant. Appreciate the explanation.
- steveklabnik 7y agoWhile it is true that I'm mostly talking about computation speed, that's not the only thing. It is true that Erlang is good at this, but I'd like to see some real numbers here. In benchmarks, this is almost never the case. For example, if you take a look at techempower's plaintext benchmark, which should purely be about IO: https://www.techempower.com/benchmarks/#section=data-r18&hw=ph&test=plaintext https://www.techempower.com/benchmarks/#section=data-r18&hw=... Phoenix, which is in Elixir, does 2.7% of the requests of the Rust frameworks. Chicago Boss, in Erlang, does 0.2%. Is this a realistic benchmark? I don't know; I can't say if the code for either of these implementation is good or not. But if it's a silly error, I think it would be good for Elixir/Erlang advocates to fix this. Early Rust implementations had extremely poor showings too. I tend to think of Erlang as being extremely reliable, with acceptable performance, rather than being the fastest IO language in the West.
- di4na 7y agoAs an admin of the elixir slack, i have to handle that one a lot. It is not really realistic at all no. But mostly because the benchmark is not realistic of a production load. That said we should do better. The main reason it is not fixed is that the few members of the community that tried to fix it had a really bad experience interacting with the repo and getting feedback of impact and simply lost interest. And we have not have people motivated enough so far because they do not really care about the benchmark. Ofc it matters to people that are not in the community but well... That said, yes we would never be close to Rust anyway (probably) but we could probably have a far better standard deviation than Rust. Something that tend to matter in production a lot but people forget reading benchmark is the spread of the response time vs the average one. We should also perform better in a noisy environment like the "cloud" one. There are hopes that the recent EEF marketing group could help find volunteers or finance someone to fix it.
- fzzzy 7y agoI think the primary consideration is interoperability with C. Being able to call into C from Rust and into Rust from C is an important feature. Systems with a heavy runtime and special stack layout like Erlang make this harder. Rust originally had a heavier runtime with a green thread implementation, and it was removed. I believe one of the major considerations at the time was bare metal programming on microcontrollers.
- heavenlyblue 7y agoAsync seems virulent. But a few days ago I was writing a small TCP app, and I could not find the timeout argument to Python’s asyncio open_connection. Then I realised I didn’t need it: I could simply create more complex behaviour by combining async primitives. I don’t even want to imagine how much code I’d need to write in order to implement something like that thread-wise (given that a library simply forgot to add it).
- zellyn 7y agoA couple of random thoughts: 1. If it's like Javascript, async and await basically boil down to converting to and from Promises. There's no reason to have to transitively perpetuate `async` annotations: you can just deal directly with a completely normal Promise object instead. (But you soon find you like `async` better :-)) 2. The “caller decides” model is actually considered good practice in Go: expose synchronous primitives, and let callers compose them with Goroutines if they want to.
- steveklabnik 7y ago> If it's like Javascript It is and it isn't. That is, > async and await basically boil down to converting to and from Promises In Rust, they basically boil down to converting to and from Futures. However, the major difference is that while in JavaScript, promises start running upon creation, in Rust, a Future doesn't start executing until you submit it to an executor. This has many implications...
- DSingularity 7y agoWhat’s the difference? Futures and promises come in pairs, right?
- steveklabnik 7y agoI'm not 100% sure what "come in pairs" means, exactly. Here's a sort of quick rundown. A Promise is in one of these states: pending, fulfilled, or rejected. When you create a promise, you set a callback function that is the "body" of the promise, and can transition it into the fulfilled or rejected state by calling either of two callbacks passed into it. When you create a Promise, it's in the pending state, but the runtime sets it up to be executed, and when a tick occurs, it will start executing automatically. In Rust, a Future is something with a poll method that returns either Pending or the final value. If you want JavaScript's behavior around failure, you make the final value a Result, it's not built-in. In Rust, unlike in JavaScript, a Future is inherently inert. Nothing happens until you actively call the poll method of said future. This means that, unlike in JavaScript, you have to either call poll yourself (not recommended) or pass it to something that will. We colloquially call those things "executors." This can lead to surprising behavior if you're used to the JavaScript model; many JS programmers coming to Rust will create a future, and then be surprised when their code compiles but doesn't work. It's usually that they forgot to submit things to an executor. In both cases, you can chain these things together, which is how they're usually used to produce some bigger computation. Okay, so from an interface perspective, that's the difference. But the difference goes a bit deeper than that. For example, since Rust futures are nested when you chain them, and because the language is always AOT compiled, a chain of Futures compiles down to a state machine with a perfectly sized stack. Whereas a Promise has a separate object for each step in the chain, with all of the excess allocations and such that implies. I am not 100% sure what the state of the art is in optimizing promises, but in Rust, you always get the most optimized thing, since it builds off of everything else in the language. Okay that's all I've got for now, this is already too long. This is a complex topic...
- pcwalton 7y agoWe already have the actor model in Rust, via threads. Some people want better performance than what threads can provide. M:N was tried in Rust and the performance was found to be insufficient (and there were other downsides too). So async/await was the next best option. If you want the actor model, you can use it! There is nothing stopping you from going thread-per-connection in Rust. It's quite fast on Linux for all but the most demanding workloads. If I were writing an ordinary network service expecting moderate traffic, I'd probably go thread-per-connection.
- tombert 7y agoI'm actually asking this sincerely; hasn't this experiment already been run with Apache vs. Nginx, with Nginx coming out literally orders of magnitude faster by using epoll and using a limited number of "real" threads?
- Scarbutt 7y agoMaybe, but for most websites Apache was never the bottleneck, it was more than enough, which is I think the point GP is making.
- markdog12 7y ago> I'm actually asking this sincerely Why do you have to say this?
- tombert 7y agoBecause on the internet it can be difficult to tell the difference between someone asking the question as some kind of "gotcha!" and between someone asking a legitimate question. I tend to be a fairly sarcastic person so when I want to make sure it's unambiguous that I'm not, I prefix my questions.
- pcwalton 7y agoThe performance differences between Apache and nginx are attributable to more than that one design decision. Besides, Apache is fast enough for most use cases.
- apta 7y ago> Personally I find Actor model concurrency so much easier to deal with (e.g. Erlang). The rules seem significantly more straightforward and easier to deal with mentally. I haven't used Erlang, but how does it deal with blocking calls? Say an actor decides to make a call to the DB. Will the runtime block the current thread the actor is running on? The other alternative to explicit async/await, which permeates the entire call stack as you pointed out, is to have green threads, like Java's upcoming fibers (see project Loom). The runtime automatically swaps out the current fiber once it makes a blocking call and switches in another non-blocking fiber to continue making progress.
- Polycryptus 7y agoErlang's VM runs a scheduler on top of a number of OS threads. If an actor blocks, that actor won't be scheduled to run until the blocking call is finished, but you won't consume a whole OS threads, and other actors will be able to run while waiting for completion.
- lmm 7y ago> 1. The cognitive overhead. Whilst the example might be intended to show off power, that function signature is not at all easy to parse mentally. Even allowing for generics it’s still a lot more difficult to think of a function that returns a future that will return the result at some point (as opposed to a function that returns a result synchronously). Disagree, because it's all compositional, which is the essence of taming complexity. You can understand what a Future is and then, separately, understand the thing the function is doing. It's similar to working with a container: a function that returns a list of results is more complex than a function that returns a single result, but it's tractable because you can understand the two aspects separately. > As the writer of a function, how am I supposed to know whether the majority of callers will get sufficient benefit to offset the increased cognitive overhead? If your function would need to wait, or calls an async function, it should be async; otherwise it should be sync. You need to define your threshold for "would need to wait" in your codebase - popular answers are any I/O, any network access, or any non-local network access - but you can answer that once and then the decision for any given function is pretty mechanical. Of course ideally you'd be able to write general-purpose functions generically such that the caller could use them as either sync or async; unfortunately Rust lacks higher-kinded types. > Personally I find Actor model concurrency so much easier to deal with (e.g. Erlang). The rules seem significantly more straightforward and easier to deal with mentally. Actors are far too unconstrained to be able to reason about a codebase that uses them. You send a message to an actor, and what does it do? Could be anything. Is it equivalent to this other message? Maybe! You didn't get a reply, has something gone wrong? Maybe! > Data flow concurrency (e.g. FRP) also seems much easier to reason about. What distinction are you drawing here? FRP seems like something you'd build on top of Futures as far as I can see?
- nullwasamistake 7y agoJava is attempting to avoid this complexity using Fibers like Go. It also makes most old school threaded code compatible without a bunch of work. Rust was originally going to use fiber threading. The reasons they didn't are lost to me and probably everyone with the amount of bikeshedding async await has seen
- 0815test 7y ago> Rust was originally going to use fiber threading. The reasons they didn't are lost to me Because it was found that you can just put this stuff in user-level libraries, it doesn't need to be part of the core language. This is what Tokio does, it provides a multi-threaded runtime that can be used together with Rust's async facilities. Romio is an experimental project which extends Tokio to work with the new async-await syntax and adds async-IO support, but this work will very likely be folded back into Tokio itself as soon as the underlying features reach stability.
- Footpost 7y agoIs there currently a reasonably mature Erlang-style threading library for Rust? Something that is to Rust what Akka is to Scala?
- steveklabnik 7y agoThere are people playing with them, but I don’t know how mature they are. Actix is one example.
- Footpost 7y agoWould you be able to venture a guess why they are not more popular? I agree that basing Rust's concurrency on an Erlang-like model would not have been a good idea, for reasons mentioned in this thread. Nevertheless the sheer convenience of Erlang-style concurrency would nevertheless be useful in quite a few scenarios where Rust is used.
- jayd16 7y agoThis is pure speculation on my part but... it seems to me that actors require a lot of immutable data sharing through message passing while Rust's whole raison d'etre is to safely share data access through intelligent pointer management and access rules. Seems like forced message passing would be out of place.
- devit 7y agoThe most efficient way of executing a non-I/O function is to just use the thread stack to store its data and optimize it for speed rather than for using little stack space. However, for functions that can block indefinitely on I/O, then such an approach would mean keeping an inefficiently-large stack around indefinitely. Hence, the better solution is to put the data on the heap, ideally in a manually engineered structure (i.e. "manual implementation of futures"), or for more ergonomics in one efficiently designed by the compiler (i.e. async/await).