DEV Community

Cover image for Rust Async Programming: Future Executors and Task Scheduling
Leapcell
Leapcell

Posted on

3 1 1 1 2

Rust Async Programming: Future Executors and Task Scheduling

Cover

Definition of Future

The Future is the core of asynchronous programming in Rust. Here's the definition of the Future trait:

#[must_use = "futures do nothing unless you `.await` or poll them"]
#[lang = "future_trait"]
pub trait Future {
    type Output;
    fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output>;
}

#[must_use = "this `Poll` may be a `Pending` variant, which should be handled"]
pub enum Poll<T> {
    Ready(T),
    Pending,
}
Enter fullscreen mode Exit fullscreen mode

A Future has an associated type Output, and a method poll() which returns a Poll<Self::Output>. Poll is an enum with two variants: Ready and Pending. By calling the poll() method, a Future can be progressed further toward completion until the task is done and switched out.

In a current poll call, if the Future is complete, it returns Poll::Ready(result), which means the value of the Future is returned. If the Future is not yet complete, it returns Poll::Pending(). At this point, the Future will be suspended, waiting to be woken up by some event (via a wake function).

Executor (Execution Scheduler)

An executor is a scheduler for a Future. While the operating system is responsible for scheduling threads, it does not schedule user-space coroutines (like Futures). Therefore, any program that uses coroutines for concurrency needs an executor to handle scheduling.

Rust's Futures are lazy—they only run when polled. One way to drive them is to call another async function inside an async function using .await, but that only solves the issue within async functions themselves. The outermost async functions still need to be driven by an executor.

Executor Runtime

Although Rust provides coroutines like Futures, it does not provide an executor at the language level. If coroutines are not used, there's no need to bring in any runtime; if they are needed, the ecosystem offers a variety of executors to choose from.

Here are 4 common executors in Rust:

  • futures: This library comes with a simple built-in executor.
  • tokio: Provides an executor; using #[tokio::main] implicitly includes Tokio’s executor.
  • async-std: Provides an executor similar to Tokio.
  • smol: Provides async-executor, primarily exposing block_on.

Wake Notification Mechanism

An executor manages a group of Futures (typically the outermost async functions), and advances them by continuously polling until they complete. Initially, the executor polls a Future once. After that, it won't actively poll again. If the poll method returns Poll::Pending, the Future is suspended until some event triggers its wake-up via the wake() function. The Future can then actively notify the executor, prompting it to resume polling and continue executing the task. This wake-then-poll cycle repeats until the Future completes.

The Waker provides a wake() method, which tells the executor that the associated task is ready to be resumed, allowing the executor to poll the corresponding Future again.

Context is a wrapper around Waker. Let's look at the Context structure used in the poll method:

pub struct Context<'a> {
    waker: &'a Waker,
    _marker: PhantomData<fn(&'a ()) -> &'a ()>,
}
Enter fullscreen mode Exit fullscreen mode

The definition and implementation of Waker are quite abstract. Internally, it uses a virtual function table (vtable) to allow for a variety of waker behaviors:

pub struct RawWakerVTable {
    clone: unsafe fn(*const ()) -> RawWaker,
    wake: unsafe fn(*const ()),
    wake_by_ref: unsafe fn(*const ()),
    drop: unsafe fn(*const ()),
}
Enter fullscreen mode Exit fullscreen mode

Rust itself does not provide an async runtime—it only defines basic interfaces in the standard library, leaving runtime behavior to be implemented by third-party runtimes. So in the standard library, you will only see the interface definitions and some high-level implementations. For example, the wake() method on Waker simply delegates the call to the corresponding function in the vtable:

impl Waker {
    /// Wake up the task associated with this `Waker`.
    #[inline]
    pub fn wake(self) {
        // The actual wakeup call is delegated through a virtual function call
        // to the implementation which is defined by the executor.
        let wake = self.waker.vtable.wake;
        let data = self.waker.data;

        // Don't call `drop` -- the waker will be consumed by `wake`.
        crate::mem::forget(self);

        // SAFETY: This is safe because `Waker::from_raw` is the only way
        // to initialize `wake` and `data` requiring the user to acknowledge
        // that the contract of `RawWaker` is upheld.
        unsafe { (wake)(data) };
    }
    ...
}
Enter fullscreen mode Exit fullscreen mode

The actual implementation of the vtable is not in the standard library—it is provided by third-party async runtimes, such as the one in the futures crate.

