I’m not sure why... but I find the poll method of the Future trait to be quite abstract
State? Polling? Anonymous functions?
Also, are we relying too heavily on the tokio crate..
I’m not sure why... but I find the poll method of the Future trait to be quite abstract
State? Polling? Anonymous functions?
Also, are we relying too heavily on the tokio crate..
The Future trait is entirely runtime agnostic. There is nothing that relies on the tokio crate.
However, it is true, that tokio has somewhat become a quasi-standard in its field - not unlike serde within its respective field.
Have you read this chapter of the book?
This is a good article if you want to really understand things:
The shape of Future comes down to how it's intended to be integrated into an executor.
The Future is intended to complete some work. It can be polled to check if it is done, but we don't want the executor to keep polling arbitrarily. So there needs to be a mechanism to tell an executor to recheck the state of a Future, which is what its Waker (passed through Context) is for.
On the one hand, we've shipped several products that are built on top of tokio. No matter what angle I try to look at "rely less on tokio" from, I don't see how it would be positive with regards to our products.
On the other hand, I'm guilty of having tied libraries to tokio where they could (with not much effort) be made more executor agnostic. I'm trying to do better on this.
The important thing to know about the Future trait is that you don't have to actually know anything about the Future trait.
There's essentially three types of Future implementation:
futures crate, that provide implementations of Future in terms of other existing Future implementations to simplify common casesasync functions or blocks, where the compiler implements one for you, again in terms of existing implementations.So you normally only actually need to know about the trait itself if you're either implementing a runtime or a combinator library.
That said, it can be easier to talk about some of the behavior and guidelines for using Futures with at least a vague definition of the Future trait: for example unlike most other languages with a Future/Promise/Task API, Rust's Futures "only make progress when polled", which requires at least a reference to the poll trait method to describe.
With that in mind, the general behavior is:
Futures represent a "value in the future", that will at some point become "ready" for you to read (at which point you discard the Future). Unlike other approaches for this (like threads and channels), Future is designed for cases where the current thread is not busy creating the value, most commonly some form of external input or output, and therefore you can produce and manage many Future values on the same thread. To do this it needs a way to represent that the value isn't ready yet, which gives you the two results variants of the poll method: Ready and Pending.
With just that, though, to handle waiting for 1000 Futures to finish, you would be forced to repeatedly call poll on all of them until they have all returned Ready, which is very inefficient! So the poll method takes a Context provided by the async runtime that lets it register when it can make progress, and wants to be polled again, which is called "waking".
I say here "can make progress" and not "will be ready" because most of the time the top level Future actually called with the context by the runtime is not a leaf Future, for example this "echo" async block:
async {
let message = read().await;
write(message).await;
}
creates a Future that when first polled will immediately call read() and when it sees the .await, store that result as the current inner future, and call poll on that with the context it was given. Likely the result isn't ready yet, so this inner future registers to wake, returns Pending, and the outer future forwards this to its own caller. Later, this wake completes when the data is available, the outer future is polled again, remembers it was at the first await, forwards to the inner Future from read(), gets the Ready result with the value for message, and continues execution to the next await, where write(message) returns a second inner Future, and the same things all happen again: the inner future is stored, polled, registers a wake, and returns a Pending result that gets forwarded out. When that one completes, the third outer poll completes the block and returns a Ready.
So, in summary: the outer Future is polled three times, returning Pending and registering a wake twice for each await, and finally returning Ready and completing. But each poll isn't just a check to see if the value is ready, but actually needed to make progress, starting other internal processes.
The trick here is now you can create a thousand of these "echo" futures, and with only a single thread poll all of them whenever they become ready! ... In theory. Getting into why that's annoying to implement directly is a bit much for this comment, but suffice to say you generally want to just spawn() any futures you're running in parallel most of the time: this effectively moves the future "into the runtime" where it will be polled to completion regardless of what you do otherwise (other than shutting down the runtime), and you can use the returned future like in other languages wherever you need the value rather than needing to ensure it's getting polled.
Think of a T: Future as a plain enum. In most cases, that's exactly what it's going to be.
enum AwaitForMe<P, R> {
Initial,
Pending<P>,
Ready<R>
}
You create your AwaitForMe::Initial. You poll it directly or you .await it, which will poll it for you behind the scenes.[1] The underlying implementation of the poll on its own will attempt to advance your enum to the next step: turning an Initial into Pending; and a Pending into a Ready<R>. And that's really all there is to it. At least, insofar as the Future itself is concerned.
On its own, the Future does nothing. It's just a "blob of state". You can poll it, but unless you or someone else actually does so, it'll just sit there.[2] At this point, unless you have a rare genetical condition, making you poll each and every Future you make manually, what you most likely want here is an "executor" (tokio): to give you a block_on you can feed your Future to.
Now, this is where the things get a bit messy. If, as we've established: 1) the Future is just a "state blob" which can only return a Pending or a Ready on each poll; and 2) the "executor" itself only knows how to poll any given Future you give it; then by 3) handing your tokio your bespoken AwaitForMe::Initial, only able to advance itself to the state of Pending for the time being; then how in the world would it know when to poll it again, to see if it's Ready or not this time around?
The answer is: it depends. Maybe your Future doesn't need to be poll'ed more than once at all. Maybe you want it to stay Pending until the end of times. Maybe you want your executor to poll on its Future's once a minute - as a poor man's cron daemon, mayhaps? But you're still reading this, so you probably want to know what a library like tokio does behind the scenes, instead.
Ironically enough: very little of it has anything to do with the Future itself. For one, in 99% of your day-to-day use cases, the kind of Future's you'll be interacting with will have something to do with the I/O managed by your OS. Think of creating sockets, listening for new connections, and just waiting. For a new chunk of data to arrive from a config file you want to read, for your writing operation to finish, for the timer to expire, etc. For that, you'll need some kind of a "driver".[3]
This should expose some form of a low-level OS interface with two main methods: scheduling your I/O operation; and waiting for at least one operation off your scheduled list to send a "signal" [4]. Almost every Future you'll ever use will have some kind of usize/id assigned to it at the time of creation. This will get thrown back and forth from your code to the OS and back, until the executor notices that this particular Future, which this particular id, is ready to be poll'ed again.
Then you'll have your "scheduler". This is the thing that will either keep all of the work you're doing (and waiting for) on a single thread only, or share it out with other workers running in multiple threads at once - for the full benefit of a "work-stealing" configuration done proper.
The "executor", depending on the configuration, could either allow for the individual "drivers" and "schedulers" to swap back and forth without much friction; or take the path of pretty much every async runtime in the rust ecosystem today - where every other kid on the block comes with their proprietary spin on the exact same kind of traits and functions and I/O libs and what have you.
The "runtime" itself is just another catch-all term that mashes together everything discussed so far in at least somewhat coherent and/or usable and/or ergonomic way.
The Future itself is still just a "state blob". You might have also noticed some of the folks above talking about Waker's. That's just another wrapper around the Future's id; itself wrapped up in the Context<'_> your poll is called with. The first time you execute your timeout Future, it might schedule itself against the "driver" and/or the worker thread pool. The next time that driver receives a "signal" that your timeout op is ready/complete, it will proceed to "wake" your Future, itself stored in some glorified HashMap<usize, Box<dyn Future>> or the like; which will poll it once again; and so on, and so forth until the Pending finally turns into a Ready. That's it.
Eventually, once the underlying async block it's a part of (itself a glorified enum) advances through all of the intermediate steps preceding it. More on that later. ↩︎
Compare that to the Promise in JS-land: by the time the Object you get by calling an async function is given back to you; it's already been scheduled to run in the next free time slot of the JS's built-in event loop. ↩︎
Ask your favourite LLM of the day to give you an overview of epoll and/or io_uring for Linux; iocp for Windows; and kqueue for MacOS/BSD. These are the things your executor will be abstracting away from you behind a bunch of proprietary Future types: one for every different kind of a task. ↩︎
Ask your LLM for the difference in between "readiness-based" and "completion-based" I/O. ↩︎