Is there a good read on different async runtimes?

Hi,

I'm trying to understand async-await in Rust, but I'm getting lost in all the different async runtimes.

From my understanding, it's not enough to have a Future, it should be executed by something (obviously).

Is there a good read on how those async runtimes are generally implemented and what is the difference between them?

I keep seeing different kinds of runtimes, I have already seen at least the following main macros:

  • async_std::main
  • tokio::main
  • actix_rt::main

And they apparently keep coming, so I would like to know more about how to do housekeeping on them.

It appears to me that those runtimes serve different purposes. For example, Actix runtime is based on Tokio, but apparently it's not possible to simply use #[tokio::main] with an Actix web server.

I would like to understand what are the implications of using different runtimes. For now it seems to be a topic that blocks me from using async-await without concern.

Thanks!

4 Likes

My understanding is that for the vast majority of applications, all that really matters is whether your runtime/executor is a single-threaded "event loop" or uses a pool of multiple threads. https://docs.rs/tokio/0.2.8/tokio/runtime/index.html#runtime-configurations might help. After that, which runtime you use is mostly a performance optimization, i.e. something you should not be concerned about at all until you run into an actual bottleneck worth writing benchmarks for.

The best post I've seen about optimizing the performance of a specific executor/runtime is Making the Tokio scheduler 10x faster, so that might help give you some idea what these "runtime" thingies are actually doing and why it affects performance so much.

async_std::main is not a runtime, but a macro you can put on your main() function to enable async/await syntax there. It's basically just sugar for calling async_std::task::block_on. For discussion of the default runtime that async_std actually ships with, see https://async.rs/blog/stop-worrying-about-blocking-the-new-async-std-runtime/. Notice again the "you generally don't need to think about it" theme.

I don't know much about actix, but actix_rt::main is also just an attribute to let main() use async/await syntax. The actual runtime that actix uses is single-threaded only, and I assume it's optimized for the usage patterns of actix actors (though maybe it only exists because actix predates futures?), but I don't get the impression it's meant to be competing with tokio or anything like that.

6 Likes

I recently implemented a tool (using a lot of help from the community in these forums, it should be noted) which does the following:

  • Allows incoming TCP connections on 127.0.0.1
  • Read incoming send "hash requests" (an algorithm, an identifier, and a filename)
  • Hash files, up to N concurrently, the rest queued
  • Return the hash (including the identifier) of each hash job to the client

I first tried to do this with async-std, but I ran into a problem and switched to tokio.

So here are some things I learned (things that you may or may not find relevant):

  • You can typically write your own custom Futures without having to worry about which runtime you're going to use. This is probably obvious to most people, but to me it was kind of confusing at first.
  • async-std looks the way it looks because they are explicitly modelling their library after the std library. I saw someone comment earlier today that it's slightly confusing that async-std calls their async Read and Write - simply - Read and Write instead of something to indicate it's async. And while I agree, I think the idea is to go pretty hard on the "just mimic std".
  • The reason I had to abandon async-std was that I couldn't find a way to split a TcpStream into a reader and writer (I wanted to keep the reader half in the connection handler and pass the writer to the hashers). tokio has such a "split" function. And oddly enough, I'm pretty sure that std has one, so I'm not sure why async-std doesn't.
  • tokio is more of a "wild and crazy" beast -- they have no interest in mimicing std. I consider tokio to be much more of a research project, where the goal is simply to discover how a "standard library" would look if async was there from the beginning. But don't take "wild and crazy" and "research project" as something negative; it's not intended as such.
  • When you're writing async code and you use Mutexes, then you'll use Mutexes from the async runtime. First I used async-std's Mutex. When I switched to tokio I used tokio's Mutex.
  • If you spawn tasks, you'll use a runtime specific spawn function.
  • The biggest difference between the runtimes that I noticed (but do keep in mind that what I did was pretty trivial) was how the initial setup of the runtime is done, and there's some interface differences in how listeners generate connection objects, but the differences are pretty small.

As it turned out, going from async-std to tokio (and I actually went back to async-std and then back to tokio again) was pretty frictionless. Just make sure you import things like Mutex into a namespace which allows you to switch between runtimes in one place.

5 Likes

Thank you very much @Ixrec for the explanation and useful links, and @blonk for your insight! :slightly_smiling_face:

So, if I understand it correctly, the differences between runtimes are minimal. It's mostly a matter of implementation and probably a little bit of public API, but they all seem to work with the same Future type, so one can basically use any runtime with their own async fns.

At the same time, I've noticed some complications with compatibility of other crates with different runtimes. Taking the same Actix, its runtime is built on Tokio, but to run Actix in Tokio one needs to do some additional setup.

This is in fact the main difficulty for me - and as @blonk noted, it's not really obvious that I generally don't have to worry about which runtime to use. Moreover, it appears that after all I do have to worry, because if, for example, Actix requires some special setup to run on Tokio, and then some other crate would provide another own runtime and require another kind of setup, then it's not so long until those very own runtimes become incompatible.

Unless they all provide similar interfaces and perform abstractly the same set of operations. But then the question is - why so many runtimes? Why cannot there be one that will be a de-facto standard, and if there is a need for another, special implementation, then this implementation would follow the rules set by this standard runtime?

I hope that the answer is immaturity of async-await ecosystem in Rust. I really hope to see a standard runtime soon, because currently it's pretty confusing.

1 Like

IMHO, this is the only real problem with async runtimes at the moment. It means that code which needs to spawn tasks directly cannot be made runtime-agnostic. The best we can do for now is return a Future and hope the caller will await it.

3 Likes

It would be great if libraries could use a trait to avoid an explicit dependency on a particular runtime.
The most obvious candidate is futures::task::Spawn. Unfortunately, the Tokio runtime does not implement Spawn, although it is due to a sound reason.

The resulting landscape looks disorganized and confusing. For example, hyper defines its own Executor trait to abstract spawning of internal tasks, furthermore the default executor can be dynamically selected to use either tokio::spawn or a boxed user-provided implementation.

As also noted in the GitHub, traits are currently not sufficiently powerful to properly abstract the runtime away.b

@Alovchin9, thanks for asking this question, it has been bothering me, too. (at one point I was trying to make tokio-based reqwest HTTP client run under async-std runtime, to no avail)

During my digging I've stumbled upon following post with rather technical notes on what is inside a runtime, and whether it is possible to combime them (I cannot say I understand it fully):

2 Likes