Building a Timer

Let's use a timer example to help understand the scheduling mechanism of a Future. The goal is: when creating a timer, a new thread is spawned that sleeps for a specific duration, and once the time window has passed, it signals the timer Future.

Note: This requires the ArcWake trait from the futures crate, which provides a convenient way to construct a Waker. Edit Cargo.toml and add the following dependency:

[dependencies]
futures = "0.3"
Enter fullscreen mode Exit fullscreen mode

Full code for the timer Future:

// future_timer.rs
use futures;
use std::{
    future::Future,
    pin::Pin,
    sync::{Arc, Mutex},
    task::{Context, Poll, Waker},
    thread,
    time::Duration,
};

pub struct TimerFuture {
    shared_state: Arc<Mutex<SharedState>>,
}

/// Shared state between the Future and the sleeping thread
struct SharedState {
    /// Indicates whether the timer (sleep) has completed
    completed: bool,

    /// When sleep ends, the thread can use this `waker` to notify `TimerFuture` to wake up the task
    waker: Option<Waker>,
}

impl Future for TimerFuture {
    type Output = ();
    fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
        // Check shared state to determine if the timer has completed
        let mut shared_state = self.shared_state.lock().unwrap();
        if shared_state.completed {
            println!("future ready. execute poll to return.");
            Poll::Ready(())
        } else {
            println!("future not ready, tell the future task how to wakeup to executor");
            // Set the `waker` so that the new thread can wake up the task once the sleep completes,
            // allowing the `Future` to be polled again.
            // The `clone` here happens on every `poll`, but ideally should only happen once.
            // The reason we clone each time is because the `TimerFuture` might move between tasks
            // in the executor; a single `waker` instance might be altered and point to the wrong task,
            // resulting in the executor running the wrong task.
            shared_state.waker = Some(cx.waker().clone());
            Poll::Pending
        }
    }
}

impl TimerFuture {
    /// Create a new `TimerFuture` which completes after the specified duration
    pub fn new(duration: Duration) -> Self {
        let shared_state = Arc::new(Mutex::new(SharedState {
            completed: false,
            waker: None,
        }));

        // Spawn a new thread
        let thread_shared_state = shared_state.clone();
        thread::spawn(move || {
            // Sleep for the specified duration to simulate a timer
            thread::sleep(duration);
            let mut shared_state = thread_shared_state.lock().unwrap();
            // Notify the executor that the timer is done and the corresponding `Future` can be polled again
            shared_state.completed = true;
            if let Some(waker) = shared_state.waker.take() {
                println!("detect future is ready, wakeup the future task to executor.");
                waker.wake()
            }
        });

        TimerFuture { shared_state }
    }
}

fn main() {
    // We haven't implemented our own executor yet, so we use the one from the `futures` crate
    futures::executor::block_on(TimerFuture::new(Duration::new(10, 0)));
}
Enter fullscreen mode Exit fullscreen mode

Execution result:

future not ready, tell the future task how to wakeup to executor
detect future is ready, wakeup the future task to executor.
future ready. execute poll to return.
Enter fullscreen mode Exit fullscreen mode

As shown above, at first the 10-second timer hasn't completed and is in a Pending state. At this point, we need to tell the task how to wake itself up when it becomes ready. After 10 seconds, the timer completes and uses the previously set Waker to wake the Future task for execution.

Building an Executor

In the previous code, we did not implement our own scheduler—we used the executor provided by the futures crate. Now, let's build a custom executor ourselves to understand how it works under the hood. However, in real-world Rust async programming, you would typically use the tokio library. Here, we're building one from scratch for learning purposes, to better understand how async works.

Key code:

// future_executor.rs
use {
    futures::{
        future::{BoxFuture, FutureExt},
        task::{waker_ref, ArcWake},
    },
    std::{
        future::Future,
        sync::mpsc::{sync_channel, Receiver, SyncSender},
        sync::{Arc, Mutex},
        task::Context,
        time::Duration,
    },
};

mod future_timer;
// Import the previously implemented timer module
use future_timer::TimerFuture;

/// Task executor: responsible for receiving tasks from a channel and executing them
struct Executor {
    ready_queue: Receiver<Arc<Task>>,
}

/// `Spawner` is responsible for creating new `Future`s and sending them to the task channel
#[derive(Clone)]
struct Spawner {
    task_sender: SyncSender<Arc<Task>>,
}

