-
Notifications
You must be signed in to change notification settings - Fork 1
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Improve blog
- Loading branch information
Showing
24 changed files
with
322 additions
and
296 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,9 +1,11 @@ | ||
# What is Asynchronous I/O? | ||
|
||
In this phase, we will add I/O to our runtime. | ||
So far, we have built an executor that can spawn and run tasks. However, we haven't talked about how it can perform I/O, such as | ||
making a network call or reading from disk. | ||
|
||
A simple approach to I/O would be to just wait for the I/O operation to complete. But such an approach, called **synchronous I/O** or **blocking I/O** would block the single-threaded executor from performing any other tasks concurrently. | ||
|
||
What we want instead is **asynchronous I/O**. In this approach, performing I/O won’t block the calling thread. This allows the executor to run other tasks and return to the original task once the I/O operation completes. | ||
What we want instead is **asynchronous I/O**. In this approach, performing I/O won’t block the calling thread. Instead, the executor switches to other tasks after making nonblocking I/O call and only resume the task when the kernel notifies the executor that the | ||
I/O operation is complete. | ||
|
||
Before we implement asynchronous I/O, we need to first look at two things: how to turn an I/O operation to non-blocking and `io_uring`. | ||
In this section, we discuss how our executor can perform asynchronous I/O. First, let's look at the primitives that enable to do that. |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,65 +1,106 @@ | ||
# API | ||
|
||
As we mentioned earlier, an executor is a task scheduler. Therefore, it needs APIs to submit tasks to the executor as well as consume the output of the tasks. | ||
Each asynchronous runtime needs an executor to manage tasks. Most asynchronous runtimes implicitly create an executor for you. | ||
|
||
There are 3 main APIs that our executor supports: | ||
For example, in Tokio an executor is created implicitly through `#[tokio::main]`. | ||
|
||
- **run**: runs the task to completion | ||
- **spawn_local**: spawns a task onto the executor | ||
- **spawn_local_into**: spawns a task onto a specific task queue | ||
``` | ||
#[tokio::main] | ||
async fn main() { | ||
println!("Hello world"); | ||
} | ||
``` | ||
|
||
Here is a simple example of using the APIs to run a simple task that performs arithmetics: | ||
Under the hood, the annotation actually creates the excutor with something like: | ||
|
||
```rust | ||
let local_ex = LocalExecutor::default(); | ||
let res = local_ex.run(async { 1 + 2 }); | ||
assert_eq!(res, 3) | ||
``` | ||
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 on the executor, which is a synchronous method and runs the task in the form of a Future (which we will cover next) until completion. | ||
To run a task, you call the `run` method, which is a synchronous method and runs the task until completion. | ||
|
||
Here is its signature: | ||
|
||
```rust | ||
pub fn run<T>(&self, future: impl Future<Output = T>) -> T | ||
``` | ||
|
||
### spawn_local | ||
|
||
To schedule a `task` onto the `executor`, use the `spawn_local` method: | ||
Here is a simple example of using the APIs to run a simple task that performs arithmetics: | ||
|
||
```rust | ||
let local_ex = LocalExecutor::default(); | ||
let res = local_ex.run(async { | ||
let first = spawn_local(async_fetch_value()); | ||
let second = spawn_local(async_fetch_value_2()); | ||
first.await.unwrap() + second.await.unwrap() | ||
}); | ||
let res = local_ex.run(async { 1 + 2 }); | ||
assert_eq!(res, 3) | ||
``` | ||
|
||
If `spawn_local` isn’t called from a local executor (i.e. inside a `LocalExecutor::run`), it will panic. Here is its signature: | ||
### 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. | ||
|
||
```rust | ||
pub fn spawn_local<T>(future: impl Future<Output = T> + 'static) -> JoinHandle<T> | ||
where | ||
T: 'static | ||
pub(crate) fn spawn<T>(&self, future: impl Future<Output = T>) -> JoinHandle<T> | ||
``` | ||
|
||
The return type of `spawn_local` is a `JoinHandle`, which is a `Future` that awaits the result of a task. We will cover abstractions like `JoinHandle` in more depth later. | ||
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`. | ||
|
||
```rust | ||
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 | ||
|
||
One of the abstractions that we will cover later is a `TaskQueue`. `TaskQueue` is an abstraction of a collection of tasks. In phase 3, we will introduce more advanced scheduling mechanisms that dictate how much time an executor spends on each `TaskQueue`. | ||
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. | ||
|
||
A single executor can have many task queues. To specify which `TaskQueue` to spawn a task to, we can invoke the `spawn_local_into` method as follows: | ||
To create a task queue and spawn a task onto that queue, we can invoke the `spawn_into` method as follows: | ||
|
||
```rust | ||
local_ex.run(async { | ||
let task_queue_handle = executor().create_task_queue(...); | ||
let task = spawn_local_into(async { write_file().await }, task_queue_handle); | ||
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. |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,15 @@ | ||
# Architecture | ||
|
||
Here is the asynchronous runtime's architecture: | ||
|
||
<img src="../images/architecture.png" width="110%"> | ||
|
||
When the programmer spawns a `future` onto the executor, a `task` is created and a `JoinHandle` is returned to the user. | ||
The user can use the `JoinHandle` to consume the output of the completed task or cancel the task. | ||
|
||
The spawned task is placed onto one of the task queues that the executor has. Each task queue holds a queue of tasks. | ||
A queue manager decides which task queue to run. In our V1, the queue manager will simply pick an arbitrary task queue to run at any moment. | ||
|
||
We will cover asynchronous I/O and io_uring in Phase 2. | ||
|
||
Next, let's perform a deep dive into the implementation details. |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,36 +1 @@ | ||
# Core abstractions | ||
|
||
Here are the core abstractions that make up our executor: | ||
|
||
- Local Executor | ||
- Task | ||
- TaskQueue | ||
- Queue Manager | ||
- JoinHandle | ||
|
||
### Task | ||
|
||
A Task is the basic unit of work in an executor. A task is created by the `run` and `spawn_local` methods. The task keeps track of whether the provided `Future` is completed. It also tracks if the task is `canceled` or `closed` and deallocates itself from the memory if it’s no longer needed. | ||
|
||
### Local Executor | ||
|
||
The `Local Executor` is responsible for tracking and running a collection of `task`s. It’s responsible for switching between different tasks and performing multitasking. | ||
|
||
### Task Queue | ||
|
||
Each TaskQueue holds a queue of `Task`. Right now, `TaskQueue` simply holds a list of Tasks. But in `Phase 4`, we will expose more advanced APIs that allow developers to specify roughly how the single-threaded executor should split up the CPU amongst the different task queues through the `shares` property. | ||
|
||
### Queue Manager | ||
|
||
A Queue Manager decides which Task Queue to run. At any point, the queue manager keeps track of which Task Queue is running. In this phase, the queue manager will simply pick an arbitrary task queue to run. | ||
|
||
### JoinHandle | ||
|
||
When a task is spawned, the user needs a way to consume the output. This is what the `JoinHandle` does - it allows the user to consume the output of the task by calling `await`. The user can also cancel the task with the handle. | ||
|
||
As shown in the example below, `spawn_local` returns a `JoinHandle` which can be `await`ed. | ||
|
||
```rust | ||
let handle = spawn_local(async { 1 + 3 }); | ||
let output = handle.await; | ||
``` | ||
# Architecture |
Oops, something went wrong.