18 ms·
Async Rust: What is a runtime? how Tokio works under the hood
- pkulak 4y agoPeople crap on async Rust because it's not the most graceful to use, but I think it's kinda genius how they've managed to make it zero-overhead. To the point that even the stack size of green "threads" is known ahead of time. The only issue I have is that it's tough not to use it. The big HTTP libraries let you opt out, but smaller libraries don't have the resources to do everything twice. I don't know what the solution is, but it would be nice to always be able to chose. It's pretty silly to use async networking in a cli app, for example, but sometimes you have to.
- CryZe 4y agoYou can always just do let no_async = block_on(something_async);
- kangalioo 4y agoI'd instinctively worry about overhead there Are there benchmarks comparing sync http libraries with block_on-wrapped async http libraries?
- bongobingo1 4y agoIs the overhead going to matter if your content to block anyway? Surely any threading etc constructs are tiny compared to wire time, etc.
- ibraheemdev 4y agoBlocking I/O+threads can actually scale very well now, and with block_on you get the worst of both worlds, but yeah, I agree that most people are probably fine with it.
- vbezhenar 4y agoMost people fine with python. Those who come to Rust are not fine with overhead.
- mcronce 4y agoThis assumes that people only use Rust for the performance. I don't think that's strictly true. 95% of what I write isn't performance-critical or even, really, performance-relevant. I still choose Rust for the vast majority of projects for ergonomic and correctness reasons.
- adwn 4y agoYou're spot on. I recently chose Rust over Python for a very small program which reads a JSON (or Hjson) file, does some checking and processing, and writes the results to a different JSON file, because Rust has serde, proper static type checking, algebraic data types, and other features that made it more productive than Python (!!!) for that specific use case. Performance wasn't even a consideration, I made judicious use of clone() and run it in debug mode.
- dagmx 4y agoSerde is such an awesome library. Having to decode serialized data in the other languages (Swift, C++, Python) I write is such a bear after using serde. Swift comes closest with codable/decodable but it's often still lacking the ergonomics of serde, especially the attribute options per field
- dagmx 4y agoSome people who come to rest may not be fine with it. A lot of people come to rust for other reasons, like compile time safety checks etc... But most importantly, the delta between the performance of block_on and not, versus block_on and Python are massively different. You can write inefficient rust and still have a huge win over Python.
- ibraheemdev 4y ago> I'd instinctively worry about overhead there Yeah, one of the nice things about blocking I/O is that you can perform it with a single syscall. With block_on(async_io), you're now dealing with registration with a reactor, polling epoll, and extra syscalls for each I/O operation. Not to mention the overhead of running the state-machine as opposed to line by line.
- ReactiveJelly 4y ago> The big HTTP libraries let you opt out (of async) At least for reqwest it just wraps the async code in something like `block_on`: https://github.com/seanmonstar/reqwest/blob/5397d2cf8eaecc9f964ab4d2b40b8f2ad24ce26d/src/blocking/client.rs#L1042 https://github.com/seanmonstar/reqwest/blob/5397d2cf8eaecc9f... And as the sibling comment says, you can do that in your own code. It does still add Tokio to your binary size, and add some compile time, and probably start a bunch of worker threads you don't need, but it does work.
- duped 4y ago> To the point that even the stack size of green "threads" is known ahead of time. This is only possible by not having stacks for those "green threads" (they're stackless coroutines, not stackful fibers/proper green threads). It puts a severe limitation on the usefulness and forces any kind of recursive coroutine to heap allocate on returns.
- JoshTriplett 4y agoOne other obvious alternative would have been to make all futures heap-allocate so that some can be recursive. I think making recursive futures invoke `Box` or similar seems consistent with Rust's general "only pay for what you actually use".
- duped 4y agoI disagree, because you're paying for boxing on return + dynamic dispatch when it's perfectly safe/sound to have a stackful coroutine that only allocates when the stack needs to grow and doesn't require dynamic dispatch. So you don't pay for heap allocated futures if they aren't necessary, and you pay less when they are. So you're actually paying a higher price than if the compiler could support stackful generators for recursive futures. In fact async just gets a lot easier to write and use if generators could be stackful, at least imho. Generators don't have the syntax nice-ness of async/await, but they're also more explicit which feels more in with the rest of Rust, where syntax sugar like async/.await() is the exception and not the norm.
- JoshTriplett 4y agoFair enough, if we could create generators and futures that switch stacks that might work.
- deleted 4y ago[deleted]
- veber-alex 4y agostackful coroutines break FFI
- saurik 4y agoSo, as someone who has been working heavily with coroutines and continuations for decades in a number of different languages across the gamut of programming paradigms, I don't really understand why these runtimes aren't "interoperable", and am hoping I just have a different idea of what that word means than the people who talk about them in the context of Rust. Like, right now I maintain a large almost-entirely-asynchronous C++ codebase using their new C++20 co_await monstrosity, and while I find the abstraction ridiculously wide and a bit obtuse, I have never had trouble "interoperating" different "runtimes" and I am not even sure how one could screw it up in a way to break that... unless maybe these "executors" are some attempt to build some kind of pseudo-thread, but I guess I just feel like that's so "amateur hour" that I would hope Rust didn't do that (right?). So, let's say you are executing inside of a coroutine (context is unspecified as it doesn't matter). When this coroutine ends it will transfer control to a continuation it was given. It now wants to block on a socket, maybe managed by Runtime A (say, Boost ASIO). That involves giving a continuation of this coroutine past the point of the transfer of control to Runtime A which will be executed by Runtime A. Now, after Runtime A calls me--maybe on some background I/O thread--I decide I would prefer y task to be executing in Runtime B. I do this sometimes because I might have a bit of computation to do but I don't want to block an I/O thread so I would prefer to be executing inside of a thread pool designed for slow background execution. In this case, I simply await Runtime B (which in this case happens to be my lightweight queue scheduler). I don't use any special syntax for this because all of these runtimes fully interoperate: I used await to wait for the socket operation and now I use await to wait until I can be scheduled. The way these control transfers work is also identical: I pass a continuation of myself after the point of the await to the scheduler which will call it when I can be scheduled. Now remember, at the beginning of this I was noting that something unspecified had called me. That is ostensibly a Runtime C here (maybe I was waiting for a callback from libwebrtc--which maintains its own runloop--because I asked it to update some ICE parameter, which it does asynchronously). It doesn't matter what it was, because now that "already happened": that event occurred and the continuation I provided was already executed and has long since completed and returned as I went on immediately to pass a continuation to someone else rather than blocking. Is this somehow not how Rust works? Is await some kind of magic "sticky" mechanism that requires the rest of this execution happen in the context of the "same" runtime which is executing the current task? I have seen people try to do that--I am looking at you, Facebook Folly--but, in my experience, attempts to do that are painfully slow as they require extra state and cause the moral equivalent of a heavyweight context switch for every call as you drag in a scheduler in places where you didn't need a scheduler. But, even when people do that, I have still never had an issue making them interoperate with other runtimes, so that can't be the issue at its core. I guess I should stare at the key place where the wording in this article just feels weird?... to me, I/O and computation are fairly disjoint, and so I can't imagine why you would ever want to have your I/O scheduler do "double-duty" to also handle "task queues". When I/O completes it completes: that doesn't involve a "queue". If you want to be part of a queue, you can await a queue slot. But it sounds like tokio is doing both? Why?
- redman25 4y agoIs it possible to build a tokio compatible library that might not be so heavy weight? Maybe this a moot point since libraries would use tokio as a dependency anyway.
- tempest_ 4y agoSmol is essentially that iirc. https://github.com/smol-rs/smol https://github.com/smol-rs/smol Though I have not used it extensively.
- carllerche 4y agoCan you elaborate on "heavy weight"? Tokio lets you opt-in to only what you need via feature flags. This lets you use a small subset of the lines of code & transitive deps.
- Linda703 4y ago[dead]
- armchairhacker 4y agoBasically, async Rust requires you to understand how “async” works under the hood. Javascript async does stuff automatically, there is an implicit runtime and you can just await or .then and get a Promise and closure which encapsulates whatever data you need and stores it on the heap. Rust lets you optimize the runtime and do polling etc. how you want. But you have to do everything explicitly. And you can store futures on the stack and customize how their represented (including the closures), but you have problems like async traits because every future has a different type. Lots of people say “async Rust is hard because async is hard”. Honestly this is false, async is easy if you Box dyn everything and use the tokio runtime. Async Rust is hard - and Rust in general is hard - because they don’t compromise performance for abstraction and encapsulation. You get exposed to the gritty internals of how async works in exchange for being able to make futures and a runtime which are optimal for your specific program.
- moonchrome 4y agoJavaScript also has the benefit of a single threaded event loop - that eliminates a whole class of complexity and dealing with schedulers etc. - there is only one. Async already gets messier in C# for example which also has a GC.
- armchairhacker 4y agoYou can have a single threaded event loop in Rust. Just, like everything else, it's an option and you have to specify it explicitly.
- moonchrome 4y agoYes but the API has to be built for multithreaded scheduling.
- staticassertion 4y agoNo, you get !Send futures.
- aaaaaaaaaaab 4y agoSo, in other languages, async introduces two function "colors": sync and async. But in Rust you also have unique "colors" for each async runtime? I must be misunderstanding something, because this sounds like pretty hare-brained...
- ben-schaaf 4y agoYou get the same thing in every language where you can choose a runtime. Whether the colors are enforced by the compiler is another matter, but since you can only have one runtime per thread and each runtime has a different API you inherently get runtime colors.
- aaaaaaaaaaab 4y ago>you can only have one runtime per thread Why? Why can’t a single thread run multiple runtimes’ event loops? It’s pretty limited what an event loop can do: it can wait on a set of FDs, or sleep for some time (or until woken up). So if the various runtimes’ event loops could implement a common interface, then I don’t see why they couldn’t share a single thread…
- ben-schaaf 4y ago> It’s pretty limited what an event loop can do: it can wait on a set of FDs, or sleep for some time (or until woken up). But that's what the runtime is. There's multiple strategies for implementing an event loop (as well as multiple async platform APIs to use), which all directly affect the lower-level abstractions. By replacing the event loop and lower-level abstractions with one common implementation all you've done is add another competing runtime. You can't have more than one event loop and thus you can't have more than one runtime.
- mcronce 4y agoIf you've got a thread running one event loop, how could you signal to it to temporarily break out of that loop and run a different event loop for a while?
- deleted 4y ago[deleted]
- ThePhysicist 4y agoI recently got started with Rust and was surprised that different async runtimes were not compatible with each other. In principle Rust has a similar interface concept like Golang, so it should be possible to specify desired behavior of a component and leave implementation to the library, so that you can switch between different ones without worrying about compatibility. Pretty much a Rust noob still so maybe I'm missing something that makes this difficult/impossible though. I've been thinking about rewriting a network library using async, but the whole async ecosystem seems a bit fragmented and immature: mio would probably be everything I need but it doesn't support channels (there's mio-extras which does but it's not compatible with the latest mio version). Tokio would probably fit the bill, though it seems to be too complex for what I actually need (just a way to poll sockets and channels to see if there's anything to read from them).
- laerus 4y agoThis is not unique to Rust. The Python async runtimes are not compatible with each other as well, there is AnyIO which is a wrapper that acts as an abstraction that makes things easier but it's still not as implicit as with languages with builtin runtimes. There is also an ongoing effort to make Rust async runtimes pluggable.
- nicoburns 4y ago> Pretty much a Rust noob still so maybe I'm missing something that makes this difficult/impossible though. It's largely just because the various library authors have not managed to agree on interface definitions. I think it'll get sorted eventually, but unfortunately doesn't seem to be a big priority for the runtime developers. It's also partially blocked on async function being available in traits, which isn't currently possible in Rust without workarounds (which wouldn't be suitable for the standard library). > Tokio would probably fit the bill, though it seems to be too complex for what I actually need Tokio is probably what you want. It might be complex under the hood, but it ought to fairly straightforward to write networking code using it (I believe polling a channel is typically as simple as calling `.recv().await` in a loop within an async function).
- samsquire 4y agoThe behind the details of Rust async is rather hard to follow There's waiters and polling that only execute your function to progress and there's pending and done. Any help to understand the relationship between the waiters, pollers and executor and runtime things would be appreciated. I wrote a M:N thread scheduler in C, Java and Rust. The C version also can schedule file reading to an IO thread but I'm nowhere near finished. https://github.com/samsquire/preemptible-thread https://github.com/samsquire/preemptible-thread Another of my ideas is to rewrite synchronous code into parallel LMAX disruptors. In other words a tree of RingBuffer each line of synchronous code its own event loop. Rather than one event loop multiplexing events from different systems you pipeline every blocking call. I think it would be very fast. Here's a write-up. https://github.com/samsquire/ideas4#51-rewrite-synchronous-code-into-lmax-disruptor-thread-pools---event-loops-that-dont-block-on-cpu-usage https://github.com/samsquire/ideas4#51-rewrite-synchronous-c...
- swayvil 4y agoEver notice that "rust" is a contraction of "red dust"? (Also, words are usually first coined as onomatopoeias. For example "dust" sounds like a swisshing pile of dust. But "red". That probably has more stuff going on there. Added layers)