Why asynchronous Rust doesn't work
theta.eu.org
theta.eu.org
Instead of carefully weighing advantages and disadvantages of both models, the decision was effectively made on the ground of "we want to ship async support as soon as possible" [1]. Unfortunately, because of this rush, Rust got stuck with a poll-based model with a whole bunch of problems without a clear solution in sight (async drop anyone?). And instead of a proper solution for self-referencing structs (yes, a really hard problem), we did end up with the hack-ish Pin solution, which has already caused a number of problems since stabilization and now may block enabling of noalias by default [2].
Many believe that Rust async story was unnecessarily rushed. While it may have helped to increase Rust adoption in the mid term, I believe it will cause serious issues in the longer term.
[1]: https://github.com/rust-lang/rust/issues/62149#issuecomment-... [2]: https://github.com/rust-lang/rust/issues/63818
So if in the future io-uring and friends become the standard can't that just be a library you could then use?
Similar to how in C you don't need the standard library to do threads or async.
For comparison, Haskell went with the library approach but has the syntactic sugar of the equivalent of `and_then` built into the language. (I am talking about Monads and do-notation.)
It's a bit like iterating in Python: for-loops are a convenient syntactic sugar to something that can be provided by a library.
It is a PhD level research problem to know if monads and do notation would be able to work in Rust. The people who are most qualified to look into it (incidentally: a lot of the same crew was who was working on async) believe that it may literally be impossible.
Application developers of course want much higher level interfaces. They don't want to do a series of reads; they want "fetch_url". But if "fetch_url" is the lowest-level API available, good luck implementing an efficient streaming media server. (Sometimes we end up with things like HTTP Live Streaming, a horrendously inefficient protocol designed for ease of use in programming environments, client- and server-side, that effectively only offer the equivalent of "fetch_url".)
Plus, IOCP models tend to heavily rely on callbacks and closures. And as demonstrated in the article, low-level languages suck at providing ergonomic first-class functions, especially if they lack GC. (It's a stretch to say that Rust even supports first-class functions.) If I were writing an asynchronous library in Rust, I'd do it the same way I'd do it in C--a low-level core that is non-blocking and stateful. For example, you repeatedly invoke something like "url_fetch_event", which returns a series of events (method, header, body chunk) or EAGAIN/EWOULDBLOCK. (It may not even pull from a socket directly, but rely on application to write source data into an internal buffer.) Then you can wrap that low-level core in progressively higher-level APIs, including alternative APIs suited to different async event models, as well as fully blocking interfaces. And if a high-level API isn't to some application developer's liking, they can create their own API around the low-level core API. This also permits easier cross-language integration. You can easily use such a low-level core API for bindings to Python, Lua, or even Go, including plugging into whatever event systems they offer, without losing functional utility.
It's the same principle with OS and systems language interfaces--you provide mechanisms that can be built upon. But so many Rust developers come from high-level application environments, including scripting language environments, where this composition discipline is less common and less relevant.
While perhaps higher level libraries are written that way, I can’t think of a reason why the primitive components of IOCP require callbacks and closures. The “poll for io-readiness and then issue non-blocking IO” and “issue async IO and then poll for completion” models can be implemented in a reactor pattern in a similar manner. It is just a question of whether the system call happens before or after the reactor loop.
EDIT: Reading some of the other comments and thinking a bit, one annoying thing about IOCP is the cancelation model. With polling IO readiness, it is really easy to cancel IO and close a socket: just unregister from epoll and close it. With IOCP, you will have to cancel the in-flight operation and wait for the completion notification to come in before you can close a socket (if I understand correctly).
Anyways, I've been playing around with implementing some async socket APIs on top of IOCP for Windows in Rust [1]. Getting the basic stuff working is relatively easy. Figuring our a cancellation model is going to be a bit difficult. And ultimately I think it would be cool if the threads polling the completion ports could directly execute the wakers in such a way that the future could be polled inline, but getting all the lifetimes right is making my head hurt.
I should have worded my message more carefully. Completion-based model is not a silver bullet which would magically solve all problems (though I think it would help a bit with the async Drop problem). The problem is that Rust async was rushed without careful deliberation, which causes a number of problems without a clear solution in sight.
As someone who observed the process, this couldn't be further from the truth. Just because one disagrees with the conclusion does not mean that the conclusion was made in haste or in ignorance.
> Without introducing Rust 2? Highly unlikely.
This is incorrect. async/await is a leaf node on the feature tree; it is supported by other language features, but does not support any others. Deprecating or removing it in favor of a replacement would not be traumatic for the language itself (less so for the third-party async/await ecosystem, of course). But this scenario is overly dramatic: the benefits of a completion-based model are not so clear-cut as to warrant such actions.
Believe me, I do understand the motivation behind the decision to push async stabilization in the developed form (at least I think I do). And I do not intend to argue in a bad faith. My point is that in my opinion the Rust team has chosen to get the mid-term boost of Rust popularity at the expense of the long-term Rust health.
Yes, you are correct that in theory it's possible to deprecate the current version of async. But as you note yourself, it's highly unlikely to happen since the current solution is "good enough".
I don't think a conscious decision of that sort was made? My impression is that at the time the road taken was understood to be the correct solution and not a compromise. Is that wrong?
The issues with Pin, problems around noalias, inability to design a proper async Drop solution, not a great compatibility with io-uring and IOCP. In my eyes they are indicators that Rust health in the async field has suffered.
>Completion based is totally better and the only reason it wasn't done was because it would take too long and Rust needed popularity soon
And who now puts words into other's mounts? Please see this comment: https://news.ycombinator.com/item?id=26407565
Despite your repeated statements that async has harmed Rust, I don't have any problem whatsoever day to day writing 10s of thousands of lines of async code with regards to what you've brought up.
Are we talking about the same Rust? I remember the debate and consideration over async was enormous and involved. It was practically the polar opposite of “without careful deliberation”.
[1] https://rust-lang.zulipchat.com/#narrow/stream/187312-wg-asy...
But I haven't touched IOCP in nearly 20 years and haven't gotten into io-uring yet, so maybe I'm missing something.
Really the biggest problem might be that switching out backends is currently very difficult in rust, even the 0.x to 1.x jump of tokio is painful. Switching from Async[Reader|Writer] to AsyncBuf[Reader|Writer] might be even harder.
However, I wouldn't call this a problem with a polling-based model.
At least part of the goal here must be to avoid allocations and reference counting. If you don't care about that, then the design could have been to 'just' pass around atomically-reference-counted buffers everywhere, including as the buffer arguments to AsyncRead/AsyncWrite. That would avoid the need for AsyncBufRead to be separate from AsyncRead. It wouldn't prevent some unidiomaticness from existing – you still couldn't, say, have an async function do a read into a Vec, because a Vec is not reference counted – but if the entire async ecosystem used reference counted buffers, the ergonomics would be pretty decent.
But we do care about avoiding allocations and reference counting, resulting in this problem. However, that means a completion-based model wouldn't really help, because a completion-based model essentially requires allocations and reference counting for the futures themselves.
To me, the question is whether Rust could have avoided this with a different polling-based model. It definitely could have avoided it with a model where the allocations for async functions are always managed by the system, just like the stacks used for regular functions are. But that would lose the elegance of async fns being 'just' a wrapper over a state machine. Perhaps, though, Rust could also have avoided it with just some tweaks to how Pin works [1]… but I am not sure whether this is actually viable. If it is, then that might be one motivation for eventually replacing Pin with a different construct, albeit a weak motivation by itself.
[1] https://www.reddit.com/r/rust/comments/dtfgsw/iou_rust_bindi...
Ie. to me, an implementation of read() that would work for a completion model could be basically:
async read<T: IntoSystemBufferSomehow>(&self, buf: T) -> Result<T, Error>
I recognize this doesn't resolve the early drop issues outlined, and it obviously does require copying to adapt it to the existing AsyncRead trait, or if you want to like.. update a buffer in an already allocated object. It's just what I would expect an api working against iocp to look like, and I feel like it avoids many of the issues you're talking about.http://concurrency.ch/Content/publications/Blaeser_Component...
Essentially each component has a buffered interface (an interface message queue), which static analysis sizes at compile time. This buffer can act as a daemon, ref counter, offline dropbox, cache, cancellation check, and can probably help with cycle checking.
Is this the sort of model which would be useful here?
Having investigated this myself, I would be very surprised to discover that it is.
The only viable solution to make AsyncRead zero cost for io-uring would be to have required futures to be polled to completion before they are dropped. So you can give up on select and most necessary concurrency primitives. You really want to be able to stop running futures you don't need, after all.
If you want the kernel to own the buffer, you should just let the kernel own the buffer. Therefore, AsyncBufRead. This will require the ecosystem to shift where the buffer is owned, of course, and that's a cost of moving to io-uring. Tough, but those are the cards we were dealt.
Having to wait for cancellation does sound expensive, especially if the end goal is to pervasively use APIs like io_uring where cancellation can be slow.
But then, in a typical use of select, you don't actually want to cancel the I/O operations represented by the other futures. Rather, you're running select in a loop in order to handle each completed operation as it comes.
So I think the endgame of this hypothetical world is to encourage having the actual I/O be initiated by a Future or Stream created outside the loop. Then within the loop you would poll on `&mut future` or `stream.next()`. This already exists and is already cheaper in some cases, but it would be significantly cheaper when the backend is io_uring.
Right. Which is more or less what the structured concurrency primitives in Kotlin, Trio, and soon Swift are doing.
You often do want to cancel them in some branches of the code that handles the result (for example, if they error). It indeed may be prohibitively expensive to wait until cancellation is complete - because io-uring cancellation requires a full round trip through the interface, the IORING_OP_ASYNC_CANCEL op is just a hint to the kernel to cancel any blocking work, you still have to wait to get a completion back before you know the kernel will not touch the buffer passed in.
And this doesn't even get into the much better buffer management strategies io-uring has baked into it, like registered buffers and buffer pre-allocation. I'm really skeptical of making those work with AsyncRead (now you need to define buffer types that deref to slices that are tracking these things independent of the IO object), but since AsyncBufRead lets the IO object own the buffer, it is trivial.
Moving the ecosystem that cares about io-uring to AsyncBufRead (a trait that already exists) and letting the low level IO code handle the buffer is a strictly better solution than requiring futures to run until they're fully, truly cancalled. Protocol libraries should already expose the ability to parse the protocol from an arbitrary stream of buffers, instead of directly owning an IO handle. I'm sure some libraries don't, but that's a mistake that this will course correct.
Yes, there is a fundamental difference between those models (otherwise we would not have two separate models).
In a poll-based model interactions between task and runtime look roughly like this:
- task to runtime: I want to read data on this file descriptor.
- runtime: FD is ready, I'll wake-up the task.
- task: great, FD is ready! I will read data from FD and then will process it.
While in a completion based model it looks roughly like this:
- task to runtime: I want data to be read into this buffer which is part of my state.
- runtime: the requested buffer is filled, I'll wake-up the task.
- task: great requested data is in the buffer! I can process it.
As you can see the primary difference is that in the latter model the buffer becomes "owned" by runtime/OS while task is suspended. It means that you can not simply drop a task if you no longer need its results, like Rust currently assumes. You have either wait for the data read request to complete or to (possibly asynchronously) request cancellation of this request. With the current Rust async if you want to integrate with io-uring you would have to use awkward buffers managed by runtime, instead of simple buffers which are part of the task state.
Even outside of integration with io-uring/IOCP we have use-cases which require async Drop and we currently don't have a good solution for it. So I don't think that the decision to allow dropping tasks without an explicit cancellation was a good one, even despite the convenience which it brings.
but I don't think you can easily move from affine types to linear types in the case of Rust, see leakpocalypse[1]
[1]: https://cglab.ca/~abeinges/blah/everyone-poops/#leakpocalyps...
Thank you for your contributions to the jailbreak community, it’s what got me started down the programming / tinkering path back in middle school and has significantly shaped the opportunities I have today. Can’t believe I’m at the point where I encountered you poking around the same threads on a forum... made my day! :)
You'd lose that bet: https://gankra.github.io/blah/linear-rust/
https://news.ycombinator.com/item?id=23579426
I continue to believe that the strongest point in that article is actually the third footnote, which correctly admits that this is mostly about a lack of appreciation.
> The Swift devs have basically the exact same argument for move-only code, and their implicit Copy bound. Hooray!
My claim here is that, given linear types, it should be trivial to use async/await style coroutines for I/O continuation. You have given no evidence against this idea.
I think this part of your comment is absolutely right and it's fatal to the argument that Rust made the wrong decision about I/O models for Rust. Maybe in the context of some other language, Rust's decision was not the best one, but not for Rust, because Rust just doesn't have linear types.
The problem here is not with Rust's async design. It's that Rust has affine types and not linear types. This is not something that could have been solved with more work on the design. It is not that there was "a decision" to allow dropping tasks; it's a constraint on the design that the language requires. (Personally, I'm unsure as to whether a practical language with true linear types is possible, but it's worth experimenting with. Rust is not and never will be that language, however.)
Also, io_uring can certainly be used via polling. Once the shared rings are set up, no syscalls are necessary afterward.
I'm not sure io_uring polling counts as polling since you're really just polling for completions, you still have all the completion-based-IO things like the in-flight operations essentially owning their buffers.
That being said, you have to restructure a lot of your application in order to be io_uring ready in order to reap the most gains. In theory, you'll also have to be a bit pickier with CPU affinities, namely when using SQPOLL (submit queue poll), which creates a kernel thread. Too much contention means such facilities will actually slow you down.
The research is changing weekly and most of the exciting stuff is still on development branches, so tl;dr (for the rest of the readers) if you're on the fence, best stick to epoll for now.
That is not an accurate summary of that comment. withoutboats may have been complaining about someone trying to revisit the decision made in 2015-2016, but as the comment itself points out, there were good reasons for that decision.
Mainly two reasons, as far as I know.
First, Rust prefers unique ownership and acyclic data structures. You can make cyclic structures work if you use RefCell and Rc and Weak, but you're giving up the static guarantees that the borrow checker gives you in favor of a bunch of dynamic checks for 'is this in use' and 'is this still alive', which are easy to get wrong. But a completion-based model essentially requires a cyclic data structure: a parent future creates a child future (and can then cancel it), which then calls back to the parent future when it's complete. You might be able to minimize cyclicity by having the child own the parent and treating cancellation as a special case, but then you lose uniqueness if one parent has multiple children.
(Actually, even the polling model has a bit of cyclicity with Wakers, but it's kept to an absolute minimum.)
Second, a completion-based model makes it hard to avoid giving each future its own dynamic allocation, whereas Rust likes to minimize dynamic allocations. (It also requires indirect calls, which is a micro-inefficiency, although I'm not convinced that matters very much; current Rust futures have some significant micro-inefficiencies of their own.) The 2016 blog post linked in the comment goes into more detail about this.
As you might guess, I find those reasons compelling, and I think a polling-based model would still be the right choice even if Rust's async model was being redesigned from scratch today. Edit: Though to be fair, the YouTube video linked from withoutboats' comment does mention that mio decided on polling simply because that's what worked best on Linux at the time (pre-io_uring), and that had some influence on how Futures ended up. But only some.
…That said, I do agree Pin was rushed and has serious problems.
How is to so, if he explicitly writes:
> Suggestions that we should revisit our underlying futures model are suggestions that we should revert back to the state we were in 3 or 4 years ago, and start over from that point. <..> Trying to provide answers to these questions would be off-topic for this thread; the point is that answering them, and proving the answers correct, is work. What amounts to a solid decade of labor-years between the different contributors so far would have to be redone again.
How should I read it except like "we did the work on the poll-based model, so we don't want for the results to go down the drain in the case if the completion-based model will turn to be superior"?
I don't agree with your assertion regarding cyclic structures and the need of dynamic allocations in the completion-based model. Both models result in approximately the same cyclisity of task states, no wonders, since task states are effectively size-bound stacks. In both models you have more or less the same finite state machines. The only difference is in how those FSMs interact with runtime and in the fact that in the completion-based model you usually pass ownership of a task state part to runtime during task suspension. So you can not simply drop a task if you no longer need its results, you have to explicitly request its cancellation from runtime.
There's a difference between "we decided this 3 years ago" and "we rushed the decision". At this point, it's no longer possible to weigh the two models on a neutral scale, because changing the model would cause a huge amount of ecosystem churn. But that doesn't mean they weren't properly weighed in the first place.
Regarding cyclicity… well, consider something like a task running two sub-tasks at the same time. That works out quite naturally in a polling-based model, but in a completion-based model you have to worry about things like 'what if both completion handlers are called at the same time', or even 'what if one of the completion handlers ends up calling the other one'.
Regarding dynamic allocations… well, what kind of desugaring are you thinking of? If you have
async fn foo(input: u32) -> String;
then a simple desugaring could be fn foo(input: u32, completion: Arc<FnOnce(String)>);
but then the function has to responsible for allocating its own memory.Sure, there are alternatives. We could do...
struct Foo { /* state */ }
impl Foo {
fn call(self: Arc<Self>, input: u32, completion: Arc<FnOnce(String)>);
}
Which by itself is no better; it still implies separate allocations. But then I suppose we could have an `ArcDerived<T>` which acts like `Arc<T>` but can point to a part of a larger allocation, so that `self` and `completion` could be parts of the same object.However, in that case, how do you deal with borrowed arguments? You could rewrite them to Arc, I suppose. But if you must use Arc, performance-wise, ideally you want to be moving references around rather than actually bumping reference counts. You can usually do that if there's just `self` and `completion`, but not if there are a bunch of other Arcs.
Also, what if the implementation misbehaved and called `completion` without giving up the reference to `self`? That would imply that any further async calls by the caller could not use the same memory. It's possible to work around this, but I think it would start to make the interface relatively ugly, less ergonomic to implement manually.
Also, `ArcDerived` would have to consist of two pointers and there would have to be at least one `ArcDerived` in every nested future, bloating the future object. But really you don't want to mandate one particular implementation of Arc, so you need a vtable, but that means indirect calls and more space waste.
Most of those problems could be solved by making the interface unsafe and using something with more complex correctness requirements than Arc. But the fact that current async fns desugar to a safe interface is a significant upside. (...Even if the safety must be provided with a bunch of macros, thanks to Pin not being built into the language.)
As far as I understand the situation, the completion-based API simply was not on the table 3 years ago. io-uring was not a thing and there was a negligible interest in properly supporting IOCP. So when a viable alternative has appeared right before stabilization of the developed epoll-centric API, the 3 year old decision has not been properly reviewed in the light of the changed environment and instead the team has pushed forward with the stabilization.
>because changing the model would cause a huge amount of ecosystem churn.
No, the discussion has happened before the stabilization (it's literally in the stabilization issue). Most of the ecosystem at the time was on futures 0.2.
Regarding your examples, I think you simply look at the problem from a wrong angle. In my opinion compiler should not desugar async fns into usual functions, instead it should construct explicit FSMs out of them. So no need for Arcs, the String would be stored directly in the "output" FSM state generated for foo. Yes, this approach is harder for compiler, but it opens some optimization capabilities, e.g. regarding the trade-off between FSM "stack" size and and number of copies which state transition functions have to do. AFAIK right now Rust uses "dumb" enums, which can be quite sub-optimal, i.e. they always minimize the "stack" size at the expense of additional data copies and they do not reorder fields in the enum variants to minimize copies.
In your example with two sub-tasks a generated FSM could look like this (each item is a transition function):
1) initialization [0 -> init_state]: create requests A and B
2) request A is complete [init_state -> state_a]: if request B is complete do nothing, else mark that request A is complete and request cancellation of task B, but do not change layout of a buffer used by request B.
3) cancellation of B is complete [state_a -> state_c]: process data from A, perform data processing common for branches A and B, create request C. It's safe to overwrite memory behind buffer B in this handler.
4) request B is complete [init_state -> state_b]: if request A is complete do nothing, else mark that request B is complete and request cancellation of task A, but do not change layout of a buffer used by request A.
5) cancellation of A is complete [state_b -> state_c]: process data from A, perform data processing common for branches A and B, create request C. It's safe to overwrite memory behind buffer A in this handler.
(This FSM assumes that it's legal to request cancellation of a completed task)
Note that handlers 2 and 4 can not be called at the same time, since they are bound to the same ring and thus executed on the same thread. Other completion handler simply can not call another handler, since they are part of the same FSM and only one FSM transition function can be executed at a time. At the first glance all those states and transitions look like an unnecessary complexity, but I think that it's how a proper select should work under the hood.
Completion APIs were always considered. They are just significantly harder for Rust to support.
https://doc.rust-lang.org/1.0.0/std/sync/struct.Future.html
Our async IO model was based on the Linux industry standard (then and now) epoll, but that is not at all what drove the switch to a polling based model, and the polling based model presents no issues whatsoever with io-uring. You do not know what you are talking about.
Can you provide a link to a design document or at the very least to a discussion with motivation for this switch outside of the desire to be as compatible as possible with the "Linux industry standard"?
>the polling based model presents no issues whatsoever with io-uring
There are no issues with io-uring compatibility to such extent that you wrote about a whole blog post about those issues: https://boats.gitlab.io/blog/post/io-uring/
IIUC the best solutions right now are either to copy data around (bye-bye zero-cost) or to use another Pin-like awkward hack with executor-based buffer management, instead of using simple and familiar buffers which are part of a future state.
The completion based futures that Alex started with were also based on epoll. The performance issues it presented had nothing to do any sort of impedence mismatch between a completion based future and epoll, because there is no impedence issue. You are confused.
>The performance issues it presented had nothing to do any sort of impedence mismatch between a completion based future and epoll
Sorry, but what? Even aturon's article states zero-cost as one of the 3 main goals. So performance issues with strong roots in the selected model is a very big problem in my book.
>You do not know what you are talking about.
>You are confused.
Please, tone down your replies.
I've been lurking your responses, but now I'm confused. If you are not using a callback based approach, then what are you using? Rust's FSM approach is predicated on polling; In other words if you aren't using callbacks, then how do you know that Future A has finished? If the answer is to use Rust's current systems, then that means the FSM is "polled" periodically, and then you still have "async Drop" problem as described in withoutboat's notorious article and furthermore, you haven't really changed Rust's design.
Edit: As I've seen you mention in other threads, you need a sound design for async Drop for this to work. I'm not sure this is possible in Rust 1.0 (as Drop isn't currently required to run in safe Rust). That said it's unfair to call async "rushed", when your proposed design wouldn't even work in Rust 1.0. I'd be hesitant to call the design of the entire language rushed just because it didn't include linear types.
>Unfortunately, this approach nevertheless forces allocation at almost every point of future composition, and often imposes dynamic dispatch, despite our best efforts to avoid such overhead.
It clearly does not apply to the model which I've described earlier.
Of course, the described FSM state transition functions can be rightfully called callbacks, which adds a certain amount of confusion.
I can agree with the argument that a proper async Drop can not be implemented in Rust 1.0, so we have to settle with a compromise solution. Same with proper self-referential structs vs Pin. But I would like to see this argument to be explicitly stated with sufficient backing of the impossibility statements.
No, I'm not talking about the state transition functions. I'm talking about the runtime - the thing that will call the state transition function. In the current design, abstractly, the runtime polls/checks every if future if it's in a runnable state, and if so executes it. In an completion based design the future itself tells the runtime that the value is ready (either driven by a kernel thread, another thread or some other callback). (conceptually the difference is, in an poll based design, the future calls waker.wake(), and in a completion one, the future just calls the callback fn). Aaron has already described why that is a problem.
The confusion I have is that both would have problems integrating io_uring into rust (due to the Drop problem; as Rust has no concept of the kernel owning a buffer), but your proposed solution seems strictly worse as it requires async Drop to be sound which is not guaranteed by Rust; which would make it useless for programs that are being written today. As a result, I'm having trouble accepting that your criticism is actually valid - what you seem to be arguing is that async/await should have never been stabilized in Rust 1.0, which I believe is a fair criticism, but it isn't one that indicates that the current design has been rushed.
Upon further thought, I think your design ultimately requires futures to be implemented as a language feature, rather than a library (ex. for the future itself to expose multiple state transition functions without allocating is not possible with the current Trait system), which wouldn't have worked without forking Rust during the prototype stage.
I think there is a misunderstanding. In a completion-based model (read io-uring, but I think IOCP behaves similarly, though I am less familiar with it) it's a runtime who "notifies" tasks about completed IO requests. In io-uring you have two queues represented by ring buffers shared with OS. You add submission queue entries (SQE) to the first buffer which describe what you want for OS to do, OS reads them, performs the requested job, and places completion queue events (CQEs) for completed requests into the second buffer.
So in this model a task (Future in your terminology) registers SQE (the registration process may be proxied via user-space runtime) and suspends itself. Let's assume for simplicity that only one SQE was registered for the task. After OS sends CQE for the request, runtime finds a correct state transition function (via meta-information embedded into SQE, which gets mirrored to the relevant CQE) and simply executes it, the requested data (if it was a read) will be already filled into a buffer which is part of the FSM state, so no need for additional syscalls or interactions with the runtime to read this data!
If you are familiar with embedded development, then it should sound quite familiar, since it's roughly how hardware interrupts work as well! You register a job (e.g. DMA transfer), dedicated hardware block does it, and notifies a registered callback after the job was done. Of course, it's quite an oversimplification, but fundamental similarity is there.
>I think your design ultimately requires futures to be implemented as a language feature, rather than a library
I am not sure if this design would have had a Future type at all, but you are right, the advocated approach requires a deeper integration with the language compared to the stabilized solution. Though I disagree with the opinion that it would've been impossible to do in Rust 1.
You cannot literally make extremely inflammatory comments about people's work, and accuse them of all sorts of things, and then get upset when they are mad about it. You've made a bunch of very serious accusations on multiple people's hard work, with no evidence, and with arguments that are shaky at best, on one of the largest and most influential forums in the world.
I mean, you can get mad about it, but I don't think it's right.
I do understand the pain of your dear work to be harshly criticized. I have experienced it many times in my career. But my critique intended as a tough love for the language in which I am heavily invested in. If you see my comments as only "extremely inflammatory"... Well, it's a shame I guess, since it's not the first case of the Rust team unnecessarily rushing something (see the 2018 edition debacle), so I guess such attitude only increases rate of mistake accumulation by Rust.
"Instead of carefully weighing advantages and disadvantages of both models," may be written in a way that more people would call "civil," but is in practice a direct attack on both the work, and the people doing the work. It is extremely difficult to not take this as a slightly more politely worded "fuck you," if I'm being honest. In some sense, that it is phrased as being neutral and "civil" makes it more inflammatory.
You can have whatever opinion that you want, of course. But you should understand that the stuff you've said here is exactly that. It may be politely worded, but is ultimately an extremely public direct attack.
However, either way, responding with condescension (which is how the 'industry standard' thing came across) and outright aggression is never going to be constructive, and if that's the only response one is able to formulate then it's time to either wait a couple hours or ask somebody else to answer on your behalf instead (I have a number of people who are kind enough to do that for me when my reaction is sufficiently exothermic to make posting a really bad idea).
boats-of-a-year ago handled a similar situation much more graciously here - https://news.ycombinator.com/item?id=22464629 - so it's entirely possibly a lockdown fatigue issue - but responding to calmly phrased criticism with outright aggression is still pretty much never a net win and defending that behaviour seems contrary to the tone the rust team normally tries to set for discussions.
Async drop would still not address the single-allocation-per-state-machine advantage of the current design that you’ve mostly not engaged with in this thread.
No worries, I like when someone disagrees with me and argues his or her position well, since it's a chance for me to learn.
>async drop has been revisited multiple times recently with no luck
The key word is "recently", meaning "after the stabilization". It's exactly my point: this problem was not sufficiently explored in my opinion prior stabilization. I would've been fine with a well argued position "async Drop is impossible without breaking language changes, so we will not care about it", but now we try to shoehorn async Drop on top of the stabilized feature.
>Async drop would still not address the single-allocation-per-state-machine advantage of the current design that you’ve mostly not engaged with in this thread.
I don't think you are correct here, please see this comment: https://news.ycombinator.com/item?id=26408524
One way to get around that is to instead of doing it like the very structureless async way actually impose lifetime restrictions on the lifetimes of async entities. For example, if you use the ideas of the structured concurrency movement ([x], for example but it has since been picked up by kotlin, swift and other projects), then the parent is guaranteed to live longer than any child thus solving most of the problem that way.
[x] https://vorpus.org/blog/notes-on-structured-concurrency-or-g...
It's from 2004 with the X10 parallel language and then Habanero in Java via their "async-finish" construct
I wonder if your quote (structured XYZ or ABC considered harmful) is inspired by
- https://conf.researchr.org/details/etaps-2019/places-2019-pa...
However, enither are definitely not the first having ideas along these lines - structures and logic like this can also be found in Erlang supervisors after all.
And as for the quote, it is quite explicitly referring to Dijkstra and structured programming constructs in nonconcurrent settings.
Habanero Java from 2011 https://www.cs.rice.edu/~vs3/PDF/hj-pppj11.pdf
That's why those are not part of his influences, he co-wrote those papers.
Don't get me wrong, it's usable and many projects use it to great effect.
But there are a few important features like async trait methods (blocked by HKT), async closures, async drop, and (potentially) existential types, that seem to linger. The unresolved problems around Pin are the most worrying aspect.
The ecosystem is somewhat fractured, partially due to a lack of commonly agreed abstractions, partially due to language limitations.
There also sadly seems to be a lack of leadership and drive to push things forward.
I'm ambivalent about the rushing aspect. Yes, async was pushed out the door. Partially due to heavy pressure from Google/Fuchsia and a large part of the userbase eagerly .awaiting stabilization.
Without stabilizing when they did, we very well might still not have async on stable for years to come. At some point you have to ship, and the benefits for the ecosystem can not be denied. It remains to be seen if the design is boxed into a suboptimal corner; I'm cautiously optimistic.
But what I disagree with is that polling was a mistake. It is what distinguishes Rusts implementation, and provides significant benefits. A completion model would require a heavier, standardized runtime and associated inefficiencies like extra allocations and indirection, and prevent efficiencies that emerge with polling. Being able to just locally poll futures without handing them off to a runtime, or cheaply dropping them, are big benefits.
Completion is the right choice for languages with a heavy runtime. But I don't see how having the Rust dictate completion would make io_uring wrapping more efficient than implementing the same patterns in libraries.
UX and convenience is a different topic. Rust async will never be as easy to use as Go, or async in languages like Javascript/C#. To me the whole point of Rust is providing as high-level, safe abstractions as possible, without constraining the ability to achieve maximum efficiency . (how well that goal is achieved, or hindered by certain design patterns that are more or less dictated by the language design is debatable, though)
You are not the first person who uses such arguments, but I don't see why they would be true. In my understanding both models would use approximately the same FSMs, but which would interact differently with a runtime (i.e. instead of registering a waker, you would register an operation on a buffer which is part of the task state). Maybe I am missing something, so please correct me if I am wrong in a reply to this comment: https://news.ycombinator.com/item?id=26407824
I guess that way it fits more the Rust philosophy.
Meanwhile C and C++ can easily adopt any async system call style because it made no assumptions in the standards about how that would be done.
Rust also didn't solve the colored functions problem. Most people think that's an impossible problem to solve without a VM/runtime (like Java Loom), but people also thought garbage collection was impossible in a systems language until Rust solved it. It could have been a great opportunity for them.
See this comment: https://news.ycombinator.com/item?id=26407440
>Meanwhile C and C++ can easily adopt any async system call style because it made no assumptions in the standards about how that would be done.
Do you know about co_await in C++20? AFAIK (I only have a very cursory knowledge about it, so I may be wrong) it also makes some trade-offs, e.g. it requires allocations, while in Rust async tasks can live on stack or statically allocated regions of memory.
Also do not forget that Rust has to ensure memory safety at compile time, while C++ can be much more relaxed about it.
https://github.com/rust-lang/rust/issues/43122#issuecomment-...
https://github.com/rust-lang/rust/issues/43122#issuecomment-...
I also did a follow up, walking through how you would implement all of the bits: https://www.infoq.com/presentations/rust-async-await/
TL;DR: rust makes you bring some sort of executor along. You can write your own, you can use someone else's. I have not done enough of a deep dive into what made it into the standard to give you a great line-by-line comparison.
Otherwise compiler are free to implement heap allocation elision (which is done in Clang).
Now compared to Rust, assuming you have a series of coroutines to process a deferred event, Rust will allocate once for the whole series while C++ would allocate once per coroutine to store them in the reactor/proactor.
> Rust will allocate once for the whole series
which, it will not.
It is true that some executors will do a single allocation for the whole series, but that is not done by Rust, nor is required. That's all!
Only if you understand "garbage collection" in a narrow sense of memory safety, no explicit free() calls, a relatively readable syntax for passing objects around, and acceptable amount of unused memory. This comes with a non-negligible amount of small text for Rust when compared to garbage-collected languages.
No, they didn't. Linear typing for systems languages had already been done in ats, cyclone, and clean, the latter two of which were a major inspiration for rust.
Venturing further into gc territory: long before rust was even a twinkle in graydon hoare's eye, smart pointers were happening in c++, and apple was experimenting with objective c for drivers.
macOS IO Kit replacement, Driver Kit, is an homage to NeXTSTEP Driver Kit name, the Objective-C framework.
The problem with languages like ATS and Cyclone is that you need heavy usage in real-world applications to prove that your approach is actually usable by developers at scale. Rust achieved that first.
A good optimizer could then have removed the counting on non-escaping local variables
Cyclone was, and ats is, a research project; not necessarily intended to achieve widespread use. And again, obj-c was being used by apple in drivers, which is certainly a real-world application.
> without GC
I don't know what you mean by this. GC is a memory management policy in which the programmer does not need to manually end the lifetimes of objects. Rust is a garbage collected language. How many manual calls to 'drop' or 'free' does the average rust program have?
ATS is not just "a blend of ML and C", it has a powerful proof system on top.
You can't just say "well, these languages were derived from C in part, THEREFORE they must be easy to adopt at scale", that doesn't follow at all.
Yes, Cyclone and ATS were research projects, that's why they were never able to accumulate the real-world experience needed to demonstrate that their ideas work at scale.
Objective-C isn't memory safe.
By "GC" here I meant memory reclamation schemes that require runtime support and object layout changes ... which is the way most people use it. If you use the term "garbage collection" in a more expansive way, so that you say Rust "is a garbage collected language", then most people are going to misunderstand you.
No, but it is garbage collected.
> By "GC" here I meant memory reclamation schemes that require [...] object layout changes
Changes with respect to what?
One example of a popular GC is the boehm GC. It provides a drop-in replacement for malloc, usable in c for existing c structures without any ABI changes.
Perhaps you are thinking specifically of compacting GCs, which usually need objects to have a header with a forwarding pointer?
> require runtime support
‘malloc’ and ‘free’ are a memory reclamation scheme that is part of the c runtime. I don't think there's any argument to be made that they are garbage collection. What's the difference between them and some other runtime support?
-----------------------------------------
Broadly, you are referring to mechanisms which can be used to implement garbage collection, but those are not what's interesting here. What's interesting is a memory management policy which supports garbage collection and is usable for a systems programming language. -----------------------------------------
> If you use the term "garbage collection" in a more expansive way, so that you say Rust "is a garbage collected language", then most people are going to misunderstand you.‘Garbage collection’ is a technical term with a specific, precise meaning. This meaning is generally understood and accepted throughout the literature. It's also the thing that's specifically interesting here: manually managing object lifetimes is error-prone and tends to lead to bugs, and bugs in systems software tend to be far-reaching, so a way to eliminate those bugs categorically is considered valuable.
> You can't just say "well, these languages were derived from C in part, THEREFORE they must be easy to adopt at scale", that doesn't follow at all.
That's fair as such, but I think the situation is a bit more nuanced than that. The semantics of ats and cyclone are largely designed to augment c directly. Ats's proof semantics in particular map very well to the semantics of c programs as written. Which, true, doesn't prove anything, but shows that there is much less to be proved: the existing paradigm can still be used.
> Yes, Cyclone and ATS were research projects, that's why they were never able to accumulate the real-world experience needed to demonstrate that their ideas work at scale.
Are the several multi-100-kloc ats compilers out there not real-world enough? If not then, on the topic of proof languages, ada/spark and isabelle/hol had proven themselves long before rust.
FWIW Wikipedia describes "garbage collection" as "a form of automatic memory management" and goes on to say "Other similar techniques include stack allocation, region inference, memory ownership ..." so whoever wrote that doesn't agree that all forms of automatic reclamation are garbage collection.
I prefer to avoid arguing about the meaning of words but it's not good to sow confusion.
> Are the several multi-100-kloc ats compilers out there not real-world enough?
Yes, projects written by the creators of the language are not enough.
> If not then, on the topic of proof languages, ada/spark and isabelle/hol had proven themselves long before rust.
Before Rust, Ada/SPARK didn't support dynamic deallocation. See https://www.adacore.com/uploads/techPapers/Safe-Dynamic-Memo..., which cites Rust.
SeL4 required 200K lines of Isabelle/HOL proofs to verify 7.5K lines of C; that approach simply doesn't scale.
What?
Several OSes have proven their value written in GC enabled systems programming languages.
They aren't as mainstream as they should due to UNIX cargo cult and anti-GC Luddites.
Rust only proved that affine types can be easier than cyclone and ATS.
This is comparing apples to oranges; Rust's general, no-assumptions-baked-in coroutines feature is called "generators", and are not yet stable. It is this feature that is internally used to implement async/await. https://github.com/rust-lang/rust/issues/43122
This is an inaccurate simplification that, admittedly, their own literature has perpetuated. Rust uses informed polling: the resource can wake the scheduler at any time and tell it to poll. When this occurs it is virtually identical to completion-based async (sans some small implementation details).
What informed polling brings to the picture is opportunistic sync: a scheduler may choose to poll before suspending a task. This helps when e.g. there is data already in IO buffers (there often is).
There's also some fancy stuff you can do with informed polling, that you can't with completion (such as stateless informed polling).
Everything else I agree with, especially Pin, but informed polling is really elegant.
By using a context with a custom "waker" implementation, you can learn which future specifically needs to be re-polled.
Normally only the executor would provide the waker implementation, so you only learn which top-level future (task) needs to be re-polled, but not what specific future within that task is ready to proceed. However, some future combinators also use a custom waker so they can be more precise about which specific future within the task should be re-polled.
var state = socket.read(buffer);
while (!state.poll()) {}
state.bytesRead...
Stateless async is accepting a connection. In 95% of servers, you just care that a connection was accepted; you don't have any state that persists across the continuation: while (!listeningSocket.poll()) {}
var socket = listeningSocket.accept();
Stateless async skirts around many of the issues that Rust async can have (because Pin etc. has to happen because of state).Isn't that called interrupting?
The terminology seems a little off here, but perhaps that is only my perception.
Interruptions are something one really tries to restrict to the hardware - kernel layers. Because when people write interruption handlers, they almost always write them wrong.
> Andrei Alexandrescu was right when he said Rust feels like it "skipped leg day" when it comes to concurrency and metaprogramming capabilities.
https://archive.is/hbBte (the original answer appears to have been deleted [0])
> Tim Sweeney was complaining about similar things, saying about Rust that is one step forward, two steps backward.
https://twitter.com/timsweeneyepic/status/121381423618448588...
(He said "Kind of one step backward and one step forward", but close enough)
[0]: https://www.quora.com/Which-language-has-the-brightest-futur...
Zig and other recent languages have been invented after Rust and Go, so they could learn from them, while Rust had to experiment a lot in order to combine async with borrow checking.
So, yes, the async situation in Rust is very awkward, and doing something beyond a Ping server is more complicated than it could be. But that’s what it takes to be a pioneer.
I'm not necessarily doubtful, tokio isn't the fastest implementation of a runtime.
But can you point to a non-trivial benchmark that shows this?
Performance claims should always come with a verifiable benchmark.
There is NO meaningful connection between the completion vs polling futures model and the epoll vs io-uring IO models. comex's comments regarding this fact are mostly accurate. The polling model that Rust chose is the only approach that has been able to achieve single allocation state machines in Rust. It was 100% the right choice.
After designing async/await, I went on to investigate io-uring and how it would be integrated into Rust's system. I have a whole blog series about it on my website: https://without.boats/tags/io-uring/. I assure you, the problems it present are not related to Rust's polling model AT ALL. They arise from the limits of Rust's borrow system to describe dynamic loans across the syscall boundary (i.e. that it cannot describe this). A completion model would not have made it possible to pass a lifetime-bound reference into the kernel and guarantee no aliasing. But all of them have fine solutions building on work that already exists.
Pin is not a hack any more than Box is. It is the only way to fit the desired ownership expression into the language that already exists, squaring these requirements with other desireable primitives we had already committed to shared ownership pointers, mem::swap, etc. It is simply FUD - frankly, a lie - to say that it will block "noalias," following that link shows Niko and Ralf having a fruitful discussion about how to incorporate self-referential types into our aliasing model. We were aware of this wrinkle before we stabilized Pin, I had conversations with Ralf about it, its just that now that we want to support self-referential types in some cases, we need to do more work to incorporate it into our memory model. None of this is unusual.
And none of this was rushed. Ignoring the long prehistory, a period of 3 and a half years stands between the development of futures 0.1 and the async/await release. The feature went through a grueling public design process that burned out everyone involved, including me. It's not finished yet, but we have an MVP that, contrary to this blog post, does work just fine, in production, at a great many companies you care about. Moreover, getting a usable async/await MVP was absolutely essential to getting Rust the escape velocity to survive the ejection from Mozilla - every other funder of the Rust Foundation finds async/await core to their adoption of Rust, as does every company that is now employing teams to work on Rust.
Async/await was, both technically and strategically, as well executed as possible under the circumstances of Rust when I took on the project in December 2017. I have no regrets about how it turned out.
Everyone who reads Hacker News should understand that the content your consuming is usually from one of these kinds of people: a) dilettantes, who don't have a deep understanding of the technology; b) cranks, who have some axe to grind regarding the technology; c) evangelists, who are here to promote some other technology. The people who actually drive the technologies that shape our industry don't usually have the time and energy to post on these kinds of things, unless they get so angry about how their work is being discussed, as I am here.
I for one, amongst many people I am sure, am deeply grateful for the work of you and your peers in getting this out!
> ...the decision was effectively made on the ground of "we want to ship async support as soon as possible" [1].
When you write:
> Moreover, getting a usable async/await MVP was absolutely essential to getting Rust the escape velocity to survive the ejection from Mozilla...
This whole situation saddens me. I wish Mozilla could have given you guys more breathing room to work on such critical parts. Regardless, thank you for your dedication.
As an aside, It would be great to have some sort of federated cred(meritocratic in some way) in hackernews, instead of a flat democratic populist point system; it would lower the potential eternal September effect.
I would love to see a personal meta-pointing system, it could be on wrapping site: if I downvote a "waste of hackers daytime" article (say a long form article about what is life) in my "daytime" profile, I get a weighted downvoted feed by other users that also downvoted this item--basically using peers that vote like you as a pre-filter. I could have multiple filters, one for quick daytime hacker scan, and one for leisure factoid. One could even meta-meta-vote and give some other hackers' handle a heavier weight...
In this thread [1] we have a more technicall discussion about those models, I suggest to continue that thread.
>I assure you, the problems it present are not related to Rust's polling model AT ALL.
I do not agree about all problems, but my OP was indeed worded somewhat poorly, as I've admitted here [2].
>Pin is not a hack any more than Box is. It is the only way to fit the desired ownership expression into the language that already exists
I can agree that it was the easiest solution, but I strongly disagree about the only one. And frankly it's quite disheartening to hear from a tech leader such absolutistic statements.
>It is simply FUD - frankly, a lie - to say that it will block "noalias,
Where did I say "will"? I think you will agree that it at the very least will it will cause a delay. Also the issue shows that Pin was not proprely thought out, especially in the light of other safety issues it has caused. And as uou can see by other comments, I am not the only one who thinks so.
>the content your consuming is usually from one of these kinds of people:
Please, satisfy my curiosity. To which category do I belong in your opinion?
Also please note that the article is not mine.
By the way, that will almost certainly be taken in a bad way. It's never a good idea to start a comment with something like "chill" or "calm down", as it feels incredibly dismissive.
> I do appreciate your work on Rust, but
There's a saying that anything before a "but" is meaningless.
This is not meant to critique the rest of the comment, just point out a couple parts that don't help in defusing the tense situation.
I have noticed this comment only after engaging with him in https://news.ycombinator.com/item?id=26410565 in which he wrote about me:
> You do not know what you are talking about.
> You are confused.
So my reaction was a bit too harsh partially due to that.
My recommendation to people that don't see how ridiculous the original post was, is to write more code and look more into things.
I personally would have just chickened out on language native async in Rust and told people to roll their own async with promise patterns or something.
Ownership semantics are hairy in Rust and require some forethought, but that's also true in C and C++ and in those languages if you get it wrong there you just blow your foot off. Rust instead tells you that the footgun is dangerously close to going off and more or less prohibits you from doing really dangerous things.
My opinion on Rust async is that it its warts are as much the fault of libraries as they are of the language itself. Async libraries are overly clever, falling into the trap of favoring code brevity over code clarity. I would rather have them force me to write just a little more boilerplate but have a clearer idea of what's going on than to rely on magic voodoo closure tricks like:
https://github.com/hyperium/hyper/issues/2446
Compare that (which was the result of hours of hacking) to their example:
https://hyper.rs/guides/server/hello-world/
WUT? I'm still not totally 100% sure why mine works and theirs works, and I don't blame Rust. I'd rather have seen this interface (in hyper) implemented with traits and interfaces. Yes it would force me to write something like a "factory," but I would have spent 30 minutes doing that instead of three hours figuring out how the fuck make_service_fn() and service_fn() are supposed to be used and how to get a f'ing Arc<> in there. It would also result in code that someone else could load up and easily understand what the hell it was doing without a half page of comments.
The rest of the Rust code in ZT 2.0 is much clearer than this. It only gets ugly when I have to interface with hyper. Tokio itself is even a lot better.
Oh, and Arc<> gets around a lot of issues in Rust. It's not as zero-cost as Rc<> and Box<> and friends but the cost is really low. While async workers are not threads, it can make things easier to treat them that way and use Arc<> with them (as long as you avoid cyclic structures). So if async ownership is really giving you headaches try chickening out and using Arc<>. It costs very very little CPU/RAM and if it saves you hours of coding it's worth it.
Oh, and to remind people: this is a systems language designed to replace C/C++, not a higher level language, and I don't expect it to ever be as simple and productive as Go or as YOLO as JavaScript. I love Go too but it's not a systems language and it imposes costs and constraints that are really problematic when trying to write (in my case) a network virtualization service that's shooting (in v2.0) for tens of gigabits performance on big machines.
<F, Fut> where F: Fn() -> Fut, Fut: Future
i.e. you call some closure f that returns a future that you can then await on. when writing that out it will look like: || {
// closure
async move {
// returned future
}
}
`make_service_fn` likely takes something like this and puts it in a struct, then for every request it will call the closure to create the future to process the request. (edit: and indeed it does, it's definition literally takes your closure and uses it to implement the Service trait, which you are free to do also if you didn't want to write it this way https://docs.rs/hyper/0.14.4/src/hyper/service/make.rs.html#...)The reason you need to clone in the closure is that is what 'closes over' the scope and is able to capture the Arc reference you need to pass to your future. Whenever make_service_fn uses the closure you pass to it, it will call the closure, which can create your Arc references, then create a future with those references "moved" in.
It's a little deceptive as this means the exact same thing as above, just with the first set of curly braces not needed
|| async move {}
This is still a closure which returns a Future. Does all of that make sense? Perhaps they could use a more explicit example, but it also helps to carefully read the type signature.In that case all I'm doing vs. their example is explicitly writing the function that returns the promise instead of letting it be "inferred?"
move || async move {}
But this is not equivalent to: || async move {}
crucially the closure is not going to take ownership of anything. This is kind of besides the point though, what I'm getting at is that both of the above are a closure which returns a future. i.e. you can also write them in this style: || {
return async move {};
}
Maybe that's more clear with the explicit return?I don't understand your second question about it begin "inferred", I never used that word. make_service_fn is a convenience function for implementing the Service trait.
This is apparently valid Rust:
let func = || println!("foo!");
I didn't know that, which is why I thought "|| async move ..." was some weird form of pseudo-async-closure instead of what it is: a function that returns an async function.
Most of the code I see always uses braces in closures for clarity, but I now see that a lot of async code does not.
It does not return an async function, it is a closure that returns a future. Carefully read the function signature I had posted:
fn foo<F, Fut>(f: F) where F: Fn() -> Fut, Fut: Future
async move {} is just a future, there is no function call. || is a closure, put them both together and you have a closure that returns a future.edit: I'm trying to think of how else to explain this. a future is just a state machine, an expression, there is no function call.
let f = async move { };
Is a valid future, you can f.await just fine.First of all yes, Rust futures use a poll model, where any state changes from different tasks don't directly call completions, but instead just schedule the original task to wake up again. I still think this is a good fit, and makes a lot of sense. It avoids a lot of errors on having a variety of state on the call stack before calling the continuation, which then gets invalidated. The model by itself also doesn't automatically make using completion based IO impossible.
However the polling model in Rust is combined with the model of always being able to drop a Future in order to cancel a task. This doesn't allow to use lower level libraries which require to do this without applying any additional workarounds.
However that part of Rusts model could be enhanced if there is enough interest in it. e.g. [1] discusses a proposal for it.
[1] https://rust-lang.zulipchat.com/#narrow/stream/187312-wg-asy...
If you want to, nothing is stopping you emulating a higher-level language - wrap all your data in Arc<_> or Arc<Mutex<_>> and store all your functions as trait objects like Box<dyn Fn(...)>. You pay for extra heap allocations and indirection, but avoid specifying generics that spread up your type hierarchy, and no longer need to play by the borrow checker's rules.
What Rust gives us is the option to _not_ pay all the costs I mentioned in the last paragraph, which is pretty cool if you're prepared to code for it.
IMO, Rust already provides a decent amount of way to simplify and skip things. Reference counting, async, proc_macro, etc.
In my experience, programming stuffs at a higher-level-language where things are heavily abstracted, like (cough) NodeJS, is easy and simple up to a certain point where I have to do a certain low-level things fast (e.g. file/byte patching) or do a system call which is not provided by the runtime API.
Often times I have to resort to making a native module or helper exe just to mitigate this. That feels like reinventing the wheel because the actual wheel I need is deep under an impenetrable layer of abstraction.
Anyway on abstraction, it's just hard, because everyone has different concept of what abstraction is. For some it's just combining couple of function calls into one, for some it is providing defaults, for some it is providing composable functions with controllable continuations.
But, I only work on Rust code sporadically, so I definitely feel the pros and cons of it when I switch back and forth to/from Rust and other languages.
The problem, IMO, isn't about allocations or ownership.
In fact, I think that a lot of the complaints about async Rust aren't even about async Rust or Futures.
The article brings up the legitimate awkwardness of passing functions/closures around in Rust. But it's perfectly fair to say that idiomatic Rust is not a functional language, and passing functions around is just not the first tool to grab from your toolbelt.
I think the actual complaint is not about "async", but actually about traits. Traits are paradoxically one of Rust's best features and also a super leaky and incomplete abstraction.
Let's say you know a bit of Rust and you're kind of working through some problem. You write a couple of async functions with the fancy `async fn foo(&x: Bar) -> Foo` syntax. Now you want to abstract the implementation by wrapping those functions in a trait. So you try just copy+pasting the signature into the trait. The compiler complains that async trait methods aren't allowed. So now you try to desugar the signature into `fn foo(&x: Bar) -> impl Future<Foo>` (did you forget Send or Unpin? How do you know if you need or want those bounds?). That doesn't work either because now you find out that `impl Trait` syntax isn't supported in traits. So now you might try an associated type, which is what you usually do for a trait with "generic" return values. That works okay, except that now your implementation has to wrap its return value in Box::pin, which is extra overhead that wasn't there when you just had the standalone functions with no abstraction. You could theoretically let the compiler bitch at you until it prints the true return value and copy+paste that into the trait implementation's associated type, but realistically, that's probably a mistake because you'd have to redo that every time you tweak the function for any reason.
IMO, most of the pain isn't really caused by async/await. It's actually caused by traits.
That's a hard problem when you have linear/affine types! Closures don't work so neatly as in Haskell; currying has to be different.
But when dealing with traits, you have to remember the orphan rules, and the implicit object-safety rules- which sucks, because you might not have planned on using trait objects when you first defined the trait, but only tried to do so later.
Async definitely makes it even more painful.
I don't know if anybody has shown that they can't ever exist, it's just that they weren't considered necessary to get the initial async features out of the door. Rather like how you can't use impl Trait in trait method signatures either (there's definitely some generics-implications-complexity going on with that one).
I call this The Dispatch Tax. Because any time you want more flexibility than the preferred static dispatch via generics can give you - oh, so you just want to store all these async fn(HttpRequest) -> Result<HttpResponse>, right? - you immediately start feeling the inconvenience and bulkiness of dynamic dispatch. It's like Rust is punishing you for using it. And with async/await taking this to a new level altogether, because you are immediately forced to understand how async funcs are transformed into ones that return Futures, how Futures are transformed into anon state machine structs; how closures are also transformed to anon structs. It's like there's no type system anymore, only structs.
That's one of the reasons, I think, why Go has won the Control Plane. Sure, projects like K8s, Docker, the whole HashiCorp suite are old news. But it's interesting and telling that even solid Rust shops like PingCAP are using Go for their control plane. It seems to me that there's some fundamental connection between flexibility of convenient dynamic dispatch and control plane tasks. And of course having the default runtime and building blocks like net and http in the standard library is a huge win.
That said, after almost three months of daily Rust it does get better. To the point when you can actually feel that some intuition and genuine understanding is there, and you can finally work on your problems instead of fighting with the language. I just wish that the initial learning curve wasn't so high.
Sure, Go hides all that, but as a result it’s also possible to have memory leaks and spend extra time/memory on dynamic dispatch without being (fully) aware of it.
type Handler = fn(Request<Body>) -> Result<Response<Body>, Error>;
let mut map: HashMap<&str, Handler> = HashMap::new();
map.insert("/", |req| { Ok(Response::new("hello".into())) });
map.insert("/about", |req| { Ok(Response::new("about".into())) });
Sure, using function pointer `fn` instead of one of the Fn traits is a bit of a cheating, but realistically you wouldn't want a handler to be a capturing closure anyway.But of course you want to use async and hyper and tokio and your favorite async db connection pool. And the moment you add `async` to the Handler type definition - well, welcome to what author was describing in the original blog post. You'll end up with something like this
type Handler = Box<dyn Fn(Request) -> BoxFuture + Send + Sync>;
type BoxFuture = Pin<Box<dyn Future<Output = Result> + Send>>;
plus type params with trait bounds infecting every method you want pass your handler to, think get, post, put, patch, etc. pub fn add<H, F>(&mut self, path: &str, handler: H)
where
H: Fn(Request) -> F + Send + Sync + 'static,
F: Future<Output = Result> + Send + 'static,
And for what reason? I mean, look at the definitions fn(Request<Body>) -> Result<Response<Body>, Error>;
async fn(Request<Body>) -> Result<Response<Body>, Error>;
It would be reasonable to suggest that if the first one is flexible enough to be stored in a container without any fuss, then the second one should as well. As a user of the language, especially in the beginning, I do not want to know of and be penalized by all the crazy transformations that the compiler is doing behind the scene.And for the record, you can have memory leaks in Rust too. But that's besides the point.
I don't think this a reasonable in Rust (or in C/C++). I 90% of the pain of futures in Rust is most users don't want to care about memory allocation and want Rust to work like JS/Scala/C#.
When using a container containing a function, you only have to think allocating memory for the function pointer, which is almost always statically allocated. However for an async function, there's not only the function, but the future as well. As a user the language now poses a problem to you, where does the memory for the future live.
1. You could statically allocate the future (ex. type Handler = fn(Request<Body>) -> ResponseFuture, where ResponseFuture is a struct that implemented Future).
But this isn't very flexible and you'd have to hand roll your own Future type. It's not as ergonomic as async fn, but I've done it before in environments where I needed to avoid allocating memory.
2. You decide to box everything (what you posted).
If Rust were to hide everything from you, then the language could only offer you (2), but then the C++ users would complain that the futures framework isn't "zero-cost". However most people don't care about "zero-cost", and come from languages where the solution is the runtime just boxes everything for you.
I kinda feel like there's this false dichotomy here: either hide and be like Java/Go or be as explicit as possible about the costs like C/C++. Is there maybe a third option, when I as a developer aware of the allocation and dispatch costs, but the compiler will do all the boilerplate for me. Something like `async dyn fn(Request) -> Result<Response>`? :)
Perhaps, but a bigger problem is that lots of folks are using Rust in a non-systems context (see HN frontpage on any random day).
But I only agree that the async part of that is unfortunate. Making dynamic dispatch have a little extra friction is a feature, not a bug, so to speak. Rust's raison d'être is "zero cost abstraction" and to be a systems language that should be viable in the same spaces as C++. Heap allocating needs to be explicit, just like in C and C++.
But, I agree that async is really unergonomic once you go beyond the most trivial examples (some of which the article doesn't even cover).
Some of it is the choices made around the async/await design (The Futures, themselves, and the "async model" is fine, IMO).
But the async syntax falls REALLY flat when you want an async trait method (because of a combination-and-overlap of no-HKTs, no-GATs, no `impl Trait` syntax for trait methods) or an async destructor (which isn't a huge deal- I think you can just use future::executor::block_on() and/or use something like the defer-drop crate for expensive drops).
Then it's compounded by the fact that Rust has these "implicit" traits that are usually implemented automatically, like Send, Sync, Unpin. It's great until you write a bunch of code that compiles just fine in the module, but you go to plug it in to some other code and realize that you actually needed it to be Send and it's not. Crap- gotta go back and massage it until it's Send or Unpin or whatever.
Some of these things will improve (GATs are coming), but I think that Rust kind of did itself a disservice with stabilizing the async/await stuff, because now they'll never be able to break it and the Pin/Unpin FUD makes me nervous. I also think that Rust should have embraced HKTs/Monads even though it's a big can of worms and invites Rust devs to turn into Scala/Haskell weenies (said with love because I'm one of them).
The static lifetime bound is annoying, too! I guess it crops up if you take a future and compose it with another one? Both future implementations have to be static types to guarantee they live long enough once passed into the new future.
All that said, the author's article reads as a bit daft. I think anyone who has tried building something complicated in C++ / Go will look at those examples and marvel at how awesome Rust's ability to understand lifetimes is (i.e. better than your own) and keep you from using resources in an unintended way. E.g., you want to keep some data alive for a closure and locally? Arc. Both need to writable? Arc Mutex. You are a genius and can guarantee this Fn will never leak and it's safe to have it not capture something by value that is used later in the program and you really need the performance of not using Arc? Cast it to a ptr and read in an unsafe block in the closure. Rust doesn't stop you from doing whatever you want in this regard, it just makes you explicitly ask for what you want rather than doing something stupid automatically and making you have a hard to find bug later down the line.
So... don’t use first-class functions so much? It’s a systems language, not a functional language for describing algorithms in CS whitepapers. Or use `move` (the article does mention this).
There are easy paths in most programming languages, and harder paths. Rust is no exception. The fact that passing around async closures with captured variables and also verifying nothing gets dropped prematurely and without resorting to runtime GC is bleeding-edge technology, so it should not be surprising that it has some caveats and isn’t always easy to do. The same could be said of trying to do reflection in Go, or garbage collection in C. These aren’t really the main use case for the tool.
That would look a lot better as a Trait with four methods.
Then maybe it was a mistake to adopt an async paradigm from functional languages that relies heavily on the idea that first-class functions are cheap and easy?
(FWIW I think Rust was right to pick Scala-style async; it's really the only nice way of working with async that I've seen, in any language. I think the mistake was not realising the importance of first-class functions and prioritising them higher)
I'm also happy to meet the only other person who had reservations about NLL! :p I have mixed feelings about it, still. It really is super ergonomic and convenient, but I really value language simplicity and NLL makes the language more complex.
I also don't think there's much Rust can do differently in regards to "functional foundations". With Rust's borrow/lifetime model, what would you even do differently with respect to `fn`, `Fn`, `FnMut`, and `FnOnce`?
Nobody is against HKTs on some sort of conceptual level. They just literally do not work to solve this problem in Rust.
Wouldn't HKTs help us abstract over function/types more easily, including closures?
Aren't GATs a special case of HKTs?
If Rust somehow had HKTs and "real" monads, we could have a do-notation instead of the Try trait+operator and Future trait+async+await, right?
I'm not saying that HKTs would fix (most of) the issues mentioned in the article around tricky closure semantics and whatnot.
In the sense that HKTs are a higher level abstraction, sure. More later.
> Aren't GATs a special case of HKTs?
My understanding is that GATs can get similar things done to HKTs for some stuff that Rust cares about, but that doesn't give them a subtyping relationship. Haskell has both higher kinded types and type families. That being said, my copy of Pierce is gathering dust.
> If Rust somehow had HKTs and "real" monads, we could have a do-notation instead of the Try trait+operator and Future trait+async+await, right?
It depends on what you mean by "somehow." That is, even if Rust had a monad trait, that does not mean that Try and Future could both implement it. This is because, in Haskell, these things have the same signatures. In Rust, they do not have the same signature. For reference:
pub trait Iterator {
type Item;
pub fn next(&mut self) -> Option<Self::Item>;
}
pub trait Future {
type Output;
pub fn poll(
self: Pin<&mut Self>,
cx: &mut Context<'_>
) -> Poll<Self::Output>;
}
While they are both traits, both with something returning their associated type:1. Iterator returns Option, while Future returns something like it. These do have the same shape in the end though, so maybe this is surmountable. (Though then you have backwards compat issues)
2. poll takes a Pin'd mutable reference to self, whereas iterator does not
3. Future takes an extra argument
These are real, practical problems that would need to be sorted, and it's not clear how, or even if it's possible to, sort them. Yes, if you handwave "they're sort of the same thing at a high enough level of abstraction!", sure, in theory, this could be done. But it is very unclear how, or if it is even possible to, get there.
This is nonsense; Try and Future are not the same thing in Haskell, there are plenty of functions you can only call with one or the other.
The point of the monad abstraction is to abstract over the part that is the same, mostly the function which Rust calls and_then:
pub fn and_then<F, B>(self, f: F) -> AndThen<Self, B, F>
where
F: FnOnce(Self::Item) -> B,
B: IntoFuture<Error = Self::Error>,
Self: Sized,
pub fn and_then<U, F>(self, op: F) -> Result<U, E>
where
F: FnOnce(T) -> Result<U, E>,
Obviously these signatures aren't quite identical, but they're actually even more similar than I thought; AndThen<Self, B, F> is a subtype of `impl Future<B, Self::Error>`, and the fact that there's no IntoResult seems like plumbing rather than anything fundamental. So if we could write an interface like: trait Monad<M<*>> {
pub fn and_then<A, B>(self: M<A>, f: F) -> impl M<B>
where
F: FnOnce(A) -> M<B>
}
then these both conform to that - in the first case with M=Future<Error=self::E> and A=Self::Item, in the second case with M=Result<Err=E> and A=T.Yes, there are low-level things you might want to do with Future or Result that you can't do via the monad interface - just as with any other high-level interface. But having the high-level interface available makes the simple, common cases a lot easier. I don't know what these "real, practical problems are", but they're certainly not at the syntactic/interface level.
> is a subtype of
Rust does not have subtyping. (except for lifetimes)
The point is, we don't actually have an existence proof that this is possible, and a lot of evidence to the contrary. It may be possible, but it's not a simple "just do x." There is a lot of handwaving in this post that would need to be hammered out, and details that would need to be fixed.
(Another point here is that "impl Trait in traits" isn't currently implemented in Rust either; this one is more feasible, though I am less sure about it when parameterized.)
This is frequently claimed but AFAICS it's no longer true post-impl trait. (I guess the counterargument is that impl trait is not a first-class type?). In any case, "Liskov-substitutable for" instead of "a subtype of" carries my point.
> The point is, we don't actually have an existence proof that this is possible, and a lot of evidence to the contrary. It may be possible, but it's not a simple "just do x."
I mean you could say the same for any feature not currently present in Rust - I appreciate that there's more to it but what I've followed of the discussions really didn't feel like there were clear blockers so much as a lack of interest. If there's genuinely a question mark about the possibility, would making an working implementation (that makes a lot of arbitrary choices about syntax, efficiency etc.) advance the conversation?
> would making an working implementation (that makes a lot of arbitrary choices about syntax, efficiency etc.) advance the conversation?
I don't know. It might, but it's also possible that the team has other objections I'm not aware of.
Well, "impl Iterator<Item=i32>" is clearly not the same thing as "Vec<i32>", but "Vec<i32>" is Liskov-substitutable for it. AFAICS it meets every definition of a subtype unless you take the position that "impl Iterator<Item=i32>" isn't a type at all, which begs the question of what "impl Iterator<Item=i32>" is - certainly it's something more than syntax sugar for "Vec<i32>", and it looks like a type and largely quacks like a type AFAICS.
> I think Rust was right to pick Scala-style async
I'm confused by this assertion. I'm more aware of procedural language origins of syntactic async/await than functional? The scala proposal in 2016 for async/await even cites C#'s design (which came in C# 5.0 in 2012) as an inspiration[1].
From there, it appears python and typescript added their equivalents in 2015 [2].
If anything, async-await feels like an extremely non-functional thing to begin with, in the sense that in a functional language it should generally be easier to treat the execution model itself as abstracted away.
Futures/promises (they mean different things in different languages), like many other things, form monads. In fact async-await is a specialization of various monad syntactic sugars that try to eliminate long callback chains that commonly affect many different sorts of monads.
Hence things like Haskell's do-notation are direct precursors to async-await (some libraries such as Scala's monadless https://github.com/monadless/monadless make it even more explicit, there lift and unlift are exactly generalized versions of async and await).
To see how async-await might be generalized, one could turn to various other specializations of the same syntax, e.g. an async that denotes a random variable and an await that draws once from the random variable.
To see the correspondence with a flatMap method (which is the main component of a monad), it's enough to look at the equivalent callback-heavy code and see that it looks something like
Future(5)
.flatMap(x ->
doSomethingFutureyWithX(x)
.flatMap(lookItsAnotherCallback)
)The fact that in a language like Haskell, you can perform something like async-await with futures (which are absolutely a kind of monad) in a natural way is precisely what I had in mind with what you quoted.
Regardless, the specific heritage of async-await syntax seems rooted in procedural languages (that do borrow much else as well from functional languages, yet are still not functional in any meaningful sense) like C# and python. They are absolutely an attempt to bring some of the power of something like monadic application (including do notation) into a procedural environment as an alternative to threads (green or otherwise), which hide the execution state machine completely.
This was inspired by do notation, which came about ~1998.
I don't think so. Async-await in both syntax and semantics is pretty firmly rooted in the FP tradition.
For semantics, the original implementation in C#, and as far as I know most of its successors, is to take imperative-looking code and transform it into CPS-ed code. That's a classic FP transformation (indeed it's one of the paradigmatic examples of first-class functions/higher-order functions) and one of the most popular methods of desugaring imperative-looking code in a functional context.
For syntax, the idea of hiding all that CPS behind an imperative-looking syntax sugar is the whole reason why do-notation and its descendants exist. (Indeed, there's an even deeper connection there specifically around CPS and monads: https://www.schoolofhaskell.com/school/to-infinity-and-beyon...)
But my point was simply that there's a pretty straight line from CPS, monads, and do-notation to async-await and so I think it's pretty fair to say that async-await is rooted in the FP tradition.
The C# version comes from F# (2007) which was in turn inspired by the "Poor Man's Concurrency Monad" implementation for Haskell (1999) (in turn inspired by Concurrent Haskell and, ultimately, Concurrent ML). It's very much a functional lineage.
I don't dispute (as I have had to say repeatedly in other branches of this) that the roots of futures as a concept are in functional programming, but the path I'm saying I see here is effectively:
- haskell/monads
|- do-notation
|- monadic futures
|- (this is new to me) F# appears to have added 'types' of do blocks, including a specifically async one?
\-> async-await as a sort of re-integration of quasi-monadic evaluation into procedural languages like C#, python, and eventually javascript and rust.
So what's weird to me is drawing a direct line between scala and rust here when the relevant precedent seems to be procedural languages distilling a thing from functional languages into a less flexible syntactic-sugar mechanism we now usually call async-await. Scala seems like a footnote here, where if you want to claim its descent from functional languages you would go farther back.As for why I mentioned Scala specifically, I understand that Rust's Futures are directly based on Scala's. I had assumed this would apply to async/await as well, but it sounds like apparently not? In any case there's not a huge difference between the Scala/C# versions of the concept AFAICS.
Huh, I actually find Rust and Scala to do async quite differently. The only thing in common to me is the monadic nature of Future itself. Otherwise I find there to be a big fundamental difference in how Scala's async is threadpool/execution-context based, while Rust's async is polling-based.
Then there are the syntactic differences around Rust having async/await and Scala... not.
I always thought of async as higher language sugar for coroutine patterns that I'd seen in use in assembler from the 1980's.
Another good example is Java, and languages running on top of the JVM.
This kind of toxicity is why I left programming behind as a career.
I think it's the "for" to the end of the sentence that does it.
I think he should use a higher level framework, or wait a few years for them to mature, and use a garbage collected language until then.
The compiler complained: “error[E0308]: mismatched types”
Of course, if speed isn’t a concern for you, then please carry on.
Therr are many tasks where what you suggests is just too slow and unpredictable.
Don't get me wrong, I think JVM is great, it's just not systems level programming.
And Google's ads backend are hardly the definition of server. Their ads front-end for example always used to be in Java. Not sure what it is these days
It is just one thread that’s actually appending to the Vec, but it’s still a multi-threaded example.
struct Database {
data: Vec<i32>
}
impl Database {
fn store(&mut self, data: i32) {
self.data.push(data);
}
}
fn main() {
let mut db = Database { data: vec![] };
do_work_and_then(|meaning_of_life| {
println!("oh man, I found it: {}", meaning_of_life);
db.store(meaning_of_life);
});
// I'd read from `db` here if I really were making a web server.
// But that's beside the point, so I'm not going to.
// (also `db` would have to be wrapped in an `Arc<Mutex<T>>`)
thread::sleep_ms(2000);
}
No threads are spawned. fn do_work_and_then(func: fn(i32)) {
thread::spawn(move || {
// Figuring out the meaning of life...
thread::sleep_ms(1000); // gee, this takes time to do...
// ah, that's it!
let result: i32 = 42;
// let's call the `func` and tell it the good news...
func(result)
});
}
`you missed a snippet, a thread is spawned.
edit: formatting
Pointing out weaknesses and things that might be done better is what helps something mature, not just praising it.
I don't see how it is [unduly] inefficient or uncomfortable for a language at Rust's level to ensure that the programmer actually thinks through the execution of the code they are writing. If one doesn't want to think about such things - and there's absolutely nothing wrong with that! - there are plenty of higher level languages that can do that legwork for them with more than good enough performance.
To me it feels a bit like pointing out that the process of baking a cake from scratch isn't very ergonomic, what with all the careful choosing and measuring of ingredients and combining them in the right order using different techniques (whisking, folding, creaming, etc). That is simply what goes into creating baked goods. If that process doesn't work for you, you can buy cake mix instead and save yourself quite a bit of time and energy - but that doesn't necessarily mean there's anything to be done better about the process of making a cake from scratch.
And to use your cake analogy, the fact that a process is complex or laborious is orthogonal to the degree to which it is ergonomic. You could imagine baking a cake in a well organized kitchen with well designed, comfortable tools, or you could imagine having to stand on your toes and reach to the back of a slightly-too-high cupboard every time you need to fetch an ingredient, and having to use a mixer with way too many settings you can never remember, and none that does exactly what you want. I think this is a better analogy for ergonomics in programming languages.
While it may be a better analogy, it doesn't really reflect the way the term is used. In my (admittedly anecdotal) experience, most people's complaints about ergonomics are more of expecting difficult things to be easier than they inherently are than of the actual quality of the tools they are presented with. People think they are complaining about a mixer with too many settings, when in reality what they are complaining about is the fact that there are several variables that go into mixing batter and dough. They buy a mixer that's targeted at a baker or chef who wants complete control over how their batter comes out or who even needs a mixer versatile enough to also mill grain or roll pasta, then are predictably lost because it's not as immediately intuitive to use as a simple hand mixer. I don't think that makes the mixer not ergonomic, I think it just makes it not the right tool for that particular person.
Sometimes complexity is a necessary consequence of the domain, and sometimes it's simply the result of poor design.
I believe the point the author is making is that they added a feature for ergonomics' sake that ended up not being ergonomic; async-style programming usually coincides with first-class functions and closures, and those are painful in Rust.
Are they, though?
The example the author gives in the article tries to write a multithreaded program as if it is a singlethreaded one. Of course that is going to be painful, whether you're trying to use futures/promises or you're using callbacks. If you want to use multiple threads safely (i.e. at all) you need to use the right structures for the job - smart pointers, mutexes, etc. This is independent of language really, even dynamically typed languages like Ruby still have mutexes for when you want to use multiple threads (though they do take control away from the programmer in the form of things like global interpreter locks).
If you don't actually want to deal with the complexities of multithreading, then don't use it. For example there are a good number of languages/runtimes that only expose a single-threaded event loop to the programmer (even though they may use thread pools in the background). But I don't think "ergonomic" should necessarily mean "abstract away every single detail" or "things should Just Work even though one is straight-up not approaching the problem correctly".
Note: I don’t really disagree with your view, but it seems that view is in the minority.
But now that state data that previously lived on a nice last-in-first-out stack has a different lifetime. Since Rust fastidiously encodes lifetimes in the type system, using async can result in having more complicated types to talk about.
To me it's just the price that must be paid to use a language that doesn't constantly spill garbage everywhere, necessitating collection.
In this specific case, only the spawned thread is mutating the Vec, but the Rust compiler is usually conservative, so it marked this as a bug.
The actual bug is one or both of the following:
1. One of the threads could cause the underlying Vec to reallocate/resize while other threads are accessing it.
2. One of the threads could drop (free) the Vec while other threads are using it.
In Rust, only one thread can “own” a data structure. This is enforced through the Send trait (edit: this is probably wrong, will defer to a Rust expert here).
In addition, you cannot share a mutable reference (pointer) to the same data across threads without synchronization. This is enforced through the Sync trait.
There are two common solutions here:
1. Clone the Vec and pass that to the thread. In other words, each thread gets its own copy of the data.
2. Wrap the Vec in a Mutex and a Arc - your type becomes a Arc<Mutex<Vec<String>>>. You can then clone() the data and pass it to the new thread. Under the hood, this maps to an atomic increment instead of a deep clone of the underlying data like in (1).
The Mutex implements the Sync trait, which allows multiple threads to mutate the data. The Arc (atomic ref count) allows the compiler to guarantee that the Vec is dropped (freed) exactly once.
IMO the only viable way out is through: Rust needs to get the ability to properly abstract over lifetimes and functions that it currently lacks (HKT is a necessary part of doing this cleanly). A good litmus test would be reimplementing all the language control flow keywords as plain old functions; done right this would obviate NLL.
But yeah that's going to take a while, and in the meantime for 99% of things you should just write OCaml. Everyone thinks they need a non-GC language because "muh performance" but those justifications rarely if ever hold up.
You should read the article! The author goes into this in fascinating detail.
The author could have made the topic about closures, but that’s not what they wanted to talk about.
Most of these issues arise for library authors. I think a lot of folks in the ecosystem today are writing libraries instead of applications, which is why we get these blog posts lamenting the lack of HKTs or difficult syntax for doing difficult things that other languages hide from you. I don't think users have the same experience when building things with the libraries.
That said, there's a bit of leakage of complexity. Sometimes it's really hard to write a convenient API (like abstracting over closures) and that leads to gnarly errors for users of those APIs. Closures are particularly bad about this (which some, including myself, would say is good and also easy to work around), and there's always room for improvement. I don't think a lack of HKTs is really holding anything essential back right now.
My point is that absent some horrible specific hacks, HKT is a necessary part of doing that. A type that abstracts over Fn, FnMut, or FnOnce is a higher-kinded type.
Rust absolutely does have first-class functions, though. Their type is `fn(T)->U`. The "three different function types" that you refer to are traits for closures. And note that closures with no state (lambdas) coerce into function types.
higher_order(is_zero);
higher_order(|n| n == 0);
fn higher_order(f: fn(i32)->bool) {
f(42);
}
fn is_zero(n: i32) -> bool {
n == 0
}
It's true that when you have a closure with state then Rust forces you to reason about the ownership of that state, but that's par for the course in Rust.> It's true that when you have a closure with state then Rust forces you to reason about the ownership of that state, but that's par for the course in Rust.
The problem isn't that you have to reason about the state ownership, it's that you can't abstract over it properly.
I write Rust code for my day job, and I frequently use map/reduce/filter. IMO if I can write all my collection-processing code using primitives like that, it's got first-class functions.
Right, that's why I want parametricity i.e. HKT. String and Int are different types with very different semantics, you can't just say "it returns a String or maybe an Int, shrug", but it's very useful to be able to write generic code and datastructures (e.g. collections) that work for String and Int.
Rust programmers tend to do all kinds of little hacks here and there to make the borrow checker happy. It can add up. The borrow checker is perfectly happy when you copy everything.
Rust is becoming one giant antipattern.
(I'm sure highly experienced Rust programmers can get it to work, but there are probably less than 1000 people on this planet that can write good Rust that outperforms C++ so does it really count.)
(and in general java will trade off memory for speed: modern GCs can be very efficient in execution time but will use 2x-3x as much memory in return. If you want low memory usage your code is going to run slower).
Because if you require heap allocation for many short-lived objects, I expect this would be one of Java strengths unless you use an object pool.
I consider this a plus because that's a pretty simple change.
Do you mind sharing an example? If you're talking about using to_owned or clone without reason, then it's fully on the developer. Some more pitfalls to avodi: https://llogiq.github.io/2017/06/01/perf-pitfalls.html
What you say definitely doesn't match my experience. I would say Java code and Rust code are roughly in the same order of processing speed but the JVM "wastes" some memory. You also have garbage collection complicating performance in some scenarios.
I'm pretty sure you can get in the same processing speed ballpark with careful programming in both languages.
Outperforming C++ is definitely harder but Java should be doable.
Well, if we could just make developers smarter then everything would be easy, but we can't. It's reasonable to ask whether real-world developers working in Rust end up doing enough extra copying to outweigh the overhead of a JVM-like garbage collector that would let them avoid ever manually copying.
There are ways to handle that, though. It's called algebraic effects and they are nicely implemented in Unison language[1] (although there they're called abilities[2]). It's very interesting language; I recommend reading [3] for good overview.
[1] https://www.unisonweb.org/
one nice thing about rust's async/await is, you don't have to use it, and if you don't, you don't pay for it in any way. sure, I run into crates that expect me to bring in a giant runtime to make a http request or two (e.g. rusoto), but for the most part I have not had any issues productively using sans-async rust during the last few years.
so, if you don't like async/await, just don't use it! it doesn't change anything about the rest of rust.
Like you, I think that’s sort of fine, because you can always roll your own. But it’s also kind of sad: one of the best things about the Rust ecosystem is that you can so effortlessly use a small crate in a way you really can’t in C++ (historically, maybe modules will finally improve this).
Mixing executors is approximately fatal, AFAICT.
You could have two executors with no problem.
What might cause issues is if code running on one executor needed close interaction with the other - such as running a task on the other executor.
Most of the time this is just avoided by using only one executor. But that's not the only way.
That's because you're focusing on precisely the points where Rust has done better than Python, and conspicuously glossing over Python's asyncio debacle ... which looks exactly like Rust's.
In a nutshell, it works like this:
1. New syntax is introduced
2. We are told it's optional
3. The ecosystem adopts it, and it be comes de facto required
So right back at ya -- I am not convinced in the least by Rust's argumentation ;)
It's unclear to me how leadership can fix this. The cat is out of the bag, and the language must now contend with a second-order embedded syntax. Avoiding this becomes increasingly difficult as it is adopted in the ecosystem; just ask the C++ guys how they feel about exceptions.
Even in a systems context I think it's pretty reasonable to want to either perform or receive a HTTP request, as soon as you do that in Rust you are funneled into Hyper or something built on top of it (like reqwest) and instantly are dependent on tokio/mio.
The very first example in the reqwest readme^1 has tokio attributes, async functions AND trait objects. It's impossible that a beginner attempting to use the language to do anything related to networking won't be guided into the async nightmare before they have even come to grips with the borrow checker.
https://docs.rs/reqwest/0.11.2/reqwest/blocking/index.html
(Maybe this uses async rust under the hood, but you don't have to care about it)
Yes! Using `sans-async` must NOT diminish our productivity in comparison with `async` counterpart.
> at some point, coding event loops became very natural/comfortable for me
In fact it is, I'm very happy with it too.
> one nice thing about rust's async/await is, you don't have to use it, and if you don't, you don't pay for it in any way.
You've said it!
Either way there are bigger function "color" problems in Rust.
If you want a target for criticism in async Rust, it should be the lack of easy conversion from generators to async functions (and back) and the cognitive difficulty of implementing a custom executor.
I also think the tangent on closures is nonsense, closures are syntactically and semantically complex because closures are hard to keep safe without a garbage collector. Reference radioactivity is a feature, not a bug.
But, it is kind of an anti feature. It's incredible that Rust allows custom async executors and the surface area for them is tiny! That said, it's kind of black magic, even for Rust. I'm willing to bet there's fewer than 20 people in the world that could make a production executor today (not that it's going to stay that way - don't get me wrong).
If there's one reason to write an async application today in Rust it's because you can write a custom executor and give it a ridiculous amount of information about how the application is running by communicating with it, while wrapping both callback and polling backed APIs from system libraries in C.
But that's really hard to do if writing an executor is harder than just writing callbacks or polling code manually.
Might be worth a listen if you are intent on building your own!
It would take a couple months of bike shedding syntax and discussing internals but I feel like there's a version of this (applied to other function colors) that allowed a library author to annotate/decorate a function for an inclusive or exclusive set of these decorations the compiler can pick to call from a calling context, and allow the function author to specialize over the combination of decorators that actually get called. Since the set of decorators is small (I can only think of 2-3, including async) this feature would be straightforward to implement albeit potentially verbose.
There's a fancy compiler pass to make async work though. You need a special control flow analysis to figure out yield points in async bodies, since this new syntax would have no await.
It's probably too late for that. It might be implemented in terms of HKT.
Many languages have a distinction between statements and expressions, and some language designers thought that was ugly and designed languages where everything is an expression.
But because of how people think, an application typically has a rough hierarchy to it by the designer's intent. So the fact that this becomes a bit more explicit because a language marks statements or async functions as having a specific place in that hierarchy isn't generally a bad thing.
(1) either you write two versions of almost every function or you can only support one use case (sync or async). If you could write generic functions which work in both cases it would be better.
(2) it creates a lot of noise writing async/await. It's like if we had to always write x = call f() in the sync case.
Assuming it's possible, one way is to create a new event loop in a thread. This means you're creating and tearing down threads for a "trivial" wrapper. And, really, because threads are expensive, you'd probably want to let the caller provide an event loop.
Really, you wind up asking the caller to tell you what event loop to use. Let's compare real async vs. the sync wrapper function:
async func main() {
await middle()
}
async func middle() {
await libraryFunc()
}
# Using the sync wrapper without hiding the loop.
func main() {
loop = getLoop()
middle(loop)
}
func middle(loop) {
libraryFuncWrapper(loop)
}
You wind up "coloring" your functions through a parameter instead of by marking them "async".Perhaps your async implementation lets you use a globally defined event loop, but, really, globals are just backdoor parameters.
None of that means that such a wrapper is never going to be a good solution. Grabbing the global event loop probably works for many use cases. It's just that it doesn't avoid the coloring problem, it only hides it.
I'm not an expert but I can't shake the thought that Rust had it backward with async and generator. Generator looks like a good bridge between sync and async. Before reading about implementing async executor and digging under what tokio and async-std is doing (it's been months and I haven't finished), I thought implementing async executor is just as easy as running looping a generator.resume() until it yields GeneratorState::Complete until I realize generator is still on nightly!
It may be because I'm a noob in Rust, but I can't write easily:
``` let some_handle_arc = Arc::new(some_handle); future::block_on([ // this future will stop running midway if some_handle is modified in some way make_future(some_handle_arc),
// make_future_stopper will modify some_handle_arc when receiving
// a certain input from a certain interface
make_future_stopper(some_handle_arc)
]);
```My initial intuition is if async is generator-based, there should be an escape hatch where the generator-runner just exit, drop the generator, without waiting for the generator to yield Generator::Complete. But again it might be from my noobness being a simple Rust hobbyist.
Sorry for the rant
I agree; and I would also point at C#, which is the originator of the async/await syntax, and has had it for over 8 years now, with the ecosystem embracing it for most of that period. It works.
It didn't break Node because Node avoids function color problem altogether. Nobody writes blocking code in Node due to its single-threaded nature.
Function color problem is the difficulty of mixing blocking and async functions. It's especially annoying in Python, where you have to spawn a thread to call blocking functions from async functions, and welcome to mutithreading bugs.
In Node the problem doesn't exist, because you don't block in its blocking functions. You either do `await f()` or `f().then()`.
There are many situations where a low-level language and the control it affords are absolutely necessary, but low-level programmers are aware of the non-negligible price they pay.
Do you have an example of Rust's inability to hide implementation details compared to, say, Java?
No, this is straight up not true. Almost the entirety of the futures ecosystem is built explicitly out of nameable structs that library authors implement Future on. They do not typically have a load of closures inside, usually none at all. The unnameable closures are generally in 'user-space', for stringing new futures together with less friction. There are different tradeoffs, but the ecosystem is not built on a bed of indescribable spaghetti quicksand.
Big mistake! I never accomplished what I set out to accomplish.
Most of my experience is in C#, where the difference between async and threaded code is mostly syntax sugar. (Until you get into the details.) (And async code makes writing UI code much cleaner.)
Trying to write async Rust as a novice is downright hostile. The tradeoff of threads makes async code such that it's only worth it if your Rust process will have highly concurrent load; as opposed to C# where refactoring threaded code to async is housekeeping.
All I would say is hang in there. It gets better (from my experience).
Rust is not something you can code along as you think. There is a bit a of upfront thinking what the data-structures & data-flow look like.
At present, I can know in advance without writing code whether implementation I am thinking in Rust will work or not (work => satisfy Rust compiler)
This is my biggest gripe with Rust. I want to write code that produces the right result first and make it safe later.
I'm trying to say, that mostly you problems was lifetimes and borrowing, like with other beginners, but problems were magnified to a level when beginner have no chances to make it work.
In the other languages where I used async, Javascript and C#, there is no penalty for using a variable across the async gap. The async keyword just means that something will block. (And plenty of other details, like your UI can paint itself.)
If you enjoy it, learn it and have fun doing high performance work at your new job.
If you’re not passionate about it, it’s OK, there are lots of work in other domains.
Just a guess.
The problems the author is describing might apply more to library authors, but for the many Rust programmers like me who are trying to solve problems, not write a framework, async is easy and practical.
I don't know much about Rust, but I found the examples in the article kind of simple. As far as I can see, the author is trying to spawn a new thread and share an object between the main thread and the new one. It's seems like an everyday paradigm.
Perhaps the problems arise when passing complex objects instead of native types (i.e. u32).
And it works very well in Rust as long as you understand the core concept of the language (ownership). Rust is special, it has this fundamental concept that you need to understand before going on, and that's add some inevitable learning curve. But once you know how it works, it just works exactly as you'd expect and in the kinds of things the author is trying to do[1], it doesn't gets in your way at all.
The post just sounds like the author didn't took the time to understand the core concepts, and is trying to brute-force their way through. This way of learning doesn't work very well for Rust, or at least not if you are impatient. (Especially because the error messages when it comes to closures are still far from Rust's overall standards).
[1] there are things where Rust's ownership is a constraint (cyclic datastrutures for instance, or shared memory between your program and a C program (io_uring, or wayland)). But those are situational.
I was really referring to the parent comment which said that the author of the article seems to be complaining about something that applies only to a specific area ("The problems the author is describing might apply more to library authors").
I said that to me, the examples in the article seemed more like an everyday paradigm (mundane, creating a thread and sharing an object).
In some languages and frameworks, I might need to share an object between threads to do this efficiently. In Rust there are other options, e.g. using Arc or a reference or moving the object into a task. In my years of programming Rust I've never wanted to share some object on a thread, because there are frameworks and libraries (Actix-Web, Tokio, Rayon, async-std, others) that give me a higher-level abstraction for doing this.
I can understand using this as a point of comparison if you're not very familiar with the language. But I have to confess that in my three years of working with Rust, I've neer had to worry about threads. I've been able to use a library to solve my concurrency or parallelism problems so that I can go back to worrying about design or reliability concerns.
This won't be everyone's perspective, after all, someone has to write those libraries. But it is mine, and I think users of such libraries far outnumber authors of such libraries.
I didn't have to deal with closures much because I'm comfortable writing a 2000 line match
0: https://github.com/serprex/openEtG/tree/master/src/rs/server
A better title would be 'Rust's async model has some flaws that are a product of its desire to ensure safety and make the true cost of certain operations visible'.
Of course, such a title would never hit the front page of hacker news.
The problems the author identifies are real, but they're not unique to Rust. You can get around them fairly easily with dynamic dispatch, which is what basically every competitor to Rust's async model does by default.
I agree with the author that Rust's use of async in the ecosystem is increasingly universal and it's starting to become a little problematic. As the author of Flume and a (soon to be released) concurrent hashmap implementation, I'm trying to work on adding primitives that successfully marry sync and async with one-another. There's no doubt that it's hard though, and function colouring is a problem.
I think that’s the most important part of the article. People like Rust but it’s becoming more complex than C++. But unlike C++ it’s more difficult to pick and choose what you use.
Rust’s death will be one by thousand cuts. “I really like the language but can’t justify all that complexity in my new small and simpke project” is what will cause the popularity to drop.
Those kind of comments are not based on looking at Rust's features on a theoretical level.
It's more from the viewpoint of "How hard is it to get something (correctly) done". Those impressions are mostly formed either from using the language and failing (or having severe difficulties) to solve a given problem, or being exposed to blog posts describing those kinds of problems.
Rust sure does not have as many features as C++, but it seems that in practice, the combination of available features (and/or the problems those create) is perceived as just as complex as C++. Though not being a fair assessment, the Rust community will have to try fighting this perception, if Rust is not to be labeled way more complex than C++ in the coming years.
I don't know if you are using Rust, but what would you say, why is that? Where does that motivation come from?
It is always comforting to dismiss something that you haven't made much effort to understand as unnecessarily overcomplex. I think we're all guilty of that at times.
On the other hand, maybe those folks decided, after giving it a serious try, it's not worth it. Mind you, that does not mean they are right. But in the end, if the effort for those people is perceived as too high, then maybe, just maybe, in practice it really is.
After more than 25 years as a developer, I learned the hard way, that programming languages, are, in the end, a matter of taste. You won't get someone to use a language, just by citing the feature list. Programming languages are a product made for developers. If they don't like it (for whatever reasons, and yes, these are at times highly subjective) they won't use it.
If I really like C and I ship a bunch of C code that gets exploited to build a botnet, that decision had consequences. I should have valued safety higher and the fact I didn't "like" safe alternatives was a distraction.
Rust is difficult to read and maintain, due to the common use of abstractions over abstractions over abstractions. Just looking at a structure, it’s already hard to understand what functions are available (hidden in traits, themselves behaving different according to cargo features…). And of course, it also has things such as operator overloading, and hidden heap memory allocations, so there’s a lot of hidden control flow happening.
It’s also even slower than C++ to compile, especially when using macros. And it doesn’t have static analysis too as C++ does. Both of these also contribute to “complexity” as in “how much effort it takes to write an application”.
I believe C++ is far more complex because, having programmed in C++ for 30 years and hanging out with other developers with similar levels of experience, I still trip over weird C++ features that I never knew existed. My latest example: Argument Dependent Lookup, which means f(a) and (f)(a) can wind up calling completely different functions. How many C++ developers know that?
In my experience, Rust has fewer complexities like that.
Can you expand on this point?
I'm confident if I actually put in the work to learn Rust I would end up liking it and I plan on doing that at some point, but it certainly doesn't help my motivation.
So you are saying:
“As someone who has never really used A or B I agree with the comparison of A and B.”
Pretty much sums up some of the comments here. People who don’t spent much (or any) time with Rust make (or support) wild statements like this.
Rust is what you can learn in a few weeks, C++ takes a lifetime and then you are dead.
I'm someone who wishes I could use Rust for everything, but I'm coming around to the idea that it isn't really suited to everything. It was designed for the problems faced by low-level programmers, and it continues to be something of a revelation in that area, and it has also really punched above its weight in totally unrelated areas, and that's great. But I don't know if we should expect it to be great at those other areas that are so far outside its core wheelhouse. I think it's good that Rust has an option for async, but comparing its async ergonomics to Go may just be an apples-and-oranges thing.
It's worth remembering that this is still a very acceptable thing to do in some cases. And spawning OS threads is sometimes easier to write and easier to read, and easier to reason about than async code.
Just because async is in the language, don't feel like you need to use it everywhere.
Except that you will have to because no alternative library exist
So just use the async functions from the library synchronously if you want.
But this is partly the reason why libraries might be more inclined to support async rather than blocking calls.
The type- and trait system got Rust very, very far, delivering static dispatch and stack allocations by default, while maintaining type safety and a high level of flexibility for a manageable complexity cost. When async Rust was incubating, these goals were upheld dogmatically, and the community set out to preserve all these traits when building async, for better and worse. However, in order to get there, Rust needed to:
- Power-use existing complex features and/or verbose wrappers such `Box`, `Send`, `Arc`, `impl Trait` types and an influx in generic type parameters. (Several of these cause function-coloring issues)
- Introduce new complexity for self-referential structs with `Pin` (and if you dare - check out "pin projection")
- Allow for custom and extensible runtimes (aka executors - responsible for scheduling), including both single- and multithreaded.
The end result is a tremendous technological achievement - essentially all requirements were upheld (to the point where async can be used even in embedded environments). However, for non-experts, it's tremendously complex - which affects almost all users, even those that don't write custom runtimes or advanced combinators.
I'm not the one to judge whether the story of async Rust is a failure, but if it is, there is not a single reason. In broad terms, the project was extremely ambitious and Rust, arguably, wasn't entirely ready for it. A couple of missing features that could possibly have helped:
- Generic Associated Types (currently causes verbose workarounds for combinators, streams etc)
- "first-class" / ergonomic support for state machines (async fns compiles to a state-machine - relevant also for async traits)
- Reliable destructors (`drop` is famously not guaranteed to run in safe Rust, preventing things like borrowing across tasks, which exacerbates the `Arc`-hell)
Additionally, async I/O kernel interfaces were maturing in parallel - e.g. io_uring was released after async Rust was stabilized (afaik), which might have resulted in a different, completion-based design.
In a GC language, you can "just" let the GC system clean up after you're done--who owns allocation and cleanup isn't an issue.
In a non-GC language, who allocates and cleans up what and who pays for that suddenly becomes a fundamental part of the domain.
For example, I have seen a zillion "lock-free" data structure libraries in Rust, and I don't think I've seen even one that actually holds to even the basic amortization guarantees like java.util.concurrent.
But, let's talk about something like CopyOnWriteArrayList.
If I add() a CopyOnWriteArrayList(), a copy of the underlying data comes into existence with the new element. That should certainly get charged to the thread doing the add(). Fine.
Some Iterators are probably still pointing at the old underlying data. Not a big deal.
So, the final Iterator() with references to old data finally finishes and gets deallocated/dropped.
Now, all the underlying data that got copied is no longer relevant and needs to be deallocated/dropped.
So, who does that?
Does that happen on a thread that next calls get()? If so, get() is now O(n) worst case. That certainly doesn't make people happy.
Does that happen on a thread that next calls add()? Well, we can have a lot of iterators that were all working and now ended. So, that add() might be deallocating a LOT of copied stuff. This might be where it has to be done, but the fact that your O() performance is dependent upon the size of the data and the number of copies that got made makes you O(n^2)-ish (O(n*k) to be more precise). That's not good.
Does the deallocate happen on thread where the last Iterator() got dropped? Having to deallocate the universe because you just happened to be the last one using the underlying data store doesn't seem like a good idea.
Perhaps you do a little bit of deallocation work on every get()? Probably good for throughput, but it sure makes the data structure a lot more complicated.
Perhaps you have an explicit deallocation call. That kind of defeats the whole point of using a language like Rust.
In a GC language, this all gets charged to the GC thread and swept under the carpet. That's what I'm thinking about when I say "amortized".
There's also a slightly different amortized. In C++, for example, when you insert() into an unordered_map(), the "normal" time is O(1), but every now and then you get an O(n) while the structure reallocates. This results in an "average/amortized" cost of O(1) if the reallocation is done intelligently (generally doubling in size every overflow).
1: Many GC languages let you run arbitrary code when the associated memory is freed, but if you have to wait for a full GC pass to happen before e.g. a lock is released or a file descriptor is flushed, that will cause many issues as well.
Also, it's been many year Pin was stabilized, and that you can write `impl Fn(Xx)` instead of using a where clause, and return closures using that syntax.
The essence of the problem is whether you can turn internal iterators into external ones without rewriting them. If you can't then your language has function colors.
It won't solve the color problem.
Unlike JS, Rust can wait for async code in blocking contexts, and it can run CPU-bound code in async contexts.
Lots of green threads get load balanced on top of a thread pool. Isn’t that the whole unique aspect of Go? So in essence they do both.
Go green threads or go routine are the only visible constructs to users. Whereas the hardware threads are not usable to users.
Edit: From the perspective of mutli-paradigm, C++ is truely a unique language. It not only has multiple paradigms, and largely contains them in a relatively harmony state.
Just like it takes care to compose thread- and process-based concurrency, it takes care to compose threads and async correctly and comprehensibly.
If you care about utilization you MUST use threads (or processes, which are a different story). So if you use async, then you have the problem of composing them, which is indeed hard. There are some C++ codebases that do this well but they're notable for being exceptions ...
There does appear to be overuse of async as a premature optimization. The default should to use threads and explicitly shared state -- basically the idioms in Go, although I'm sure ownership complicates that a lot.
But there doesn't seem to be any concerted effort to standardize green threads. Win32 has had fibers for a while, but nobody's actually using them, and runtimes generally don't support them. Even .NET tried to do it in 1.x, but then dropped support in later versions.
Yes. Fortunately Go has enough well-exercised standard libraries that this usually isn't a big problem.
This is not a concern for most Rust users, but it is a concern for large companies that are potential patrons of an up-and-coming programming language. I'm not saying it's wrong to cater to their needs, rather that it's not something over which regular Rust users should lose sleep.
• Timeouts and cancellation. Rust's async code can be aborted from the outside (with correct cancellation and cleanup of everything in progress). OTOH if you run imperative blocking code, you need every blocking function to cooperate.
• Full utilization of CPU and I/O in workloads that mix both. Async executor balances threadpools/polling queues for these automatically. OTOH if you just use regular blocking calls with no state machines/continuations, it will be hard to separate these two. A single mixed-use threadpool will oscillate between I/O-bound threads and underutilizing CPU, and CPU-bound threads and idling I/O.
I ended up here recently.
This part is truly awful. I can forgive a lot, and much of what appears in this blog post falls under than umbrella, but the tokio/std futures schism is offensively discouraging. I strongly suspect it is a symptom of some great dysfunction.
Maintaining Rust code is expensive. More than completing the first iteration of a project. Language such as Go or C are boring, but don’t have that issue.
Closures are hard, but a lot of it's for good reason. As with all of the hard things Rust does, it's because it's trying to be correct.
Having played with this a bit recently, I think folks are going to end up:
- assuming Tokio (now that it’s 1.xx)
- mark nearly everything as async
- push callers to use rt.block_on to wait for an async function from non-async code
I definitely miss the easy concurrency of golang the author refers to (more so in the colored functions post), particularly since AFAICT, tokio is basically going to become the default assumption. So Rust is technically pluggable here, but won’t be in practice. Does that mean that embedded folks end up having their own !tokio that they prefer? Maybe! But they’ll get locked out of the “assume tokio” ecosystem.
I've noticed this as well. And then there are the libs that don't support async, like diesel. I lost time trying to find the right combination of libraries for a web project. There are a lot of things I don't like about Go, but this part is easier.
When something foundational, like networking, gains a defacto dependency on tokio, it seems inevitable that a large footprint of the ecosystem will eventually depend on it.
Doing so then colored/infected everything with async. It wasn’t actually bad in the end, but if you’re just trudging along trying to do your job, you suddenly walk into a “and now update all of this”. Staring at the innards of reqwest::blocking though made me realize “if I care about performance at all, I really shouldn’t use this”. One of my main reasons was so that I’d know there was only my inclusion of tokio/the version I wanted (reqwest lagged a bit, so then I had hyper w/ tokio1.0 and reqwest with an older hyper with an older tokio... and I was worried about understanding that interaction).
Popol is designed as a minimal ergonomic wrapper
around poll, built for use cases such as peer-to-peer
networking, where you typically have no more than a
few hundred concurrent connections. It’s meant to be
familiar enough for those with experience using mio,
but a little easier to use, and a lot smaller.
It shows a lot of promise.Whereas others (Go for example) sacrifice some efficiency and performance to ergonomics.
The current model also still requires a lot of boxing and cloning, so it is far from being “zero cost” as advertised.
Yes, because the "cost" in "zero-cost" only refers to runtime cost.
The loom model is VM managed thread stacks with user space context switching. The costs are all quantifiable and a suspended "virtual" thread uses memory directly proportional to the amount of stuff on the stack, same as any coro implementation. However there is no async/await or coloured functions. The VM just makes threads super cheap
Where your sync functions have X considerations and your async functions have separate Y considerations - BUT, async/await sugar makes these widely different behaviors look nearly identical - so you get "clean" sync code aesthetics on your asynchronous code and are sort of on your own when tracking the behavior of its implication.
https://www.modernescpp.com/index.php/c-20-coroutines-the-fi...
Dummy event loop example:
Probably the only true comparable might be the scylladb seastar framework. And it's just that, with all the burdens that come with C++ coding. You won't find every client and server protocol under the sun implemented with it either which arguable async rust is building towards.
At a high level, you provide a pure function that takes the current value. The system re-runs it as many times as needed to sort out collisions with other threads, then stores the new value -- which then becomes the argument for the pure update functions running in all the other threads.
Has anyone considered an STM for Rust?
I understand completely why most people disagree - I always disagreed even louder.
Those are different things! Anything pure (i.e. returns same output) could still require a closure if it captures heap-allocated data - which in Rust would entail free-ing and transferring ownership and all that.
The problem is that it exists. Or rather, that multiple async libraries exist and every attempt to employ asynchronous programming in Rust inevitably leaves you with the problem of aligning these different, redundant, incompatible, incomplete, evolving async libraries, which is an unmitigated disaster.
Rust, all by itself, is a non-trivial hill climb for developers. They're willing to bust out the climbing tools and make the attempt because they see the value. But then they get hit in the head by debris like futures::io::AsyncRead <-> tokio::io::AsyncRead and that is it; they come down off the hill and go find something else to scale. Worse yet they don't talk about it; they love Rust, the idea of Rust, and they want it to thrive so they avoid being critical.
IO and async is fundamental. It's too fundamental to suffer multiple, partial, incompatible, ever evolving realizations. This is the number one issue that threatens Rust. People have only so much patience to spare; only so many attempts they're will to make climbing the same hill.
I have hope. Companies like Amazon and Microsoft are making serious commitments to Rust because they too see the value. They're going to get knocked in the head by the same falling debris. And so there is a chance that while this is all still young and the MBAs and lawyers and rent seekers aren't yet savvy, Rust will receive the sort of tedious, expensive labor it so desperately needs to mature.
I think this is the real fundamental disagreement here (well, at least with the async stuff). (And the comments slightly earlier, regarding "the color of your function".)
The way I think about the various async stories out there (like, all of them, Go's, Rust's, Python's, C w/ OS threads), is that they boil down to roughly three or so primitives: (I haven't formalized this … and someday I should probably write a blog post on it, so it's not going to be perfect.)
1. separate executions of code (threads, green or not, goroutines, etc.)
2. selection (the ability to block on **multiple** threads, simultaneously,
but return after *one* is finished or ready. It's not join().)
3. cancellation (the ability to interrupt and cancel a thread)
"Was […] OS threads not an acceptable solution?" No: the selection story is terrible (without involving epoll. You'll pretty much have to build this out around conditions, and it's painful), and the cancellation story is close to non-existent (you have to somehow transmit to the thread that it should cancel, and then that thread needs to obey it). In combination, some things are outright impossible: if you're blocking on a socket recv in a thread, how do you get notified if you were cancelled? (You literally can't, with the primitives that most OSes offer, without resorting to hacks, or epoll.) (Some OSes have calls to kill threads. They are basically one-way tickets to UB.)Golang's solutions get closer, but I don't think they're appropriate for a systems language that cares about low-level performance & memory layout to the degree that Rust does.¹ But even golang struggles here: the cancellation story is missing. Golang's approach here is retroactive: you have a context object, and you must thread it through function calls to anywhere it might be required. If your API didn't think to do that, you're SOL as a consumer, but worse, even as a coder, adding it is going to be a right PITA.
This isn't to say Rust's story is perfect here, either: it is tempting to think that dropping a future is cancellation, and it is very, very close (it provides a decent default). But sometimes cancellation also needs to propagate across an I/O boundary (usually, across a network socket to a server, to cancel a request) and those are async. (I think there is some work being done in Rust with asynchronous drops.)
These problems show up in JavaScript — whose Promise lacks cancellation — and Python (which has cancellation via raising CancelledError; also, Python really, IMO, messed up the terminology. Between futures, coroutines, tasks, and awaitables, there's ~2 real types (futures and tasks), and the rest are … IDK, weird distinctions that are hard to keep straight.)
¹Now, this is the heart of the other part of the article, the half about closures and fn types. Rust cares about ownership (as it allows programs to avoid not just memory issues, but a whole host of bugs resulting from ownership confusion that happen even in memory-safe languages like Python) and it cares about the performance implications of things like dynamic dispatch, or separate allocations for the data associated with the callback that you want to pass. And between trait types & generics, it lets you choose, but that does result in some verbosity. You can also choose Arc, but some of us don't want that in all cases.
(I don't agree with the "color of your function" argument/article, either: async functions in Rust are fundamentally still functions, they just return a Future<T> instead of a T. That distinction is important; we're not just after the T, and the future is something we're going to block/wait on; it is its own type, with its own operations — specifically, and more or less, the ones above! —, that are crucial to how the code functions.)
N to M is much harder to get cancellation right, but again if you write the scheduler, you can define the cancellation points, so you are not beholden to undefined behavior. Instantly cancelling a thread is just not solvable, and for Rust, which cares about low-level performance &c. then long-running computations could explicitly check for cancellation when it is needed. Threads that perform I/O regularly will naturally hit cancellation points.
Cancellation is a huge problem in concurrency, and the usual answer to "how do I cancel a thread" is "you don't want to cancel a thread" which is rather unsatisfying. As you point out, it's not like using async and/or promises automatically solves the problem either; the only way to get it right is to build it in from the start.
Sure, you can stop polling the future at any time, but that doesn't mean that you left the system state consistent.
Careful thought is needed on every call to await in an async function. It might be the last thing that async function ever does.
That's probably something that should be true in a well-implemented system anyway. After all, processes could be killed at any point.
With the asyncification of the Rust ecosystem it does make life difficult. Maybe the Rust async implementation is fundamentally sound, but the user experience for non-trivial cases isn't quite there.
I've seen a few comments talking about the choice of Rust adopting a poll model as opposed to a completion model. Am I correct in assuming these are the same things as a reactor (kqeue/epoll etc.) vs a proactor (IOCP) and that a proactor can be implemented in terms of a reactor.
> Am I correct in assuming these are the same things as a reactor (kqeue/epoll etc.) vs a proactor (IOCP)
Close enough, yes.
> a proactor can be implemented in terms of a reactor.
Yes.
From writing a large SaaS companys code in async Scala, function color was none of my problems.
Like, in both Windows and Linux, threads exist as a callback function (pointer to function) with its own stack, in which a void* parameter is passed. Can this be done in Rust? Can multithreading be done without closures?
> a closure which is generally easier to use
I didn't experimented much with closures so I can't discuss about simplicity yet (but the article explains that "Closures can be pretty radioactive").
> within the limitations of C
Threads are exposed like that by the operating system. The C implementation is just a way to have access to what the operating system exposes, so I think it should be more like a limitation of the operating system?
In rust and C++ you can make a function with associated state easily and pass it around, both as a raw value with a unique type (though it's difficult to name), and as a reference or heap-allocated value which can be handled generically.
In all these cases you need to make sure that any pointers remain valid until the function has finished executing. In C and C++ the compiler cannot help you, in rust the compiler can catch cases where function could be called after those pointers are no longer valid. This is what causes the 'radioactive' property of closures containing references to other data (they are also radioactive in C++, just at runtime and unpredictably, as opposed to at compile time in rust).
If the example code in the article was written in C++ (and the complaint about the closure type which is also present in C++ was addressed), there would be a subtle concurrency bug, because the database handle could be closed and freed before the closure function actually executes.
In all these cases the solution is either making sure the closure runs before the data it needs becomes invalid (which is not generally easy to do if you're planning to use it to spawn a thread), or you move the data onto the heap, either arranging so the closure frees the data when it executes (and then guaranteeing that it executes), or using some shared reference count system to ensure it only gets freed when all code which might want to use it is done. In Rust and C++ there are mechanisms in the standard library and their closure implementations to achieve this, in C you must do it manually.
I was talking about that at the lowest (OS) level, the thread mechanics is exactly what C presents us as a thread (and all we can do with a thread). The rest (the alternative options C++ and Rust implement) is handy to have, built on top of the standard implementation, but not necessary to actually operate with threads (with tradeoffs, of course). So I don't see it as a limitation: it's just the way it is; the way the OS expose multithreading to its users through its libraries.
Not having those handy features is not a limitation (in what multithread regards). What a I could probably see as a limitation for a system language perhaps it's the fact that I cannot easily reach what the OS exposes (as multithreading implementation) and that I have to use a special construct to reach it. But if in Rust, Closures are just function pointers, then I guess it's fine (and this is what I am trying to understand with your explanations and with the article).
I think Haskell is stuck like you say because if your code is overly wordy, it will run slowly too, yet Rust is still relatively fast even if you do .clone() a lot.
Rust's ideal use case are the scenarios that use stuff like MISRA-C, kernel and drivers code.
The design team has done a wonderful work pushing for affine types into mainstream, however outside of forbidden-GC scenarios, the productivity loss versus any kind of automatic memory management approach, isn't worth it.
(I agree with Animats comment elsewhere in this thread blessing all threaing models as "OK in isolation". It's just they don't play well together in a single language.)
Deterministic destruction is obviously very useful for resource management, and not just files and database handles, but also locks and scope guards.
Single ownership allows working with mutable data without dangers of shared mutability. Without it you either need true immutability, or defensive coding by proactively copying inputs to ensure nobody else can unexpectedly mutate data you have a reference to.
Single ownership also happens to nicely enforce interfaces that require functions to be called only once or that objects can't be used in invalid state, e.g. after a call to .deinit() or after intermediate steps in builders/fluent interfaces/iterator chains.
It's in this (HN) comment thread and a bunch of informed comments on the original article:
https://github.com/eeeeeta/blog-comments/issues/10#issuecomm...
https://github.com/eeeeeta/blog-comments/issues/10#issuecomm...
https://github.com/eeeeeta/blog-comments/issues/10#issuecomm...
The comments are correct in my view -- Rust can't/shouldn't do what Haskell did, which was to create use a general purpose abstraction that is essentially able to carry "the world" (as state) along with easy-to-trade chained functions on that state (whenever it gets realized). Haskell might have solved the problem, but it has paid a large price in language difficulty (perceived or actual) because of it, not mentioning the structural limitations of what Rust can do and it's constraints. The trade-off just isn't worth it for the kind of language Rust aims to be.
Realistically, I think this issue is big but not enough to write off rust for me personally (as the author seems to have) -- I'd just do the move + Arc<T> shenanigans because if you've been building java applications with IoC and/or other patterns that generally require global-ish singletons (given example was a DB being used by a server), this isn't the worst complexity trade-off you've had to make, though the Rust compiler is a lot more ambitious, and Rust has a cleaner, better, more concise type system as far as I'm concerned.
I think another thing I've gained from this article is another nice little case where Haskell (if you've taken the time to grok it sufficiently, which can be a long time) offers a bit of a nicer general solution than Rust, assuming you were in a world where those two were actually even substitutes. In the much more likely world where you might compare Rust and Go2, this might be a win for Go2, but the rest of the language features would put rust on top for me.
Author wants to have node style callbacks
"I’d like to make a simple function that does some work in the background, and lets us know when it’s done by running another function with the results of said background work."
Author is unhappy with Rust.
What I would like to have out of Rust async runtimes is the Go version: Coroutines and moving waiting IO automatically to a thread queue.
Cargo, macros, const generics and `#[cfg]` wouldn’t exist any more, replaced with comptime evaluation.
The standard library would be half its current size (considering its redundant functions, deprecated functions, or constructions that Clippy wants to be replaced with other constructions).
IDE integration would be considered immediately.
Per-function allocators wouldn’t be an afterthought (the current proposal is really bad, but it’s too late to do something better).
And async would be part of the language and the standard library, “colorless” and completion-based.
The problem is that it’s too late. The Rust language itself is mature, and cannot afford major breaking changes any more. So, improvements can only be made on top of what’s already there, limiting their integration, and making the whole thing more and more bloated and complicated.
I’m super interested that you think that’s something that can exist, how would it work, what would it look like in your mind ?
Basically using "await" just means you are declaring opportunity for concurrency at the call site, but it is still valid to await a synchronous function. And it is valid to call a function synchronously, even if it internally supports concurrency via await.
- comptime evaluation aren't something Rust designers are interested in, as it doesn't fit their functional programming mindset[1], (and it's not a new concept to Zig, D has had something similar for long).
- An IDE-first compiler is a good idea (and a must have for a production language today) but I'm not sure how feasible it is to start with something like that. If it's going to slow down early development, then it's definitely not a good move to go there first, but I don't know.
- per function allocation is a pain in the ass from a usability perspective. In fact it recreates the same “function color” problem you'd like to avoid. Just think of your non-allocating functions as blue and allocating ones as red functions, you can't call a red function from a blue one.
- Zig's “colorblind” asynchronous function rely on automagically switching between async and sync functionn at compile-time, and I really don't see that fitting with Rust's thrive for explicitness.
[1]: https://twitter.com/pcwalton/status/1369114008045772804
I am curious as to how Zig comptime is going to interact with IDE features.
Zig generics are dynamically typed; Rust generics are statically typed. Everyone in Rust was absolutely aware of dynamically-typed generics and they were actively rejected.
Explicit allocators were likewise rejected. They were deemed to add too much noise in the 99% case in which you just want to use the system malloc. You might disagree with that decision, but none of Rust's designers consider it a mistake.
I'm also very skeptical that the ability to automatically switch between async and sync would be the right decision for Rust. It may be the right decision for Zig. But in Rust we have to, for example, work properly with OS TLS and any sort of automatic async/sync switching would be a footgun.
Yet now when you consider how well JS was able to add support for asynchronous programming, it's pretty clear that JavaScript was actually a great programming language all along.
JavaScript was designed to evolve. That's the difference between intelligence and wisdom. Unlike intelligence, wisdom takes into account the unknown.
Async in Rust uses a radically different model than JS or golang, but that's not to say the other languages are bad. It's because Rust has different priorities, and needed an async model that works well without a garbage collector.
They are only fragmenting development.
In my opinion there are only two programming languages worth mastering: C+ (C syntax compiled with cl/g++) on client and Java SE (8u181) on server. That said C++ (namespaces/string/stream) and JavaScript (HTML5) can be useful for client/web.
All other languages are completely garbage! Not only do they have huge opportunity cost, but they make it harder for this civilization to be useful long term!
We have linux/windows and C/Java, instead of making another language in C, make something useful that is open-source instead!
Edit: Most downvoters here will be technology naive; they think everything gets better for eternity, but in reality everything hits it's peak at some point and electron processors and their OS/languages have now explored all viable avenues. Accept that and stop wasting time!
And what about C#? It's in a similar boat as Java.
Also, how is the Java memory model different from Go for this use case? They both allow mutation by sharing.
I'm waiting for user space network, that is the last feature that will make the switch worth: my system uses almost as much kernel copy CPU as my user space process!!!
C# is an ok alternative but they went for value types instead of sticking to the VM. Java has atleast 10 years head start on C#, also Microsoft.
Go has no VM but uses GC, thay miss half of the requirements to make Joint Parallelism!
WebAssembly has a VM but no GC, that is more interesting, unfortunately there is no glue code in the reference implementations!
The low latency GCs and the general performance improvements seem quite good though.
> C# is an ok alternative but they went for value types
Java is also getting value types (project Valhalla).
> Go has no VM but uses GC, thay miss half of the requirements to make Joint Parallelism!
What do you mean by Joint Parallelism? I didn't find relevant resources. And is it bound to having a VM?
Joint Parallelism is a term that I invented, it means loosely defined: if two threads can read and write to the same memory at the same time without it causing too much loss... basically you need atomic concurrency which is only really efficient on a complex memory model VM with GC if you want high level language support:
"While I'm on the topic of concurrency I should mention my far too brief chat with Doug Lea. He commented that multi-threaded Java these days far outperforms C, due to the memory management and a garbage collector. If I recall correctly he said "only 12 times faster than C means you haven't started optimizing"." - Martin Fowler https://martinfowler.com/bliki/OOPSLA2005.html
"Many lock-free structures offer atomic-free read paths, notably concurrent containers in garbage collected languages, such as ConcurrentHashMap in Java. Languages without garbage collection have fewer straightforward options, mostly because safe memory reclamation is a hard problem..." - Travis Downs https://travisdowns.github.io/blog/2020/07/06/concurrency-co...
I'm not an expert, I can't explain why these things are true, because I'm trying to make something useful on top of C/Java instead of going too deep.
But what I do know is that my app. server kicks everythings ass to the stone age in terms of performance and ease of use: https://github.com/tinspin/rupy
It's facinating to see humans struggle with the wrong tools, and defending those same tools without even trying the alternatives (I have also worked with perl, PHP and C# so I know why those are bad).
But I'm sure time will flush the bad apples out eventually, specially when electricity prices go up.
* https://openjdk.java.net/projects/zgc/
* https://openjdk.java.net/projects/shenandoah/
Thank you for the rest of the post, it's very insightful :)
It's not fun to deal with NUMA domains efficiently. For hardware engineers, it's not fun implementing cache coherency protocol efficiently.
There is a reason why even Intel was experimenting with channels back in 2010 and you see more and more "network on chip", "cluster on chip" designs with non cache coherent memory.
As we need more cores, shared-memory synchronization becomes too costly.
Source:
- https://en.wikipedia.org/wiki/Single-chip_Cloud_Computer
PS: I'm not even talking about the difficulty of writing correct concurrent data structures.
Even glibc authors get that wrong https://news.ycombinator.com/item?id=26385761
Most programming languages are using construct that assume memory coherency for synchronization (like atomics). It may very well be that hardware channels and DMA become more prevalent in the future as more and more cores are packed and shared memory becomes prohibitive. This would be a totally different paradigm.
I can't explain it, just claim that my code works and performs accordingly: https://github.com/tinspin/rupy