23.xChapter 23 summary and quiz
Async is the course's most specialized chapter, and the one most honest about its costs. Review, then a conceptual exercise.
Quick review
Async is for IO-bound work, many tasks that mostly wait (network, disk, timers) (23.1). CPU-bound work wants threads (chapter 22); async wants tasks, lightweight units far cheaper than threads, so a few threads can juggle tens of thousands of waiting tasks. Don't use async for CPU-bound work, simple CLI tools, or low concurrency; it's a specialized tool, not a performance upgrade.
An async fn returns a lazy Future (23.2): calling it runs nothing until the future is driven. .await drives a future and yields its value, and can only be used inside an async function, so async "colors" the call chain upward. The chain bottoms out at a runtime, which Rust deliberately doesn't ship, so async code does nothing until you add one.
Tokio is the standard runtime (23.3): #[tokio::main] turns async fn main into a real main that runs the runtime; tokio::spawn creates concurrent tasks; tokio::time::sleep(...).await waits without blocking, so many waits overlap (two 100ms sleeps finish in ~100ms). Use async equivalents, never blocking std calls, inside tasks.
The pitfalls (23.4): blocking the executor (a blocking call freezes all tasks on the thread, fix with async versions or spawn_blocking); non-Send values held across .await (futures must be Send to spawn, the chapter-22 rule again, fix with Arc or drop-before-await); and cancellation at await points (code after an await may never run, put must-run cleanup in Drop).
Quiz time
Question #1
A program resizes 10,000 images as fast as possible. Async or threads? Why?
Show solution
Threads (or rayon). Image resizing is CPU-bound, the bottleneck is computation, not waiting, so the way to go faster is more cores working at once. Async helps only with IO-bound waiting, of which there's none here; making this async would add complexity and a runtime dependency for zero benefit. Use rayon's par_iter or a thread pool.
Question #2
What does this print, and why?
async fn greet() { println!("hello"); }
fn main() {
let _f = greet();
println!("done");
}Show solution
It prints only done (and the compiler warns the future is unused). Calling greet() builds a lazy Future but doesn't run its body, and nothing drives it: main isn't async so it can't .await, and there's no runtime. So hello never prints. To run it you'd need a runtime (e.g. #[tokio::main] async fn main) and to .await the future.
Question #3
Why does tokio::spawn require its future to be Send, and how does that connect to chapter 22?
Show solution
Tokio runs tasks across multiple threads and may move a task between them, so a spawned future must be safe to send between threads, i.e. Send (chapter 22's marker trait). A future captures any value held across its .await points, so holding a non-Send value (like Rc) across an await makes the whole future non-Send and unspawnable. It's the identical Send rule from chapter 22, applied to futures; the fix is the same (use Arc, or drop the value before the await).
Question #4
Why should must-run cleanup go in a Drop implementation rather than in code after an .await?
Show solution
Because async tasks can be cancelled at an .await point: if the future is dropped while suspended there, it stops and any code after that await never runs, so cleanup written after an await can be skipped. A value's Drop runs even when the future is cancelled and dropped, so RAII-style cleanup (chapter 21) survives cancellation. Put guaranteed cleanup in Drop, not in post-await statements.
Chapter exercise
No code to run this time, a design question, since the lesson is about judgment. For each scenario, say whether you'd use async, threads/rayon, or neither (plain sequential code), and why:
- A command-line tool that reads one CSV file, sums a column, and prints the total.
- A chat server holding 50,000 simultaneous client connections, mostly idle.
- A program that computes SHA-256 hashes of 100,000 files on local disk as fast as possible.
Show solution
-
Neither, plain sequential code. It does one quick read and one computation; there's no concurrency to exploit and nothing to overlap. Async would add a runtime and complexity for nothing; threads would add coordination for nothing. Keep it simple.
-
Async. This is the textbook case: a huge number of concurrent, IO-bound, mostly-waiting connections. A thread per connection (50,000 threads) would waste gigabytes of stacks and crush the scheduler; lightweight async tasks let a few threads juggle them all, since most are idle at any moment. This is exactly what async was built for.
-
Threads /
rayon. Hashing is CPU-bound (the work is computation, not waiting), and the files are independent, so it parallelizes cleanly across cores.files.par_iter().map(hash).collect()withrayon(chapter 22) is the natural fit. (There's some disk IO, but the bottleneck is the hashing; if it were pure IO you'd reconsider, but "as fast as possible" on local files points at CPU parallelism.)
The meta-point: the right concurrency tool follows from the bottleneck. Waiting at scale → async; computing in parallel → threads/rayon; neither → sequential. Most programs are the third case, and reaching for concurrency they don't need is its own mistake.
You've now seen all three concurrency stories Rust offers, threads, parallelism, and async, and, just as importantly, when to use none of them. Chapter 24 shifts to a different kind of capability entirely: macros, code that writes code at compile time. It opens by finally answering a question you've carried since the very first program: what that ! after println actually means.