API
Each asynchronous runtime needs an executor to manage tasks. Most asynchronous runtimes implicitly create an executor for you.
For example, in Tokio an executor is created implicitly through #[tokio::main]
.
#[tokio::main]
async fn main() {
println!("Hello world");
}
Under the hood, the annotation actually creates the excutor with something like:
fn main() {
tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
.unwrap()
.block_on(async {
println!("Hello world");
})
}
In Node.js, the entire application runs on a single event loop. The event loop is initialized when the node
command is run.
In Tokio and Node.js, the developer can write asynchronous code without ever knowing the existence of the executor. With mini-glommio
, developers need to create the executor explicitly.
The two main APIs of our executor are:
- run: spawns a task onto the executor and wait until it completes
- spawn: spawns a task onto the executor
Pretty simple right? All we need is the ability to put a task onto the executor and to run the task until completion.
Run
To run a task, you call the run
method, which is a synchronous method and runs the task until completion.
Here is its signature:
#![allow(unused)] fn main() { pub fn run<T>(&self, future: impl Future<Output = T>) -> T }
Here is a simple example of using the APIs to run a simple task that performs arithmetics:
#![allow(unused)] fn main() { let local_ex = LocalExecutor::default(); let res = local_ex.run(async { 1 + 2 }); assert_eq!(res, 3) }
spawn
The whole point of an asynchronous runtime is to perform multitasking. The spawn
method
allows the programmer to spawn a task onto the executor without waiting for it to complete.
#![allow(unused)] fn main() { pub(crate) fn spawn<T>(&self, future: impl Future<Output = T>) -> JoinHandle<T> }
The spawn
method returns a JoinHandle
which is a future that returns the output of the task
when it completes.
Note that the spawn
method can technically be run outside a run
block. However, that means
the programmer would need to manually poll
the JoinHandle
to wait until it completes or use another
executor to poll the JoinHandle
.
Running spawn
inside the run
block allows the programmer to just await
the JoinHandle
.
Here is an example for how to use spawn
.
#![allow(unused)] fn main() { let local_ex = LocalExecutor::default(); let res = local_ex.run(async { let first = local_ex.spawn(async_fetch_value()); let second = local_ex.spawn(async_fetch_value_2()); first.await.unwrap() + second.await.unwrap() }); }
spawn_local_into
This is a more advanced API that gives a developer more control over the priority of tasks. Instead of placing all the tasks onto a single TaskQueue
(which is just a collection of tasks), we can instead create different task queues and place each task into one of the queues.
The developer can then set configurations that control how much CPU share each task queue gets.
To create a task queue and spawn a task onto that queue, we can invoke the spawn_into
method as follows:
#![allow(unused)] fn main() { local_ex.run(async { let task_queue_handle = executor().create_task_queue(...); let task = local_ex.spawn_into(async { write_file().await }, task_queue_handle); } ) }
Next, I will cover the Rust primitives that our executor uses - Future, Async/Await, and Waker. Feel free to skip if you are already familiar with these. However, if you are not familiar with them, even if you aren't interested in Rust, I strongly advice understanding them as those concepts are crucial in understanding how asynchronous runtimes work under the hood.