Lesson 68 of 94
Async Fundamentals
Threads are great for a handful of parallel jobs, but a server handling 50,000 open connections can't afford 50,000 OS threads, each with its own stack. Rust's answer is async/await: functions that can pause while they wait for I/O, so that one OS thread can juggle thousands of in-flight tasks.
Three ideas shape everything in this lesson:
- Rust futures are lazy. Calling an
async fndoes nothing until the result is.awaited (or spawned). - There's no built-in runtime. The language provides
async/.awaitand theFuturetrait; a library (almost always Tokio) provides the scheduler, timers, and async I/O. - Waiting points are explicit. Every place a task can pause is marked with
.await.
| Concept | Rust |
|---|---|
| an async computation | impl Future<Output = T> |
| waiting for a result | .await (inside async code) |
| running in the background | tokio::spawn(async { ... }) |
| waiting for several futures | tokio::join!(a, b) |
| timeouts | tokio::time::timeout(...) |
| sleeping | tokio::time::sleep(...).await |
| blocking work | tokio::task::spawn_blocking |
All examples use Tokio (tokio = { version = "1", features = ["full"] } in Cargo.toml).
1. Your first async program
An async fn returns a future. .await runs it to completion and gives you its output. #[tokio::main] starts the Tokio runtime and runs main's future on it.
use std::time::Duration;
async fn fetch_greeting(name: &str) -> String {
tokio::time::sleep(Duration::from_millis(50)).await; // pretend this is a network call
format!("Hello, {name}!")
}
#[tokio::main]
async fn main() {
let greeting = fetch_greeting("Ana").await;
println!("{greeting}");
}Hello, Ana!tokio::time::sleep pauses only this task; the thread is free to run other tasks meanwhile. That's the core idea of async.
2. main can't be async on its own
Without a runtime there's nothing to run the future, so a plain async fn main is rejected:
async fn main() {
println!("hello");
}error[E0752]: `main` function is not allowed to be `async`
--> src/main.rs:1:1
|
1 | async fn main() {
| ^^^^^^^^^^^^^^^ `main` function is not allowed to be `async`#[tokio::main] is a macro that rewrites main into a normal function that builds a runtime and calls block_on on your async body (example 23 does this by hand).
3. Futures are lazy
Calling an async fn only creates a future: a value describing work to be done. Nothing runs until it's awaited. The compiler even warns about futures that are created and never used.
async fn send_email(to: &str) -> bool {
println!(" sending email to {to}");
true
}
#[tokio::main]
async fn main() {
println!("creating the future");
let future = send_email("ana@example.com"); // nothing printed yet!
println!("future created, not started");
let sent = future.await; // now it runs
println!("sent: {sent}");
}creating the future
future created, not started
sending email to ana@example.com
sent: trueLaziness is what makes Rust futures cheap: no thread, no allocation, no scheduling until you ask for the result.
4. async fn is sugar for fn -> impl Future
These two functions are equivalent. The second spells out what the first means: a normal function that returns a future, whose body is an async block.
use std::future::Future;
async fn price_a(sku: &str) -> u32 {
if sku == "BOOK" { 1500 } else { 0 }
}
fn price_b(sku: &str) -> impl Future<Output = u32> + '_ {
async move { if sku == "BOOK" { 1500 } else { 0 } }
}
#[tokio::main]
async fn main() {
println!("{} {}", price_a("BOOK").await, price_b("BOOK").await);
}1500 1500The + '_ says the future borrows sku: the future holds on to its arguments until it completes. async fn adds that for you.
5. async blocks
An async { ... } block is an expression that produces a future, like a closure produces a function. async move moves captured variables into the future.
#[tokio::main]
async fn main() {
let user = String::from("ana");
let load_profile = async move {
// `user` was moved into this future
format!("profile of {user}")
};
let load_orders = async { vec![101, 102] };
println!("{}", load_profile.await);
println!("{:?}", load_orders.await);
}profile of ana
[101, 102]6. .await in sequence adds up
Awaiting one future after another runs them one at a time, just like sequential blocking calls. Three 100 ms calls take about 300 ms.
use std::time::{Duration, Instant};
async fn call_service(name: &str, ms: u64) -> String {
tokio::time::sleep(Duration::from_millis(ms)).await;
format!("{name} ok")
}
#[tokio::main]
async fn main() {
let start = Instant::now();
let a = call_service("users", 100).await;
let b = call_service("orders", 100).await;
let c = call_service("stock", 100).await;
println!("{a}, {b}, {c}");
println!("took at least 300 ms: {}", start.elapsed() >= Duration::from_millis(300));
}users ok, orders ok, stock ok
took at least 300 ms: true.await doesn't mean "run in the background". It means "run this now, and let other tasks use the thread while it waits".
7. tokio::join!: run futures concurrently
To wait for several things at once, join! polls all the futures together and returns all their results. The total time is the slowest one, not the sum.
use std::time::{Duration, Instant};
async fn call_service(name: &str, ms: u64) -> String {
tokio::time::sleep(Duration::from_millis(ms)).await;
format!("{name} ok")
}
#[tokio::main]
async fn main() {
let start = Instant::now();
let (a, b, c) = tokio::join!(
call_service("users", 100),
call_service("orders", 150),
call_service("stock", 100),
);
println!("{a}, {b}, {c}");
println!("took under 300 ms: {}", start.elapsed() < Duration::from_millis(300));
}users ok, orders ok, stock ok
took under 300 ms: truejoin! runs all futures in the current task, interleaving them on one thread. No extra threads or tasks are created.
8. tokio::spawn: an independent task
tokio::spawn hands a future to the runtime as a new task, which runs in the background, possibly on another worker thread. It returns a JoinHandle; awaiting it gives Result<T, JoinError> (an error if the task panicked or was cancelled).
use std::time::Duration;
async fn resize_image(id: u32) -> String {
tokio::time::sleep(Duration::from_millis(30)).await;
format!("image-{id}-small.png")
}
#[tokio::main]
async fn main() {
let handle = tokio::spawn(resize_image(7)); // starts running right away
println!("main continues while the task runs");
let file = handle.await.unwrap();
println!("task produced {file}");
}main continues while the task runs
task produced image-7-small.pngSpawned tasks are cheap, can be many, and are scheduled by the runtime onto a small pool of OS threads. Unlike a bare future, a spawned task runs even if nobody awaits its handle.
9. Spawned tasks must own their data
Like thread::spawn, tokio::spawn requires a 'static future: the task may outlive the current function, so it can't borrow local variables.
#[tokio::main]
async fn main() {
let report = String::from("q3.pdf");
let handle = tokio::spawn(async {
println!("uploading {report}");
});
handle.await.unwrap();
}error[E0373]: async block may outlive the current function, but it borrows `report`, which is owned by the current function
--> src/main.rs:4:31
|
4 | let handle = tokio::spawn(async {
| ^^^^^ may outlive borrowed value `report`
5 | println!("uploading {report}");
| ------ `report` is borrowed here
|
= note: async blocks are not executed immediately and must either take a reference or ownership of outside variables they use
help: to force the async block to take ownership of `report` (and any other referenced variables), use the `move` keyword
|
4 | let handle = tokio::spawn(async move {
| ++++Add move to the block (async move { ... }), and clone first if you still need the value, exactly as with threads.
10. Tasks are cheap: spawn ten thousand
A Tokio task is a small heap allocation, not a thread. Spawning 10,000 tasks that each sleep for 100 ms takes a little over 100 ms in total.
use std::time::{Duration, Instant};
#[tokio::main]
async fn main() {
let start = Instant::now();
let handles: Vec<_> = (0..10_000u64)
.map(|i| {
tokio::spawn(async move {
tokio::time::sleep(Duration::from_millis(100)).await;
i * 2
})
})
.collect();
let mut sum = 0;
for h in handles {
sum += h.await.unwrap();
}
println!("sum: {sum}");
println!("10,000 tasks finished in under 1 s: {}", start.elapsed() < Duration::from_secs(1));
}sum: 99990000
10,000 tasks finished in under 1 s: true11. Never block inside async code
Async works because tasks give the thread back at every .await. A blocking call (std::thread::sleep, a synchronous file read, a long computation) holds the thread, and every other task on that thread stops. Here, on a single-threaded runtime, two "concurrent" tasks run one after the other because they block:
use std::time::{Duration, Instant};
async fn blocking_work() {
std::thread::sleep(Duration::from_millis(100)); // BAD: blocks the whole thread
}
async fn async_work() {
tokio::time::sleep(Duration::from_millis(100)).await; // GOOD: yields while waiting
}
#[tokio::main(flavor = "current_thread")]
async fn main() {
let start = Instant::now();
tokio::join!(blocking_work(), blocking_work());
println!("blocking sleep: at least 200 ms: {}", start.elapsed() >= Duration::from_millis(200));
let start = Instant::now();
tokio::join!(async_work(), async_work());
println!("async sleep: under 200 ms: {}", start.elapsed() < Duration::from_millis(200));
}blocking sleep: at least 200 ms: true
async sleep: under 200 ms: trueThis is the most common async bug: Tokio has no way to know that a function will block.
12. spawn_blocking for blocking or CPU-heavy work
When you must call blocking code (a synchronous library, heavy hashing, image processing), move it to Tokio's blocking thread pool with spawn_blocking. The async tasks keep running.
use std::time::Duration;
fn hash_password(password: &str) -> u64 {
// Stand-in for a deliberately slow hash like bcrypt.
std::thread::sleep(Duration::from_millis(50));
password.bytes().fold(5381u64, |h, b| h.wrapping_mul(33) ^ b as u64)
}
#[tokio::main]
async fn main() {
let password = String::from("hunter2");
let hashing = tokio::task::spawn_blocking(move || hash_password(&password));
// Meanwhile, async work continues on the runtime threads.
tokio::time::sleep(Duration::from_millis(10)).await;
println!("async work done while hashing");
let hash = hashing.await.unwrap();
println!("hash: {hash:x}");
}async work done while hashing
hash: d09e4b3fdd67Rule of thumb: if something can take more than about 100 microseconds without reaching an .await, it doesn't belong directly in async code.
13. Timeouts
tokio::time::timeout(duration, future) returns Ok(output) if the future finished in time, or Err(Elapsed) otherwise. The slow future is dropped, which cancels it.
use std::time::Duration;
use tokio::time::{sleep, timeout};
async fn query(ms: u64) -> &'static str {
sleep(Duration::from_millis(ms)).await;
"rows"
}
#[tokio::main]
async fn main() {
match timeout(Duration::from_millis(100), query(20)).await {
Ok(rows) => println!("fast query: {rows}"),
Err(_) => println!("fast query timed out"),
}
match timeout(Duration::from_millis(100), query(500)).await {
Ok(rows) => println!("slow query: {rows}"),
Err(e) => println!("slow query: {e}"),
}
}fast query: rows
slow query: deadline has elapsed14. Cancellation is dropping
In Rust, dropping a future cancels it: it simply stops being polled, and any values it owned are dropped (running their Drop cleanup). A future that's cancelled mid-way stops at its last .await.
use std::time::Duration;
struct Connection(&'static str);
impl Drop for Connection {
fn drop(&mut self) {
println!(" closing {}", self.0);
}
}
async fn download() -> usize {
let _conn = Connection("download socket");
for chunk in 1..=10 {
tokio::time::sleep(Duration::from_millis(20)).await;
println!(" chunk {chunk}");
}
10
}
#[tokio::main]
async fn main() {
let result = tokio::time::timeout(Duration::from_millis(50), download()).await;
println!("result: {result:?}");
} chunk 1
chunk 2
closing download socket
result: Err(Elapsed(()))This makes timeouts and races cheap and safe, but it also means any .await can be the last line a future ever runs. Code that must not stop half-way (like writing two related records) should be written with that in mind; Tokio's docs call this cancel safety.
15. Aborting a spawned task
A spawned task keeps running even if its JoinHandle is dropped. To stop it, call abort(); awaiting the handle afterwards returns a JoinError that reports cancellation.
use std::time::Duration;
#[tokio::main]
async fn main() {
let ticker = tokio::spawn(async {
let mut ticks = 0u32;
loop {
tokio::time::sleep(Duration::from_millis(10)).await;
ticks += 1;
}
#[allow(unreachable_code)]
ticks
});
tokio::time::sleep(Duration::from_millis(50)).await;
ticker.abort();
match ticker.await {
Ok(ticks) => println!("finished with {ticks}"),
Err(e) => println!("cancelled: {}", e.is_cancelled()),
}
}cancelled: true16. A panicking task doesn't take down the runtime
If a spawned task panics, the panic is caught by the runtime and reported through the JoinHandle as an error. Other tasks keep running, just like a panicking thread.
#[tokio::main]
async fn main() {
let ok = tokio::spawn(async { "fine" });
let bad = tokio::spawn(async {
let data: Vec<u32> = Vec::new();
data[3] // index out of bounds: panics
});
println!("ok task: {:?}", ok.await);
let err = bad.await.unwrap_err();
println!("bad task panicked: {}", err.is_panic());
}ok task: Ok("fine")
bad task panicked: true17. Errors and ? in async functions
async fn works with Result and ? exactly like normal functions. main can return Result too.
use std::time::Duration;
#[derive(Debug)]
struct User {
id: u32,
name: String,
}
async fn find_user(id: u32) -> Result<User, String> {
tokio::time::sleep(Duration::from_millis(10)).await;
match id {
1 => Ok(User { id, name: String::from("ana") }),
_ => Err(format!("user {id} not found")),
}
}
async fn greeting(id: u32) -> Result<String, String> {
let user = find_user(id).await?; // propagate the error
Ok(format!("Hi {} (#{})", user.name, user.id))
}
#[tokio::main]
async fn main() -> Result<(), String> {
println!("{}", greeting(1).await?);
println!("{:?}", greeting(2).await);
Ok(())
}Hi ana (#1)
Err("user 2 not found")18. Borrowing in async functions
A non-spawned future can borrow freely: join! on futures that borrow local data works, because everything completes before the function returns. Only spawn needs 'static.
async fn count_matching(lines: &[String], word: &str) -> usize {
tokio::task::yield_now().await; // a real await point
lines.iter().filter(|l| l.contains(word)).count()
}
#[tokio::main]
async fn main() {
let log: Vec<String> = ["GET /a 200", "GET /b 500", "POST /c 500", "GET /d 200"].map(String::from).to_vec();
// Both futures borrow `log`; no cloning or Arc needed.
let (errors, gets) = tokio::join!(count_matching(&log, " 500"), count_matching(&log, "GET"));
println!("errors: {errors}, GETs: {gets}, lines still owned here: {}", log.len());
}errors: 2, GETs: 3, lines still owned here: 419. Async closures
Rust 2024 supports async closures: async |args| { ... }. They're handy for passing async behavior to helper functions, which take them through the AsyncFn family of traits (AsyncFn, AsyncFnMut, AsyncFnOnce).
use std::time::Duration;
async fn with_retries<T, E: std::fmt::Display>(attempts: u32, op: impl AsyncFn(u32) -> Result<T, E>) -> Result<T, E> {
let mut n = 1;
loop {
match op(n).await {
Ok(v) => return Ok(v),
Err(e) if n < attempts => {
println!(" attempt {n} failed: {e}");
n += 1;
}
Err(e) => return Err(e),
}
}
}
#[tokio::main]
async fn main() {
let flaky = async |attempt: u32| {
tokio::time::sleep(Duration::from_millis(5)).await;
if attempt < 3 { Err(format!("timeout #{attempt}")) } else { Ok("payload") }
};
println!("{:?}", with_retries(5, flaky).await);
println!("{:?}", with_retries(2, flaky).await);
} attempt 1 failed: timeout #1
attempt 2 failed: timeout #2
Ok("payload")
attempt 1 failed: timeout #1
Err("timeout #2")20. Recursion needs a box
An async fn's future contains the futures it awaits, so a recursive async fn would contain itself and have infinite size. The compiler asks you to box the recursive call:
async fn count_down(n: u32) {
if n > 0 {
count_down(n - 1).await;
}
}
#[tokio::main]
async fn main() {
count_down(3).await;
}error[E0733]: recursion in an async fn requires boxing
--> src/main.rs:1:1
|
1 | async fn count_down(n: u32) {
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^
2 | if n > 0 {
3 | count_down(n - 1).await;
| ----------------------- recursive call here
|
= note: a recursive `async fn` call must introduce indirection such as `Box::pin` to avoid an infinitely sized futureBox::pin puts the inner future on the heap, which gives it a fixed size:
use std::collections::HashMap;
// A directory tree: folder -> children. Sum sizes recursively, "fetching" each folder asynchronously.
async fn total_size(tree: &HashMap<&str, Vec<(&str, u64)>>, folder: &str) -> u64 {
tokio::task::yield_now().await; // pretend: a remote call per folder
let mut sum = 0;
for (child, size) in tree.get(folder).into_iter().flatten() {
sum += size + Box::pin(total_size(tree, child)).await;
}
sum
}
#[tokio::main]
async fn main() {
let tree = HashMap::from([
("/", vec![("/docs", 0), ("/readme.md", 4)]),
("/docs", vec![("/docs/a.pdf", 120), ("/docs/img", 0)]),
("/docs/img", vec![("/docs/img/logo.png", 36)]),
]);
println!("total: {} KB", total_size(&tree, "/").await);
}total: 160 KBPin means "this value won't move in memory again", which futures need because they can hold references into themselves. You rarely deal with it directly beyond Box::pin.
21. What a future is: poll
Under the sugar, a future is a value with a poll method. The runtime calls poll; the future returns Poll::Ready(value) when done, or Poll::Pending after arranging to be woken (via the Waker in the Context) when it can make progress. Here's one written by hand:
use std::future::Future;
use std::pin::Pin;
use std::task::{Context, Poll};
/// A future that needs to be polled `remaining` more times before it's ready.
struct Countdown {
remaining: u32,
polls: u32,
}
impl Future for Countdown {
type Output = u32;
fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<u32> {
self.polls += 1;
if self.remaining == 0 {
Poll::Ready(self.polls)
} else {
self.remaining -= 1;
cx.waker().wake_by_ref(); // "poll me again soon"
Poll::Pending
}
}
}
#[tokio::main]
async fn main() {
let polls = Countdown { remaining: 3, polls: 0 }.await;
println!("ready after {polls} polls");
}ready after 4 pollsReal futures (sockets, timers) return Pending and hand the Waker to the OS event loop, which wakes the task when data arrives. .await is the syntax that calls poll for you and returns control to the runtime on Pending.
22. Futures without Tokio: block_on
The Future trait is in std, and any executor can run it. The futures crate has a tiny one, block_on, which runs a future on the current thread. It's enough for futures that don't need Tokio's timers or I/O.
use futures::executor::block_on;
async fn add(a: i32, b: i32) -> i32 {
a + b
}
async fn compute() -> i32 {
let x = add(1, 2).await;
let y = add(x, 10).await;
x * y
}
fn main() {
// A normal (non-async) main that runs one future to completion.
let result = block_on(compute());
println!("result: {result}");
}result: 39Calling tokio::time::sleep under block_on would panic, since there's no Tokio runtime running. Libraries usually document which runtime they need; in practice that's almost always Tokio.
23. Building a runtime by hand
#[tokio::main] is shorthand for this. Building the runtime yourself lets you choose the number of worker threads, name them, or run async code from inside a synchronous program.
use std::time::Duration;
fn main() {
let runtime = tokio::runtime::Builder::new_multi_thread()
.worker_threads(2)
.thread_name("api-worker")
.enable_all() // timers and I/O
.build()
.unwrap();
let result = runtime.block_on(async {
let a = tokio::spawn(async {
tokio::time::sleep(Duration::from_millis(10)).await;
std::thread::current().name().map(str::to_string)
});
a.await.unwrap()
});
println!("task ran on {result:?}");
}task ran on Some("api-worker")flavor = "current_thread" (in the attribute) or Builder::new_current_thread() runs everything on one thread: less overhead, deterministic ordering, but no parallelism. Tokio's default is multi-threaded with one worker per CPU core.
24. yield_now: being a good neighbor
Tokio schedules cooperatively: a future runs until it reaches an .await that isn't ready, and only then can anything else run on that thread. A long loop with no awaits starves everything else. tokio::task::yield_now().await gives others a turn. You can see the effect with two futures under join!, which switches between them only at pending .awaits:
use std::cell::RefCell;
async fn worker(name: &str, log: &RefCell<Vec<String>>, polite: bool) {
for i in 1..=3 {
log.borrow_mut().push(format!("{name}{i}"));
if polite {
tokio::task::yield_now().await; // let the other future run
}
}
}
#[tokio::main(flavor = "current_thread")]
async fn main() {
let log = RefCell::new(Vec::new());
tokio::join!(worker("A", &log, false), worker("B", &log, false));
println!("without yield: {:?}", log.take());
tokio::join!(worker("A", &log, true), worker("B", &log, true));
println!("with yield: {:?}", log.take());
}without yield: ["A1", "A2", "A3", "B1", "B2", "B3"]
with yield: ["A1", "B1", "B2", "A2", "A3", "B3"]Without yielding, A runs to completion before B starts. With yielding they interleave; the exact order comes from tokio::join! rotating which future it polls first on each round, for fairness. Both futures run in one task on one thread, so a plain RefCell is enough to share the log. The same starvation applies between spawned tasks: a task that computes for a long time without awaiting holds its worker thread the whole time.
25. Realistic: a dashboard from three services
A request handler that needs data from three backend services. Calling them concurrently with try_join! cuts the latency to the slowest call, and the first error short-circuits the whole thing.
use std::time::{Duration, Instant};
#[derive(Debug)]
struct Dashboard {
user: String,
unread: u32,
balance_cents: i64,
}
async fn fetch_user(id: u32) -> Result<String, String> {
tokio::time::sleep(Duration::from_millis(80)).await;
Ok(format!("user-{id}"))
}
async fn fetch_unread(id: u32) -> Result<u32, String> {
tokio::time::sleep(Duration::from_millis(120)).await;
Ok(id * 3)
}
async fn fetch_balance(id: u32) -> Result<i64, String> {
tokio::time::sleep(Duration::from_millis(100)).await;
if id == 0 { Err(String::from("billing service: account 0 is locked")) } else { Ok(12_345) }
}
async fn dashboard(id: u32) -> Result<Dashboard, String> {
let (user, unread, balance_cents) = tokio::try_join!(fetch_user(id), fetch_unread(id), fetch_balance(id))?;
Ok(Dashboard { user, unread, balance_cents })
}
#[tokio::main]
async fn main() {
let start = Instant::now();
println!("{:?}", dashboard(7).await);
println!("concurrent (under 250 ms): {}", start.elapsed() < Duration::from_millis(250));
println!("{:?}", dashboard(0).await);
}Ok(Dashboard { user: "user-7", unread: 21, balance_cents: 12345 })
concurrent (under 250 ms): true
Err("billing service: account 0 is locked")26. Realistic: retry with exponential backoff
A client for a flaky service: retry failed calls with growing delays, and give up after a limit. The shared attempt counter is an atomic so the example can report how many calls were made.
use std::sync::atomic::{AtomicU32, Ordering};
use std::time::Duration;
static CALLS: AtomicU32 = AtomicU32::new(0);
async fn flaky_payment(amount: u32) -> Result<String, String> {
let n = CALLS.fetch_add(1, Ordering::Relaxed) + 1;
tokio::time::sleep(Duration::from_millis(5)).await;
if n % 4 != 0 { Err(format!("503 on call {n}")) } else { Ok(format!("charged {amount}")) }
}
async fn charge_with_backoff(amount: u32, max_attempts: u32) -> Result<String, String> {
let mut delay = Duration::from_millis(10);
for attempt in 1..=max_attempts {
match flaky_payment(amount).await {
Ok(receipt) => return Ok(receipt),
Err(e) if attempt < max_attempts => {
println!(" attempt {attempt}: {e}, retrying in {delay:?}");
tokio::time::sleep(delay).await;
delay *= 2;
}
Err(e) => return Err(format!("giving up after {attempt} attempts: {e}")),
}
}
unreachable!()
}
#[tokio::main]
async fn main() {
println!("{:?}", charge_with_backoff(500, 5).await);
println!("{:?}", charge_with_backoff(900, 2).await);
println!("total calls: {}", CALLS.load(Ordering::Relaxed));
} attempt 1: 503 on call 1, retrying in 10ms
attempt 2: 503 on call 2, retrying in 20ms
attempt 3: 503 on call 3, retrying in 40ms
Ok("charged 500")
attempt 1: 503 on call 5, retrying in 10ms
Err("giving up after 2 attempts: 503 on call 6")
total calls: 627. Realistic: a TCP echo server and client
Real async I/O: a server that accepts connections and spawns a task per client, and a client that talks to it. Binding to port 0 lets the OS pick a free port. AsyncReadExt/AsyncWriteExt provide read/write_all as async methods.
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::net::{TcpListener, TcpStream};
async fn handle_client(stream: TcpStream) -> std::io::Result<()> {
let (reader, mut writer) = stream.into_split();
let mut lines = BufReader::new(reader).lines();
while let Some(line) = lines.next_line().await? {
writer.write_all(format!("echo: {}\n", line.to_uppercase()).as_bytes()).await?;
}
Ok(())
}
#[tokio::main]
async fn main() -> std::io::Result<()> {
let listener = TcpListener::bind("127.0.0.1:0").await?;
let addr = listener.local_addr()?;
// Server: one task per connection.
tokio::spawn(async move {
loop {
let (stream, _) = listener.accept().await.unwrap();
tokio::spawn(handle_client(stream));
}
});
// Client
let stream = TcpStream::connect(addr).await?;
let (reader, mut writer) = stream.into_split();
let mut replies = BufReader::new(reader).lines();
for msg in ["hello", "async rust"] {
writer.write_all(format!("{msg}\n").as_bytes()).await?;
println!("{}", replies.next_line().await?.unwrap());
}
Ok(())
}echo: HELLO
echo: ASYNC RUSTThis server can hold thousands of idle connections with a few OS threads, because each waiting connection is just a paused task.
28. Realistic: async file I/O
tokio::fs mirrors std::fs with async functions. (Internally it uses spawn_blocking, because most operating systems don't offer truly async file APIs; for sockets, the I/O is genuinely non-blocking.)
use tokio::fs;
use tokio::io::AsyncWriteExt;
#[tokio::main]
async fn main() -> std::io::Result<()> {
let path = std::env::temp_dir().join(format!("orders-{}.csv", std::process::id()));
let mut file = fs::File::create(&path).await?;
file.write_all(b"id,amount\n1,1999\n2,500\n3,12000\n").await?;
file.flush().await?;
let text = fs::read_to_string(&path).await?;
let total: u32 = text.lines().skip(1).filter_map(|l| l.split_once(',')?.1.parse::<u32>().ok()).sum();
println!("{} orders, total {total}", text.lines().count() - 1);
fs::remove_file(&path).await?;
println!("file removed: {}", !fs::try_exists(&path).await?);
Ok(())
}3 orders, total 14499
file removed: trueSummary
async fnreturns a lazy future; nothing runs until it's.awaited or spawned.- A runtime (Tokio) runs futures.
#[tokio::main]sets one up;Buildergives you control. - Sequential
.awaits add up;join!/try_join!run futures concurrently in one task;tokio::spawncreates independent tasks that must own their data. - Never block in async code. Use async APIs (
tokio::time::sleep,tokio::fs,tokio::net) andspawn_blockingfor blocking or CPU-heavy work. - Dropping a future cancels it;
timeoutandabortbuild on that. Panics in tasks surface asJoinError. - Async closures (
async |x| ...) andAsyncFnbounds pass async behavior around; recursive async calls needBox::pin. - Underneath, a future is a state machine with a
pollmethod, woken by aWaker.
Next: Tokio Patterns: select!, JoinSet, structured concurrency.
