Syntax Cache
BlogMethodFeaturesHow It WorksBuild a Game
  1. Home
  2. Rust
  3. Rust Async Practice: async/await, Futures & Tokio Basics
Rust35 exercises

Rust Async Practice: async/await, Futures & Tokio Basics

Practice Rust async programming including async fn, .await syntax, Future trait, and async runtime basics.

Common ErrorsQuick ReferencePractice
Warm-up1 / 2

Can you write this from memory?

Declare an async function called fetch_data that returns a String.

On this page
  1. 1Tokio Setup
  2. 2Compiler Error E0728: .await Only Allowed Inside async Functions
  3. 3Compiler Error E0277: Future Cannot Be Sent Between Threads Safely
  4. 4WRONG → RIGHT: Forgetting .await
  5. 5Mental Model: Futures Are Lazy
  6. 6join! vs spawn: Choosing the Right Concurrency
  7. 7WRONG → RIGHT: Blocking the Async Runtime
  8. 8WRONG → RIGHT: Holding Locks Across .await
  9. 9Further Reading
Tokio SetupCompiler Error E0728: .await Only Allowed Inside async FunctionsCompiler Error E0277: Future Cannot Be Sent Between Threads SafelyWRONG → RIGHT: Forgetting .awaitMental Model: Futures Are Lazyjoin! vs spawn: Choosing the Right ConcurrencyWRONG → RIGHT: Blocking the Async RuntimeWRONG → RIGHT: Holding Locks Across .awaitFurther Reading

Async Rust enables concurrent I/O without threads. async fn returns a Future. .await waits for it. An async runtime (like Tokio) drives execution.

The concepts are straightforward, but the syntax has quirks. When do you need .await? Why doesn't your async fn run until awaited? How do you handle errors in async code?

This page focuses on async/await fundamentals.

Related Rust Topics
Rust Closures: Fn, FnMut, FnOnce & CapturesRust Error Handling: Result, Option, ? OperatorRust Concurrency: Threads, Mutex, Channels & Arc

Most async Rust code uses Tokio as its runtime. Add it to your Cargo.toml before anything else:

[dependencies]
tokio = { version = "1", features = ["macros", "rt-multi-thread", "time"] }

macros enables #[tokio::main] and #[tokio::test]. rt-multi-thread gives you the multi-threaded scheduler. Add "full" instead if you want everything.

Ready to practice?

Start practicing Rust Async: async/await, Futures & Tokio Basics with spaced repetition

The most common first error. You wrote .await in a regular fn:

// WRONG: .await outside async context
// fn main() {
//     let data = fetch_data().await;  // error[E0728]
// }

// RIGHT: Use #[tokio::main] to make main async
#[tokio::main]
async fn main() {
    let data = fetch_data().await;
    println!("{data}");
}

async fn fetch_data() -> String {
    "data".to_string()
}

If you cannot make the function async (e.g., inside a trait impl), build a runtime manually:

fn main() {
    let rt = tokio::runtime::Runtime::new().unwrap();
    rt.block_on(async {
        let data = fetch_data().await;
        println!("{data}");
    });
}

tokio::spawn requires Send + 'static because the runtime can move tasks between threads. Three common causes:

// WRONG: Rc is not Send
// use std::rc::Rc;
// let shared = Rc::new("data".to_string());
// tokio::spawn(async move {
//     println!("{shared}");  // error[E0277]
// });

// RIGHT: Use Arc instead
use std::sync::Arc;
let shared = Arc::new("data".to_string());
tokio::spawn(async move {
    println!("{shared}");
});

