Lesson 27 of 44
Workshop 4: Concurrency Patterns
Each project starts with a problem and requirements, then grows through stages. Every stage is a complete program that compiles and runs. The last stage is the full program, followed by a table of its parts and extension tasks.
The projects use only earlier lessons: JoinSet and Structured Concurrency, Graceful Shutdown, Backpressure and Load Shedding, Actors, Worker Pools, and Pipelines, Semaphore, Notify, Barrier, and OnceCell, oneshot, broadcast, and watch, TCP Clients and Servers, and Time: sleep, timeout, and interval.
Projects 15, 17, 18, and 20 run under the paused clock, so their times are exact. Projects 16 and 19 use real sockets and real (short) timers. Their output is fixed by waiting for events (a reply, a watch change) and never by sleeping for a guessed time.
Project 15: Bounded-concurrency crawler
A crawler fetches a list of pages. Fetching every page at once overloads the remote site, so at most three fetches may run at the same time. The fetch is simulated with a sleep, and one page fails.
Requirements:
- Every page is fetched in its own task, collected in a
JoinSet. - At most
limitfetches run at once, enforced by aSemaphore. - Results print in the order they finish, with the time since the start.
- Two error policies: collect every error, or stop at the first one and cancel the rest.
- The program reports the peak number of fetches that ran at once.
Stage 1: All pages at once
Each page becomes a task. join_next returns results in completion order. The page delays are all different, so the order is fixed.
use std::time::Duration;
use tokio::task::JoinSet;
use tokio::time::{Instant, sleep};
const PAGES: [(&str, u64); 7] = [
("/home", 120), ("/about", 80), ("/blog", 200), ("/blog/1", 60),
("/blog/2", 90), ("/contact", 150), ("/faq", 70),
];
async fn fetch(path: &str, ms: u64) -> Result<usize, String> {
sleep(Duration::from_millis(ms)).await;
if path == "/about" { Err(format!("{path}: timeout")) } else { Ok(path.len() * 100) }
}
#[tokio::main(flavor = "current_thread", start_paused = true)]
async fn main() {
let start = Instant::now();
let mut set = JoinSet::new();
for (path, ms) in PAGES {
set.spawn(async move { (path, fetch(path, ms).await) });
}
while let Some(joined) = set.join_next().await {
let (path, result) = joined.unwrap();
println!("[{:>3} ms] {path} -> {result:?}", start.elapsed().as_millis());
}
}[ 60 ms] /blog/1 -> Ok(700)
[ 70 ms] /faq -> Ok(400)
[ 80 ms] /about -> Err("/about: timeout")
[ 90 ms] /blog/2 -> Ok(700)
[120 ms] /home -> Ok(500)
[150 ms] /contact -> Ok(800)
[200 ms] /blog -> Ok(500)All seven fetches start at time 0 and finish at their own delay. Nothing limits them.
Stage 2: Limit with a semaphore
Each task first acquires a permit and holds it while it fetches. With three permits, a fourth task waits until one finishes. Two counters record how many fetches are active and the highest value seen.
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering::SeqCst};
use std::time::Duration;
use tokio::sync::Semaphore;
use tokio::task::JoinSet;
use tokio::time::{Instant, sleep};
const PAGES: [(&str, u64); 7] = [
("/home", 120), ("/about", 80), ("/blog", 200), ("/blog/1", 60),
("/blog/2", 90), ("/contact", 150), ("/faq", 70),
];
async fn fetch(path: &str, ms: u64) -> Result<usize, String> {
sleep(Duration::from_millis(ms)).await;
if path == "/about" { Err(format!("{path}: timeout")) } else { Ok(path.len() * 100) }
}
#[tokio::main(flavor = "current_thread", start_paused = true)]
async fn main() {
let start = Instant::now();
let sem = Arc::new(Semaphore::new(3));
let (active, peak) = (Arc::new(AtomicUsize::new(0)), Arc::new(AtomicUsize::new(0)));
let mut set = JoinSet::new();
for (path, ms) in PAGES {
let (sem, active, peak) = (sem.clone(), active.clone(), peak.clone());
set.spawn(async move {
let _permit = sem.acquire().await.unwrap();
peak.fetch_max(active.fetch_add(1, SeqCst) + 1, SeqCst);
let result = fetch(path, ms).await;
active.fetch_sub(1, SeqCst);
(path, result)
});
}
while let Some(joined) = set.join_next().await {
let (path, result) = joined.unwrap();
println!("[{:>3} ms] {path} -> {result:?}", start.elapsed().as_millis());
}
println!("peak in flight: {}", peak.load(SeqCst));
}[ 80 ms] /about -> Err("/about: timeout")
[120 ms] /home -> Ok(500)
[140 ms] /blog/1 -> Ok(700)
[200 ms] /blog -> Ok(500)
[210 ms] /blog/2 -> Ok(700)
[270 ms] /faq -> Ok(400)
[290 ms] /contact -> Ok(800)
peak in flight: 3The semaphore hands out permits in the order tasks asked for them, so /blog/1 starts when /about finishes at 80 ms. The peak is 3, never 7.
Stage 3: Error policy
crawl takes the policy as an argument. Under FailFast, the first error calls abort_all. Aborted tasks come back from join_next as cancelled JoinErrors, and the loop counts them. This is the full program.
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering::SeqCst};
use std::time::Duration;
use tokio::sync::Semaphore;
use tokio::task::JoinSet;
use tokio::time::{Instant, sleep};
const PAGES: [(&str, u64); 7] = [
("/home", 120), ("/about", 80), ("/blog", 200), ("/blog/1", 60),
("/blog/2", 90), ("/contact", 150), ("/faq", 70),
];
#[derive(Debug, Clone, Copy)]
enum Policy { CollectAll, FailFast }
async fn fetch(path: &str, ms: u64) -> Result<usize, String> {
sleep(Duration::from_millis(ms)).await;
if path == "/about" { Err(format!("{path}: timeout")) } else { Ok(path.len() * 100) }
}
async fn crawl(policy: Policy, limit: usize) {
let start = Instant::now();
let sem = Arc::new(Semaphore::new(limit));
let (active, peak) = (Arc::new(AtomicUsize::new(0)), Arc::new(AtomicUsize::new(0)));
let mut set = JoinSet::new();
for (path, ms) in PAGES {
let (sem, active, peak) = (sem.clone(), active.clone(), peak.clone());
set.spawn(async move {
let _permit = sem.acquire().await.unwrap();
peak.fetch_max(active.fetch_add(1, SeqCst) + 1, SeqCst);
let result = fetch(path, ms).await;
active.fetch_sub(1, SeqCst);
(path, result)
});
}
let (mut ok, mut errors, mut cancelled) = (0, 0, 0);
while let Some(joined) = set.join_next().await {
let at = start.elapsed().as_millis();
match joined {
Ok((path, Ok(size))) => { ok += 1; println!("[{at:>3} ms] {path} -> {size} bytes"); }
Ok((_, Err(e))) => {
errors += 1;
println!("[{at:>3} ms] error: {e}");
if matches!(policy, Policy::FailFast) { set.abort_all(); }
}
Err(e) if e.is_cancelled() => cancelled += 1,
Err(e) => panic!("task failed: {e}"),
}
}
println!("{policy:?}: ok={ok} errors={errors} cancelled={cancelled} peak={}", peak.load(SeqCst));
}
#[tokio::main(flavor = "current_thread", start_paused = true)]
async fn main() {
crawl(Policy::CollectAll, 3).await;
println!("---");
crawl(Policy::FailFast, 3).await;
}[ 80 ms] error: /about: timeout
[120 ms] /home -> 500 bytes
[140 ms] /blog/1 -> 700 bytes
[200 ms] /blog -> 500 bytes
[210 ms] /blog/2 -> 700 bytes
[270 ms] /faq -> 400 bytes
[290 ms] /contact -> 800 bytes
CollectAll: ok=6 errors=1 cancelled=0 peak=3
---
[ 80 ms] error: /about: timeout
FailFast: ok=0 errors=1 cancelled=6 peak=3CollectAll reports six pages and one error. FailFast stops at 80 ms: the two fetches in progress and the four waiting ones are cancelled, so the run ends after one error.
| Part | Role |
|---|---|
Semaphore |
limits concurrent fetches; permits are released when _permit drops |
JoinSet |
owns the tasks; join_next yields results as they finish |
Policy |
chooses between collecting errors and cancelling on the first |
abort_all |
cancels every task in the set, including tasks waiting for a permit |
peak counter |
proves the limit held |
Extension tasks:
- Retry each failed page once before counting it as an error.
- Give each fetch a
timeoutof 100 ms and count timeouts separately. - Replace the fixed list with a queue: each fetched page adds new paths, and the crawler stops when the queue and the set are both empty.
Project 16: Graceful-shutdown server
A line-based TCP server must stop cleanly: stop accepting, let work in progress finish, and give up on work that takes too long.
Requirements:
hellostyle lines are answered withecho <line>.WORK <ms>repliesSTARTED, works for that many milliseconds, then repliesDONE <ms>.- One task per connection, counted by a
TaskTracker. - Shutdown stops the accept loop, closes idle connections, and lets running
WORKfinish. - After a drain deadline, a second token cancels the work that is still running.
Stage 1: Stop accepting and close idle connections
The accept loop and each connection use select! on the token. next_line is cancel safe, so a cancelled read loses no data. The client type wraps a connection so later stages can reuse it.
use std::net::SocketAddr;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader, Lines};
use tokio::net::tcp::{OwnedReadHalf, OwnedWriteHalf};
use tokio::net::{TcpListener, TcpStream};
use tokio_util::sync::CancellationToken;
use tokio_util::task::TaskTracker;
async fn handle(stream: TcpStream, stop: CancellationToken) {
let (reader, mut writer) = stream.into_split();
let mut lines = BufReader::new(reader).lines();
loop {
tokio::select! {
_ = stop.cancelled() => break,
line = lines.next_line() => {
let Ok(Some(line)) = line else { break };
writer.write_all(format!("echo {line}\n").as_bytes()).await.unwrap();
}
}
}
}
async fn serve(listener: TcpListener, stop: CancellationToken, tracker: TaskTracker) {
loop {
tokio::select! {
_ = stop.cancelled() => break,
accepted = listener.accept() => {
let (stream, _) = accepted.unwrap();
tracker.spawn(handle(stream, stop.clone()));
}
}
}
}
struct Client { lines: Lines<BufReader<OwnedReadHalf>>, writer: OwnedWriteHalf }
impl Client {
async fn connect(addr: SocketAddr) -> Client {
let (reader, writer) = TcpStream::connect(addr).await.unwrap().into_split();
Client { lines: BufReader::new(reader).lines(), writer }
}
async fn send(&mut self, line: &str) {
self.writer.write_all(format!("{line}\n").as_bytes()).await.unwrap();
}
async fn recv(&mut self) -> Option<String> {
self.lines.next_line().await.ok().flatten()
}
}
#[tokio::main]
async fn main() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let (stop, tracker) = (CancellationToken::new(), TaskTracker::new());
let server = tokio::spawn(serve(listener, stop.clone(), tracker.clone()));
let mut client = Client::connect(addr).await;
client.send("hello").await;
println!("client got: {:?}", client.recv().await);
println!("shutdown: stop accepting");
stop.cancel();
server.await.unwrap();
tracker.close();
tracker.wait().await;
println!("drained, open connections: {}", tracker.len());
println!("client sees: {:?}", client.recv().await);
}client got: Some("echo hello")
shutdown: stop accepting
drained, open connections: 0
client sees: NoneThe server task is awaited first, so no new connection can join the tracker. Then close and wait return once every handler has exited. The idle client sees the end of its stream.
Stage 2: Let running work finish
WORK runs outside the select!, so cancelling the token does not interrupt it. The handler checks the token again on the next loop turn and exits then. The client waits for STARTED before shutdown begins, which fixes the order.
use std::net::SocketAddr;
use std::time::Duration;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader, Lines};
use tokio::net::tcp::{OwnedReadHalf, OwnedWriteHalf};
use tokio::net::{TcpListener, TcpStream};
use tokio::time::sleep;
use tokio_util::sync::CancellationToken;
use tokio_util::task::TaskTracker;
async fn handle(stream: TcpStream, stop: CancellationToken) {
let (reader, mut writer) = stream.into_split();
let mut lines = BufReader::new(reader).lines();
loop {
tokio::select! {
_ = stop.cancelled() => break,
line = lines.next_line() => {
let Ok(Some(line)) = line else { break };
let reply = if let Some(ms) = line.strip_prefix("WORK ") {
writer.write_all(b"STARTED\n").await.unwrap();
let ms: u64 = ms.parse().unwrap();
sleep(Duration::from_millis(ms)).await;
format!("DONE {ms}\n")
} else {
format!("echo {line}\n")
};
writer.write_all(reply.as_bytes()).await.unwrap();
}
}
}
}
async fn serve(listener: TcpListener, stop: CancellationToken, tracker: TaskTracker) {
loop {
tokio::select! {
_ = stop.cancelled() => break,
accepted = listener.accept() => {
let (stream, _) = accepted.unwrap();
tracker.spawn(handle(stream, stop.clone()));
}
}
}
}
struct Client { lines: Lines<BufReader<OwnedReadHalf>>, writer: OwnedWriteHalf }
impl Client {
async fn connect(addr: SocketAddr) -> Client {
let (reader, writer) = TcpStream::connect(addr).await.unwrap().into_split();
Client { lines: BufReader::new(reader).lines(), writer }
}
async fn send(&mut self, line: &str) {
self.writer.write_all(format!("{line}\n").as_bytes()).await.unwrap();
}
async fn recv(&mut self) -> Option<String> {
self.lines.next_line().await.ok().flatten()
}
}
#[tokio::main]
async fn main() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let (stop, tracker) = (CancellationToken::new(), TaskTracker::new());
let server = tokio::spawn(serve(listener, stop.clone(), tracker.clone()));
let mut worker = Client::connect(addr).await;
worker.send("WORK 100").await;
println!("worker: {:?}", worker.recv().await);
println!("shutdown: stop accepting");
stop.cancel();
server.await.unwrap();
tracker.close();
tracker.wait().await;
println!("drained, open connections: {}", tracker.len());
println!("worker: {:?}", worker.recv().await);
println!("worker: {:?}", worker.recv().await);
}worker: Some("STARTED")
shutdown: stop accepting
drained, open connections: 0
worker: Some("DONE 100")
worker: Nonewait returned only after DONE 100 was written. The reply is still in the socket buffer, so the client reads it after the server has finished. The next read sees the end of the stream.
Stage 3: Drain deadline and hard stop
A second token, hard, cancels work that outlives the deadline. run_until_cancelled returns None when the token fires first, and the handler then drops the connection. timeout around tracker.wait() is the deadline. This is the full program.
use std::net::SocketAddr;
use std::time::Duration;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader, Lines};
use tokio::net::tcp::{OwnedReadHalf, OwnedWriteHalf};
use tokio::net::{TcpListener, TcpStream};
use tokio::time::{sleep, timeout};
use tokio_util::sync::CancellationToken;
use tokio_util::task::TaskTracker;
async fn handle(stream: TcpStream, stop: CancellationToken, hard: CancellationToken) {
let (reader, mut writer) = stream.into_split();
let mut lines = BufReader::new(reader).lines();
loop {
tokio::select! {
_ = stop.cancelled() => break,
line = lines.next_line() => {
let Ok(Some(line)) = line else { break };
let reply = if let Some(ms) = line.strip_prefix("WORK ") {
writer.write_all(b"STARTED\n").await.unwrap();
let ms: u64 = ms.parse().unwrap();
if hard.run_until_cancelled(sleep(Duration::from_millis(ms))).await.is_none() {
break;
}
format!("DONE {ms}\n")
} else {
format!("echo {line}\n")
};
writer.write_all(reply.as_bytes()).await.unwrap();
}
}
}
}
async fn serve(
listener: TcpListener,
stop: CancellationToken,
hard: CancellationToken,
tracker: TaskTracker,
) {
loop {
tokio::select! {
_ = stop.cancelled() => break,
accepted = listener.accept() => {
let (stream, _) = accepted.unwrap();
tracker.spawn(handle(stream, stop.clone(), hard.clone()));
}
}
}
}
struct Client { lines: Lines<BufReader<OwnedReadHalf>>, writer: OwnedWriteHalf }
impl Client {
async fn connect(addr: SocketAddr) -> Client {
let (reader, writer) = TcpStream::connect(addr).await.unwrap().into_split();
Client { lines: BufReader::new(reader).lines(), writer }
}
async fn send(&mut self, line: &str) {
self.writer.write_all(format!("{line}\n").as_bytes()).await.unwrap();
}
async fn recv(&mut self) -> Option<String> {
self.lines.next_line().await.ok().flatten()
}
}
#[tokio::main]
async fn main() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let (stop, hard, tracker) = (CancellationToken::new(), CancellationToken::new(), TaskTracker::new());
let server = tokio::spawn(serve(listener, stop.clone(), hard.clone(), tracker.clone()));
let (mut quick, mut stuck, mut idle) =
(Client::connect(addr).await, Client::connect(addr).await, Client::connect(addr).await);
quick.send("WORK 100").await;
stuck.send("WORK 60000").await;
idle.send("hi").await;
println!("quick: {:?}", quick.recv().await);
println!("stuck: {:?}", stuck.recv().await);
println!("idle: {:?}", idle.recv().await);
println!("shutdown: stop accepting");
stop.cancel();
server.await.unwrap();
tracker.close();
if timeout(Duration::from_millis(300), tracker.wait()).await.is_err() {
println!("deadline passed, still busy: {}", tracker.len());
hard.cancel();
tracker.wait().await;
}
println!("drained, open connections: {}", tracker.len());
println!("quick: {:?}", quick.recv().await);
println!("quick: {:?}", quick.recv().await);
println!("stuck: {:?}", stuck.recv().await);
println!("idle: {:?}", idle.recv().await);
}quick: Some("STARTED")
stuck: Some("STARTED")
idle: Some("echo hi")
shutdown: stop accepting
deadline passed, still busy: 1
drained, open connections: 0
quick: Some("DONE 100")
quick: None
stuck: None
idle: NoneThe idle connection closes at once and quick finishes within the deadline. The 60-second job does not, so the deadline fires with one connection busy. Cancelling hard ends it and the client sees the stream close without DONE.
| Part | Role |
|---|---|
stop token |
tells the accept loop and idle connections to exit |
hard token |
cancels work still running after the deadline |
TaskTracker |
counts connection tasks; close plus wait is the drain |
timeout around wait |
the drain deadline |
run_until_cancelled |
makes one await point cancellable by hard |
Extension tasks:
- Send a
BYEline from the server to each idle client before it closes. - Make the deadline a command-line style argument and test a zero deadline.
- Add a
SHUTDOWNcommand that any client can send to start the sequence.
Project 17: Cron-style scheduler
A scheduler runs named jobs at fixed periods. Jobs run at the same time, a long job must not pile up runs, and the scheduler stops without cutting a run in half.
Requirements:
- Each job has a name, a period, and a run time.
- Every job loops on its own
interval, first firing one period after the start. - A job that is still running when its next tick arrives skips that tick.
- Stopping lets a run in progress finish, then reports how many runs each job made.
Stage 1: One job on an interval
interval_at sets the first tick to one period after the start. The paused clock makes each line's time exact.
use std::time::Duration;
use tokio::time::{Instant, interval_at, sleep};
#[tokio::main(flavor = "current_thread", start_paused = true)]
async fn main() {
let start = Instant::now();
let period = Duration::from_millis(300);
let mut ticker = interval_at(start + period, period);
for _ in 0..3 {
ticker.tick().await;
println!("[{:>4} ms] report starts", start.elapsed().as_millis());
sleep(Duration::from_millis(50)).await;
}
}[ 300 ms] report starts
[ 600 ms] report starts
[ 900 ms] report startsStage 2: Several jobs in a JoinSet
Each job is a function, and a JoinSet runs all of them. The tasks return (name, runs). Jobs finish at different times, so the results are sorted by name before printing.
use std::time::Duration;
use tokio::task::JoinSet;
use tokio::time::{Instant, interval_at, sleep};
async fn job(name: &'static str, period_ms: u64, work_ms: u64, rounds: u32, start: Instant) -> (&'static str, u32) {
let period = Duration::from_millis(period_ms);
let mut ticker = interval_at(start + period, period);
for _ in 0..rounds {
ticker.tick().await;
println!("[{:>4} ms] {name} starts", start.elapsed().as_millis());
sleep(Duration::from_millis(work_ms)).await;
}
(name, rounds)
}
#[tokio::main(flavor = "current_thread", start_paused = true)]
async fn main() {
let start = Instant::now();
let mut set = JoinSet::new();
set.spawn(job("report", 300, 50, 3, start));
set.spawn(job("cleanup", 470, 100, 2, start));
let mut done = Vec::new();
while let Some(joined) = set.join_next().await {
done.push(joined.unwrap());
}
done.sort();
println!("{done:?}");
}[ 300 ms] report starts
[ 470 ms] cleanup starts
[ 600 ms] report starts
[ 900 ms] report starts
[ 940 ms] cleanup starts
[("cleanup", 2), ("report", 3)]The two jobs interleave by time. Neither waits for the other.
Stage 3: Skip missed ticks and stop on a token
MissedTickBehavior::Skip drops ticks that came due while the job was still running. A CancellationToken replaces the fixed round count. The job checks it only between runs, so a run in progress always finishes. This is the full program.
use std::time::Duration;
use tokio::task::JoinSet;
use tokio::time::{Instant, MissedTickBehavior, interval_at, sleep};
use tokio_util::sync::CancellationToken;
async fn job(
name: &'static str,
period_ms: u64,
work_ms: u64,
start: Instant,
stop: CancellationToken,
) -> (&'static str, u32, u128) {
let period = Duration::from_millis(period_ms);
let mut ticker = interval_at(start + period, period);
ticker.set_missed_tick_behavior(MissedTickBehavior::Skip);
let mut runs = 0;
loop {
tokio::select! {
_ = stop.cancelled() => break,
_ = ticker.tick() => {}
}
println!("[{:>4} ms] {name} starts", start.elapsed().as_millis());
sleep(Duration::from_millis(work_ms)).await;
runs += 1;
}
(name, runs, start.elapsed().as_millis())
}
#[tokio::main(flavor = "current_thread", start_paused = true)]
async fn main() {
let start = Instant::now();
let stop = CancellationToken::new();
let mut set = JoinSet::new();
for (name, period, work) in [("report", 300, 50), ("cleanup", 470, 100), ("backup", 400, 650)] {
set.spawn(job(name, period, work, start, stop.clone()));
}
sleep(Duration::from_millis(1000)).await;
println!("[{:>4} ms] stop requested", start.elapsed().as_millis());
stop.cancel();
let mut done = Vec::new();
while let Some(joined) = set.join_next().await {
done.push(joined.unwrap());
}
done.sort();
for (name, runs, ended) in done {
println!("{name}: {runs} runs, exited at {ended} ms");
}
}[ 300 ms] report starts
[ 400 ms] backup starts
[ 470 ms] cleanup starts
[ 600 ms] report starts
[ 900 ms] report starts
[ 940 ms] cleanup starts
[1000 ms] stop requested
backup: 1 runs, exited at 1050 ms
cleanup: 2 runs, exited at 1040 ms
report: 3 runs, exited at 1000 msbackup starts at 400 ms and runs for 650 ms. Its tick at 800 ms comes due during the run and is skipped, so its next tick would be at 1200 ms. The stop request at 1000 ms arrives during the run, which ends at 1050 ms. The other two jobs are idle at 1000 ms and exit at once.
| Part | Role |
|---|---|
interval_at |
first tick one period after the start, so no job fires at time 0 |
MissedTickBehavior::Skip |
avoids catch-up runs after a slow run |
select! on token and tick |
stops between runs, never during one |
JoinSet |
runs the jobs and returns their run counts |
| paused clock | makes every printed time exact |
Extension tasks:
- Add a
jitterof a few milliseconds to each period, computed from the job name so the output stays fixed. - Compare
SkipwithBurstandDelayforbackupand print the start times. - Add a job that is removed while running by aborting its
AbortHandle, and report what the abort lost.
Project 18: Async connection pool
A pool keeps a fixed number of expensive connections and lends them out. A caller waits when all are in use, and a borrowed connection returns to the pool by itself when the caller is done with it.
Requirements:
- The pool has a maximum size. Connections are created on demand, up to that size.
getwaits for a free connection.get_timeoutgives up after a deadline.- The borrowed value is an RAII guard: dropping it returns the connection.
- A guard can mark its connection broken. The pool discards it and creates a replacement later.
Stage 1: Guard that returns the connection
The idle list is a std::sync::Mutex<Vec<Conn>>. Its critical sections are short and never cross an .await. The semaphore counts free slots. Drop pushes the connection back before the _permit field drops, because fields drop after the drop method runs.
use std::ops::Deref;
use std::sync::atomic::{AtomicUsize, Ordering::SeqCst};
use std::sync::{Arc, Mutex};
use tokio::sync::{OwnedSemaphorePermit, Semaphore};
struct Conn { id: usize }
struct Pool {
idle: Mutex<Vec<Conn>>,
slots: Arc<Semaphore>,
created: AtomicUsize,
}
struct PooledConn { conn: Option<Conn>, pool: Arc<Pool>, _permit: OwnedSemaphorePermit }
impl Pool {
fn new(size: usize) -> Arc<Pool> {
Arc::new(Pool { idle: Mutex::new(Vec::new()), slots: Arc::new(Semaphore::new(size)), created: AtomicUsize::new(0) })
}
async fn get(self: &Arc<Self>) -> PooledConn {
let permit = self.slots.clone().acquire_owned().await.unwrap();
let reused = self.idle.lock().unwrap().pop();
let conn = reused.unwrap_or_else(|| Conn { id: self.created.fetch_add(1, SeqCst) });
PooledConn { conn: Some(conn), pool: self.clone(), _permit: permit }
}
}
impl Deref for PooledConn {
type Target = Conn;
fn deref(&self) -> &Conn { self.conn.as_ref().unwrap() }
}
impl Drop for PooledConn {
fn drop(&mut self) {
if let Some(conn) = self.conn.take() {
println!(" returning conn {}", conn.id);
self.pool.idle.lock().unwrap().push(conn);
}
}
}
#[tokio::main(flavor = "current_thread")]
async fn main() {
let pool = Pool::new(2);
let a = pool.get().await;
println!("got conn {}", a.id);
drop(a);
let b = pool.get().await;
let c = pool.get().await;
println!("got conn {} and conn {}", b.id, c.id);
println!("created: {}", pool.created.load(SeqCst));
}got conn 0
returning conn 0
got conn 0 and conn 1
created: 2
returning conn 1
returning conn 0The first connection is reused after it is returned. Only when two are held at once does the pool create a second. At the end of main, c then b drop and return their connections.
Stage 2: Callers wait for a free connection
Five tasks share a pool of two. Each holds its connection for a different time, so the schedule is fixed under the paused clock. Tasks 2 to 4 wait until a slot frees.
use std::ops::Deref;
use std::sync::atomic::{AtomicUsize, Ordering::SeqCst};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use tokio::sync::{OwnedSemaphorePermit, Semaphore};
use tokio::time::{Instant, sleep};
struct Conn { id: usize }
struct Pool {
idle: Mutex<Vec<Conn>>,
slots: Arc<Semaphore>,
created: AtomicUsize,
}
struct PooledConn { conn: Option<Conn>, pool: Arc<Pool>, _permit: OwnedSemaphorePermit }
impl Pool {
fn new(size: usize) -> Arc<Pool> {
Arc::new(Pool { idle: Mutex::new(Vec::new()), slots: Arc::new(Semaphore::new(size)), created: AtomicUsize::new(0) })
}
async fn get(self: &Arc<Self>) -> PooledConn {
let permit = self.slots.clone().acquire_owned().await.unwrap();
let reused = self.idle.lock().unwrap().pop();
let conn = reused.unwrap_or_else(|| Conn { id: self.created.fetch_add(1, SeqCst) });
PooledConn { conn: Some(conn), pool: self.clone(), _permit: permit }
}
}
impl Deref for PooledConn {
type Target = Conn;
fn deref(&self) -> &Conn { self.conn.as_ref().unwrap() }
}
impl Drop for PooledConn {
fn drop(&mut self) {
if let Some(conn) = self.conn.take() {
self.pool.idle.lock().unwrap().push(conn);
}
}
}
#[tokio::main(flavor = "current_thread", start_paused = true)]
async fn main() {
let start = Instant::now();
let pool = Pool::new(2);
let mut tasks = Vec::new();
for i in 0..5u64 {
let pool = pool.clone();
tasks.push(tokio::spawn(async move {
let conn = pool.get().await;
println!("[{:>3} ms] task {i} uses conn {}", start.elapsed().as_millis(), conn.id);
sleep(Duration::from_millis(100 + 10 * i)).await;
}));
}
for task in tasks {
task.await.unwrap();
}
println!("created: {}", pool.created.load(SeqCst));
}[ 0 ms] task 0 uses conn 0
[ 0 ms] task 1 uses conn 1
[100 ms] task 2 uses conn 0
[110 ms] task 3 uses conn 1
[220 ms] task 4 uses conn 0
created: 2Five tasks used two connections. created stays at 2 because a returned connection is reused before a new one is made.
Stage 3: Timeout and broken connections
get_timeout wraps get in timeout. The dropped get future releases its place in the semaphore queue, so a timed-out caller leaks nothing. discard marks a guard so that Drop does not return the connection. Its permit still frees the slot, and the next get creates a new connection. This is the full program.
use std::ops::Deref;
use std::sync::atomic::{AtomicUsize, Ordering::SeqCst};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use tokio::sync::{OwnedSemaphorePermit, Semaphore};
use tokio::time::{Instant, sleep, timeout};
struct Conn { id: usize }
struct Pool {
idle: Mutex<Vec<Conn>>,
slots: Arc<Semaphore>,
created: AtomicUsize,
}
struct PooledConn { conn: Option<Conn>, pool: Arc<Pool>, _permit: OwnedSemaphorePermit }
impl Pool {
fn new(size: usize) -> Arc<Pool> {
Arc::new(Pool { idle: Mutex::new(Vec::new()), slots: Arc::new(Semaphore::new(size)), created: AtomicUsize::new(0) })
}
async fn get(self: &Arc<Self>) -> PooledConn {
let permit = self.slots.clone().acquire_owned().await.unwrap();
let reused = self.idle.lock().unwrap().pop();
let conn = reused.unwrap_or_else(|| Conn { id: self.created.fetch_add(1, SeqCst) });
PooledConn { conn: Some(conn), pool: self.clone(), _permit: permit }
}
async fn get_timeout(self: &Arc<Self>, wait: Duration) -> Option<PooledConn> {
timeout(wait, self.get()).await.ok()
}
}
impl PooledConn {
fn discard(mut self) {
let conn = self.conn.take().unwrap();
println!(" discarding broken conn {}", conn.id);
}
}
impl Deref for PooledConn {
type Target = Conn;
fn deref(&self) -> &Conn { self.conn.as_ref().unwrap() }
}
impl Drop for PooledConn {
fn drop(&mut self) {
if let Some(conn) = self.conn.take() {
self.pool.idle.lock().unwrap().push(conn);
}
}
}
#[tokio::main(flavor = "current_thread", start_paused = true)]
async fn main() {
let start = Instant::now();
let pool = Pool::new(2);
let mut holders = Vec::new();
for _ in 0..2 {
let pool = pool.clone();
holders.push(tokio::spawn(async move {
let _conn = pool.get().await;
sleep(Duration::from_millis(200)).await;
}));
}
sleep(Duration::from_millis(1)).await;
let waited = pool.get_timeout(Duration::from_millis(50)).await;
println!("[{:>3} ms] get_timeout(50 ms): {}", start.elapsed().as_millis(), if waited.is_some() { "got one" } else { "gave up" });
let waited = pool.get_timeout(Duration::from_millis(500)).await.unwrap();
println!("[{:>3} ms] get_timeout(500 ms): conn {}", start.elapsed().as_millis(), waited.id);
waited.discard();
let fresh = pool.get().await;
println!("after discard: conn {}, created {}", fresh.id, pool.created.load(SeqCst));
println!("free slots while held: {}", pool.slots.available_permits());
for holder in holders {
holder.await.unwrap();
}
}[ 51 ms] get_timeout(50 ms): gave up
[200 ms] get_timeout(500 ms): conn 1
discarding broken conn 1
after discard: conn 0, created 2
free slots while held: 1Both connections are busy for 200 ms. The 50 ms wait gives up. The 500 ms wait succeeds at 201 ms and gets one of the released connections. After discard, the pool has one idle connection and one free slot. The next get takes the idle one, so no new connection is created yet.
| Part | Role |
|---|---|
Semaphore |
one permit per allowed connection; bounds waiting callers |
Mutex<Vec<Conn>> |
idle list, locked briefly and never across an .await |
PooledConn |
RAII guard: Deref to use it, Drop to return it |
| field order | _permit is last so the connection is back in the list before the slot frees |
get_timeout |
bounds the wait with timeout |
discard |
drops a broken connection instead of returning it |
Extension tasks:
- Add a health check on
get: if the idle connection reports itself closed, discard it and create a new one. - Record the maximum number of callers waiting at once, using an atomic counter around
acquire_owned. - Add
close, which callsSemaphore::closeso waiting callers get an error.
Project 19: TCP proxy with round-robin and health checks
A proxy accepts client connections and forwards each to one of several backends in turn. A health checker probes the backends and the proxy skips any that do not answer.
Requirements:
- Three backends answer
PINGwithPONGand any other line withb<N>: <line>. A backend can be switched into a hung state where it accepts connections but never replies. - The proxy picks backends round-robin among the healthy ones and forwards bytes with
copy_bidirectional. - A health task probes every backend on an interval. Each probe has a timeout. The result is published on a
watchchannel. - The proxy drops a client immediately when no backend is healthy.
Stage 1: Round-robin proxy
Each client connection is forwarded to addrs[n % 3]. The client helper sends one line and reads one reply.
use std::net::SocketAddr;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader, copy_bidirectional};
use tokio::net::{TcpListener, TcpStream};
async fn backend(name: usize, listener: TcpListener) {
loop {
let (stream, _) = listener.accept().await.unwrap();
tokio::spawn(async move {
let (reader, mut writer) = stream.into_split();
let mut lines = BufReader::new(reader).lines();
while let Ok(Some(line)) = lines.next_line().await {
if writer.write_all(format!("b{name}: {line}\n").as_bytes()).await.is_err() {
break;
}
}
});
}
}
async fn proxy(listener: TcpListener, addrs: Vec<SocketAddr>) {
let mut n = 0;
loop {
let (mut client, _) = listener.accept().await.unwrap();
let target = addrs[n % addrs.len()];
n += 1;
tokio::spawn(async move {
let mut upstream = TcpStream::connect(target).await.unwrap();
let _ = copy_bidirectional(&mut client, &mut upstream).await;
});
}
}
async fn ask(proxy: SocketAddr, msg: &str) -> String {
let mut stream = TcpStream::connect(proxy).await.unwrap();
stream.write_all(format!("{msg}\n").as_bytes()).await.unwrap();
let mut line = String::new();
BufReader::new(&mut stream).read_line(&mut line).await.unwrap();
if line.is_empty() { "(closed)".into() } else { line.trim().into() }
}
#[tokio::main]
async fn main() {
let mut addrs = Vec::new();
for name in 0..3 {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
addrs.push(listener.local_addr().unwrap());
tokio::spawn(backend(name, listener));
}
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let front = listener.local_addr().unwrap();
tokio::spawn(proxy(listener, addrs));
for i in 0..6 {
println!("request {i} -> {}", ask(front, &format!("hello {i}")).await);
}
}request 0 -> b0: hello 0
request 1 -> b1: hello 1
request 2 -> b2: hello 2
request 3 -> b0: hello 3
request 4 -> b1: hello 4
request 5 -> b2: hello 5Requests go to backends 0, 1, 2, 0, 1, 2. The proxy is a byte forwarder, so it does not parse the lines.
Stage 2: Health checks on a watch channel
The up flag simulates a hung backend: a hung backend reads lines and ignores them. probe sends PING and needs PONG within 50 ms. health runs probes every 20 ms and publishes the vector of results with send_if_modified, which notifies receivers only when the vector changed. The proxy reads the latest vector with borrow on each accept. wait_for in main waits for the health task to notice a change.
use std::net::SocketAddr;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering::SeqCst};
use std::time::Duration;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader, copy_bidirectional};
use tokio::net::{TcpListener, TcpStream};
use tokio::sync::watch;
use tokio::time::{interval, timeout};
async fn backend(name: usize, listener: TcpListener, up: Arc<AtomicBool>) {
loop {
let (stream, _) = listener.accept().await.unwrap();
let up = up.clone();
tokio::spawn(async move {
let (reader, mut writer) = stream.into_split();
let mut lines = BufReader::new(reader).lines();
while let Ok(Some(line)) = lines.next_line().await {
if !up.load(SeqCst) { continue; }
let reply = if line == "PING" { "PONG".to_string() } else { format!("b{name}: {line}") };
if writer.write_all(format!("{reply}\n").as_bytes()).await.is_err() { break; }
}
});
}
}
async fn probe(addr: SocketAddr) -> bool {
let attempt = async {
let mut stream = TcpStream::connect(addr).await.ok()?;
stream.write_all(b"PING\n").await.ok()?;
let mut line = String::new();
BufReader::new(&mut stream).read_line(&mut line).await.ok()?;
Some(line.trim() == "PONG")
};
timeout(Duration::from_millis(50), attempt).await.ok().flatten().unwrap_or(false)
}
async fn health(addrs: Vec<SocketAddr>, tx: watch::Sender<Vec<bool>>) {
let mut tick = interval(Duration::from_millis(20));
loop {
tick.tick().await;
let mut now = Vec::new();
for addr in &addrs {
now.push(probe(*addr).await);
}
tx.send_if_modified(|current| if *current != now { *current = now; true } else { false });
}
}
async fn proxy(listener: TcpListener, addrs: Vec<SocketAddr>, status: watch::Receiver<Vec<bool>>) {
let mut n = 0;
loop {
let (mut client, _) = listener.accept().await.unwrap();
let healthy: Vec<usize> = status.borrow().iter().enumerate().filter(|(_, up)| **up).map(|(i, _)| i).collect();
if healthy.is_empty() { continue; }
let target = addrs[healthy[n % healthy.len()]];
n += 1;
tokio::spawn(async move {
if let Ok(Ok(mut upstream)) = timeout(Duration::from_millis(100), TcpStream::connect(target)).await {
let _ = copy_bidirectional(&mut client, &mut upstream).await;
}
});
}
}
async fn ask(proxy: SocketAddr, msg: &str) -> String {
let mut stream = TcpStream::connect(proxy).await.unwrap();
stream.write_all(format!("{msg}\n").as_bytes()).await.unwrap();
let mut line = String::new();
BufReader::new(&mut stream).read_line(&mut line).await.unwrap();
if line.is_empty() { "(closed)".into() } else { line.trim().into() }
}
#[tokio::main]
async fn main() {
let flags: Vec<Arc<AtomicBool>> = (0..3).map(|_| Arc::new(AtomicBool::new(true))).collect();
let mut addrs = Vec::new();
for (name, up) in flags.iter().enumerate() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
addrs.push(listener.local_addr().unwrap());
tokio::spawn(backend(name, listener, up.clone()));
}
let (tx, rx) = watch::channel(vec![true; 3]);
tokio::spawn(health(addrs.clone(), tx));
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let front = listener.local_addr().unwrap();
tokio::spawn(proxy(listener, addrs, rx.clone()));
for i in 0..3 {
println!("request {i} -> {}", ask(front, &format!("hello {i}")).await);
}
flags[1].store(false, SeqCst);
let mut watcher = rx.clone();
watcher.wait_for(|status| !status[1]).await.unwrap();
println!("health: {:?}", *rx.borrow());
for i in 3..7 {
println!("request {i} -> {}", ask(front, &format!("hello {i}")).await);
}
}request 0 -> b0: hello 0
request 1 -> b1: hello 1
request 2 -> b2: hello 2
health: [true, false, true]
request 3 -> b2: hello 3
request 4 -> b0: hello 4
request 5 -> b2: hello 5
request 6 -> b0: hello 6After backend 1 hangs, the health task marks it down and the proxy alternates between backends 0 and 2. The client never waits on the hung backend, because it is not chosen.
Stage 3: Recovery and no healthy backend
The last stage adds two phases: backend 1 recovers and rejoins, then every backend hangs and the proxy drops clients at once. The code below is the full program. The ask helper prints (closed) when the proxy drops the connection.
use std::net::SocketAddr;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering::SeqCst};
use std::time::Duration;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader, copy_bidirectional};
use tokio::net::{TcpListener, TcpStream};
use tokio::sync::watch;
use tokio::time::{interval, timeout};
async fn backend(name: usize, listener: TcpListener, up: Arc<AtomicBool>) {
loop {
let (stream, _) = listener.accept().await.unwrap();
let up = up.clone();
tokio::spawn(async move {
let (reader, mut writer) = stream.into_split();
let mut lines = BufReader::new(reader).lines();
while let Ok(Some(line)) = lines.next_line().await {
if !up.load(SeqCst) { continue; }
let reply = if line == "PING" { "PONG".to_string() } else { format!("b{name}: {line}") };
if writer.write_all(format!("{reply}\n").as_bytes()).await.is_err() { break; }
}
});
}
}
async fn probe(addr: SocketAddr) -> bool {
let attempt = async {
let mut stream = TcpStream::connect(addr).await.ok()?;
stream.write_all(b"PING\n").await.ok()?;
let mut line = String::new();
BufReader::new(&mut stream).read_line(&mut line).await.ok()?;
Some(line.trim() == "PONG")
};
timeout(Duration::from_millis(50), attempt).await.ok().flatten().unwrap_or(false)
}
async fn health(addrs: Vec<SocketAddr>, tx: watch::Sender<Vec<bool>>) {
let mut tick = interval(Duration::from_millis(20));
loop {
tick.tick().await;
let mut now = Vec::new();
for addr in &addrs {
now.push(probe(*addr).await);
}
tx.send_if_modified(|current| if *current != now { *current = now; true } else { false });
}
}
async fn proxy(listener: TcpListener, addrs: Vec<SocketAddr>, status: watch::Receiver<Vec<bool>>) {
let mut n = 0;
loop {
let (mut client, _) = listener.accept().await.unwrap();
let healthy: Vec<usize> = status.borrow().iter().enumerate().filter(|(_, up)| **up).map(|(i, _)| i).collect();
if healthy.is_empty() { continue; }
let target = addrs[healthy[n % healthy.len()]];
n += 1;
tokio::spawn(async move {
if let Ok(Ok(mut upstream)) = timeout(Duration::from_millis(100), TcpStream::connect(target)).await {
let _ = copy_bidirectional(&mut client, &mut upstream).await;
}
});
}
}
async fn ask(proxy: SocketAddr, msg: &str) -> String {
let mut stream = TcpStream::connect(proxy).await.unwrap();
stream.write_all(format!("{msg}\n").as_bytes()).await.unwrap();
let mut line = String::new();
let _ = BufReader::new(&mut stream).read_line(&mut line).await;
if line.is_empty() { "(closed)".into() } else { line.trim().into() }
}
#[tokio::main]
async fn main() {
let flags: Vec<Arc<AtomicBool>> = (0..3).map(|_| Arc::new(AtomicBool::new(true))).collect();
let mut addrs = Vec::new();
for (name, up) in flags.iter().enumerate() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
addrs.push(listener.local_addr().unwrap());
tokio::spawn(backend(name, listener, up.clone()));
}
let (tx, rx) = watch::channel(vec![true; 3]);
tokio::spawn(health(addrs.clone(), tx));
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let front = listener.local_addr().unwrap();
tokio::spawn(proxy(listener, addrs, rx.clone()));
let mut watcher = rx.clone();
println!("phase 1: all up");
for i in 0..3 {
println!(" request {i} -> {}", ask(front, &format!("hello {i}")).await);
}
println!("phase 2: backend 1 hangs");
flags[1].store(false, SeqCst);
watcher.wait_for(|status| !status[1]).await.unwrap();
println!(" health: {:?}", *rx.borrow());
for i in 3..7 {
println!(" request {i} -> {}", ask(front, &format!("hello {i}")).await);
}
println!("phase 3: backend 1 recovers");
flags[1].store(true, SeqCst);
watcher.wait_for(|status| status[1]).await.unwrap();
println!(" health: {:?}", *rx.borrow());
for i in 7..10 {
println!(" request {i} -> {}", ask(front, &format!("hello {i}")).await);
}
println!("phase 4: every backend hangs");
for flag in &flags {
flag.store(false, SeqCst);
}
watcher.wait_for(|status| status.iter().all(|up| !up)).await.unwrap();
println!(" health: {:?}", *rx.borrow());
println!(" request 10 -> {}", ask(front, "hello 10").await);
}phase 1: all up
request 0 -> b0: hello 0
request 1 -> b1: hello 1
request 2 -> b2: hello 2
phase 2: backend 1 hangs
health: [true, false, true]
request 3 -> b2: hello 3
request 4 -> b0: hello 4
request 5 -> b2: hello 5
request 6 -> b0: hello 6
phase 3: backend 1 recovers
health: [true, true, true]
request 7 -> b1: hello 7
request 8 -> b2: hello 8
request 9 -> b0: hello 9
phase 4: every backend hangs
health: [false, false, false]
request 10 -> (closed)The round-robin counter keeps counting while the healthy set changes, so the backend chosen after a change depends on the counter and the size of the healthy list. In phase 4 the proxy has no target and drops the client, which reads an empty line.
| Part | Role |
|---|---|
backend with up flag |
echo server that can be made to hang without closing sockets |
probe |
connect, PING, expect PONG, all inside one timeout |
health with interval |
probes on a schedule and publishes only changes |
watch channel |
one writer, many readers, always holds the latest status |
proxy |
picks a healthy backend by counter and forwards bytes |
copy_bidirectional |
copies both directions until both sides close |
Extension tasks:
- Require two failed probes before marking a backend down, to avoid flapping.
- Track connections per backend and choose the backend with the fewest (least connections).
- Close in-flight client connections to a backend when it goes down, using a per-backend
CancellationToken.
Project 20: Supervised actors with restart
An actor holds state and handles messages one at a time. If the actor panics, a supervisor starts a new one, waits longer after each failure, and gives up after a limit. Callers keep using the same handle.
Requirements:
- The actor is a task with a mailbox (
mpsc). Requests carry aoneshotsender for the reply. - A
Boommessage panics the actor. - The supervisor awaits the actor's
JoinHandle, sees the panic as aJoinError, waits 100 ms, 200 ms, 400 ms, and restarts. - The mailbox survives restarts, so messages sent during the wait are handled by the new actor.
- After three restarts the supervisor stops, and the handle reports that the actor is gone.
Stage 1: An actor that panics
Without a supervisor, a panic ends the task. The reply sender inside the message is dropped during unwinding, so the caller gets an error instead of waiting forever. The mailbox receiver is dropped with the task, so later sends fail. The hook that prints panic messages is silenced to keep the output fixed.
use tokio::sync::{mpsc, oneshot};
enum Cmd {
Add(i64, oneshot::Sender<i64>),
Boom(oneshot::Sender<i64>),
}
#[derive(Clone)]
struct Handle { tx: mpsc::Sender<Cmd> }
impl Handle {
async fn call(&self, make: impl FnOnce(oneshot::Sender<i64>) -> Cmd) -> Result<i64, &'static str> {
let (reply, answer) = oneshot::channel();
self.tx.send(make(reply)).await.map_err(|_| "actor stopped")?;
answer.await.map_err(|_| "no reply")
}
async fn add(&self, n: i64) -> Result<i64, &'static str> { self.call(|r| Cmd::Add(n, r)).await }
async fn boom(&self) -> Result<i64, &'static str> { self.call(Cmd::Boom).await }
}
async fn actor(mut inbox: mpsc::Receiver<Cmd>) {
let mut total = 0;
while let Some(cmd) = inbox.recv().await {
match cmd {
Cmd::Add(n, reply) => { total += n; let _ = reply.send(total); }
Cmd::Boom(_reply) => panic!("boom"),
}
}
}
#[tokio::main(flavor = "current_thread")]
async fn main() {
std::panic::set_hook(Box::new(|_| {}));
let (tx, rx) = mpsc::channel(8);
let handle = Handle { tx };
let task = tokio::spawn(actor(rx));
println!("add 5 -> {:?}", handle.add(5).await);
println!("add 3 -> {:?}", handle.add(3).await);
println!("boom -> {:?}", handle.boom().await);
let joined = task.await;
println!("task panicked: {}", joined.unwrap_err().is_panic());
println!("add 1 -> {:?}", handle.add(1).await);
}add 5 -> Ok(5)
add 3 -> Ok(8)
boom -> Err("no reply")
task panicked: true
add 1 -> Err("actor stopped")The two error messages differ. no reply means the request was received but the actor died before answering. actor stopped means the mailbox is closed.
Stage 2: A supervisor that restarts
The receiver moves into Arc<tokio::sync::Mutex<Receiver>>. Each actor incarnation locks it for its whole life. The lock guard drops when the actor unwinds, and Tokio's mutex has no poisoning, so the next incarnation can lock it. The supervisor spawns the actor, awaits its JoinHandle, and on a panic sleeps and spawns again. State inside the actor (total) starts from zero each time.
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::{Mutex, mpsc, oneshot};
use tokio::time::{Instant, sleep};
enum Cmd {
Add(i64, oneshot::Sender<i64>),
Boom(oneshot::Sender<i64>),
}
#[derive(Clone)]
struct Handle { tx: mpsc::Sender<Cmd> }
impl Handle {
async fn call(&self, make: impl FnOnce(oneshot::Sender<i64>) -> Cmd) -> Result<i64, &'static str> {
let (reply, answer) = oneshot::channel();
self.tx.send(make(reply)).await.map_err(|_| "actor stopped")?;
answer.await.map_err(|_| "no reply")
}
async fn add(&self, n: i64) -> Result<i64, &'static str> { self.call(|r| Cmd::Add(n, r)).await }
async fn boom(&self) -> Result<i64, &'static str> { self.call(Cmd::Boom).await }
}
async fn actor(inbox: Arc<Mutex<mpsc::Receiver<Cmd>>>) {
let mut inbox = inbox.lock().await;
let mut total = 0;
while let Some(cmd) = inbox.recv().await {
match cmd {
Cmd::Add(n, reply) => { total += n; let _ = reply.send(total); }
Cmd::Boom(_reply) => panic!("boom"),
}
}
}
async fn supervise(inbox: Arc<Mutex<mpsc::Receiver<Cmd>>>, start: Instant) {
let mut restarts = 0u32;
loop {
match tokio::spawn(actor(inbox.clone())).await {
Ok(()) => break,
Err(e) if e.is_panic() => {
let delay = Duration::from_millis(100 << restarts);
restarts += 1;
println!("[{:>3} ms] actor panicked, restart {restarts} in {delay:?}", start.elapsed().as_millis());
sleep(delay).await;
}
Err(_) => break,
}
}
}
#[tokio::main(flavor = "current_thread", start_paused = true)]
async fn main() {
std::panic::set_hook(Box::new(|_| {}));
let start = Instant::now();
let (tx, rx) = mpsc::channel(8);
let handle = Handle { tx };
tokio::spawn(supervise(Arc::new(Mutex::new(rx)), start));
println!("add 5 -> {:?}", handle.add(5).await);
println!("add 3 -> {:?}", handle.add(3).await);
println!("boom -> {:?}", handle.boom().await);
println!("[{:>3} ms] add 1 -> {:?}", start.elapsed().as_millis(), handle.add(1).await);
println!("[{:>3} ms] add 1 -> {:?}", start.elapsed().as_millis(), handle.add(1).await);
}add 5 -> Ok(5)
add 3 -> Ok(8)
[ 0 ms] actor panicked, restart 1 in 100ms
boom -> Err("no reply")
[ 0 ms] add 1 -> Ok(1)
[100 ms] add 1 -> Ok(2)The add 1 call is sent right after the crash and waits in the mailbox. It is answered at 100 ms by the second incarnation, which started with total = 0.
Stage 3: Backoff limit and giving up
The supervisor stops after max_restarts. It returns, its Arc to the mailbox drops, the channel closes, and callers get actor stopped. The restart count is returned to main. This is the full program.
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::{Mutex, mpsc, oneshot};
use tokio::time::{Instant, sleep};
enum Cmd {
Add(i64, oneshot::Sender<i64>),
Boom(oneshot::Sender<i64>),
}
#[derive(Clone)]
struct Handle { tx: mpsc::Sender<Cmd> }
impl Handle {
async fn call(&self, make: impl FnOnce(oneshot::Sender<i64>) -> Cmd) -> Result<i64, &'static str> {
let (reply, answer) = oneshot::channel();
self.tx.send(make(reply)).await.map_err(|_| "actor stopped")?;
answer.await.map_err(|_| "no reply")
}
async fn add(&self, n: i64) -> Result<i64, &'static str> { self.call(|r| Cmd::Add(n, r)).await }
async fn boom(&self) -> Result<i64, &'static str> { self.call(Cmd::Boom).await }
}
async fn actor(inbox: Arc<Mutex<mpsc::Receiver<Cmd>>>) {
let mut inbox = inbox.lock().await;
let mut total = 0;
while let Some(cmd) = inbox.recv().await {
match cmd {
Cmd::Add(n, reply) => { total += n; let _ = reply.send(total); }
Cmd::Boom(_reply) => panic!("boom"),
}
}
}
async fn supervise(inbox: Arc<Mutex<mpsc::Receiver<Cmd>>>, max_restarts: u32, start: Instant) -> u32 {
let mut restarts = 0u32;
loop {
match tokio::spawn(actor(inbox.clone())).await {
Ok(()) => return restarts,
Err(e) if e.is_panic() => {
if restarts == max_restarts {
println!("[{:>3} ms] actor panicked, giving up", start.elapsed().as_millis());
return restarts;
}
let delay = Duration::from_millis(100 << restarts);
restarts += 1;
println!("[{:>3} ms] actor panicked, restart {restarts} in {delay:?}", start.elapsed().as_millis());
sleep(delay).await;
}
Err(_) => return restarts,
}
}
}
#[tokio::main(flavor = "current_thread", start_paused = true)]
async fn main() {
std::panic::set_hook(Box::new(|_| {}));
let start = Instant::now();
let (tx, rx) = mpsc::channel(8);
let handle = Handle { tx };
let supervisor = tokio::spawn(supervise(Arc::new(Mutex::new(rx)), 3, start));
println!("add 5 -> {:?}", handle.add(5).await);
for round in 1..=4 {
println!("boom {round} -> {:?}", handle.boom().await);
}
println!("add 1 -> {:?}", handle.add(1).await);
println!("restarts used: {}", supervisor.await.unwrap());
}add 5 -> Ok(5)
[ 0 ms] actor panicked, restart 1 in 100ms
boom 1 -> Err("no reply")
[100 ms] actor panicked, restart 2 in 200ms
boom 2 -> Err("no reply")
[300 ms] actor panicked, restart 3 in 400ms
boom 3 -> Err("no reply")
[700 ms] actor panicked, giving up
boom 4 -> Err("no reply")
add 1 -> Err("actor stopped")
restarts used: 3Each crash doubles the wait: 100, 200, 400 ms. The fourth crash exceeds the limit, so the supervisor stops at 700 ms, and every later call fails with actor stopped. Each boom call is answered with no reply at the moment of the crash, and the next boom is queued while the supervisor sleeps.
| Part | Role |
|---|---|
Handle |
cloneable sender wrapper; call builds a message with a oneshot reply |
actor |
processes one message at a time and owns its state |
Arc<Mutex<Receiver>> |
keeps the mailbox alive across restarts |
supervise |
awaits the JoinHandle, tells a panic from a clean exit with is_panic |
backoff 100 << restarts |
doubling delay before each restart |
| restart limit | turns a crash loop into a visible failure |
Extension tasks:
- Reset the restart counter when an incarnation has run for more than one second without a panic.
- Keep the state across restarts by having the actor send
totalto the supervisor after every message and receive it back on start. - Supervise two actors with one supervisor using a
JoinSetand restart only the one that failed.
Interview points
- A
Semaphoreacquired inside each task bounds concurrency while theJoinSetstill holds every task. Acquiring beforespawnalso bounds the number of tasks. abort_allon aJoinSetcancels tasks that are running and tasks that are waiting for a permit.join_nextreports them as cancelledJoinErrors.- A graceful shutdown has an order: stop accepting, close idle connections, drain running work with a deadline, then cancel what is left.
TaskTracker::waitcompletes only afterclosehas been called and the count reaches zero.select!on a token is safe only around cancel-safe futures such asnext_line. Work started in a branch body is not cancelled by the token.interval_atsets the first tick.MissedTickBehavior::Skipdrops ticks that came due during a slow run.- Pool guards return their resource in
Drop. Field order decides whether the resource is back before the slot is released. - A dropped
timeout(get())releases its place in the semaphore queue, so a timed-out caller holds nothing. watch::send_if_modifiedwakes receivers only when the value changed.wait_forchecks the current value first, so a change made before the call is not missed.- A health check needs its own timeout, because a hung peer accepts connections and never replies.
- A supervisor sees a task panic as
JoinError::is_panic. Keeping the mailbox outside the task lets callers keep one handle across restarts.
Summary
- Project 15 combines
JoinSet,Semaphore, and an error policy (collect all or fail fast withabort_all). - Project 16 shuts down in stages: cancel token, accept loop exit,
TaskTrackerdrain, deadline withtimeout, and a second token for the hard stop. - Project 17 runs jobs on
interval_atwithSkip, and stops between runs with aCancellationToken. - Project 18 builds a pool from
Semaphore,Mutex<Vec<_>>, and an RAII guard.get_timeoutanddiscardextend it. - Project 19 forwards bytes with
copy_bidirectional, probes backends undertimeout, and publishes status on awatchchannel. - Project 20 restarts a panicked actor with doubling backoff and a restart limit, keeping the mailbox in an
Arc<Mutex<Receiver>>.
