8 ms·
Six ways to make async Rust easier
- littlestymaar 5y ago> I believe that the better reason for asynchronous is it enables modeling complex flow control efficiently. For example, patterns like pausing or canceling an in-flight operation are challenging without asynchronous programming. This is exactly why I think async/await is a must have for any language aimed at back-end. Nodejs has a lot of pitfalls (promise cancellation being a major one), but moving from it to Go felt like huge step back to me: having to use channels to synchronize your tasks feels really cumbersome when you're used to promises/Future with combinators and async/await.
- convolvatron 5y agoI find continuations alot easier to reason about as a programmer. I think async/await was chosen here because in simple cases it pisses off the borrower less
- lmm 5y agoInteresting, I find continuations utterly impossible to reason about, to the point where even if something is implemented with continuations I need some restricted model to reason about it in (e.g. iteratees).
- cube2222 5y agoI obviously can't see your code, but in my experience, working with Go daily for years, explicit channels are an extremely rare occurrence. If you want to run multiple tasks and wait for them to finish, you use a WaitGroup. If you want to stop processing on first error encountered, you use an ErrGroup. And cancellation with contexts also works very well overall. I actually think Go does it way better than all the "visible async/await" languages. Because usually when you write code, you just want to await the result, so in Go, awaiting is the default. In order to run a task without awaiting immediately, you use the 'go' keyword (at least that's the semantics of it all). It also omits the function coloring problem (this is a very big advantage), because all functions are async. For my use cases channels are usually only useful in two situations: 1. Asychronous producers and consumers. 2. Creating an "actor" which receives messages on a channel/s and processes them in a serialized manner. Where the messages come from multiple event sources. As an aside, I do understand why performance oriented languages like Rust choose the async/await path, as async by default carries a performance penalty / requires a runtime. I don't however accept it in non-performance oriented languages.
- akiselev 5y ago> As an aside, I do understand why performance oriented languages like Rust choose the async/await path, as async by default carries a performance penalty / requires a runtime. I don't however accept it in non-performance oriented languages. Ironically, I think Rust futures could be expanded into Go-like uncolored functions relatively easily without even much of a runtime. Since they're stack allocated by default, driving a future to completion is a matter of calling Future::poll in a while loop and handling the waker callback. I feel like the runtimes are there mostly to provide a unified interface to kernel APIs like io_uring/epoll/etc and ease ownership/task allocation.
- Animats 5y agoGo really does do this better. Go is a green thread system. In Rust, if you're actually doing any compute work, you're stalling out the async system. In Go, if you compute for a while, the scheduler will let someone else run. You can get all the CPUs working. This is well matched to writing web back ends. Rust's "Async" seems to be designed for a very specific use case - a program running a very large number of network connections, most of which are waiting. If you're doing something where you need all available CPUs to get the work done, it's a bad fit.
- anderskaseorg 5y agoYou make it sound like Rust doesn’t support background CPU computation. Rust has always had threads (https://doc.rust-lang.org/book/ch16-01-threads.html https://doc.rust-lang.org/book/ch16-01-threads.html), along with incredibly powerful libraries like Rayon (https://github.com/rayon-rs/rayon https://github.com/rayon-rs/rayon) to take advantage of them. async/await wasn’t specifically targeted for that use case because it didn’t need to be. But you can use threads with async, if you want, using a multi-threaded scheduler like Tokio with the rt-multi-thread feature flag (https://docs.rs/tokio/1.7.0/tokio/runtime/index.html#multi-thread-scheduler https://docs.rs/tokio/1.7.0/tokio/runtime/index.html#multi-t...).
- staticassertion 5y ago> In Rust, if you're actually doing any compute work, you're stalling out the async system. Just use threads? Or add explicit yields.
- axegon_ 5y agoI think the problem is that many people seem to think of rust as a back end language to begin with. And while there are crates that aim at that, the big problem there is that rust itself is not meant for that and it is simply bad at it. Rust is a systems language and this is where it shines. I'm currently using it for micro controllers and sensors and it is a perfect fit in this space. It genuinely feels like someone has given me a set of precision tools after doing everything with sticks and stones in the form of C. The reason why I think it's a bad choice for back end is not because of the limited ecosystem either. Take actix for example. In order to handle resources, your codebase inevitably becomes a huge pile of Arc<Mutex<_>> and Arc<RwLock<_>>s and mpsc's and sooner rather then later this end up biting back, most commonly in the form of an endless rabbit hole of read/write locks. And when I first saw sync/await in the rfcs, that's exactly what I feared would happen. Tokio is another elephant in the room that needs to be addressed: a ton of crates depend on different and completely incompatible versions of it. Most people get this wrong when they get overexcited about rust. And then there are those who get it right. Full disclosure, I haven't tried it or gone through the source code but InfluxDB IOx seems like a perfect choice for rust and a good use case for async/awaits.
- akiselev 5y agoThat comic at the end perfectly sums up my reaction (mostly because I want GATs though :)). I'm not fully sold on Rust's existing async/await implementation but how would this suggestion even be implemented? It's a massive breaking change on multiple fronts and I don't know how a new edition would paper over the impedance mismatch between the new system and async libraries written in the older edition. Feels like I need another year or two of using async rust in production to even begin to form an opinion on this. How would this work on embedded? I haven't done embedded since long before I could use async/await or Rust but it sounds like implicit anything (except maybe autoref/deref) is a deal breaker.
- carllerche 5y agoI believe it would be possible to implement using an edition in a way that any *old* code is compatible with new code. Regarding implicitness, a lot in Rust already is implicit (e.g. type inference). Good implicitness only feels weird at first, before we get used to it. I linked to Aaron Turon's article on reasoning footprint, but as a follow up, there is withoutboat's article on "not explicit" that goes into the topic more: https://boats.gitlab.io/blog/post/2017-12-27-things-explicit-is-not/ https://boats.gitlab.io/blog/post/2017-12-27-things-explicit...
- staticassertion 5y agoIt feels like the fundamental issue here is shared mutable state, and much of the solution is to just not have that. I don't believe this requires a change to async-await though. It just means you want to wrap up your async-await in its own synchronization primitive, or just avoid the need for it. This is easy to do with a channel system, or an actor system, where the queue is the synchronizing primitive. An actor's message handler can internally be asynchronous, so long as it processes messages one at a time (but it can yield during processing to another actor). We can get actors in rust pretty trivially imo. It's just a task with state and a message interface.
- carllerche 5y agoInteresting, could you elaborate on what you mean by "shared mutable state"? In the parse_line example (first one in the post), what state is shared and with whom is it shared?
- k1t 5y agoNot GP, but perhaps he's thinking of the TcpStream, which is a sort of "shared state" between executions of parse_line? Canceling an async task feels a lot like aborting a partially executed DB transaction without rolling back any changes.
- nyanpasu64 5y ago&TcpStream is a shared reference with interior mutability, and its state can change when you await and let other futures take a turn at accessing the same TcpStream. I prefer how CondVar encodes this property into its types: it gives you a MutexGuard (providing exclusive access), but requires you explicitly release it when you let other threads have a turn at accessing the shared data: https://doc.rust-lang.org/std/sync/struct.Condvar.html#method.wait https://doc.rust-lang.org/std/sync/struct.Condvar.html#metho... Is there a reason the function in the article couldn't be written to accept a &mut TcpStream, which doesn't let others interact with the same TcpStream while the function is running or awaiting?
- layoutIfNeeded 5y agoThese personas (i.e. “Alan”) are annoying enough when used by business people, but in the context of a programming language it’s beyond cringe.
- hermanradtke 5y agoI found it helpful that it was making the use cases concrete. So much of the debates around programming languages suffer from people not being concrete enough.
- Shikadi 5y agoPrefer Alice and Bob?
- heavyset_go 5y agoI'm unfamiliar with the use of in as a keyword in the following snippet from the OP. I can't find anything on Google about it, either. Can anyone tell me more about it? while let Some(line_in) in parse_line(&reader).await? { broadcast_line(line_in)?; }
- devit 5y agoIt's a mistake, it's supposed to be a "=".
- heavyset_go 5y agoThanks, I thought something new got snuck in after taking my thumb off the pulse of Rust development a while back.
- tialaramex 5y agoI don't think you're allowed to do this, but it's in a hypothetical, so the code doesn't actually work anyway. If you were allowed to use in this way, it feels reasonable right? parse_line eventually gave us an Iterator of line_in values, we iterate it until we get a None instead of a Some with a line_in value, and that exits the loop.
- heavyset_go 5y ago> If you were allowed to use in this way, it feels reasonable right? Yeah, that's why I was confused. I can imagine seeing something like this implemented with macros.
- vlovich123 5y agoIn the implicit await example, how does the compiler know which sections can be run in parallel and which require the results of previous computation. async fn my_fn_one() -> uint32 { println!("two"); 5 } async fn my_fn_two() { println!("two"); } async fn my_fn_three(x: uint32) {…} async fn mixup() { let one = my_fn_one(); my_fn_two(); my_fn_three(one) } Does this mean then that one, two, three all have their execution scheduled synchronously with no way to express parallelism? Or is the compiler figuring out the dependencies and generating a magic state machine where non-dependencies are spawned in parallel and joined before returning? Cause if it’s the latter that’s really interesting and exciting although I wonder if it’s known to be solvable in all use cases or there are use cases where the compiler can’t figure it out (but then also what if the programmer wants finer-grained control of the execution?)
- leshow 5y agoAs far as I can tell there is no parallelism in this code (fyi rust doesn't have a uint32 type), if you want things to run in parallel you need to spawn a task just as you do with threads. Async gives you concurrency, each formerly blocking thing turns into a yield where a scheduler is free to run some other task, but it doesn't automatically give you parallelism.
- beltsazar 5y ago> Does this mean then that one, two, three all have their execution scheduled synchronously with no way to express parallelism? > Or is the compiler figuring out the dependencies and generating a magic state machine where non-dependencies are spawned in parallel and joined before returning? No for both. Firstly, when you do: let one = my_fn_one(); the variable `one` is not of type u32 (no uint32 in Rust), but of type* Future<Output=u32>. Therefore, you can't actually pass it into my_fn_three, which takes u32 as a parameter. Also, when you do `my_fn_one()`, you don't actually call the function—the body of my_fn_one is executed only when it's awaited, i.e. `my_fn_one().await` or: let one_future = my_fn_one(); let one_value: u32 = one_future.await; Back to your original questions. When an async function foo awaits another async function bar, from the foo's perspective the awaiting operation is "blocking", which means the next lines of code won't be executed until it is finished. For example: async fn foo() { let x = bar().await; let y = 42; // This line won't be executed until awaiting bar is finished let z = baz().await; // Neither will this line } So, the answer to your first question is one, two, three all will be executed "synchronously" / "sequentially". To your second question, the compiler won't create a dependency graph of futures and concurrently execute futures that are independent to each other. The good news is that futures can be executed concurrently, but you need to explicitly build a dependency graph, by spawning a task [1] and/or using `futures::join` macro [2]. For example, you can concurrently execute my_fn_two and my_fn_three by: async fn mixup() { let one_value = my_fn_one().await; // Execute my_fn_two and my_fn_three concurrently. // Notice that there is no await because we pass futures to futures::join. futures::join!(my_fn_two(), my_fn_three(one_value)); } Or better, because my_fn_two is independent from my_fn_one, you can concurrently execute {my_fn_one, my_fn_three} and {my_fn_two} by: async fn mixup() { let one_three_future = async { let one_value = my_fn_one().await; my_fn_three(one_value).await; }; futures::join!(my_fn_two(), one_three_future); } [1] https://docs.rs/tokio/1.7.0/tokio/task/fn.spawn.html https://docs.rs/tokio/1.7.0/tokio/task/fn.spawn.html [2] https://docs.rs/futures/0.3.15/futures/macro.join.html https://docs.rs/futures/0.3.15/futures/macro.join.html * or more precisely "of an anonymous type that implements"
- BBC-vs-neolibs 5y ago"With the 2026 edition, adding #[abort_safe] to an asynchronous statement would generate an AbortSafeFuture implementation instead of Future. Any async function written with pre-2026 editions implements the AbortSafeFuture trait, making all existing async code compatible with the new edition (recall that an abort-safe statement is callable from any context)." I can't help but read this as a spoof on the spelling reform of the English language? [0] 0: http://www.i18nguy.com/twain.html http://www.i18nguy.com/twain.html
- Diggsey 5y agoThis is very interesting: I have a bit of an aversion to implicit await, but guaranteed completion does make that slightly less bad in my mind. There are a few things that I felt were a bit glossed over: 1) How exactly does an executor fullfill the contract of the new unsafe "poll" method? eg. what happens if there is a panic in one of the futures, or in the executor itself? Even if the executor catches the panic, what about the future that itself panicked - clearly that will not be polled to completion. 2) How does cancellation work exactly? You mentioned that somehow the underlying async operations return an "interrupted" error. Who controls cancellation of a future? (the executor?). How does that agent know which "leaf futures" need to be interrupted? How does it signal to those "leaf futures" that they should return immediately? Does this mean that all cancellable futures must return a `Result<T, impl From<InterruptedError>>`? 3) Do you think this change would help with problems like https://github.com/http-rs/async-h1/pull/179 https://github.com/http-rs/async-h1/pull/179 ?
- afranchuk 5y agoIt's not clear to me how an implicit await could be implemented; a counterexample that immediately comes to mind is any of the FutureExt combinators in the futures crate. The await keyword indicates the point at which something is done, but there could be multiple points in an expression that change the resulting type in the rest of the expression! Ex: my_fut().map(|x| x.y).then(other_async_thing) Awaiting after my_fut or .map() produces different types for resolving the rest of the expression. Also, I've often done things like binding a future in a scope and later awaiting it at different times (but always completing it). Making await implicit would make this impossible, unless a backdoor was kept in to indicate that you want an explicit await (which just comes down to the call site and shouldn't affect the Future implementation). Maybe these are moot points if futures must always complete, I haven't thought through the cases enough yet. Perhaps if futures can't be cancelled then FutureExt would not be necessary, and instead of passing `Future` types (into functions and elsewhere) we'd pass around `FnOnce() -> impl Future` types, or better yet `AsyncFnOnce()` since currently `FnOnce` suffers from not being able to specify a lifetime of an argument in the returned Future (which has hindered me many times).