Add a minimal Futures executor by Matthias247 · Pull Request #65875 · rust-lang/rust (original) (raw)

Hey, I came here through @withoutboats post about Global Executors from TWiR, here: https://boats.gitlab.io/blog/post/global-executors/ , I just wanted to chip in my opinion.
Before anything else, thanks for everyone here working on async. I really appreciate it!

I am generally against having any kind of global or default executor inside std. I prefer to have some unified interface that will allow me to swap executors (and possibly reactors) seamlessly.
For sure I know less than the rest people in this discussion about the internals of how Futures work, but I have been using Futures in my Rust code since the first day they showed up in nightly, so at least I have a lot of experience as a Futures user.

My reasoning against Default/Global Executors

(1) A global executor can not be the best in everything for everyone, so most of the time users will use their own executor. Different people with different motives will have different ideas of how the global executor should be implemented. I use a special executor for tests (A deterministic single threaded executor, allowing time travel and breaking on deadlocks). I will definitely not use the global executor for my tests, unless it is also deterministic and allows me to break on deadlocks.

@Matthias247 wrote:

having some simple executor that just allows for writing some async code without having to import a library.

I think that this is something we can also say about random generators for example. I believe that we don't have a default random generator in std because everyone might have a different idea of what a good random generator is. Some people want fast random generators, and some people want cryptographically secure random, and even about what is really good cryptographic secure random people don't seem to agree. I think that this is a good thing. I have a very fine taste in random generation myself, and I like the fact that I get to pick my random generator myself.

(2) If the global executor becomes too convenient, people might actually use it. As a result, I expect that library writers will shove the executor arbitrarily inside their libraries. The problem with a code that contains a hidden executor is that it doesn't play nice with the rest of your async code. For example: I once wanted to have an http server, so I tried actix-web, and it turned out actix-web has its own executor. If I ended up using actix-web, my program would have contained two different executors!

This is something I believe could happen very often if a very convenient and "official" global executor exists. Looking at this from the opposite perspective, if there are no "official" global executors at all, I expect that library authors will have to be more agnostic about the executors being used.

I was working with async code in python for a very long time, first in Twisted and then in asyncio (previously Tulip). One of the things I disliked the most was the default loop ("loop" is how they call the Executor + Reactor in python). Many python libraries were using the default loop to spawn tasks, which made it very difficult to test any code that touched those libraries. I spent full nights trying to debug code, only to eventually find out that some task of some other library was spawned on a different loop than mine.

(3) Once we have an executor inside std, we might not be able to remove it because of backwards compatiblity.

Unified spawn interface instead of a Global Executor

I have been using futures-preview for a while, and now I ported my code to futures=0.3.1. This crate contains the Spawn trait. In my opinion this trait is a move in the correct direction. Many of my functions now take an impl Spawn as an argument. This allows me to easily replace the executor.

About the reactor: It is some kind of an invisible beast for me. I have never seen it, and all I know is that if I get things mixed up things end up not working silently, very much not what I'm used to have with Rust. See for example this issue: rust-lang/futures-rs#1285 , when I naively tried to spawn a future that uses a Tokio feature (timer) on a futures-preview's ThreadPool.

I will be happy to have an abstract reactor interface, allowing me to easily replace it.

@withoutboats wrote in the "Global Executors" article:

Ideally, many of these library authors would not need to spawn tasks at all. Indeed, I think probably most libraries which spawn tasks should be rewritten to do something else

Is it because spawning means allocation? I agree that library authors should strive to provide the user zero cost interfaces, but I think that sometimes you might want a library to be able to use your Executor.

Library example

Lets look at a simple example: an HTTP server library. The final function the library provide could be something like fn server() -> impl Future<...>, creating a future state machine. The user will then have the responsibility of spawning the future returned from the function.

However, the server itself might need to spawn tasks, for example, maybe for WebRTC, or some TLS periodic key replacement, or something else. Then in my opinion a reasonable solution would be to have a function with the following signature: fn server(spawner: impl Spawn) -> impl Future<...>. The user of the library would then provide his own spawner to the server. If I ever want to, I should be able to provide my test spawner to the server() function, or my super fast production spawner.

And if at this point we had a Global Executor, things could really go wrong. The library author might be tempted to take the easy route, and just use the Global Executor from std. In that case the function signature will still be fn server() -> impl Future<...>, but this time the server will internally use the Global Executor to spawn his tasks. (Generally this is what I dislike the most about global things: they sneak in behind your back and you can not spot them from the signature of the function. Those kind of things happen not very often with Rust, and I hoped to keep having it this way).

Now if I ever want to use my own executor I will not be able to provide it to server(), and my final running program will have more than one executor. In addition, writing any tests that include calls to server() in my code will become much more difficult.