/// A `Future` that can schedule itself (by sending itself to the task channel), and wait to be executed
struct Task {
    /// The in-progress `Future` that will complete at some point in the future
    ///
    /// Technically, a `Mutex` here is unnecessary because we're executing everything on a single thread.
    /// But Rust isn't smart enough to know that the `Future` is not being shared across threads,
    /// so we use `Mutex` to satisfy the compiler's requirements for thread safety.
    ///
    /// A production-grade executor wouldn't use a `Mutex` here because it introduces overhead.
    /// Instead, it would use `UnsafeCell`.
    future: Mutex<Option<BoxFuture<'static, ()>>>,

    /// Allows this task to re-submit itself into the task queue, waiting for the executor to `poll` it
    task_sender: SyncSender<Arc<Task>>,
}

fn new_executor_and_spawner() -> (Executor, Spawner) {
    // Maximum number of buffered tasks in the task channel (queue length)
    // This implementation is simplified; real-world executors handle this differently
    const MAX_QUEUED_TASKS: usize = 10_000;
    let (task_sender, ready_queue) = sync_channel(MAX_QUEUED_TASKS);
    (Executor { ready_queue }, Spawner { task_sender })
}

impl Spawner {
    fn spawn(&self, future: impl Future<Output = ()> + 'static + Send) {
        let future = future.boxed();
        let task = Arc::new(Task {
            future: Mutex::new(Some(future)),
            task_sender: self.task_sender.clone(),
        });
        println!("first dispatch the future task to executor.");
        self.task_sender.send(task).expect("too many tasks queued.");
    }
}

/// Implements `ArcWake` to define how to wake a task and schedule it for execution
impl ArcWake for Task {
    fn wake_by_ref(arc_self: &Arc<Self>) {
        // Wake is implemented by sending the task back into the task channel,
        // so the executor will `poll` it again.
        let cloned = arc_self.clone();
        arc_self
            .task_sender
            .send(cloned)
            .expect("too many tasks queued");
    }
}

impl Executor {
    /// Actually run the `Future` tasks by continuously receiving and executing them
    fn run(&self) {
        let mut count = 0;
        while let Ok(task) = self.ready_queue.recv() {
            count += 1;
            println!("received task. {}", count);
            // Retrieve the future; if it hasn't finished (still Some), then `poll` it and try to complete it
            let mut future_slot = task.future.lock().unwrap();
            if let Some(mut future) = future_slot.take() {
                // Create a `LocalWaker` based on the task itself
                let waker = waker_ref(&task);
                let context = &mut Context::from_waker(&*waker);
                // `BoxFuture<T>` is an alias for `Pin<Box<dyn Future<Output = T> + Send + 'static>>`
                // `as_mut` converts it to `Pin<&mut dyn Future + Send + 'static>`
                if future.as_mut().poll(context).is_pending() {
                    println!("executor run the future task, but is not ready, create a future again.");
                    // Future is not yet done, so put it back and wait for the next poll
                    *future_slot = Some(future);
                } else {
                    println!("executor run the future task, is ready. the future task is done.");
                }
            }
        }
    }
}

fn main() {
    let (executor, spawner) = new_executor_and_spawner();

    // Wrap the TimerFuture in a task and dispatch it to the scheduler for execution
    spawner.spawn(async {
        println!("TimerFuture await");
        // Create a timer Future and wait for it to complete
        TimerFuture::new(Duration::new(10, 0)).await;
        println!("TimerFuture Done");
    });

    // Drop the spawner so the executor knows no more tasks will be submitted
    drop(spawner);

    // Run the executor until the task queue is empty
    // Once the task runs, it will print "howdy!", pause for 2 seconds, then print "done!"
    executor.run();
}
Enter fullscreen mode Exit fullscreen mode

Execution result:

first dispatch the future task to executor.
received task. 1
TimerFuture await
future not ready, tell the future task how to wakeup to executor
executor run the future task, but is not ready, create a future again.
detect future is ready, wakeup the future task to executor.
received task. 2
future ready. execute poll to return.
TimerFuture Done
executor run the future task, is ready. the future task is done.
Enter fullscreen mode Exit fullscreen mode

On the first scheduling attempt, the task is not yet ready and returns Pending. The task is then informed how it should be woken up when it becomes ready. Later, when the event is ready, the task is woken up as instructed and scheduled for execution.

