Task scheduling in Rust async runtimes
Rust asynchronous runtimes schedule tasks cooperatively using event-driven executors that drive lazy future state machines to completion. Tasks run on worker threads until they yield at an await point or return pending, registering a waker to be requeued when external resources become ready. Multi-threaded runtimes then distribute these ready tasks across worker thread run queues and balance execution using work-stealing algorithms.
Cooperative Scheduling and State Machine Execution
Rust futures are lazy and will not do anything unless they are actively driven to completion [1]. Unlike asynchronous mechanisms in other programming environments, a Rust future does not represent a computation occurring in the background, but is rather the computation itself [2]. The owner of the future holds responsibility for advancing the computation by polling the future through calls to Future::poll [3]. In this execution model, Rust futures are structured as state machines [4].
A task is a lightweight, non-blocking unit of execution similar to an operating system thread, but it is managed by the Tokio runtime rather than the operating system scheduler [5]. Because tasks are scheduled by the Tokio runtime instead of the operating system, creating new tasks or switching between them avoids context switches and incurs fairly low overhead [6]. Tasks implement cooperative multitasking where a task runs until it yields to indicate to the runtime scheduler that it cannot continue executing, prompting the runtime to switch to the next task [7]. Because future execution operates as a state machine, Tokio can only reschedule upon reaching an await point [8]. Control flow returns to the worker thread only on state transitions that result in pending or ready statuses [9].
Polling Loops and the Waker Mechanism
Future executors take a collection of top-level futures and run them to completion by calling poll whenever the future can make progress [10]. Receiving Poll::Pending indicates to the caller that the future will complete at a later time and that the caller should invoke poll again later [11]. The Context passed to poll provides a waker() method returning a Waker bound to the current task, whose wake() method signals to the executor that the associated task should be scheduled for execution [12]. Wakers inform the executor exactly which task has become ready, allowing executors to poll only the futures that are ready to make progress [13]. When a future returns Poll::Pending, it must ensure that the waker is signalled eventually, as failing to do so causes the task to hang indefinitely [14]. When futures indicate readiness to make progress by calling wake(), they are placed back onto a queue and polled again until completed [15].
Multi-Threaded Schedulers and Work Stealing
In Tokio, an event-driven platform for asynchronous applications, users submit tasks through spawn, and the scheduler decides how to execute them, most often using a multi-threaded scheduler [16]. The multi-threaded scheduler dispatches tasks to a fixed thread pool where each worker thread maintains a local run queue to store pending tasks [17]. Upon starting, each worker thread enters an execution loop to sequentially fetch and run tasks from its run queue [18]. To address queue imbalance, Tokio employs work stealing, where a worker whose run queue is empty attempts to steal tasks from the queues of other workers to execute [19].
Blocking Task Management and Lifecycle Control
Tasks should avoid performing system calls or operations that block a thread, since blocking prevents other tasks running on the same thread from executing [20]. If polling invokes a blocking API, the worker thread becomes stuck on that task, preventing other tasks on that worker's run queue from being scheduled timely despite work stealing [21]. For blocking operations, Tokio provides task::spawn_blocking to execute a blocking function on a dedicated thread pool for blocking tasks rather than spawning a non-blocking future on the runtime [22]. Unlike spawn_blocking, the block_in_place function transitions the current worker thread into a blocking thread while moving other tasks on that thread to another worker thread [23]. Calling and awaiting task::yield_now causes the current task to yield to the runtime scheduler, allowing other tasks to be scheduled [24]. Spawned tasks can be cancelled using JoinHandle::abort or AbortHandle::abort, signalling the task to shut down the next time it yields at an await point [25]. When shut down, the task stops running at whichever await point it yielded at, and all local variables are destroyed by running their destructors [26]. Tasks created using spawn_blocking cannot be aborted because they are not asynchronous [27].
Key facts
- Rust futures are lazy and will not do anything unless actively driven to completion by an executor [1][10].
- A task is a lightweight, non-blocking unit of execution managed cooperatively by the Tokio runtime rather than the operating system scheduler [5][7].
- Future executors drive top-level futures to completion by calling poll, with control flow returning to worker threads only on state transitions [9][10].
- When a future returns pending, signalling its associated waker notifies the executor to place the task back onto a queue for further polling [12][14][15].
- Tokio's multi-threaded scheduler distributes tasks across a fixed thread pool of worker threads, balancing workloads through work stealing when local run queues become empty [17][19].
- Synchronous operations that block a thread can stall worker threads and are isolated using dedicated thread pools via
spawn_blockingor thread conversion viablock_in_place[21][22][23]. - Tasks can yield execution to the scheduler using
yield_nowor be cancelled at an await point using abort handles, though blocking tasks cannot be aborted [24][25][27].
Sources
-
Applied: Build an Executor - Asynchronous Programming in Rust rust-lang.github.io
- [1]
Rust’s Future s are lazy: they won’t do anything unless actively driven to completion.
- [10]
Future executors take a set of top-level Future s and run them to completion by calling poll whenever the Future can make progress.
- [13]
Waker s tell the executor exactly which task has become ready, allowing them to poll just the futures that are ready to make progress.
- [15]
When Future s indicate that they are ready to make progress by calling wake(), they are placed back onto a queue and poll is called again, repeating until the Future has completed.
- [1]
-
Async in depth | Tokio - An asynchronous Rust runtime tokio.rs
- [2]
Unlike how futures are implemented in other languages, a Rust future does not represent a computation happening in the background, rather the Rust future is the computation itself.
- [3]
The owner of the future is responsible for advancing the computation by polling the future. This is done by calling Future::poll.
- [4]
Rust futures are state machines.
- [11]
Receiving Poll::Pending indicates to the caller that the future will complete at a later time and the caller should invoke poll again later.
- [12]
The Context argument to poll has a waker() method. This method returns a Waker bound to the current task. The Waker has a wake() method. Calling this method signals to the executor that the associated task should be scheduled for execution.
- [14]
When a future returns Poll::Pending, it must ensure that the waker is signalled at some point. Forgetting to do this results in the task hanging indefinitely.
- [2]
-
tokio::task - Rust docs.rs
- [5]
A task is a light weight, non-blocking unit of execution. A task is similar to an OS thread, but rather than being managed by the OS scheduler, they are managed by the Tokio runtime.
- [6]
Because tasks are scheduled by the Tokio runtime rather than the operating system, creating new tasks or switching between tasks does not require a context switch and has fairly low overhead.
- [7]
In cooperative multitasking, a task is allowed to run until it yields, indicating to the Tokio runtime’s scheduler that it cannot currently continue executing. When a task yields, the Tokio runtime switches to executing the next task.
- [20]
Tasks should generally not perform system calls or other operations that could block a thread, as this would prevent other tasks running on the same thread from executing as well.
- [22]
The task::spawn_blocking function is similar to the task::spawn function discussed in the previous section, but rather than spawning a non-blocking future on the Tokio runtime, it instead spawns a blocking function on a dedicated thread pool for blocking tasks.
- [23]
Unlike spawn_blocking, however, block_in_place works by transitioning the current worker thread to a blocking thread, moving other tasks running on that thread to another worker thread.
- [24]
In addition, this module provides a task::yield_now async function that is analogous to the standard library’s thread::yield_now. Calling and await ing this function will cause the current task to yield to the Tokio runtime’s scheduler, allowing other tasks to be scheduled.
- [25]
Spawned tasks may be cancelled using the JoinHandle::abort or AbortHandle::abort methods. When one of these methods are called, the task is signalled to shut down next time it yields at an .await point.
- [26]
When tasks are shut down, it will stop running at whichever .await it has yielded at. All local variables are destroyed by running their destructor.
- [27]
Be aware that tasks spawned using spawn_blocking cannot be aborted because they are not async.
- [5]
-
How Tokio schedule tasks: A hard Lesson learnt rustmagazine.org
- [8]
Tokio can only reschedule on reaching an await, since future execution is a state machine
- [9]
control flow returns to the worker thread only on state transitions (Pending or Ready).
- [16]
Tokio is an event-driven, non-blocking I/O platform for writing asynchronous applications, users submit tasks via spawn, then Tokio’s scheduler decides how to execute them, most of time using a multi-threaded scheduler.
- [17]
Multi-threaded scheduler dispatches tasks to a fixed thread pool, each worker thread has a local run queue to save pending tasks.
- [18]
When starts, each worker thread will enter a loop to sequentially fetch and execute tasks in its run queue.
- [19]
Tokio uses work stealing to address this - when a worker’s run queue is empty, it tries to “steal” tasks from other workers’ queues to execute.
- [21]
If fut_one.poll() contains blocking API, the worker thread will be stuck on that task. Tasks on that worker’s run queue are likely to not be scheduled timely despite work stealing.
- [8]