Skip to Content
Course content

45: The Tokio Runtime Basics

Click on the "Edit" button in the top corner of the screen to edit your slide content.

Up until now, we've talked about async and await as language features. But here is the secret that often trips people up when they first dive into Rust: the language itself doesn't actually know how to execute an async function. It just knows how to build a state machine that can be executed. To actually make something happen, you need a runtime. In the Rust ecosystem, that almost always means Tokio.

The Illusion of Concurrency

When I first started with Tokio, I made a classic mistake. I thought that because I was using async functions, I was automatically getting concurrency. I wrote a health checker that looped through a list of server IPs and used runtime.block_on() inside the loop. It looked like this:

// The naive, "fake async" way
for ip in server_list {
    // This blocks the current thread until the future completes
    let status = runtime.block_on(check_health(ip)); 
    println!("Server {} is {}", ip, status);
}

I felt clever because I was using async functions, but in reality, I had just written a synchronous program with extra steps. block_on tells the runtime: "Stop everything else on this thread and do not move a single inch until this specific future is finished." If you have 100 servers and each takes one second to respond, your program takes 100 seconds. You've completely defeated the purpose of asynchronous I/O.

Letting the Scheduler Take the Wheel

The "correct" way to handle this is to stop trying to control the execution yourself and instead hand your tasks over to the Tokio scheduler. This is where #[tokio::main] and tokio::spawn come in. Instead of blocking the thread, we spawn the future onto the runtime. This tells Tokio: "Here is a piece of work. Run it whenever you have a free thread, and let me know when it's done."

#[tokio::main]
async fn main() {
    let mut handles = vec![];

    for ip in server_list {
        // We don't await here! We spawn a background task.
        let handle = tokio::spawn(async move {
            check_health(ip).await
        });
        handles.push(handle);
    }

    // Now we wait for all the spawned tasks to finish
    for handle in handles {
        let _ = handle.await;
    }
}

Now, the behavior changes entirely. Tokio's multi-threaded scheduler sees those 100 spawned tasks and distributes them across all available CPU cores. While one task is waiting for a network packet to arrive from Server A, the scheduler swaps it out and uses that thread to start the request for Server B. Your 100-second program suddenly takes as long as the slowest single server response.

The Hidden Cost of Spawning

You might wonder why we don't just spawn everything. There's a trade-off here. When you use tokio::spawn, the future you provide must be Send and have a 'static lifetime. This is because Tokio can move that task from one worker thread to another to balance the load (this is called work-stealing).

This is where you'll start hitting the "borrow checker wall." You can't easily pass a reference to a local variable into a spawned task because the runtime might outlive the function that created the variable. I've spent plenty of hours fighting std::sync::Arc and Mutex because of this. It's the price you pay for true, multi-threaded concurrency. If your task is very small and doesn't involve heavy I/O, the overhead of spawning and the complexity of managing Arc might actually make your code slower or more verbose than just using a simple join_all on a collection of futures.




📋 Practical Task

Exercise: Building a Concurrent Port Scanner

Your goal is to create a basic port scanner that checks whether a set of ports on localhost are open. Instead of checking them one-by-one (synchronously), you will use the Tokio runtime to check them concurrently.

Requirements:

  • Create a list of ports to scan (e.g., vec![80, 443, 8080, 22, 3000]).
  • Use #[tokio::main] to initialize the runtime.
  • Use tokio::spawn to create a separate task for each port check.
  • Inside each task, attempt to connect to "127.0.0.1:port" using tokio::net::TcpStream::connect.
  • Print whether the port is "Open" or "Closed" based on whether the connection succeeded.
  • Ensure the main function waits for all spawned tasks to complete before exiting.
Rating
0 0

There are no comments for now.

to be the first to leave a comment.