I think that to answer your questions, I will first introduce another piece of the puzzle: Notifications. When a future is waiting for something (a timer, a network request), it doesn't make sense to poll the future during this operation because that future currently has nothing more to do. To fix this issue, the poll
function introduces a context parameter, which contains a Waker
. The future is supposed to store this Waker
somewhere, and when the future is ready to continue, the wake function on this waker should be called.
Now the question is, if you are implementing delay_for
, how do you trigger a wake-up when the timer elapses? You need some sort of code running externally to the future itself to do this. In Tokio, this external code is part of the executor, and the executor ensures that Tokio's network IO and timer utilities are woken up when the operations are ready to continue. It's the same deal in async-std: The executor sends out notifications on behalf of the futures. The executor module in the futures crate provides a very simple single-threaded executor without any timer or network-IO utilities.
Anyway, this answers why some of the utility functions provided by the executors are not executor agnostic: They need external code to handle wake-up notifications. And of course, if you wanted to make an executor agnostic timer, you could just spawn a thread, do a thread::sleep
and emit the wake-up, but that wouldn't be as efficient as Tokio's shared timer utilities.
That said, some utilities do not need external code: For example a channel can coordinate the two halves and make one half wake the other half up, meaning that no external code is needed to handle that situation. In fact the entire tokio::sync
module is executor agnostic for this reason. Additionally the entire futures crate provides exclusively executor agnostic utilities.
As for the history behind async await: Instead of building your futures using async blocks, you can also do it by combining various methods such as map
, then
and other functions similar to the combinators on iterators. You can still find them here. The futures crate was where we experimented with the Future
trait and figured out how it should look, and back then Tokio more or less had the same role it had now: Run the futures and provide utilities that require help from the executor. The async-std crate was introduced around when async await was stabilized.
You may have noticed that I always specifically talked about network IO as opposed to file IO. This is because your OS usually provides either no or very poor APIs for interacting with the file system asynchronously. To handle this, Tokio introduces a spawn_blocking
function that can run blocking code on a separate thread, and notify the asynchronous code once this blocking operation finishes. This works by the Tokio thread pool actually having two kinds of threads: Core threads and blocking threads. The core threads are limited by the number of cpus and run the futures you spawn, whereas there can be up to 512 blocking threads at any one time, and these blocking threads are tasked with running blocking pieces of code such as file IO. The reason there are so many blocking threads is that, unlike futures, every single blocking operation monopolizes a full thread.
Various smaller comments:
Note that you can also use Tokio by building a Runtime
object and interacting with that.
Don't do this! You're running blocking code in an async function, and monopolizing an entire thread in the core Tokio threadpool for your operation. Just do this:
#[tokio::main]
async fn main() {
let futures: Vec<_> = vec![foo, foo, foo, foo]
.iter()
.enumerate()
.map(|(index, func)| func(index as u64))
.collect();
let results = join_all(futures).await;
println!("Got Results {:?}", results);
}
You shouldn't run an executor inside an executor.