Bounding Parallelism: A Fixed Worker Pool on One Job Channel
The first design question in any concurrent service decides its failure mode under load: when 10,000 requests arrive, do you spawn 10,000 tasks — or 8 workers pulling from a queue? Unbounded spawn turns traffic spikes into memory exhaustion and uncontrolled latency; a fixed pool of workers consuming one job channel bounds parallelism by construction — at most N jobs are in flight, and the queue absorbs the burst.
The service is a key-value store: clients submit Set(key, value) and Get(key, reply_tx) jobs, workers apply them. Tokio's mpsc channel is the job queue — Sender is cheap to clone (fan-in from many acceptors), and the channel itself is bounded, which adds a second backpressure layer: when the queue is full, the producer's send().await waits instead of allocating. The Sender half also survives across the service's lifetime cheaply, so accept-loop tasks, admin endpoints, and internal timers can all submit jobs without knowing how many workers exist — the pool size becomes a tuning knob you change in one constant.
use tokio::sync::mpsc;
const WORKERS: usize = 8;
const QUEUE: usize = 1024;
pub struct Pool { tx: mpsc::Sender<Job> }
impl Pool {
pub fn start(store: Store) -> Self {
let (tx, rx) = mpsc::channel::<Job>(QUEUE);
let rx = std::sync::Arc::new(tokio::sync::Mutex::new(rx));
for id in 0..WORKERS {
let rx = std::sync::Arc::clone(&rx);
let store = store.clone();
tokio::spawn(async move {
loop {
let job = { rx.lock().await.recv() }.await; // release lock between recvs
let Some(job) = job else { break }; // all senders dropped
job.apply(&store);
}
tracing::debug!(id, "worker exited");
});
}
Pool { tx }
}
}
Read the mechanics, because each is an interview question in disguise. Tokio's Receiver is deliberately not Clone — one queue, many consumers must share it, so it goes inside Arc<tokio::sync::Mutex<>>. The lock guard's scope is one recv(): you lock, call recv().await, and drop the guard before handling the job ({ rx.lock().await.recv() }.await — the braces end the guard's lifetime at the inner expression). Holding the lock across job processing would serialize the whole pool through one mutex — the classic bug that makes a "concurrent" service single-threaded. The else { break } arm is the termination path: recv() returns None exactly when every Sender has been dropped, which later becomes the shutdown mechanism. And the pool size is a constant you tune against the workload: CPU-bound jobs want ≈ cores, I/O-bound jobs want more; either way the count is a decision with a number, not an accident of scheduling.
The std::thread variant deserves one paragraph because interviewers ask for it: spawn WORKERS OS threads sharing Arc<Mutex<Receiver<Job>>> around std::sync::mpsc::channel, block on rx.recv() (no .await), and send() never blocks because the std channel is unbounded (or use sync_channel(N) for bounds). Same architecture, different scheduler — tokio multiplexes workers onto a few OS threads, std pays a kernel thread (≈8 MB stack virtual, real context-switch cost) per worker. Being able to sketch both is the difference between knowing tokio and knowing concurrency.
Two load-testing habits separate the capstone from a toy: drive submit() from a client task that times each round-trip and reports p50/p99 latency, not just throughput — a pool that moves 50k jobs/s with a 2-second tail is a failing service — and watch the queue depth. When the bounded channel is full often, send().await is where your clients feel the overload; that visibility tells you whether the answer is more workers, a bigger queue, or admission control upstream. The channel-plus-shared-state split formalized here is the same one MPSC Channels and Arc Mutex built separately — this capstone welds them together.
Graceful Shutdown with a Broadcast Channel, Then Prove It with Criterion
A load balancer sends SIGTERM and your service must drain: stop accepting, let in-flight jobs finish, drop workers in an orderly way, exit 0. Killing threads is not shutdown — a worker mid-set might hold the lock when the process dies. The mechanism that composes with the architecture you already built: a shutdown signal every worker can receive, delivered by a tokio::sync::broadcast channel (the multi-consumer sibling of mpsc — one sender, every listener gets the message).
use tokio::sync::broadcast;
pub struct Pool {
tx: mpsc::Sender<Job>,
stop: broadcast::Sender<()>,
handles: Vec<tokio::task::JoinHandle<()>>,
}
impl Pool {
pub fn shutdown(self) {
let _ = self.stop.send(()); // wake every worker
drop(self.tx); // queue drains, recv() returns None
for h in self.handles {
let _ = tokio::time::timeout(std::time::Duration::from_secs(5), h).await;
}
}
}
// inside each worker, replacing the bare loop:
let mut stop_rx = stop.subscribe();
loop {
tokio::select! {
job = rx.lock().await.recv() => {
let Some(job) = job else { break };
job.apply(&store);
}
_ = stop_rx.recv() => break, // exit AFTER the current job
}
}
Walk the sequence, because its correctness is subtle in three places. stop.send(()) reaches every worker — broadcast fans one message out to all current and future subscribers, unlike mpsc where each message goes to exactly one receiver; the alternative sketched in interviews, an AtomicBool polled per iteration, works too but a blocked recv() never checks the flag, so you'd still need a wakeup — which is precisely what the broadcast (or a sync_channel sentinel job) provides. Then drop(self.tx) closes the queue: the current job finishes first — each worker completes its critical section before noticing closure — and the next recv() returns None, taking the else break. That ordering is what "graceful" means here: no job is abandoned mid-flight. The per-worker timeout on JoinHandle bounds the drain; a worker still stuck after 5 seconds is logged and, since the function returns anyway, the process proceeds to exit. select! returning from the job branch first also naturally keeps serving until the signal arrives — new work during drain is handled, and shutdown cannot interrupt a job, because the future doing the work is the one being awaited in the same branch. tracing::info!("shutdown complete", workers = handles.len()) makes the drain visible in the logs.
Last, the claim every optimization needs: numbers. Criterion drives the hot path in a controlled harness — warm-up, many samples, statistics, and a regression detector that fails a CI comparison when a change loses more than the noise threshold:
use criterion::{criterion_group, criterion_main, Criterion, Throughput};
fn bench_set_get(c: &mut Criterion) {
let rt = tokio::runtime::Builder::new_current_thread().enable_time().build().unwrap();
let pool = rt.block_on(async { Pool::start(Store::default()) });
let mut group = c.benchmark_group("kv");
group.throughput(Throughput::Elements(1));
group.bench_function("set-then-get", |b| {
b.iter(|| rt.block_on(async {
let (reply, rx) = tokio::sync::oneshot::channel();
pool.submit(Job::Set("k".into(), vec![0u8; 64])).await.unwrap();
pool.submit(Job::Get("k".into(), reply)).await.unwrap();
criterion::black_box(rx.await.unwrap());
}));
});
group.finish();
}
criterion_group!(benches, bench_set_get);
criterion_main!(benches);
Run it before each change and after: baseline with a 4-worker pool, then 8, then the sharded store from the previous chapter, and write the three throughput numbers into the README with the hardware they were measured on. The discipline is the deliverable — "I sharded the map because criterion showed 1.3× → 3.9× ops/s at 8 workers" is a sentence that ends optimization debates. That before/after documentation, plus the production gate from CLI Tool Capstone, is what makes this capstone a service you could actually run: bounded queue, short locks, observable shutdown, and a benchmark trail of every decision.
Quiz
1. Why is the tokio mpsc Receiver placed in Arc<tokio::sync::Mutex<>> across N worker tasks, and why is the lock only held around recv()?
2. The service uses both a job channel (mpsc) and an Arc<Mutex<HashMap>>. What is the design rationale for that split?
3. In the shutdown sequence, what does each of these two steps guarantee: `stop.send(())` on the broadcast channel, then `drop(self.tx)`?
4. You suspect the global Mutex<HashMap> caps write throughput at 8 workers. What does the correct optimization process look like?
Flashcards
Question
Why a fixed worker pool over unbounded tokio::spawn, and how is the pool built?
Click to reveal answer
Answer
Unbounded spawn lets a traffic spike allocate tasks without limit (memory + latency collapse); N workers with a bounded mpsc queue cap in-flight work and add backpressure (send().await waits when full). Build it as N spawned tasks sharing one Receiver via Arc<tokio::sync::Mutex<>>, locking only around recv() so job handling runs concurrently.
Question
What goes in the channel vs behind Arc<Mutex<>>, and what are the lock rules?
Click to reveal answer
Answer
Jobs (work requests) travel as owned messages through mpsc; the shared data every job observes (the store) sits behind Arc<Mutex<>>. Rules: keep critical sections to one map operation (never a send, file write, or await), recover or propagate poisoning deliberately (into_inner() when invariants can't break), and shard by key hash when measurements show the single lock capping throughput.
Question
How does graceful shutdown work with tokio::sync::broadcast, and why both stop.send and drop(tx)?
Click to reveal answer
Answer
broadcast fans one message to ALL subscribers, so stop.send(()) wakes every worker to break after its in-flight job; dropping the last Sender closes the queue so recv() returns None, giving a second exit path for any worker that missed the signal. In-flight jobs finish first (graceful), and a timeout on each JoinHandle bounds the drain.
Question
Why broadcast (or an AtomicBool + wakeup) instead of mpsc for the shutdown signal?
Click to reveal answer
Answer
mpsc delivers each message to exactly ONE receiver, but shutdown must reach every worker — broadcast delivers one message to all current subscribers. An AtomicBool alone is insufficient when workers block in recv(): a blocked worker never re-reads the flag, so any flag design still needs a wakeup — a sentinel job, a notify, or the broadcast channel.
Question
How does criterion prove an optimization, and what does a benchmark record contain?
Click to reveal answer
Answer
criterion runs warm-up + many samples, reports statistics with confidence intervals, and compares against a stored baseline, failing CI on regressions beyond noise. A useful record: throughput (Throughput::Elements), ops/s before and after the change, pool/shard configuration, and the hardware — e.g. '4 workers 1.3× → 8 workers 3.9× ops/s' turns an optimization into a documented decision.
Revision Notes
Key Takeaways
- 1. A fixed pool on a bounded mpsc channel caps in-flight work by construction; unbounded spawn converts traffic spikes into memory exhaustion instead of queueing
- 2. Message passing carries work, Arc<Mutex<>> carries shared data — and a lock held across job processing (or any await) silently serializes the entire pool
- 3. Graceful shutdown is two triggers: a broadcast signal every worker receives, and dropping the last Sender so recv() returns None; in-flight jobs complete before the drain timeout expires
- 4. broadcast exists for fan-out (mpsc is one-receiver-per-message); an AtomicBool flag still needs a wakeup because a worker blocked in recv() never polls it
- 5. Criterion turns optimization into evidence: throughput elements, stored baselines, regression detection, and README numbers with hardware — the shard-the-map decision follows from a measured plateau
Interview Tips
- • "Design a concurrent key-value service in Rust" — draw the funnel: bounded mpsc job queue, N worker tasks, newtype-wrapped Arc<Mutex<HashMap>> with one-op critical sections; then volunteer shutdown and benchmarks before the interviewer asks
- • "Your server got slower under load — what do you check?" — measurement first (criterion or tracing spans per op), then the suspects in order: lock held across await, unbounded queue growth, one giant critical section; end with sharding-by-key as the structured fix
- • "How would you implement graceful shutdown?" — broadcast to all workers + drop the last Sender for the queue-closed path, drain in-flight jobs, timeout the JoinHandles; naming BOTH exit paths shows you've thought about the worker that misses the signal
- • "std::sync::Mutex or tokio::sync::Mutex?" — std for short critical sections with no awaits (faster, no runtime); tokio's only when the guard must cross an await, and then only as a last resort — channels are usually the better answer
Cheat Sheet
Concurrent Service Capstone
Pool skeleton:
let (tx, rx) = mpsc::channel::<Job>(1024); // bounded = backpressure
let rx = Arc::new(tokio::sync::Mutex::new(rx)); // Receiver: not Clone
for id in 0..WORKERS { let rx = rx.clone(); tokio::spawn(async move {
let mut stop_rx = stop.subscribe();
loop { tokio::select! {
job = rx.lock().await.recv() => { let Some(j) = job else { break }; j.apply(&store); }
_ = stop_rx.recv() => break,
} } }); }
Guard scope = recv() only; hold it across apply() and the pool is single-threaded. Worker count is tuned: CPU-bound ≈ cores, I/O-bound higher.
State split: jobs in the channel, data in Arc<Mutex<HashMap>> (newtype-wrapped, private lock). Critical section = one map op. Poisoning: unwrap_or_else(into_inner) only when invariants can't break. Contention measured? Shard by hash(key) % N.
Graceful shutdown: stop.send(()) on broadcast → every worker finishes its job and breaks; drop(tx) closes the queue → recv() returns None as the second exit path; timeout(5s, join_handle) bounds the drain.
Benchmarks: criterion group with Throughput::Elements, runtime in the bench, black_box on results; record before/after ops/s + config + hardware in the README. Benchmark → change → re-benchmark; never optimize on a hunch.