Rust track

Lesson 22 · Part 5: Cargo, Testing, Concurrency & Async

async/await

Asynchronous programming, futures, executors and select!

Part 5: Cargo, Testing, Concurrency & Async4 min readFree lesson

async/await is Rust's model for efficiently handling multiple asynchronous tasks (I/O, timers, network calls) within a single thread. The standard library itself does not have a runtime, so to actually run asynchronous code, a runtime like tokio is necessary. In this edition, we summarize the meaning of async fn, what .await does, the pattern for achieving concurrency with tokio::join!, and asynchronous channels all at once.

Learning Objectives

  • Understand that async fn does not execute immediately and returns a Future.
  • Know the roles of .await and the runtime.
  • Write an asynchronous main using the #[tokio::main] macro.
  • Run multiple tasks concurrently using tokio::join!.
  • Use asynchronous message channels with tokio::sync::mpsc.

Key Concepts

1) async fn and Future

async fn greet(name: &str) {
    println!("Hello, {name}");
}

Even if you call this function, the body does not execute immediately. Instead, it returns a value that bundles the "task" called Future.

let fut = greet("Rust"); //Nothing has happened yet
fut.await;               //At this time, the runtime executes the body

2) What .await does

  • It pauses at that spot until Future finishes.
  • "Pausing" does not mean occupying the thread while waiting — the runtime temporarily switches to another Future on the same thread and comes back.

3) Runtime — tokio

The Rust standard library does not have an asynchronous runtime. The most popular runtime is tokio.

Cargo.toml:

[dependencies]
tokio = { version = "1", features = ["full"] }

#[tokio::main] macro enables asynchronous main.

#[tokio::main]
async fn main() {
    /* ... */
}

4) tokio::join! — Concurrency

Runs multiple Futures simultaneously and waits for all to finish.

let (a, b) = tokio::join!(task1(), task2());

Compared to sequential await, the only difference is when they are combined — execution is simultaneous.

5) tokio::task::spawn — Background task

Asynchronous version of thread::spawn. Spawns a new task and returns a JoinHandle. (Much lighter than a thread — tens of thousands in one process are fine.)

6) tokio::sync::mpsc

Asynchronous channel. The difference from std::sync::mpsc in Episode 21 is only that send().await and recv().await are possible.

Examples

Example 1 — ex01_simple.rs: First async fn

//Example 22-1: First async fn and tokio::main macro
//
//`async fn` does not run immediately when called, but returns a `Future`.
//`.await` is the trigger that actually drives that Future forward.
//The `#[tokio::main]` macro automatically starts the tokio runtime.

use tokio::time::{sleep, Duration};

async fn greet(name: &str) {
    println!("Hello, {name}!");
    sleep(Duration::from_millis(50)).await;
    println!("Farewell to {name}.");
}

#[tokio::main]
async fn main() {
    println!("Program start");
    greet("Rust").await;
    greet("Tokio").await;
    println!("Program end");
}

Example 2 — ex02_join.rs: Concurrent execution

//Example 2 of Chapter 22: Running multiple asynchronous tasks concurrently with tokio::join!
//
//If two tasks are executed sequentially, it takes 100ms + 100ms = 200ms,
//but if executed concurrently with `join!`, it finishes in about 100ms.

use std::time::Instant;
use tokio::time::{sleep, Duration};

async fn task(id: u32, ms: u64) -> u32 {
    println!("[{id}] start");
    sleep(Duration::from_millis(ms)).await;
    println!("[{id}] end");
    id
}

#[tokio::main]
async fn main() {
    let start = Instant::now();

    //Awaiting two tasks concurrently — both Futures progress at the same time
    let (a, b) = tokio::join!(task(1, 100), task(2, 100));

    println!("Result: {a}, {b}");
    println!("Total elapsed: {:?}", start.elapsed());
}

Example 3 — ex03_channel.rs: tokio mpsc

//Example 3 of Episode 22: Asynchronous message channel with tokio::sync::mpsc
//
//Similar to std::sync::mpsc from Episode 21, but sending and receiving can `.await`
//Works naturally on an async runtime.

use tokio::sync::mpsc;
use tokio::task;

#[tokio::main]
async fn main() {
    let (tx, mut rx) = mpsc::channel::<String>(8); //Buffer 8

    //Producer — spawned as a separate async task
    let producer = task::spawn(async move {
        for word in ["hello", "async", "rust"] {
            tx.send(word.to_string()).await.expect("Receiver closed");
        }
        //tx is dropped here → rx also becomes aware of the termination
    });

    //Consumer — receive in the main task
    while let Some(msg) = rx.recv().await {
        println!("Received: {msg}");
    }

    producer.await.expect("Producer task panics");
    println!("Completed");
}

Common Mistakes

