Pierre Zemb's Blog

Unlocking Tokio's Hidden Gems: Determinism, Paused Time, and Local Execution

Table of contents

Tokio is the powerhouse of asynchronous Rust, celebrated for its blazing speed and robust concurrency primitives. Many of us interact with its core components daily—spawn, select!, async fn, and the rich ecosystem of I/O utilities. But beyond these well-trodden paths lie some incredibly potent, albeit less-publicized, features that can dramatically elevate your testing strategies, offer more nuanced task management, and grant you surgical control over your runtime's execution.

Today, let's pull back the curtain on a few of these invaluable tools: current-thread runtimes for embracing single-threaded flexibility with !Send types, seeded runtimes for taming non-determinism, and the paused clock for mastering time in your tests.

🔗Effortless !Send Futures with Current-Thread Runtimes

While Tokio's multi-threaded scheduler is a marvel for CPU-bound and parallel I/O tasks, there are scenarios where a single-threaded execution model is simpler or even necessary. This is particularly true when dealing with types that are not Send (i.e., cannot be safely transferred across threads), such as Rc<T> or RefCell<T>, or when you want to avoid the overhead and complexity of synchronization primitives like Arc<Mutex<T>> for state shared only within a single thread of execution.

Tokio's Builder::new_current_thread() followed by build_local() (part of the same Builder API) provides a streamlined way to create a runtime that executes tasks on the thread that created it. This setup inherently supports spawning !Send futures using tokio::task::spawn_local without needing to manually manage a LocalSet for basic cases. This approach aligns well with ongoing discussions in the Tokio community aimed at simplifying !Send task management.

