DEV Community

Gregory Chris
Gregory Chris

Posted on

Async in Rust: The Basics of async/await

Async in Rust: The Basics of async/await

Asynchronous programming is no longer a luxury for modern software development—it's a necessity. Whether you're fetching data from a remote API, handling concurrent tasks, or building high-performance servers, async programming enables you to handle I/O-bound operations efficiently without blocking your entire application.

In this blog post, we’ll dive deep into the world of async and await in Rust, demystify how they work, and build a simple HTTP fetcher using the popular reqwest library. By the end, you'll understand the basics of Rust's async model, its key advantages, and how to avoid common pitfalls.


Why Async?

Before we jump into Rust's async/await syntax, let’s understand why asynchronous programming matters. In traditional synchronous programming, when your program performs an I/O operation—like reading a file or sending an HTTP request—it pauses execution and waits for the operation to complete. This is fine for simple use cases, but it can become a bottleneck in high-performance applications.

Imagine baking a batch of cookies. If you were to bake them synchronously, you'd preheat the oven, bake one tray, wait for it to finish, and then start the next tray. Asynchronous programming is like preheating the oven, putting in one tray, and preparing the next tray while the first one bakes. You maximize your productivity by not waiting idly.

Rust’s async/await syntax allows you to write asynchronous code that looks and feels like synchronous code, making it easier to read and reason about—without sacrificing performance.


The Basics of Async/Await in Rust

Rust's async/await model is built on top of futures, which represent values that may not be available yet. When you declare a function as async, it returns a Future. A Future is a value that represents some computation that hasn’t finished yet but can be awaited to retrieve its result.

Here’s a simple example:

use std::time::Duration;
use tokio::time::sleep;

async fn say_hello() {
    println!("Hello, world!");
    sleep(Duration::from_secs(2)).await;
    println!("Hello again, after 2 seconds!");
}

#[tokio::main]
async fn main() {
    say_hello().await;
}
Enter fullscreen mode Exit fullscreen mode

What’s Happening Here?

  1. The async keyword: This marks the function say_hello as asynchronous. It means the function returns a Future that can be awaited.
  2. The await keyword: The .await keyword is used to "pause" execution until the Future is complete. Here, we await sleep, which delays execution for 2 seconds.
  3. The tokio runtime: Rust's async functions require an executor to run. In this example, we’re using the popular tokio runtime, which we’ll talk more about later.

Building an Async HTTP Fetcher

To see async/await in action, let’s build a simple HTTP fetcher using the reqwest crate. This fetcher will perform an HTTP GET request and print the response body.

Step 1: Setting Up the Project

Create a new Rust project:

cargo new async_http_fetcher
cd async_http_fetcher
Enter fullscreen mode Exit fullscreen mode

Add dependencies to your Cargo.toml:

[dependencies]
tokio = { version = "1", features = ["full"] }
reqwest = { version = "0.11", features = ["json"] }
Enter fullscreen mode Exit fullscreen mode
  • tokio: A runtime for asynchronous programming.
  • reqwest: A popular HTTP client that supports async operations.

Step 2: Writing the Code

Here’s our async HTTP fetcher:

use reqwest::Error;

async fn fetch_url(url: &str) -> Result<String, Error> {
    let response = reqwest::get(url).await?; // Perform the GET request
    let body = response.text().await?; // Extract the response body as a string
    Ok(body)
}

#[tokio::main]
async fn main() {
    let url = "https://jsonplaceholder.typicode.com/posts/1";

    match fetch_url(url).await {
        Ok(content) => println!("Response:\n{}", content),
        Err(err) => eprintln!("Error fetching URL: {}", err),
    }
}
Enter fullscreen mode Exit fullscreen mode

What’s Happening Here?

  1. reqwest::get: This performs the HTTP GET request asynchronously. It returns a Future that resolves to a Response.
  2. .await: We await the response, allowing the program to continue handling other tasks while waiting for the HTTP request to complete.
  3. Error handling: The ? operator propagates errors, and we handle them gracefully in the main function using match.

Run the code:

cargo run
Enter fullscreen mode Exit fullscreen mode

You should see the JSON response printed to your terminal.


Under the Hood: How Async Works in Rust

Rust’s async model is zero-cost, meaning it doesn’t impose runtime overhead like garbage collection or thread pools. Instead, Rust uses lightweight state machines to represent Futures. When you call an async function, the compiler transforms it into a state machine that tracks its progress. This ensures maximum efficiency.

Single vs Multi-Threaded Executors

By default, async tasks in Rust don’t create threads. Instead, an executor (like tokio or async-std) schedules and runs them. Executors can run tasks on a single thread or distribute them across multiple threads.

For example:

  • Single-threaded executor: Useful for applications that are CPU-bound or need shared access to a single resource.
  • Multi-threaded executor: Ideal for I/O-bound tasks like web servers or database queries.

Common Pitfalls and How to Avoid Them

1. Blocking the Async Runtime

One of the most common mistakes is calling a blocking function (like std::thread::sleep or std::fs::read) inside an async context. This blocks the entire executor, defeating the purpose of async.

Solution: Use async equivalents, like tokio::time::sleep or tokio::fs.

// Bad: Blocks the runtime
std::thread::sleep(Duration::from_secs(1));

// Good: Non-blocking
tokio::time::sleep(Duration::from_secs(1)).await;
Enter fullscreen mode Exit fullscreen mode

2. Unpinned Futures

Futures in Rust are not automatically "pinned," meaning they can be moved in memory. This can cause issues if you hold references inside an async block.

Solution: Use Box::pin or Pin types when necessary.


3. Not Running the Runtime

Async functions don’t execute unless an executor starts them. Forgetting to use #[tokio::main] or running the runtime will result in no output.


Key Takeaways

  1. Async in Rust: Rust’s async/await syntax makes asynchronous programming simpler and more ergonomic while maintaining zero-cost abstractions.
  2. Futures: Async functions return Futures, which represent values that may not yet be available.
  3. Executors: Async tasks require an executor (like tokio) to run.
  4. Avoid pitfalls: Use non-blocking equivalents for blocking operations and ensure your runtime is properly set up.

Next Steps

If you enjoyed learning about async/await, here are some suggestions to deepen your knowledge:

  1. Explore async libraries: Check out tokio, async-std, and reqwest for more capabilities.
  2. Build a real project: Create an async web scraper or API client to practice.
  3. Learn about advanced topics: Investigate Rust's async primitives like JoinHandle, select!, and tokio::spawn.

Happy coding—and welcome to the world of async Rust! 🚀

Top comments (0)