5/5
## The Synchronous Kitchen: Making Hamburgers Step-by-Step Imagine you're in a kitchen, tasked with making a hamburger. In a typical, straightforward (or synchronous) approach, you'd perform each step one after the other: toast the bun, then cook the patty, then prepare the vegetables, and finally, get the cheese. Let's model this in Rust. First, we define our ingredients. These are simple structs for now, representing the components of our hamburger. We'll use `#![allow(unused)]` at the top of our file, as these structs won't have fields or methods in this simplified example. ```rust #![allow(unused)] struct Tomato; struct Lettuce; struct Cheese; struct Patty; struct Bun; ``` Next, we define the `Hamburger` itself, which will hold all our prepared ingredients: ```rust struct Hamburger { pub tomato: Tomato, pub lettuce: Lettuce, pub cheese: Cheese, pub patty: Patty, pub bun: Bun, } ``` Now, let's create functions to prepare each component. These functions will initially be synchronous, meaning they execute and return their result immediately. ```rust fn toast_bun() -> Bun { Bun } fn cook_patty() -> Patty { Patty } fn get_veggies() -> (Tomato, Lettuce) { (Tomato, Lettuce) } fn get_cheese() -> Cheese { Cheese } ``` With our preparation functions ready, we can write a function to make the hamburger sequentially: ```rust fn make_hamburger_seq() -> Hamburger { let bun = toast_bun(); let patty = cook_patty(); let (tomato, lettuce) = get_veggies(); let cheese = get_cheese(); println!("🍔 (seq) is ready"); // Log when ingredients are ready Hamburger { tomato, lettuce, cheese, patty, bun, } } ``` Finally, our `main` function will call `make_hamburger_seq` to produce our hamburger: ```rust fn main() { make_hamburger_seq(); } ``` In this setup, each step must complete before the next one begins. `toast_bun()` finishes, then `cook_patty()` starts, and so on. This is efficient for a single chef working on one task at a time, but what if some tasks could be done simultaneously to speed things up? ## Introducing Asynchronous Cooking with `async` and `await` Our goal is to make the hamburger preparation steps concurrent. Instead of waiting for the bun to toast before starting to cook the patty, we want these tasks to happen (or at least make progress) at the same time. This is where `async` and `await` come into play in Rust. **Making Functions Asynchronous (`async fn`)** To transform a synchronous function into an asynchronous one, we prepend the `async` keyword to `fn`. An `async fn` doesn't return its value directly; instead, it returns a "future." A future is a value that represents a computation that might not have completed yet but will produce a result eventually. Let's convert our preparation functions and the `make_hamburger_seq` function: ```rust async fn toast_bun() -> Bun { Bun } async fn cook_patty() -> Patty { Patty } async fn get_veggies() -> (Tomato, Lettuce) { (Tomato, Lettuce) } async fn get_cheese() -> Cheese { Cheese } async fn make_hamburger_seq() -> Hamburger { // ... (implementation to be updated) } ``` **Awaiting Results (`.await`)** When you call an `async` function, you get back a future. To get the actual result from this future, you use the `.await` keyword. Using `.await` on a future will pause the execution of the current `async` function until the awaited future completes and yields its value. This pausing is non-blocking, meaning other tasks can run while the current one is waiting. Let's update `make_hamburger_seq` to use `.await` when calling our newly asynchronous preparation functions: ```rust async fn make_hamburger_seq() -> Hamburger { let bun = toast_bun().await; let patty = cook_patty().await; let (tomato, lettuce) = get_veggies().await; let cheese = get_cheese().await; println!("🍔 (seq-async) is ready"); Hamburger { tomato, lettuce, cheese, patty, bun, } } ``` With these changes, our functions are now asynchronous, but `make_hamburger_seq` will still execute its steps one after another because each `.await` waits for the previous task to complete before moving on. To run these truly concurrently, we need an async runtime and a way to tell the runtime to execute multiple futures at once. ## Firing Up the Async Grill: Setting Up the Tokio Runtime Asynchronous code in Rust needs an "async runtime" to manage and execute these asynchronous tasks (futures). The runtime is responsible for polling futures, scheduling tasks, and driving them to completion. `tokio` is a popular and powerful async runtime for Rust. **Adding Tokio Dependency** First, we need to add `tokio` to our project's dependencies. Open your `Cargo.toml` file and add the following line under `[dependencies]`: ```toml # Cargo.toml [dependencies] tokio = { version = "1.44", features = ["full"] } # Use a recent version of tokio ``` The `"full"` feature flag enables all of Tokio's functionalities, which is convenient for getting started. **Using `#[tokio::main]`** Tokio provides an attribute macro, `#[tokio::main]`, that transforms a standard `main` function into an entry point for the Tokio runtime. It sets up the runtime and executes the `async` code within `main`. **Making `main` Asynchronous** Since our `main` function will now call `async` functions (like `make_hamburger_seq`) and potentially use `.await`, it also needs to be declared as `async fn`. Here's how our `main` function looks with Tokio: ```rust #[tokio::main] async fn main() { make_hamburger_seq().await; } ``` At this stage, our code is fully asynchronous. However, as noted before, `make_hamburger_seq` still processes ingredients sequentially because each `toast_bun().await` must complete before `cook_patty().await` can start, and so on. We've set the stage for concurrency, but we haven't explicitly instructed our tasks to run in parallel yet. ## Concurrent Chef: Speeding Up with `tokio::join!` To truly harness the power of asynchronous programming and make our hamburger preparation faster, we need a way to run multiple `async` operations concurrently. Tokio provides the `tokio::join!` macro for this purpose. **New Concurrent Function (`make_hamburger`)** Let's create a new function, `make_hamburger`, that uses `tokio::join!` to prepare all ingredients at the same time: ```rust async fn make_hamburger() -> Hamburger { // Call tokio::join! with the async function calls (futures) // Note: .await is NOT used on the individual calls here let (bun_res, patty_res, veggies_res, cheese_res) = tokio::join!( toast_bun(), // This returns a Future<Output = Bun> cook_patty(), // This returns a Future<Output = Patty> get_veggies(), // This returns a Future<Output = (Tomato, Lettuce)> get_cheese() // This returns a Future<Output = Cheese> ); // tokio::join! returns a tuple of results. // In this simple case, they directly return the values. // If the async functions returned Result<T, E>, then bun_res, etc., would be Result types. let bun = bun_res; let patty = patty_res; let (tomato, lettuce) = veggies_res; let cheese = cheese_res; println!("🍔 (concurrent) is ready"); Hamburger { tomato, lettuce, cheese, patty, bun, } } ``` **Understanding `tokio::join!`** * `tokio::join!` takes one or more futures as arguments. In our case, these are the direct calls to `toast_bun()`, `cook_patty()`, `get_veggies()`, and `get_cheese()`. Notice that we don't use `.await` on these individual calls when passing them to `join!`. * It runs these futures concurrently. This means the runtime can switch between them, allowing them all to make progress simultaneously. If one future is waiting for an I/O operation (like reading a file, or in our analogy, waiting for the toaster), other futures can continue to execute. * `tokio::join!` itself will wait until *all* the provided futures have completed. * It returns a tuple containing the results of each future, in the same order they were passed to the macro. **Updating `main` for Concurrency** Now, let's update our `main` function to use this new, concurrent hamburger-making function: ```rust #[tokio::main] async fn main() { make_hamburger().await; // Call the new concurrent function } ``` With this change, `toast_bun`, `cook_patty`, `get_veggies`, and `get_cheese` can all "happen" at the same time (or at least make progress concurrently). This can significantly speed up the overall hamburger-making process, especially if these tasks involve waiting. ## The Power of Patience: Why `.await` is Crucial (Lazy Futures) A critical concept in Rust's `async` programming model is that **futures are lazy**. When you call an `async` function, like `make_hamburger()`, it creates and returns a future. However, simply creating the future doesn't mean its code starts running. The future represents the *potential* for a computation and its result, but it won't do any work until it is actively polled or driven to completion. This polling typically happens in one of two ways: 1. By using `.await` on the future. 2. By passing the future to a combinator like `tokio::join!` or spawning it as a task on an async runtime (e.g., using `tokio::spawn`). **Demonstrating Laziness** Consider what happens if we remove `.await` from the call to `make_hamburger()` in our `main` function: ```rust #[tokio::main] async fn main() { let fut = make_hamburger(); // fut is a Future<Output = Hamburger> // No work related to make_hamburger() is done yet. // Even just calling it without assigning: // make_hamburger(); // This also does nothing without .await } ``` If you run this code, you'll notice that the `"🍔 (concurrent) is ready"` message from `make_hamburger` does *not* appear. This is because the `make_hamburger` future was created but never polled or awaited. It's like having a recipe but never starting to cook. To actually execute the future and get its result (or make it perform its side effects, like printing to the console), you must `.await` it: ```rust #[tokio::main] async fn main() { let fut = make_hamburger(); fut.await; // Now the future is executed, and its work is done. // More commonly, you'd write it as: // make_hamburger().await; } ``` With `.await` present, the `make_hamburger` future is driven to completion by the Tokio runtime, all its internal operations (including the `tokio::join!`) are executed, and the "🍔 (concurrent) is ready" message will appear. This laziness is a fundamental design choice that gives Rust's `async` system efficiency and control. ## Your Async Rust Culinary Recap We've journeyed from a simple, step-by-step synchronous kitchen to a more efficient, concurrent operation using Rust's `async`/`await` capabilities with the Tokio runtime. Let's recap the key ingredients: * **`async fn`**: This keyword declares an asynchronous function. Instead of returning a value directly, it returns a `Future` that will eventually produce the value. * **`Future`**: A type representing a value that may not be available yet. Futures are lazy; they don't perform any work until they are polled, typically by being `.await`ed or run by an executor. * **`.await`**: This keyword is used inside an `async fn` to pause its execution until the `Future` it's waiting on completes. While paused, other `async` tasks can run, enabling concurrency. Once the awaited `Future` is resolved, `.await` returns its result. * **Async Runtime (e.g., Tokio)**: A library that manages the scheduling and execution of `async` tasks (futures). It provides the engine that polls futures and drives them to completion. * **`#[tokio::main]`**: An attribute macro from the Tokio crate that simplifies setting up the Tokio runtime and makes the `main` function an `async` entry point for your application. * **`tokio::join!`**: A macro that takes multiple futures and runs them concurrently. It waits for all of them to complete and then returns a tuple containing their results. This is a primary tool for achieving parallelism for a known set of related asynchronous tasks. By understanding and applying these concepts, you can write Rust programs that perform multiple operations concurrently, leading to more responsive and efficient applications, much like a well-coordinated team of chefs in a busy kitchen.
Imagine you're in a kitchen, tasked with making a hamburger. In a typical, straightforward (or synchronous) approach, you'd perform each step one after the other: toast the bun, then cook the patty, then prepare the vegetables, and finally, get the cheese. Let's model this in Rust.
First, we define our ingredients. These are simple structs for now, representing the components of our hamburger. We'll use #![allow(unused)]
at the top of our file, as these structs won't have fields or methods in this simplified example.
Next, we define the Hamburger
itself, which will hold all our prepared ingredients:
Now, let's create functions to prepare each component. These functions will initially be synchronous, meaning they execute and return their result immediately.
With our preparation functions ready, we can write a function to make the hamburger sequentially:
Finally, our main
function will call make_hamburger_seq
to produce our hamburger:
In this setup, each step must complete before the next one begins. toast_bun()
finishes, then cook_patty()
starts, and so on. This is efficient for a single chef working on one task at a time, but what if some tasks could be done simultaneously to speed things up?
async
and await
Our goal is to make the hamburger preparation steps concurrent. Instead of waiting for the bun to toast before starting to cook the patty, we want these tasks to happen (or at least make progress) at the same time. This is where async
and await
come into play in Rust.
Making Functions Asynchronous (async fn
)
To transform a synchronous function into an asynchronous one, we prepend the async
keyword to fn
. An async fn
doesn't return its value directly; instead, it returns a "future." A future is a value that represents a computation that might not have completed yet but will produce a result eventually.
Let's convert our preparation functions and the make_hamburger_seq
function:
Awaiting Results (.await
)
When you call an async
function, you get back a future. To get the actual result from this future, you use the .await
keyword. Using .await
on a future will pause the execution of the current async
function until the awaited future completes and yields its value. This pausing is non-blocking, meaning other tasks can run while the current one is waiting.
Let's update make_hamburger_seq
to use .await
when calling our newly asynchronous preparation functions:
With these changes, our functions are now asynchronous, but make_hamburger_seq
will still execute its steps one after another because each .await
waits for the previous task to complete before moving on. To run these truly concurrently, we need an async runtime and a way to tell the runtime to execute multiple futures at once.
Asynchronous code in Rust needs an "async runtime" to manage and execute these asynchronous tasks (futures). The runtime is responsible for polling futures, scheduling tasks, and driving them to completion. tokio
is a popular and powerful async runtime for Rust.
Adding Tokio Dependency
First, we need to add tokio
to our project's dependencies. Open your Cargo.toml
file and add the following line under [dependencies]
:
The "full"
feature flag enables all of Tokio's functionalities, which is convenient for getting started.
Using #[tokio::main]
Tokio provides an attribute macro, #[tokio::main]
, that transforms a standard main
function into an entry point for the Tokio runtime. It sets up the runtime and executes the async
code within main
.
Making main
Asynchronous
Since our main
function will now call async
functions (like make_hamburger_seq
) and potentially use .await
, it also needs to be declared as async fn
.
Here's how our main
function looks with Tokio:
At this stage, our code is fully asynchronous. However, as noted before, make_hamburger_seq
still processes ingredients sequentially because each toast_bun().await
must complete before cook_patty().await
can start, and so on. We've set the stage for concurrency, but we haven't explicitly instructed our tasks to run in parallel yet.
tokio::join!
To truly harness the power of asynchronous programming and make our hamburger preparation faster, we need a way to run multiple async
operations concurrently. Tokio provides the tokio::join!
macro for this purpose.
New Concurrent Function (make_hamburger
)
Let's create a new function, make_hamburger
, that uses tokio::join!
to prepare all ingredients at the same time:
Understanding tokio::join!
tokio::join!
takes one or more futures as arguments. In our case, these are the direct calls to toast_bun()
, cook_patty()
, get_veggies()
, and get_cheese()
. Notice that we don't use .await
on these individual calls when passing them to join!
.
It runs these futures concurrently. This means the runtime can switch between them, allowing them all to make progress simultaneously. If one future is waiting for an I/O operation (like reading a file, or in our analogy, waiting for the toaster), other futures can continue to execute.
tokio::join!
itself will wait until all the provided futures have completed.
It returns a tuple containing the results of each future, in the same order they were passed to the macro.
Updating main
for Concurrency
Now, let's update our main
function to use this new, concurrent hamburger-making function:
With this change, toast_bun
, cook_patty
, get_veggies
, and get_cheese
can all "happen" at the same time (or at least make progress concurrently). This can significantly speed up the overall hamburger-making process, especially if these tasks involve waiting.
.await
is Crucial (Lazy Futures)A critical concept in Rust's async
programming model is that futures are lazy.
When you call an async
function, like make_hamburger()
, it creates and returns a future. However, simply creating the future doesn't mean its code starts running. The future represents the potential for a computation and its result, but it won't do any work until it is actively polled or driven to completion.
This polling typically happens in one of two ways:
By using .await
on the future.
By passing the future to a combinator like tokio::join!
or spawning it as a task on an async runtime (e.g., using tokio::spawn
).
Demonstrating Laziness
Consider what happens if we remove .await
from the call to make_hamburger()
in our main
function:
If you run this code, you'll notice that the "🍔 (concurrent) is ready"
message from make_hamburger
does not appear. This is because the make_hamburger
future was created but never polled or awaited. It's like having a recipe but never starting to cook.
To actually execute the future and get its result (or make it perform its side effects, like printing to the console), you must .await
it:
With .await
present, the make_hamburger
future is driven to completion by the Tokio runtime, all its internal operations (including the tokio::join!
) are executed, and the "🍔 (concurrent) is ready" message will appear. This laziness is a fundamental design choice that gives Rust's async
system efficiency and control.
We've journeyed from a simple, step-by-step synchronous kitchen to a more efficient, concurrent operation using Rust's async
/await
capabilities with the Tokio runtime. Let's recap the key ingredients:
async fn
: This keyword declares an asynchronous function. Instead of returning a value directly, it returns a Future
that will eventually produce the value.
Future
: A type representing a value that may not be available yet. Futures are lazy; they don't perform any work until they are polled, typically by being .await
ed or run by an executor.
.await
: This keyword is used inside an async fn
to pause its execution until the Future
it's waiting on completes. While paused, other async
tasks can run, enabling concurrency. Once the awaited Future
is resolved, .await
returns its result.
Async Runtime (e.g., Tokio): A library that manages the scheduling and execution of async
tasks (futures). It provides the engine that polls futures and drives them to completion.
#[tokio::main]
: An attribute macro from the Tokio crate that simplifies setting up the Tokio runtime and makes the main
function an async
entry point for your application.
tokio::join!
: A macro that takes multiple futures and runs them concurrently. It waits for all of them to complete and then returns a tuple containing their results. This is a primary tool for achieving parallelism for a known set of related asynchronous tasks.
By understanding and applying these concepts, you can write Rust programs that perform multiple operations concurrently, leading to more responsive and efficient applications, much like a well-coordinated team of chefs in a busy kitchen.
A flavorful walkthrough to The Asynchronous Hamburger: Mastering `async`/`await` and `tokio::join!` - Discover how to cook up concurrent Rust applications by transforming a synchronous hamburger recipe using `async fn` and `.await`. Learn to integrate the Tokio runtime, utilize `tokio::join!` for parallel ingredient preparation, and grasp the crucial concept of lazy futures.
Previous lesson
Previous
Next lesson
Next
Give us feedback
Course Overview
About the course
Introduction to the Rust programming language
Rust variables and functions
Scalar types, arrays, strings, enum, structs, vectors, and hash maps in Rust
Rust control flows: If / else, if let and let else, loop, match
Rust ownership, including borrow and references
Rust error handling
Rust Modules
Rust Traits
Last updated on July 11, 2025
Rust Developer
Rust Programming BasicsDuration: 6min
Duration: 18min
Duration: 47min
Duration: 15min
Duration: 19min
Duration: 8min
Duration: 12min
Duration: 46min
Duration: 14min
Course Overview
About the course
Introduction to the Rust programming language
Rust variables and functions
Scalar types, arrays, strings, enum, structs, vectors, and hash maps in Rust
Rust control flows: If / else, if let and let else, loop, match
Rust ownership, including borrow and references
Rust error handling
Rust Modules
Rust Traits
Last updated on July 11, 2025