Other causes: holding a &reference across .await (not 'static), or capturing a non-Send type like Cell or MutexGuard (from std) across a yield point. The concurrency guide explains Send and Sync traits in detail.

async fn fetch() -> String {
    "data".to_string()
}

// WRONG: Calling async fn without .await
// let result = fetch(); // result is a Future, not a String!
// println!("{result}"); // ERROR: Future doesn't implement Display

// RIGHT: Await the future
#[tokio::main]
async fn main() {
    let result = fetch().await; // now it's a String
    println!("{result}");
}

Futures do nothing until polled. Calling an async function without .await just constructs the future—no work runs. This is different from JavaScript promises, which start immediately.

async fn desugars to a regular function that returns an impl Future. The future does nothing until an executor polls it. .await is the common way to drive that polling.

This is different from JavaScript promises, which start executing immediately. In Rust, fetch() without .await just constructs the future — no network call happens, no work runs.

Use join! for concurrent futures within the same task (no Send required). Use spawn for background tasks on the runtime (requires Send + 'static). Use spawn_blocking for CPU-heavy or non-async work.

PatternWhat it doesRequires Send?
future.awaitSequential — one future at a timeNo
tokio::join!(a, b)Concurrent within the current taskNo
tokio::spawn(fut)New background task on the runtimeYes (Send + 'static)
tokio::spawn_blocking(f)Blocking/CPU work on a dedicated thread poolYes
tokio::select!Races futures, cancels losersNo

Key distinction: join! runs multiple futures concurrently within a single task. spawn creates a new task that can be scheduled across threads, which is why it requires Send.

// join! — concurrent, same task, no Send needed
let (a, b) = tokio::join!(fetch_users(), fetch_posts());

// spawn — background task, must be Send + 'static
let handle = tokio::spawn(async { process_data().await });
let result = handle.await.unwrap();

// select! — race futures, cancel the loser
tokio::select! {
    val = fetch_primary() => println!("primary: {val}"),
    _ = tokio::time::sleep(Duration::from_secs(5)) => println!("timeout"),
}

Never call blocking I/O (like std::fs::read) inside an async task—it starves the executor. Use tokio::fs for async file I/O or spawn_blocking for sync work.

Calling blocking I/O inside an async task starves the executor — other tasks on that thread cannot progress:

// WRONG: Blocking I/O starves the async executor
// async fn read_config() -> String {
//     std::fs::read_to_string("config.toml").unwrap()  // blocks!
// }

// RIGHT: Use tokio::fs for async file I/O
async fn read_config() -> String {
    tokio::fs::read_to_string("config.toml").await.unwrap()
}

// Or for CPU-heavy / non-async-compatible work:
let result = tokio::task::spawn_blocking(|| {
    expensive_computation()
}).await.unwrap();

Rule of thumb: if a function doesn't return a Future and takes more than a few microseconds, wrap it in spawn_blocking. Error propagation with ? works identically in async functions -- see error handling for the full pattern.

A lock guard held across an .await point keeps the lock held while the task is suspended, blocking other tasks:

// WRONG: MutexGuard held across .await
// async fn update(mutex: &tokio::sync::Mutex<Vec<String>>) {
//     let mut guard = mutex.lock().await;
//     let data = fetch_data().await;  // guard still held!
//     guard.push(data);
// }

// RIGHT: Await first, then lock
async fn update(mutex: &tokio::sync::Mutex<Vec<String>>) {
    let data = fetch_data().await;      // await first
    let mut guard = mutex.lock().await;  // then lock
    guard.push(data);                    // quick update, guard drops
}

If you must hold a lock across .await, use tokio::sync::Mutex (not std::sync::Mutex). But prefer restructuring to minimize lock duration. Move closures play a key role in async tasks -- the closures guide covers capture modes and the move keyword that tokio::spawn requires.

  • The Rust Async Book — official guide to async/await
  • Tokio Tutorial — hands-on Tokio introduction
  • Tokio: Spawning — why spawn requires Send + 'static
  • How Async Works: Polling and Wakers — the machinery under the hood

When to Use Rust Async: async/await, Futures & Tokio Basics

  • Use async for I/O-bound operations (network, file system).
  • Use threads for CPU-bound operations instead.
  • Use `tokio::join!` to run multiple futures concurrently.
  • Use `tokio::select!` to race futures and handle the first completion.
  • Use `tokio::spawn` to run a future in the background.

Check Your Understanding: Rust Async: async/await, Futures & Tokio Basics

Prompt

What happens when you call an async function without .await?

What a strong answer looks like

Nothing executes. Async functions return a Future, which is lazy. The code inside only runs when the Future is polled (typically via .await or by an async runtime).

What You'll Practice: Rust Async: async/await, Futures & Tokio Basics

Write async functions with async fnAwait futures with .await syntaxUse #[tokio::main] for async mainHandle errors with ? in async codeRun futures concurrently with join!Race futures with select!Spawn background tasks with tokio::spawnUnderstand Future trait basics

Common Rust Async: async/await, Futures & Tokio Basics Pitfalls

  • Your code compiles but nothing happens — you called an async function without `.await`, so you got a `Future` instead of a result. Add `.await` after the call.
  • You see `error[E0728]: await is only allowed inside async functions` — you used `.await` in a sync `fn`. Either make the function `async fn` or wrap the block in `async { }` and spawn it on a runtime.
  • The runtime hangs or becomes unresponsive — you called blocking I/O (like `std::fs::read`) inside an async task, starving the executor. Use `tokio::fs` or `spawn_blocking` for sync work.
  • You get a deadlock with `Mutex` across `.await` — the lock guard lives across the yield point, blocking other tasks from acquiring it. Scope the guard so it drops before `.await`, or use `tokio::sync::Mutex`.
  • You see `error[E0277]: future cannot be sent between threads safely` — the future holds a non-Send type like `Rc` or a borrow across `.await`. Switch to `Arc` or restructure to drop the reference before yielding.

Rust Async: async/await, Futures & Tokio Basics FAQ

Why do I need a runtime like Tokio?

Futures are lazy and need something to poll them. A runtime manages task scheduling, I/O polling, and timer handling. Without it, async functions never execute.

What is the difference between async and threads?

Threads run in parallel on multiple cores. Async tasks run concurrently on fewer threads by yielding during I/O. Use async for I/O-bound work (thousands of connections), threads for CPU-bound work.

Can I use async in a library without choosing a runtime?

Yes. Return `impl Future` and let the caller provide the runtime. Avoid `#[tokio::main]` in libraries. Only executables should choose the runtime.

How do I handle errors in async code?

Use `Result` and `?` just like sync code. Async functions can return `Result<T, E>`. The `?` operator works with `.await`: `client.get(url).await?`.

What does Pin mean in async Rust?

Pin prevents a value from moving in memory. Some futures are self-referential and would break if moved. Pin guarantees the future stays in place while being polled.

Why does tokio::spawn require Send?

Tokio can move tasks between worker threads when they yield at an .await point. Send guarantees the future and everything it captures can safely cross thread boundaries. If your future holds an Rc or a non-Send type, the compiler rejects it.

How do I run a non-Send future?

Use tokio::task::LocalSet and spawn_local(). A LocalSet keeps tasks pinned to the current thread, removing the Send requirement. This is useful when working with non-Send types like Rc or thread-local resources.

Rust Async: async/await, Futures & Tokio Basics Syntax Quick Reference

Async function
async fn fetch() -> String { "data".to_string() }
Await
let data = fetch().await;
Tokio main
#[tokio::main]
async fn main() {
    let _ = fetch().await;
}
Async block
let fut = async { 42 };
Error handling
let resp = client.get(url).await?;
Join futures
let (a, b) = tokio::join!(f1, f2);
Spawn task
tokio::spawn(async {
    println!("background task");
});
Select
tokio::select! {
    v = f1 => println!("f1: {v}"),
    v = f2 => println!("f2: {v}"),
}
Sleep
tokio::time::sleep(Duration::from_secs(1)).await;

Rust Async: async/await, Futures & Tokio Basics Sample Exercises

Example 1Difficulty: 1/5

Fill in the keyword to make this function asynchronous.

async
Example 2Difficulty: 1/5

Fill in the return type annotation (which becomes the Future's Output type). The literal 42 defaults to this type.

i32
Example 3Difficulty: 1/5

Fill in the keyword to create an async block that returns 42.

async

+ 32 more exercises

Further Reading

  • Rust Newtype Pattern: Catch Unit Bugs at Compile Time18 min read

Also in Other Languages

JavaScript Async/Await Practice

Start practicing Rust Async: async/await, Futures & Tokio Basics

Free daily exercises with spaced repetition. No credit card required.

← Back to Rust Syntax Practice
Syntax Cache

Build syntax muscle memory with spaced repetition.

Product

  • Pricing
  • Our Method
  • Daily Practice
  • Design Patterns
  • Interview Prep

Resources

  • Blog
  • Compare
  • Cheat Sheets
  • Vibe Coding
  • Muscle Memory

Languages

  • Python
  • JavaScript
  • TypeScript
  • Rust
  • SQL
  • GDScript

Legal

  • Terms
  • Privacy
  • Contact

© 2026 Syntax Cache

Cancel anytime in 2 clicks. Keep access until the end of your billing period.

No refunds for partial billing periods.