Breaking it down
Let’s take some time to go over what we just did. There isn’t much code, but a lot is happening.
let mut client = client::connect("127.0.0.1:6379").await?;
The client::connect
function is provided by the mini-redis
crate. It asynchronously establishes a TCP connection with the specified remote address. Once the connection is established, a client
handle is returned. Even though the operation is performed asynchronously, the code we write looks synchronous. The only indication that the operation is asynchronous is the .await
operator.
What is asynchronous programming?
Most computer programs execute in the same order that it is written. The first line executes, then the next, and so on. With synchronous programming, when a program encounters an operation that cannot be completed immediately, it will block until the operation completes. For example, establishing a TCP connection requires an exchange with a peer over the network, which can take a sizeable amount of time. During this time, the thread is blocked.
With asynchronous programming, operations that cannot complete immediately are suspended to the background. The thread is not blocked, and can continue running other things. Once the operation completes, the task is unsuspended and continues processing from where it left off. Our example from before only has one task, so nothing happens while it is suspended, but asynchronous programs typically have many such tasks.
Although asynchronous programming can result in faster applications, it often results in much more complicated programs. The programmer is required to track all the state necessary to resume work once the asynchronous operation completes. Historically, this is a tedious and error-prone task.
Compile-time green-threading
Rust implements asynchronous programing using a feature called async/await
. Functions that perform asynchronous operations are labeled with the async
keyword. In our example, the connect
function is defined like this:
use mini_redis::Result;
use mini_redis::client::Client;
use tokio::net::ToSocketAddrs;
pub async fn connect<T: ToSocketAddrs>(addr: T) -> Result<Client> {
// ...
}
The async fn
definition looks like a regular synchronous function, but operates asynchronously. Rust transforms the async fn
at compile time into a routine that operates asynchronously. Any calls to .await
within the async fn
yield control back to the thread. The thread may do other work while the operation processes in the background.
Although other languages implement
async/await
too, Rust takes a unique approach. Primarily, Rust’s async operations are lazy. This results in different runtime semantics than other languages.
If this doesn’t quite make sense yet, don’t worry. We will explore async/await
more throughout the guide.
Using async/await
Async functions are called like any other Rust function. However, calling these functions does not result in the function body executing. Instead, calling an async fn
returns a value representing the operation. This is conceptually analogous to a zero-argument closure. To actually run the operation, you should use the .await
operator on the return value.
For example, the given program
async fn say_world() {
println!("world");
}
#[tokio::main]
async fn main() {
// Calling `say_world()` does not execute the body of `say_world()`.
let op = say_world();
// This println! comes first
println!("hello");
// Calling `.await` on `op` starts executing `say_world`.
op.await;
}
outputs:
hello
world
The return value of an async fn
is an anonymous type that implements the Future
trait.
Async main
function
The main function used to launch the application differs from the usual one found in most of Rust’s crates.
- It is an
async fn
- It is annotated with
#[tokio::main]
An async fn
is used as we want to enter an asynchronous context. However, asynchronous functions must be executed by a runtime. The runtime contains the asynchronous task scheduler, provides evented I/O, timers, etc. The runtime does not automatically start, so the main function needs to start it.
The #[tokio::main]
function is a macro. It transforms the async fn main()
into a synchronous fn main()
that initializes a runtime instance and executes the async main function.
For example, the following:
#[tokio::main]
async fn main() {
println!("hello");
}
gets transformed into:
fn main() {
let mut rt = tokio::runtime::Runtime::new().unwrap();
rt.block_on(async {
println!("hello");
})
}
The details of the Tokio runtime will be covered later.
Cargo features
When depending on Tokio for this tutorial, the full
feature flag is enabled:
tokio = { version = "1", features = ["full"] }
Tokio has a lot of functionality (TCP, UDP, Unix sockets, timers, sync utilities, multiple scheduler types, etc). Not all applications need all functionality. When attempting to optimize compile time or the end application footprint, the application can decide to opt into only the features it uses.
For now, use the “full” feature when depending on Tokio.