This build_local() method not only simplifies handling !Send types today but also reflects the direction Tokio is heading. The Tokio team is exploring ways to make this even more integrated and ergonomic through a proposed LocalRuntime type (#6739). The vision for LocalRuntime is a runtime that is inherently !Send (making !Send task management seamless within its context), where tokio::spawn and tokio::task::spawn_local would effectively behave identically.

This proposed enhancement is linked to a discussion about potentially deprecating the existing tokio::task::LocalSet (#6741). While LocalSet currently offers fine-grained control for running !Send tasks (e.g., within specific parts of larger, multi-threaded applications), it comes with complexities, performance overhead, and integration challenges that LocalRuntime aims to resolve.

So, what's the takeaway for you?

Essentially, Tokio is moving towards making single-threaded !Send execution more straightforward and deeply integrated. The build_local() method is a current gem that aligns you with this forward-looking approach.

Here's how you typically set one up (the build_local() way):

use tokio::runtime::Builder;

fn main() {
    let mut rt = Builder::new_current_thread()
        .enable_all() // Enable I/O, time, etc.
        .build_local(&mut Default::default()) // Builds a runtime on the current thread
        .unwrap();

    // The runtime itself is the 'LocalSet' in this context
    rt.block_on(async {
        // Spawn !Send futures here using tokio::task::spawn_local(...)
        // For example:
        let rc_value = std::rc::Rc::new(5);
        tokio::task::spawn_local(async move {
            println!("RC value: {}", *rc_value);
        }).await.unwrap();

        println!("Running !Send futures on a current-thread runtime!");
    });
}

This approach simplifies designs where tasks don't need to cross thread boundaries, allowing for more straightforward state management.

🔗Taming Non-Determinism: Seeded Runtimes

One of the challenges in testing concurrent systems is non-determinism. When multiple futures are ready to make progress simultaneously, such as in a tokio::select! macro, the order in which they are polled can vary between runs. This can make reproducing and debugging race conditions or specific interleavings tricky.

Tokio offers a solution: seeded runtimes. By providing a specific RngSeed when building the runtime, you can make certain scheduler behaviors deterministic. This is particularly useful for select! statements involving multiple futures that become ready around the same time.

Consider this example, which demonstrates how a seed can influence which future 'wins' a select! race:

use tokio::runtime::{Builder, RngSeed};
use tokio::time::{sleep, Duration};

// Example function to show deterministic select!
fn demo_deterministic_select() {
    // Try changing this seed to see the select! behavior change (but consistently per seed).
    let seed = RngSeed::from_bytes(b"my_fixed_seed_001");
    // e.g., let seed = RngSeed::from_bytes(b"another_seed_002");

    let mut rt = Builder::new_current_thread()
        .enable_time()
        // Pausing the clock is crucial here to ensure both tasks become ready 
        // at the *exact same logical time* after we call `tokio::time::advance`.
        // This makes the seed's role in tie-breaking very clear.
        .start_paused(true)
        .rng_seed(seed)     // Apply the seed for deterministic polling order
        .build_local(&mut Default::default())
        .unwrap();

    // Now, let's run some tasks and see select! in action.
    rt.block_on(async {
        let task_a = async {
            sleep(Duration::from_millis(50)).await;
            println!("Task A finished.");
            "Result from A"
        };

        let task_b = async {
            sleep(Duration::from_millis(50)).await;
            println!("Task B finished.");
            "Result from B"
        };

        // Advance time so both sleeps complete and both tasks become ready.
        tokio::time::advance(Duration::from_millis(50)).await;

        // With the same seed, the select! macro will consistently pick the same
        // branch if both are ready. Change the seed to see if the other branch gets picked.
        tokio::select! {
            res_a = task_a => {
                println!("Select chose Task A, result: '{}'", res_a);
            }
            res_b = task_b => {
                println!("Select chose Task B, result: '{}'", res_b);
            }
        }
    });
}

fn main() {
    demo_deterministic_select();
}

🔗Mastering Time: Paused Clock and Auto-Advancement

Testing time-dependent behavior (timeouts, retries, scheduled tasks) can be slow and flaky. Waiting for real seconds or minutes to pass during tests is inefficient. Tokio's time facilities can be paused and manually advanced, giving you precise control over the flow of time within your tests.

When you initialize a runtime with start_paused(true), the runtime's clock will not advance automatically based on wall-clock time. Instead, you use tokio::time::advance(Duration) to move time forward explicitly.

What's particularly neat is Tokio's auto-advance feature when the runtime is paused and idle. This works because Tokio's runtime separates the executor (which polls your async code until it's blocked) from the reactor (which wakes tasks based on I/O or timer events). If all tasks are sleeping, the executor is idle. The reactor can then identify the next scheduled timer, allowing Tokio to automatically advance its clock to that point. This prevents tests from hanging indefinitely while still allowing for controlled time progression.

Here's your example illustrating this:

use tokio::time::{Duration, Instant, sleep};

async fn auto_advance_kicks_in_when_idle_example() {
    let start = Instant::now();

    // Sleep for 5 seconds. Since the runtime is paused, this would normally hang.
    // However, if no other tasks are active, Tokio auto-advances time.
    sleep(Duration::from_secs(5)).await;

    let elapsed = start.elapsed();

    // This will be exactly 5 seconds (simulated time)
    assert_eq!(elapsed, Duration::from_secs(5));

    println!("Elapsed (simulated): {:?}", elapsed);
}

In this scenario, sleep(Duration::from_secs(5)).await doesn't cause your test to wait for 5 real seconds. Because the clock is paused and this sleep is the only pending timed event, Tokio advances its internal clock by 5 seconds, allowing the sleep to complete almost instantaneously in real time. This makes testing timeouts, scheduled events, and other time-sensitive logic fast and reliable.

🔗Conclusion

Tokio offers more than just speed; it's a powerful toolkit. Features like current-thread runtimes for !Send tasks, seeded runtimes for deterministic tests, and a controllable clock for time-based logic help build robust and debuggable async Rust applications. These 'hidden gems' allow you to confidently handle complex concurrency and testing. So, explore Tokio's depth—the right tool for your challenge might be closer than you think.


Feel free to reach out with any questions or to share your experiences with simulation testing. You can find me on Bluesky, Twitter or through my website.

Tags: #rust #tokio #async #testing #concurrency #deterministic