Language Internals
Part 9 of 11 · Rust Language ProficiencyConcurrency — Threads, async & Tokio
Threads, Send/Sync, async/await, Tokio.
- 1Gist
- 2Maps
- 3Q&A
- 4Sandbox
Voice readout needs Web Speech Synthesis in this browser.
Question ladder
L1
Does calling an async function run it?
Answer
No. An async fn returns a future that needs an executor (for example Tokio). Nothing runs until that executor polls it.
L2
What is Send?
Answer
tokio::spawn requires Send + 'static futures; futures that are not Send run on a LocalSet via spawn_local.
L3
What is Sync?
Answer
A shared reference to the type is Send. Arc requires the inner type to be Send and Sync in the usual sharing pattern.
L4
When are OS threads the right tool?
Answer
CPU-bound work, or a blocking library. rayon is the data-parallel form of the same idea.
L5
When is Tokio the right tool?
Answer
Many sockets or other waits. The runtime overlaps those waits. It does not make pure CPU faster.
L6
std Mutex or tokio Mutex?
Answer
std Mutex is correct if you never await while the guard is held. If the lock must span an await, use the runtime's mutex.
L7
How do you cancel a task?
Answer
Drop the future, or abort the JoinHandle. Cleanup runs in Drop, not in a catch of a special exception.
Failure modes
Blocking the worker
Sync file, DNS, or a CPU loop runs on a Tokio worker and stalls other tasks.
Await under a std Mutex
The guard is not Send, or another task needs the lock and deadlocks the worker.
Non-Send capture
An Rc or a raw local is moved into a multi-thread spawn and the compiler rejects it.
Forgotten spawn
A future is built and dropped without polling, so the work never starts.
Misconceptions
async fn starts a thread.
It builds a future. The runtime decides the threads. A current-thread runtime is one thread.
Tokio makes CPU work parallel.
It schedules waits. CPU work belongs on threads, rayon, or spawn_blocking.
Send is a runtime lock.
It is a compile-time marker. The lock is still Mutex or similar.
Interviewer traps
Comparing a JavaScript Promise to a Rust future that has already started.
The Promise schedules on call. The future is lazy until poll.
Holding the GIL story as the Rust story.
Safe Rust refuses the data race. The cost is Send, Sync, and the lock around shared mutation.
Design scenario
Same prompt for every reader.
Requirements
Tokio for the sockets, spawn_blocking or a thread pool for the CPU transform, a Send cache, and cancellation by dropping the task.
Traffic / scale
Hundreds of concurrent connections, a few CPU jobs.
Latency
Socket waits overlap. The CPU job must leave the worker.
Consistency
A dropped task does not publish a partial result.
Availability
A std Mutex is not held across await.
Failure assumptions
- The CPU transform runs inline on the async worker.
- The cache is an Rc.
- The future is constructed and never spawned.
Constraints
- Stay on threads and async. Do not design a broker.
Prompt
A service fans out HTTP calls and sometimes runs a CPU transform. In-flight work must stop when the client disconnects.
Threads or a runtime
Prefer
Match the work
CPU and blocking calls use threads. Many waits use async on an explicit runtime.
- spawn starts an OS thread.
- await polls a future.
- Send is required to cross threads.
Alternative
One host scheduler
JavaScript schedules on the event loop. Python asyncio is cooperative on one thread.
- A Promise starts when you call it.
- CPU work needs workers or processes.
- The runtime is not a crate you choose.
Overview
Safe Rust will not compile a data race. The cost is markers and locks. OS threads run in parallel. Async tasks share a runtime you opt into. Tokio is the common runtime. It is not part of the language.
The JS/TS language hub is the host-loop habit. The Python language hub is the GIL and asyncio habit. Neither replaces Send or an explicit runtime.
Comparative
| Concern | TypeScript | Python asyncio | Rust and Tokio |
|---|---|---|---|
| Starts work | the Promise runs | the coroutine needs a loop | the future is lazy |
| Runtime | built into the host | asyncio.run | explicit, often Tokio |
| Parallel CPU | workers | processes | threads or rayon |
Decisions
- 1
Step 1 Classify the work
- nextStep 2 CPU-bound or I/O-bound
- ?
Step 2 CPU-bound or I/O-bound
- CPUStep 3a std::thread or rayon - closure is Send + 'static, or use thread::scope
- many sockets, I/OStep 3b async fn returns a lazy Future
- 3
Step 3a std::thread or rayon - closure is Send + 'static, or use thread::scope
- 4
Step 3b async fn returns a lazy Future
- nextStep 4 Runtime polls it - tokio::spawn needs Send + 'static
- 5
Step 4 Runtime polls it - tokio::spawn needs Send + 'static
- nextStep 5 Share state - Arc plus Mutex, or channels
- blocking call inside asyncFailure path - worker stalls; move it to spawn_blocking
- 6
Step 5 Share state - Arc plus Mutex, or channels
- nextStep 6 Drop lock guards before .await
- 7
Step 6 Drop lock guards before .await
- std MutexGuard held across .awaitFailure path - future is not Send, or deadlock
- 8
Failure path - worker stalls; move it to spawn_blocking
- 9
Failure path - future is not Send, or deadlock
Lesson map
Concurrency — Threads, async & Tokio
Threads, Send/Sync, async/await, Tokio.
Architecture. Step 1 Classify the work Ready. Step 2 CPU-bound or I/O-bound Ready. Step 3a std::thread or rayon - closure is Send + 'static, or use thread::scope Ready. Step 3b async fn returns a lazy Future Ready. Step 4 Runtime polls it - tokio::spawn needs Send + 'static Ready. Step 5 Share state - Arc plus Mutex, or channels Ready. Step 6 Drop lock guards before .await Ready. Failure path - worker stalls; move it to spawn_blocking Ready. Failure path - future is not Send, or deadlock Ready
Select a node to see why it exists, or an edge to see the protocol, direction, effect, and consequence.
Mermaid export
flowchart TB A["Step 1 Classify the work Ready"] B["Step 2 CPU-bound or I/O-bound Ready"] C["Step 3a std::thread or rayon - closure is Send + 'static, or use thread::scope Ready"] D["Step 3b async fn returns a lazy Future Ready"] E["Step 4 Runtime polls it - tokio::spawn needs Send + 'static Ready"] F["Step 5 Share state - Arc plus Mutex, or channels Ready"] G["Step 6 Drop lock guards before .await Ready"] X["Failure path - worker stalls move it to spawn_blocking Ready"] Y["Failure path - future is not Send, or deadlock Ready"] A -->|continues| B B -->|CPU| C B -->|many sockets, I/O| D D -->|continues| E E -->|continues| F F -->|continues| G E -->|blocking call inside async| X G -->|std MutexGuard held across .await| Y
Press Run. Snippets must be self-contained — no network, files, or native modules.
Rosetta — spawn work
A thread returns a join handle. The closure must be Send and 'static.
use std::thread;
fn main() {
let handle = thread::spawn(|| 21 * 2);
println!("{}", handle.join().unwrap());
}An async task needs a runtime. The attribute is Tokio, not the language:
// #[tokio::main]
// async fn main() {
// let v = async { 21 * 2 }.await;
// println!("{v}");
// }async function main() {
const v = await Promise.resolve(21 * 2);
console.log(v);
}
main();import asyncio
async def main() -> None:
v = await asyncio.sleep(0, result=21 * 2)
print(v)
asyncio.run(main())The TypeScript Promise is scheduled by calling main. The Python coroutine runs when asyncio.run drives it. The Rust future is inert until something polls it.
Rosetta — a channel
use std::sync::mpsc;
use std::thread;
fn main() {
let (tx, rx) = mpsc::channel();
thread::spawn(move || tx.send(7).unwrap());
println!("{}", rx.recv().unwrap());
}const pending = Promise.resolve(7);
pending.then((n) => console.log(n));from queue import Queue
from threading import Thread
q: Queue[int] = Queue()
Thread(target=lambda: q.put(7)).start()
print(q.get())Message passing keeps the owner of each value obvious. Shared mutation is the harder pattern.
Rosetta — shared state
use std::sync::{Arc, Mutex};
use std::thread;
fn main() {
let n = Arc::new(Mutex::new(0));
let mut handles = Vec::new();
for _ in 0..4 {
let n = Arc::clone(&n);
handles.push(thread::spawn(move || {
*n.lock().unwrap() += 1;
}));
}
for handle in handles {
handle.join().unwrap();
}
}let n = 0;
n += 1;
console.log(n);from threading import Lock, Thread
n = 0
lock = Lock()
def bump() -> None:
global n
with lock:
n += 1
threads = [Thread(target=bump) for _ in range(4)]
for t in threads:
t.start()
for t in threads:
t.join()
print(n)Rust requires a Mutex (or atomics) for shared mutation across threads, plus Arc when threads must be 'static (thread::spawn). std::thread::scope threads can borrow &Mutex from the stack with no Arc, because the scope joins them before it returns. JavaScript shared mutation usually stays on one thread. Python still needs the lock for a compound update.
Pitfalls if you arrived from JavaScript
- Futures are lazy until awaited or spawned.
- The runtime is a crate, not the language.
- Do not await while a
std::sync::Mutexguard is held. spawnon a multi-thread runtime needs'static + Send.- Cancellation is dropping the future.
- Use
spawn_blockingfor sync or CPU work inside async.
Interview Q&A
Does async fn run on many threads?
Answer
An async fn returns a future that needs an executor (for example Tokio). tokio::spawn requires Send + 'static futures; futures that are not Send run on a LocalSet via spawn_local. A current-thread runtime never leaves one thread.
std Mutex or tokio Mutex?
Answer
The standard Mutex is fine when the guard does not cross an await. If you must hold the lock across an await, use the async mutex so the worker is not stuck.
What is Send?
Answer
The value may move to another thread. Rc is not Send. Arc of a Send and Sync inner value is.
What starts the work?
Answer
thread spawn starts an OS thread. await or spawn polls the future. Building the future does neither.
How do you cancel?
Answer
Drop the future or abort the join handle. There is no CancelledError to re-raise. Cleanup belongs in Drop.
Where does CPU work go?
Answer
Threads or rayon, or spawn_blocking if you are already on Tokio. Do not run it on the async worker.
How is this different from the JavaScript event loop?
Answer
The host loop starts a Promise when you create the chain. Rust will not poll until you ask, and it will not share a non-Send value across threads.
How is this different from Python asyncio?
Answer
asyncio is cooperative on one thread unless you add processes. Rust threads are real parallelism, and async is a separate runtime you select. rustc 1.98.1 checks Send. Tokio is the runtime crate.
Pitfalls
- A future that is built and dropped.
- CPU or blocking I/O on a Tokio worker.
- A std Mutex guard held across await.
- An Rc captured by a spawned task.
- Assuming async means parallel.
A handler awaits three HTTP calls and then sorts a large array in place. Say which part stays on Tokio and which part must leave the worker, and name the bound on the cache handle.