Writing Async Code in Rust
Traditional synchronous code executes step-by-step, blocking subsequent operations until the current one finishes. In I/O-bound scenarios…
Traditional synchronous code executes step-by-step, blocking subsequent operations until the current one finishes. In I/O-bound scenarios, like reading from a file or waiting for a network response, this could lead to inefficiencies as the CPU remains idle during wait times.
Asynchronous programming allows these operations to be non-blocking. While one task waits for I/O, other tasks can utilize the CPU. This is particularly advantageous for scalable systems that handle numerous concurrent operations.
Key Concepts
1. Futures and Tasks
A Future in Rust represents a value that will be available at some point in the future. It’s not the actual value but a promise of a value. When a future is ready to provide its value, it returns Poll::Ready(value). If it’s not ready, it returns Poll::Pending.
Example:
use std::future::Future;
use std::task::{Context, Poll};
use std::pin::Pin;
struct ExampleFuture;
impl Future for ExampleFuture {
type Output = i32;
fn poll(self: Pin<&mut Self>, _cx: &mut Context) -> Poll<Self::Output> {
Poll::Ready(42)
}
}
Here, ExampleFuture is a trivial Future that is always ready and returns the value 42.
2. Async/Await
The async/await syntax provides a convenient way to write and use asynchronous code that looks like synchronous code. Any function marked async returns a Future.
Example:
async fn foo() -> i32 {
42
}
async fn bar() {
let result = foo().await;
println!("{}", result);
}
In this example, foo is an async function that returns a Future<i32>. In bar, we call foo() and await its result. This doesn’t block the entire thread but yields control until Future is resolved.
3. Executors
Executors are responsible for running asynchronous tasks. They continuously poll futures to check if they’re complete.
Example:
Using the tokio runtime:
#[tokio::main]
async fn main() {
let value = foo().await;
println!("{}", value);
}
Here, #[tokio::main] denotes that the executor should be used for driving the async function main.
4. Pinning
Pinning ensures a value’s memory location doesn’t change, which is important for types like futures that can’t safely move around in memory after they’ve started executing.
Example:
Using the Box::pin method to pin a future:
let my_future = async { 42 };
let pinned_future = Box::pin(my_future);
By pinning the future, we guarantee it will not move in memory.
Example: A Simple Asynchronous Web Server
Let’s build a basic async web server using the tokio runtime and the hyper crate.
First, add the dependencies to your Cargo.toml:
[dependencies]
tokio = { version = "1", features = ["full"] }
hyper = "0.14"
Now, the code:
use hyper::{Body, Request, Response, Server};
use hyper::service::{make_service_fn, service_fn};
async fn handle_request(req: Request<Body>) -> Result<Response<Body>, hyper::Error> {
let response = match (req.method(), req.uri().path()) {
(&hyper::Method::GET, "/") => {
Response::new(Body::from("Hello, World!"))
},
_ => {
Response::builder()
.status(404)
.body(Body::from("Not Found"))
.unwrap()
},
};
Ok(response)
}
#[tokio::main]
async fn main() {
let make_service = make_service_fn(|_conn| {
let service = service_fn(handle_request);
async { Ok::<_, hyper::Error>(service) }
});
let addr = ([127, 0, 0, 1], 8080).into();
let server = Server::bind(&addr).serve(make_service);
println!("Server running on http://{}", addr);
if let Err(e) = server.await {
eprintln!("server error: {}", e);
}
}
Run the code, and your server should be up at http://127.0.0.1:8080.
Going Beyond: Additional Concepts and Best Practices
5. Async Traits
One limitation of async functions is that they can’t be used in traits directly because async fn traits would make the traits generic over the returned future type. Libraries like async-trait can be used to overcome this limitation, allowing you to define and use async functions in traits seamlessly.
6. Channels
Channels in Rust are used for communication between threads or tasks. You’ll often work with tokio::sync::mpscasync codeone that provides a multi-producer, single-consumer channel perfectly suited for async environments.
7. Error Handling
Rust’s Result type is used extensively for error handling. In async code, you’ll often see Result<T, E> where E the hyper::Error or another library-specific error type might be. Combinators like map_err the ? operator is crucial for propagating and handling errors in async code.
Best Practices:
- Know Your Executor: Each executor may have unique characteristics. Be sure to refer to its documentation and best practices.
- Limit await in Loops: If possible, gather all futures you wish to resolve and use functions like tokio::join! or futures::future::join_all to handle them concurrently.
- Utilize Task Pools: For CPU-bound operations within an async context, use task pools like tokio::task::spawn_blocking to avoid blocking the main async executor.
- Stay Updated: The async ecosystem in Rust is rapidly evolving. Libraries, patterns, and best practices may change as the ecosystem matures.
Advanced Topics to Explore
- Streams: Just as Future it represents a single asynchronous value, Stream it represents a sequence of asynchronous values. Libraries futures provide utilities for working with them.
- Async I/O with tokio: Explore the asynchronous I/O capabilities, including file operations, networking, and timers.
- Rust’s Memory Model: Understanding Rust’s ownership, borrowing, and lifetime principles is crucial, even more so in async contexts where data lifetimes span multiple contexts.
Check out more articles about Rust in my Rust Programming Library!
Wrapping Up
Writing async code in Rust provides the power of concurrency without sacrificing the language’s core promise of safety. As with any paradigm, continuous learning and hands-on experience are key to mastering async Rust.
Stay tuned, and happy coding!
Check out more articles about Rust in my Rust Programming Library!
Visit my Blog for more articles, news, and software engineering stuff!
Follow me on Medium, LinkedIn, and Twitter.
All the best,
CTO | Tech Lead | Senior Software Engineer | Cloud Solutions Architect | Rust 🦀 | Golang | Java | ML AI & Statistics | Web3 & Blockchain