Skip to Content
Course content

44: Async Rust with async/await

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

I've seen a lot of developers move into Rust and treat asynchronous programming as if it were just "multithreading with less boilerplate." It’s a common trap. In reality, async in Rust is a completely different mental model. When you mark a function as async, you aren't telling the compiler to run it on another thread; you're telling it to transform that function into a state machine that can be paused and resumed.

The Cost of Waiting in Line

Let's look at a scenario I deal with often: fetching metadata for a list of remote resources. Imagine you have a list of ten different API endpoints, and you need to get a status report from each. The naive way to do this is a simple for loop with a blocking HTTP client.

// The naive, blocking approach
fn fetch_all_statuses(urls: Vec&str]) -> Vec<String> {
    let mut results = Vec::new();
    for url in urls {
        // This blocks the entire thread until the server responds
        let response = reqwest::blocking::get(url).unwrap().text().unwrap();
        results.push(response);
    }
    results
}

This works, but it's agonizingly slow. If each request takes 200ms, you're spending two full seconds just sitting there. Your CPU is doing absolutely nothing while the network card waits for packets to fly across the ocean. You might think, "I'll just wrap each call in std::thread::spawn," but threads are expensive. Spawning a thousand threads for a thousand requests will eat your RAM and spend more time context-switching than actually doing work.

Stop Waiting, Start Scheduling

This is where async/await comes in. When we switch to an async runtime like tokio, we stop thinking about "blocking" and start thinking about "yielding." In the async version, when you .await a request, you're essentially saying: "I can't go any further until this data arrives. Go ahead and use this thread to do something else in the meantime."

However, there's a subtle trap here. If you just put .await inside a loop, you're still running things sequentially. You're just doing it with more expensive syntax.

// Still sequential, just using async keywords
async fn fetch_all_statuses_naive(urls: Vec<String>) -> Vec<String> {
    let mut results = Vec::new();
    for url in urls {
        // We are still waiting for one to finish before starting the next
        let response = reqwest::get(url).await.unwrap().text().await.unwrap();
        results.push(response);
    }
    results
}

To actually get the performance boost we're after, we need to create a collection of Futures and then drive them to completion concurrently. A Future in Rust is lazy; it does nothing until it is polled. By creating a list of futures and using something like join_all, we tell the runtime to track all of them at once.

use futures::future::join_all;

async fn fetch_all_statuses_efficient(urls: Vec<String>) -> Vec<String> {
    let tasks = urls.into_iter().map(|url| async move {
        reqwest::get(url).await.unwrap().text().await.unwrap()
    });

    // join_all polls all the futures concurrently on the runtime
    join_all(tasks).await
}

The Hidden Trade-off: Complexity and Lifetimes

Now, I have to be honest with you: this isn't a free lunch. The moment you move into async Rust, you're going to fight the borrow checker more than usual. Because an async block can be paused and resumed, the compiler has to ensure that any reference you hold stays valid for the entire life of the Future.

You'll find yourself using Arc and Mutex more frequently, and you'll run into the dreaded 'static lifetime requirement when spawning tasks. If you spawn a task with tokio::spawn, the runtime has no idea how long that task will run, so it cannot hold references to local variables on the stack. You have to move ownership into the async block using the move keyword.

My rule of thumb? Don't go async unless you are I/O bound. If you're doing heavy math or image processing, async will actually slow you down due to the overhead of the state machine. But for network requests and database queries, it's the only way to build a system that scales.




πŸ“‹ Practical Task

Exercise: Concurrent Website Health Checker

Build a small utility that takes a list of URLs and checks their HTTP status codes concurrently. Your program should not wait for one website to respond before requesting the next.

  • Use the tokio runtime and the reqwest crate (with the json feature).
  • Create a function async fn check_health(url: String) -> Result<u16, reqwest::Error> that returns the status code of a given URL.
  • In your main function (marked with #[tokio::main]), initialize a vector of at least five different URLs.
  • Use futures::future::join_all to execute these checks concurrently.
  • Print the final results in the format: URL: [url] - Status: [code].

Challenge: Handle the errors gracefully. If a URL is invalid or the server is down, instead of calling .unwrap(), return a custom error message or a 0 status code so that one failing request doesn't crash the entire batch.

Rating
0 0

There are no comments for now.

to be the first to leave a comment.