Async/Await Await os.phil-opp.com/async-await… Praying for Rust
In this article we will discuss cooperative multitasking and the async/await feature in Rust. We’ll take a closer look at how async/await works in Rust, including the design of Future traits, state machine transitions and pinning. We then add basic async/await support to our kernel by creating an asynchronous keyboard task and a basic executor.
This article is publicly available on Github. If you have any questions, please make an issue on Github. You can also leave a comment at the bottom, and the full source code for this article is available in the post-12 branch.
Multitasking
Multitasking is one of the basic characteristics of most operating systems and refers to the ability to perform multiple tasks concurrently. For example, you might be reading this while running some other program, such as a text editor or terminal window. Even if you only have one browser window open, there are still various background tasks running, managing your desktop window, checking for updates or indexing files.
Although it may appear that all tasks are running in parallel, the CPU core can only perform one task at a time. To create the illusion of tasks running in parallel, the operating system quickly switches between active tasks so that each one moves forward a little. Because computers run so fast, we don’t notice these switches most of the time.
While a single-core CPU can only perform a single task at a time, a multi-core CPU can truly perform multiple tasks in parallel. For example, an 8-core CPU can run eight tasks simultaneously. We’ll cover how to set up a multicore CPU in a future article. In this article, we focus on single-core cpus for simplicity. (It’s worth noting that all multicore cpus start with a single active core, so we can currently consider them single-core cpus.)
There are two forms of multitasking: Cooperative multitasking requires tasks to periodically relinquish control of the CPU so that other tasks can move forward. Preemptive multitasking leverages an operating system feature to switch tasks at any point in time by forcing a task to stop. Below we’ll discuss both forms of multitasking in more detail and examine their respective strengths and weaknesses.
Preemptive Multitasking
The idea behind preemptive multitasking is that the operating system controls when to switch tasks. To do this, it takes advantage of the fact that it regains CPU control with each interrupt. In this way, tasks can be switched whenever the system has new input. For example, it can switch tasks when the mouse moves or when a network packet arrives. The operating system can also determine the exact length of time a task is allowed to run by configuring a hardware timer to send interrupts after a specified time.
The following figure illustrates the task switching process during a hardware outage:
In the first line, the CPU is executing Task A1 in Program A. All other tasks are suspended. In the second line, a hardware interrupt arrives at the CPU. As described in the Hardware Interrupts article, the CPU immediately stops execution of task A1 and jumps to a table defined in the Interrupt Descriptor table, An interrupt handler in IDT. With this interrupt handler, the operating system now controls the CPU again, enabling it to switch to task B1 instead of continuing with task A1.
Save the state
Because tasks can be interrupted at any time, they may be in the middle of some computation. To be able to recover later, the operating system must back up the entire state of the task, including its Call stack and the values of all CPU registers. This process is called context switch
Because the call stack can be very large, the operating system typically sets up a separate call stack for each task, rather than backing up the call stack each time the task switches. Such a task with a separate call stack is called a thread of execution or thread for short. After using a separate call stack for each task, only the contents of the registers (including program counters and stack Pointers) need to be saved during context switches. This approach minimizes the overhead of context switches, which is important because context switches happen 100 times per second.
discuss
The main advantage of preemptive multitasking is that the operating system has complete control over how long a task is allowed to execute. In this way, it ensures that each task gets a fair slice of CPU time without relying on task cooperation. This is especially important when running third-party tasks or when multiple users share a system.
The downside of preemptive multitasking is that each task requires its own stack. This results in higher memory usage per task and often limits the number of tasks in the system compared to a shared stack. Another disadvantage is that the operating system must save the full CPU register state with each task switch, even though the task may only be using a small part of the register.
Preemptive multitasking and threading are fundamental components of an operating system because they make it possible to run unreliable user-mode programs. We will discuss these concepts fully in future articles. But in this article, we’ll focus on collaborative multitasking, which also provides useful functionality for our kernel.
Cooperative Multitasking
Instead of forcing the suspension of running tasks at any moment, collaborative multitasking lets each task run until it voluntarily relinquishes control of the CPU. This allows the task to pause itself at the right point in time, such as when it needs to wait for an I/O operation.
Collaborative multitasking is often used at the programming language level, for example in the form of coroutines or async/await. The idea is that the programmer or compiler inserts yield operations into the program, which relinquish control of the CPU and allow other tasks to run. For example, you can insert a yield after each iteration of a complex loop.
A common combination is collaborative multitasking and asynchronous operations. Instead of waiting for one operation to complete and preventing other tasks from running, asynchronous operations return a “not ready” state if the operation has not finished. In this case, the waiting task can perform a yield operation to allow another task to run.
Save the state
Because tasks define their own pause points, they do not need an operating system to store their state. They can save exactly the state they need to resume execution later, before they pause themselves, which usually leads to better performance. For example, a task that has just finished a complex calculation might only need to back up the final result of the calculation because it no longer needs the results of any intermediate processes.
The language-supported collaborative multitasking implementation can even back up the required portions of the call stack before pausing. For example, the async/await implementation in Rust stores all local variables that will be used in an automatically generated structure (described below). By backing up the relevant parts of the call stack before pausing, all tasks can share a single call stack, resulting in a smaller memory consumption per task. This also makes it possible to create almost any number of collaborative tasks without running out of memory.
discuss
The downside of cooperative multitasking is that a non-cooperative task can run indefinitely. Thus, a malicious or buggy task can prevent other tasks from running and slow down or even lock up the entire system. Therefore, collaborative multitasking should only be used if all tasks are known to be able to collaborate. To take a counter example, it’s not a good idea to have an operating system that relies on collaboration between arbitrary user-level programs.
Still, the powerful performance and memory benefits of collaborative multitasking make it a good approach to use within programs, especially when combined with asynchronous operations. Because the operating system kernel is a performance-critical program that interacts with asynchronous hardware, collaborative multitasking seems like a good way to achieve concurrency.