Up to now pulse has been polite and slow. It checks one endpoint, waits for the whole round trip, prints the result, then checks the next. With one service that's fine. With a dozen services each taking 80ms, a full sweep takes nearly a second, and the program spends 99% of that time doing nothing but waiting on sockets.
The fix is obvious: fire all the requests at once. The fix is also where, in every language I'd used before, I had eventually shipped a data race, two threads touching the same value, one of them writing, the bug appearing once a week in production and never on my machine.
So I braced for it. I wrote the concurrent version. And Rust refused to compile it, not with a vague warning, but with a precise explanation of the exact race I was about to create.
TL;DR: Rust's async model uses
Futures driven by a runtime like Tokio;.awaityields control while waiting on I/O so one thread can juggle thousands of in-flight requests. The moment you share mutable state across tasks, theSendandSyncmarker traits and the borrow checker force you to make it safe, usually withArc<Mutex<T>>(shared ownership + mutual exclusion) or by not sharing at all and passing ownership through a channel. The same ownership rules from Parts 1 and 2 make data races a compile error. That's "fearless concurrency," and it is not marketing.
The Series
- Part 1, Ownership and the borrow checker
- Part 2, Borrowing, lifetimes, and serde
- Part 3, Async, Tokio, and sharing state across tasks (you are here)
- Part 4, The TUI with ratatui, error handling, and RAII cleanup
- Part 5, Traits, iterators, zero-cost abstractions, and the release build
Going Async
A network request is mostly waiting. Async lets a single thread start a request, set it aside the instant it would block, and do other work until the response is ready. We swap the blocking reqwest client for the async one and add Tokio, the runtime that actually drives the waiting.
[dependencies]
reqwest = { version = "0.12", features = ["json"] }
tokio = { version = "1", features = ["full"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
futures = "0.3"use std::time::Instant;
#[derive(Debug)]
struct Probe {
url: String,
status: u16,
latency_ms: u128,
}
async fn check(url: String) -> Probe {
let start = Instant::now();
let status = match reqwest::get(&url).await {
Ok(resp) => resp.status().as_u16(),
Err(_) => 0, // 0 == unreachable, for now
};
Probe { url, status, latency_ms: start.elapsed().as_millis() }
}
#[tokio::main]
async fn main() {
let probe = check("https://httpbin.org/status/200".into()).await;
println!("{probe:?}");
}Two new pieces of syntax. async fn returns a Future: a value representing "work that will eventually produce a Probe." Crucially, a future is lazy: calling check(...) does nothing until something polls it. .await is that something, it runs the future, and at each point the future would block on I/O, it yields control back to the runtime so the thread can go do other work. #[tokio::main] is a macro that wraps main in a runtime that does the polling.
This single-request version isn't faster than blocking. The win shows up only when you have many futures in flight at once.
Concurrency for Free (Almost)
If the futures are independent, you can poll them all together. futures::future::join_all takes a collection of futures and drives them concurrently on one thread, finishing when the last one does.
use futures::future::join_all;
#[tokio::main]
async fn main() {
let urls = vec![
"https://httpbin.org/delay/0".to_string(),
"https://httpbin.org/status/200".to_string(),
"https://httpbin.org/status/503".to_string(),
// ...a dozen of these
];
let start = std::time::Instant::now();
let futures = urls.into_iter().map(check); // a future per url, not yet run
let probes: Vec<Probe> = join_all(futures).await;
println!("checked {} endpoints in {:?}", probes.len(), start.elapsed());
}The difference is not subtle.
| Strategy | 12 endpoints, ~80ms each | Wall-clock time |
|---|---|---|
Sequential (for url { check(url).await }) |
sum of all latencies | ~960 ms |
Concurrent (join_all) |
max of all latencies | ~120 ms |
Sequential: total time is the sum of all four, roughly 320ms. Concurrent: total time is the single slowest request, roughly 75ms. The thread juggles all four while each awaits I/O instead of waiting for one to fully finish before starting the next.
One thread, no extra OS threads spawned, an 8x speedup. This is the entire reason async exists: I/O-bound work where the bottleneck is waiting, not computing.
The Wall: Shared Mutable State
join_all is clean because the futures are independent and the results come back in one tidy Vec. Real monitors aren't always that tidy. I wanted each check to push its result into a shared collection as it finished, say, a running list the UI could read. So I spawned each check as its own task with tokio::spawn and tried to push into a shared Vec:
#[tokio::main]
async fn main() {
let mut probes: Vec<Probe> = Vec::new();
for url in urls {
tokio::spawn(async move {
let probe = check(url).await;
probes.push(probe); // <- here be dragons
});
}
}This does not compile, and the error is a small lecture:
error[E0373]: async block may outlive the current function, but it
borrows `probes`, which is owned by the current function
|
| probes.push(probe);
| ^^^^^^ `probes` is borrowed here
|
note: function requires argument type to outlive `'static`And if you try to fix it by capturing a &mut probes into every task, you collide with the rule from Part 2: you cannot hand out multiple mutable borrows of probes. tokio::spawn makes it worse on purpose, a spawned task may run on a different thread and may outlive the function that created it, so anything it captures must be safe to send to another thread and must live long enough. A plain &mut Vec<Probe> shared across threads is precisely a data race, and the compiler will not be talked into it.
This is the moment that converted me. In Go or Java I would have written this exact loop, it would have compiled, and it would have mostly worked, until two tasks pushed at the same instant and corrupted the vector's internal length, or a reallocation happened mid-push. Rust didn't let me get that far.
Send and Sync: The Traits That Police Threads
How does the compiler know? Two marker traits, automatically derived for almost every type:
Send: a type whose ownership can be safely transferred to another thread.Sync: a type that can be safely shared by reference (&T) across threads.
tokio::spawn requires the future you give it to be Send and 'static. A future that holds a &mut Vec<Probe> borrowing a local is neither, so it's rejected. The genius is that these traits are structural: a type is Send only if all its fields are Send. This is why Rc<T> (a single-threaded reference count) is not Send, its counter isn't atomic, so sharing it across threads would race the count, while Arc<T> (atomic reference count) is Send and Sync. The fix to a whole class of threading bugs is often literally swapping Rc for Arc, and the compiler tells you exactly when you need to.
Arc + Mutex: Shared Ownership, Safe Mutation
To share mutable state across tasks you need two things the compiler will accept:
- Shared ownership: more than one task needs to own the value, since any of them might be the last one alive. That's
Arc<T>: an Atomically Reference-Counted pointer. Cloning anArcdoesn't copy the data; it bumps an atomic counter and hands you another handle to the same value. When the lastArcdrops, the value drops. - Safe mutation:
Arc<T>only gives shared (&T) access, and you can't mutate through a shared reference.Mutex<T>bridges that: locking it returns an exclusive guard, and the type system guarantees only one task holds the guard at a time.
Together, Arc<Mutex<T>> is the canonical "shared mutable state across threads" type.
use std::sync::{Arc, Mutex};
#[tokio::main]
async fn main() {
let probes = Arc::new(Mutex::new(Vec::<Probe>::new()));
let mut handles = Vec::new();
for url in urls {
let probes = Arc::clone(&probes); // a new handle to the SAME vec
handles.push(tokio::spawn(async move {
let probe = check(url).await;
probes.lock().unwrap().push(probe); // lock, push, unlock
}));
}
for h in handles {
h.await.unwrap(); // wait for every task
}
let probes = probes.lock().unwrap();
println!("collected {} probes", probes.len());
}Arc::clone(&probes) is cheap and explicit, it's an atomic increment, and Rust makes you write it so you never accidentally deep-copy. Each task owns its own handle; the Vec lives until the last handle drops. probes.lock() blocks until this task has exclusive access, hands back a guard, and, RAII again, straight from Part 1, the lock releases automatically when the guard goes out of scope at the end of the push. You cannot forget to unlock.
Three owners, one value. Only the lock-holder (B) mutates; C blocks until B's guard drops. The compiler guarantees no two writers at once.
A real footgun worth naming: holding a standard
std::sync::Mutexguard across an.awaitcan deadlock a Tokio task, because the task may be parked while still holding the lock. Lock, mutate, and drop the guard before you await, as the code above does, or usetokio::sync::Mutex, which is designed to be held across await points.
The Cleaner Answer: Don't Share, Send
Arc<Mutex<T>> works, but every lock is a tiny point of contention, and shared mutable state is harder to reason about than code that doesn't have any. Often the better design is to not share at all: have each task own its result and hand it off through a channel. One task produces, one task consumes, and ownership moves cleanly between them, no locks.
use tokio::sync::mpsc;
#[tokio::main]
async fn main() {
let (tx, mut rx) = mpsc::channel::<Probe>(32);
for url in urls {
let tx = tx.clone(); // each producer gets a sender
tokio::spawn(async move {
let probe = check(url).await;
let _ = tx.send(probe).await; // ownership of probe MOVES into the channel
});
}
drop(tx); // drop the original so the channel closes when producers finish
let mut probes = Vec::new();
while let Some(probe) = rx.recv().await { // single consumer collects them
probes.push(probe);
}
println!("collected {} probes", probes.len());
}Each Probe has exactly one owner the entire time, it's created in a task, then moved into the channel, then moved out to the consumer. There is no moment where two threads can touch it, so there is nothing to lock and nothing to race. This is the Rust restatement of an old concurrency proverb: don't communicate by sharing memory; share memory by communicating. For pulse, this is the model we'll keep, worker tasks probe endpoints and stream results to the part of the program that owns the screen.
| Pattern | Use it when | Cost |
|---|---|---|
join_all |
Fixed batch, want all results together | Simplest, but waits for the whole batch |
Arc<Mutex<T>> |
Many tasks mutating one shared structure | Lock contention; easy to misuse across .await |
Channel (mpsc) |
Producers generate values a consumer collects | A little setup; usually the cleanest design |
Why This Felt Like Magic
Here's the thing that reframed Rust for me. The rules that made Parts 1 and 2 a fight, one owner, shared XOR mutable, no reference outlives its referent, are exactly the rules you need for safe concurrency. A data race is, definitionally, two threads accessing one value with at least one writing and no synchronization. That is shared-and-mutable-at-once across threads, which the borrow checker already forbids. So the compiler that nagged you about printing a variable twice is the same compiler that, for free, proves your concurrent code has no data races.
Other languages bolt thread-safety on as a runtime concern: locks you remember to take, race detectors you run in CI and hope catch it, conventions in a wiki. Rust folds it into the type system you were already fighting. You don't get fewer concurrency bugs because you're more careful. You get fewer because the careless version doesn't build.
Where We Are
pulse can now sweep a whole fleet of endpoints concurrently and stream the results back without a single lock-related bug being possible:
- Async turns I/O waiting into concurrency on a single thread;
.awaityields, the runtime juggles. Send/Syncare the marker traits the compiler uses to police what crosses thread boundaries, and whyArcworks whereRcdoesn't.Arc<Mutex<T>>is shared ownership plus mutual exclusion for when you genuinely must share.- Channels move ownership between tasks so you often don't have to share at all.
- The ownership rules from earlier are the data-race guarantees. Same rules, free safety.
We have a fast, correct engine. What we don't have is anything to look at, it's all println!. Time to build the actual terminal interface: a live, refreshing dashboard. That means raw terminal mode, an event loop, rendering widgets, and the genuinely hard question of how to guarantee the terminal gets restored even if the program crashes mid-draw. Error handling and RAII are about to do real work.
Next: Part 4, The TUI with ratatui, error handling, and RAII cleanup.
Hiring or have a project?
Let's build something that holds.
Full-stack engineering, system design, and legacy modernization. Available for freelance, contract, and full-time roles.