Asynchronous Processing Flow

The Reactor Pattern is a classic design pattern used to build high-performance event-driven systems. The executor and reactor are components of this pattern. The Reactor Pattern is composed of three main parts:

  • Task: A unit of work to be executed. Tasks can be paused and yield control to the executor, waiting to be rescheduled later.
  • Executor: A scheduler that maintains tasks ready to run (the ready queue) and tasks that are blocked (the wait queue).
  • Reactor: Maintains an event queue. When an event occurs, it notifies the executor to wake a specific task to be run.

The executor schedules tasks for execution. When a task cannot proceed but is not yet complete, it will be suspended, and a proper wake-up condition will be registered. Later, if the reactor receives an event that satisfies the wake-up condition, it wakes the suspended task. The executor can then resume polling this task. This cycle repeats until the task is complete.

Rust’s asynchronous handling via Future is a typical implementation of the Reactor Pattern.

Taking tokio as an example:

async/await provides syntax-level support, and Future is the data structure representing asynchronous tasks. When .await is called, the executor will schedule and execute the task.

Tokio’s scheduler runs across multiple threads. Each thread runs tasks from its own ready queue. If a thread’s queue is empty, it can steal tasks from other threads' queues (a strategy called work-stealing).

When a task can no longer make progress and returns Poll::Pending, the scheduler suspends it and sets an appropriate wake-up condition using a Waker. The reactor uses the OS’s async I/O mechanisms (such as epoll, kqueue, or IOCP) to monitor I/O events. When a relevant event is triggered, the reactor calls Waker::wake(), which wakes the suspended Future. The Future is placed back into the ready queue and awaits execution.

Summary

Future is the core abstraction in Rust’s asynchronous programming model, representing operations that will complete at some point in the future. Rust’s Futures are lazy—they require an executor to drive them. This execution is implemented through polling:

  • In a current poll cycle, if a Future is complete, it returns Poll::Ready(result), providing the final value.
  • If the Future is not yet complete, it returns Poll::Pending(). At this point, the Future is suspended and awaits an external event to wake it up via a Waker.

The Waker provides a wake() method to notify the executor which task should be resumed. When wake() is called, the executor knows that the task associated with the Waker is ready to make progress and polls the Future again. This wake → poll → suspend cycle continues until the Future is finally completed.

Each asynchronous task generally goes through three stages:

  1. Polling Phase: The executor initiates polling on a Future. If it hits a point where it can't make further progress (Poll::Pending), the task is suspended and enters the waiting phase.
  2. Waiting Phase: The event source (typically called a reactor) registers a Waker to wait for an event. When the event occurs, it triggers the Waker to wake the associated Future, transitioning to the wake-up phase.
  3. Wake-Up Phase: When the event occurs, the corresponding Future is woken by its Waker. The executor schedules the Future for polling again. The task progresses until it either completes or reaches another Pending point. This cycle repeats until the task is fully completed.

We are Leapcell, your top choice for hosting Rust projects.

Leapcell

Leapcell is the Next-Gen Serverless Platform for Web Hosting, Async Tasks, and Redis:

Multi-Language Support

  • Develop with Node.js, Python, Go, or Rust.

Deploy unlimited projects for free

  • pay only for usage — no requests, no charges.

Unbeatable Cost Efficiency

  • Pay-as-you-go with no idle charges.
  • Example: $25 supports 6.94M requests at a 60ms average response time.

Streamlined Developer Experience

  • Intuitive UI for effortless setup.
  • Fully automated CI/CD pipelines and GitOps integration.
  • Real-time metrics and logging for actionable insights.

Effortless Scalability and High Performance

  • Auto-scaling to handle high concurrency with ease.
  • Zero operational overhead — just focus on building.

Explore more in the Documentation!

Try Leapcell

Follow us on X: @LeapcellHQ


Read on our blog

AWS Q Developer image

Your AI Code Assistant

Automate your code reviews. Catch bugs before your coworkers. Fix security issues in your code. Built to handle large projects, Amazon Q Developer works alongside you from idea to production code.

Get started free in your IDE

Top comments (0)

Sentry image

See why 4M developers consider Sentry, “not bad.”

Fixing code doesn’t have to be the worst part of your day. Learn how Sentry can help.

Learn more

👋 Kindness is contagious

DEV shines when you're signed in, unlocking a customized experience with features like dark mode!

Okay