Life of a Task

This page illustrates the lifecycle of a task and how it interacts with the various parts of the executor.

#![allow(unused)]
fn main() {
let local_ex = LocalExecutor::default();
let task1_result = local_ex.run(async {
    let task2_handle = spawn_local({ async_read_file(...).await });
    task2_handle.await
});
}

Spawning the Task

When the LocalExecutor is created, a default TaskQueue is created. When local_ex.run(...) is called, the executor spawns a task with the Future created from the async block. It creates a task and schedules the task onto the default TaskQueue. Let’s name this task as Task1.

Running Task1

Spawning the task would create a JoinHandle for Task1. The LocalExecutor creates a loop that will only exit when Task1 is completed. The executor verifies when the task is completed by polling the JoinHandle. If it’s completed, the loop exits, and the output of the task is returned. Otherwise, the executor begins running tasks from active task queues.

To run the task, the executor would go through all the TaskQueues and execute all the tasks in them. It does so by creating an outer loop that loops through theTaskQueues and creating an inner loop that runs all the tasks in each TaskQueue.

To run a task, the executor pops the task from the task queue and runs it. When the task is run, it creates a Waker with the RAW_WAKER_VTABLE. Let’s call the created Waker Waker1. Waker1's responsibility is to reschedule Task1 onto the TaskQueue when wake() is called.

Next, the executor polls the user-provided Future with Waker1. As a reminder, the user-provided Future is the Future created from the following async block:

#![allow(unused)]
fn main() {
async {
    let task2_handle = spawn_local(async { async_read_file(...).await });
    task2_handle.await
}
}

When the Future is polled, it would first spawn a task with the Future created from async { async_read_file(...).await }. Let’s call the spawned task Task2. Spawning Task2 would also create a JoinHandle for it.

Next, handle.await is called, which would poll the JoinHandle. Since Task2 is not complete, the waker is registered as Task2’s awaiter. This waker corresponds to Waker1. The idea is that Task2 is blocking Task1. So when Task2 completes, Waker1::wake() would be invoked. This would notify the executor that Task1 is ready to progress again by scheduling Task1 onto the TaskQueue.

Running Task2

After Task1::run() completes, we are back to the inner loop that runs all the tasks from the active TaskQueue. Since Task2 is now in the TaskQueue, the executor would pop it off from the TaskQueue to execute it.

When Task2 is run, a Waker for Task2 is created. Let’s call it Waker2. Next, the Future created from async { async_read_file(...).await } would be polled with Waker2. Since we haven’t covered how I/O works, let’s treat async_read_file as a black box. All we need to know is that when the operation is completed, Waker2::wake() will be invoked which will reschedule Task2.

After async_read_file is completed, Task2 is rescheduled back on the TaskQueue. We are back on the inner loop that runs the default TaskQueue. It would pop Task2 off the TaskQueue and poll it. This time, the Future is completed. This would notify Task1 that Task2 has been completed by waking up Waker1. This would reschedule Task1 and push it back onto the TaskQueue.

Completing Task1

We are back to the loop that runs the default TaskQueue. It would pop Task1 from the TaskQueue and run it. It would poll the Future which would return Poll::Ready. Finally, we can exit both the inner loop and the outer loop since there are no more tasks in any of the TaskQueues to run.

After run_task_queues finishes executing, the executor would [poll Task1's JoinHandle again](https://github.com/brianshih1/mini-glommio/blob/7025a02d91f19e258d69e966f8dfc98eeeed4ecc/src/executor/local_executor.rs#L77), which would return Poll::Pending. Then the executor can finally return the output result.