Skip to content

Commit

Permalink
Improve blog
Browse files Browse the repository at this point in the history
Improve blog
  • Loading branch information
brianshih1 authored Jul 27, 2024
2 parents 2f44d55 + 0a7b8b9 commit 95d1793
Show file tree
Hide file tree
Showing 24 changed files with 322 additions and 296 deletions.
2 changes: 1 addition & 1 deletion mdbook/book.toml
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,4 @@ authors = ["Brian Shih"]
language = "en"
multilingual = false
src = "src"
title = "Building a Thread-Per-Core, Asynchronous Framework like Glommio"
title = "Building an Asynchronous Runtime like Glommio"
2 changes: 1 addition & 1 deletion mdbook/src/SUMMARY.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
- [Async/Await](./executor/async-await.md)
- [Waker](./executor/waker.md)
- [Implementation Details](./executor/implementation-details.md)
- [Core abstractions](./executor/core-abstractions.md)
- [Architecture](./executor/architecture.md)
- [Task](./executor/task.md)
- [Running the Task](./executor/raw_task.md)
- [TaskQueue](./executor/task_queue.md)
Expand Down
14 changes: 8 additions & 6 deletions mdbook/src/async_io/api.md
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
# API

Our goal here is to implement a set of internal APIs to make it easy to convert synchronous operations into asynchronous ones.
Our goal here is to implement a set of internal APIs to make it easy to convert synchronous I/O operations into asynchronous ones.

Whether we’re dealing with sockets or files, converting a synchronous operation to an asynchronous one roughly follows these steps:
Here are the rough steps to convert a blocking I/O operation into an asynchronous one:

- we need to set the file descriptor to non-blocking
- we need to perform the non-blocking operation
- we need to tell `io_uring` to monitor the file descriptor by submitting an `SQE`
- since the operation is asynchronous, we need to store the poller’s `waker` and invoke `wake()` when the I/O operation is complete. We detect when an I/O operation is complete when the corresponding `CQE` is posted to the `io_uring`'s completion queue.
- we set the file descriptor to non-blocking
- we perform the non-blocking operation
- we tell `io_uring` to monitor the file descriptor by submitting an `SQE`
- we store the poller’s `waker` and invoke `wake()` when the I/O operation is complete. We detect when an I/O operation is complete when the corresponding `CQE` is posted to the `io_uring`'s completion queue.