Q. I called async fn but nothing happens.

A. The return value of async fn is a Future that bundles the "things to do." You need to attach .await or hand it to tokio::spawn(...) at runtime for it to actually execute.

Q. Difference between tokio::join! and the two .await?

A. Two sequential .await executions are sequential — the next starts only after the previous task finishes. join! runs the two tasks simultaneously. The more tasks that wait on external operations like I/O, the bigger the difference.

Q. It seems like a tokio::spawn task never finishes.

A. When main ends, the tokio runtime also terminates, forcibly stopping any spawned tasks. If you want to wait until they finish, explicitly join them with task.await.

Q. Which one between std::sync::mpsc and tokio::sync::mpsc?

A. If sending and receiving occurs in an async context (inside async fn), use tokio::sync::mpsc. For regular thread-to-thread channels, use std::sync::mpsc. Using a std channel in an async context can block threads and interfere with other tasks.

Summary

  • async fn returns Future, and .await triggers the progress.
  • An asynchronous runtime is needed — the most common choice is tokio.
  • Multiple tasks at once with tokio::join!, background tasks with tokio::spawn.
  • In async contexts, use async channels like tokio::sync::mpsc.

Try it yourself

Solve the assignment below. The reference solution is shown below.

Example files

The runnable files used in this lesson. Copy them into your own project and experiment.

src/async_await/Cargo.tomlTOML
[package]
name = "async_await"
version = "0.1.0"
edition = "2021"
default-run = "async_await"

[dependencies]
tokio = { version = "1", features = ["full"] }
src/async_await/src/main.rsRust
fn main() {
    println!("This crate has three executable examples.");
    println!();
    println!("  cargo run --bin ex01_simple");
    println!("  cargo run --bin ex02_join");
    println!("  cargo run --bin ex03_channel");
}

Assignment

Problem 1 — async sleep + print

  • File name: homework_01/src/main.rs
  • Key concepts: async fn, .await, tokio::time::sleep

Requirements

  • Define the function delayed_print(name: &str, ms: u64) as async.
  • After waiting ms milliseconds, sleep, then print "{name} completed ({ms}ms)".
  • In main, await twice in order.
    • delayed_print("Task 1", 50)
    • delayed_print("Task 2", 30)

Expected output

Task 1 completed (50ms)
Task 2 completed (30ms)

Hint

  • use tokio::time::{sleep, Duration};
  • Use the #[tokio::main] macro to make main async.

Problem 2 — Concurrent execution with tokio::join!

  • File name: homework_02/src/main.rs
  • Key concept: tokio::join!, asynchronous concurrency

Requirements

  • Async function square(n: u64) -> u64 returns n*n after 50ms.
  • In main, execute three tasks concurrently using tokio::join!(square(3), square(4), square(5)).
  • Print the sum.

Expected output

3² + 4² + 5² = 50

Hint

  • sleep(Duration::from_millis(50)).await; n * n.
  • let (a, b, c) = tokio::join!(...);.

Check your answers

Try solving it yourself first, then compare your answer with the reference solution below.

Show the reference solution

Try the assignment yourself first — comparing your attempt with a working solution is where most of the learning happens.

homework_01/Cargo.tomlTOML
[package]
name = "homework_01"
version = "0.1.0"
edition = "2021"

[dependencies]
tokio = { version = "1", features = ["full"] }
homework_01/src/main.rsRust
//Key Points:
//- `async fn` does not execute immediately, so `.await` is required.
//- The `#[tokio::main]` macro can convert a synchronous main to an async main in one line.

use tokio::time::{sleep, Duration};

async fn delayed_print(name: &str, ms: u64) {
    sleep(Duration::from_millis(ms)).await;
    println!("{name} completed ({ms}ms)");
}

#[tokio::main]
async fn main() {
    delayed_print("Task1", 50).await;
    delayed_print("Task2", 30).await;
}
homework_02/Cargo.tomlTOML
[package]
name = "homework_02"
version = "0.1.0"
edition = "2021"

[dependencies]
tokio = { version = "1", features = ["full"] }
homework_02/src/main.rsRust
//Key Points:
//- `tokio::join!` executes multiple Futures simultaneously and returns the results at once.
//- Three 50ms tasks take 150ms if awaited sequentially, about 50ms if using join!.

use tokio::time::{sleep, Duration};

async fn square(n: u64) -> u64 {
    sleep(Duration::from_millis(50)).await;
    n * n
}

#[tokio::main]
async fn main() {
    let (a, b, c) = tokio::join!(square(3), square(4), square(5));
    println!("3² + 4² + 5² = {}", a + b + c);
}

Learn this lesson with a mentor

Work through the Rust track on your own, or join a guided cohort at our Pitampura campus and online.

Call NowEnroll Now