To make it easier to implement new asynchronous operations, we introduce `Async`, an adapter for I/O types inspired by the [async_io crate](https://docs.rs/async-io/latest/async_io/). `Async` abstracts away the steps listed above so that developers who build on top of `Async` don’t have to worry about things like `io_uring`, `Waker`, `O_NONBLOCK`, etc.

Expand Down Expand Up @@ -38,3 +38,5 @@ let res = local_ex.run(async {
handle_connection(stream);
});
```

Next, let's look at what the `Async` adapter actually does.
6 changes: 3 additions & 3 deletions mdbook/src/async_io/core-abstractions.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Core abstractions

In general, we can break down how the executor performs asynchronous I/O into 3 steps:
We can break down how the executor performs asynchronous I/O into 3 steps:

- setting the I/O handle to be non-blocking by setting the `O_NONBLOCK` flag on the file descriptor
- performing the non-blocking operation and registering interest in `io_uring` by submitting a `SQE` to the `io_uring` instance's `submission_queue`
Expand All @@ -26,7 +26,7 @@ pub struct Async<T> {

### Source

The `Source` is a bridge between the executor and the I/O handle. It contains properties pertaining to the I/O handle that are relevant to the executor. For example, it contains tasks that are blocked by operations on the I/O handle.
The `Source` is a bridge between the executor and the I/O handle. It contains the rwa file descriptor for the I/O event as well as properties that are relevant to the executor. For example, it contains wakers for blocked tasks waiting for the I/O operation to complete.

```rust
pub struct Source {
Expand Down Expand Up @@ -72,6 +72,6 @@ struct SourceMap {
}
```

As we can see, the `Reactor` holds a `SleepableRing`, which is just a wrapper around an `iou::IoUring` instance. Glommio uses the `[iou` crate](https://docs.rs/iou/latest/iou/) to interact with Linux kernel’s `io_uring` interface.
As we can see, the `Reactor` holds a `SleepableRing`, which is just a wrapper around an `iou::IoUring` instance. Glommio uses the [iou crate](https://docs.rs/iou/latest/iou/) to interact with Linux kernel’s `io_uring` interface.

The `Reactor` also contains a `SourceMap`, which contains a `HashMap` that maps a unique ID to a `Source`. The unique ID is the same ID used as the `SQE`'s user_data. This way, when a CQE is posted to the `io_uring`'s completion queue, we can tie it back to the corresponding `Source`.
8 changes: 5 additions & 3 deletions mdbook/src/async_io/intro.md
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.
2 changes: 1 addition & 1 deletion mdbook/src/async_io/io_uring.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Io_uring

On this page, I’ll provide a surface-level explanation of how `io_uring` works. If you want a more in-depth explanation, check out [this tutorial](https://unixism.net/loti/what_is_io_uring.html) or [this article](https://developers.redhat.com/articles/2023/04/12/why-you-should-use-iouring-network-io#:~:text=io_uring is an asynchronous I,O requests to the kernel).
In this page, I’ll provide a surface-level explanation of how `io_uring` works. If you want a more in-depth explanation, check out [this tutorial](https://unixism.net/loti/what_is_io_uring.html) or [this redhat article](https://developers.redhat.com/articles/2023/04/12/why-you-should-use-iouring-network-io#:~:text=io_uring).

As mentioned, `io_uring` manages file descriptors for the users and lets them know when one or more of them are ready.

Expand Down
2 changes: 1 addition & 1 deletion mdbook/src/async_io/non_blocking_mode.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Nonblocking Mode

In Rust, by default, many I/O operations, such as reading a file, are blocking. For example, in the code snippet below, the `TcpListener::accept` call will block the calling thread until a new TCP connection is established.
In most programming languages, I/O operations are blocking by default. For example, in the following example the `TcpListener::accept` call will block the thread until a new TCP connection is established.

```rust
let listener = TcpListener::bind("127.0.0.1:8080").unwrap();
Expand Down
95 changes: 68 additions & 27 deletions mdbook/src/executor/api.md
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.
15 changes: 15 additions & 0 deletions mdbook/src/executor/architecture.md
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.
21 changes: 14 additions & 7 deletions mdbook/src/executor/async-await.md
Original file line number Diff line number Diff line change
@@ -1,22 +1,27 @@
# Async/Await

`Async/Await` lets the programmer write code that looks like normal synchronous code. But the compiler then turns the code into asynchronous code. The `async` keyword can be used in a function signature to turn a synchronous function into an asynchronous function that returns a future:
`Async/Await` is syntactic sugar for building state machines. Any code wrapped around the `async ` block becomes a `future`.
This allows the programmer to begin a task without waiting for it to complete. Only when the `future` is awaited does the task block the execution.

The way `async/await` works is that programmers write code that looks like synchronous code. But the compiler then turns the code into asynchronous code. `Async/Await` is based on two keywords: `async` and `await`. During compilation, any code block wrapped inside the `async` keyword is converted into a state machine in the form of a `Future`.
During compilation, the compiler turns code wrapped in the `async` keyword into a pollable state machine.

As a simple example, the following async function `one_fn` may be compiled into `compiled_one_fn`, which is a function that returns a `Future`.
As a simple example, let's look at the following async function `f`:

```rust
async fn one_fn() -> u32 {
async fn f() -> u32 {
1
}
```

The compiler may compile `f` to something like:

fn compiled_one_fn() -> impl Future<Output = u32> {
```
fn compiled_f() -> impl Future<Output = u32> {
future::ready(1)
}
```

To gain a better intuition for how asynchronous code gets converted into a state machine, let’s look at a more complex `async` function. We are going to convert the `notify_user` method below into a state machine that implements the `Future` trait.
Let's look at a more complex example:

```rust
async fn notify_user(user_id: u32) {
Expand All @@ -27,7 +32,9 @@ async fn notify_user(user_id: u32) {
}
```

The method above first fetches the user’s information. It then sends an email if the user’s group matches `1`.
The function above first fetches the user's data, and conditionally sends an email to that user.



If we think about the function as a state machine, here are its possible states:

Expand Down
37 changes: 1 addition & 36 deletions mdbook/src/executor/core-abstractions.md
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
Loading

0 comments on commit 95d1793

Please sign in to comment.