Capstone — a small concurrent service, and the honest costs
Lessons 01 to 21 built one mechanism at a time; this one assembles them into a single program that compiles, runs, and prints the same thing every time, then does what the series has trained you for: it adds up the bill. The five questions explain why each piece is the one the law forced; then the bargain is priced — promises that cost nothing at run time (identical assembly), checks that cost a measured handful of nanoseconds, and what the promise never covered. The point is not the service; it is that you can now read any design as a ledger — proof paid at compile time, cycles at run time, a learning curve by people — and say where that trade is right.
New idea: assemble the mechanisms into one working service, walk the five questions over it to show each piece was forced, then price the bargain in three currencies — compile-time proof (0 ns), run-time checks (a measured few ns each), and the human learning curve — and name where the bargain is the wrong one.
Forces next: Trust, constrain, verify: which belongs where in your own work, and what will you now ask of every line of code?
1 · The program
A job server. The main thread hands out work; three worker threads price grocery items against a shared read-only table and total lists of numbers; results come back, are sorted, and are printed. It uses most of the track: an enum for the closed set of requests (Lesson 09), a small error enum with ? (Lesson 10), a generic helper with a trait bound (Lessons 11–12), one Box<dyn Fn> handler (Lessons 13–14), an Arc<Mutex<_>> counter and a read-only table shared through a scope (Lessons 15–17), a Drop guard for shutdown (Lesson 03), and no unsafe. It is made deterministic on purpose — results are collected and sorted before printing — so its output is a fact the compiler check can verify:
use std::collections::HashMap;
use std::fmt;
use std::sync::{mpsc, Arc, Mutex};
use std::thread;
enum Job { Word(String), Sum(Vec<u32>), Stop } // a closed set of requests (L09)
#[derive(Debug)]
enum JobError { Unknown(String), Overflow(u64) } // failure is a value (L10)
impl fmt::Display for JobError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
JobError::Unknown(w) => write!(f, "unknown item {w:?}"),
JobError::Overflow(n) => write!(f, "total {n} overflows u32"),
}
}
}
#[derive(Default)]
struct Stats { ok: u32, failed: u32 }
struct Report(Arc<Mutex<Stats>>); // prints the tally when dropped (L03)
impl Drop for Report {
fn drop(&mut self) {
let s = self.0.lock().unwrap();
println!("shutdown: {} ok, {} failed", s.ok, s.failed);
}
}
fn total<T: Copy + Into<u64>>(xs: &[T]) -> Result<u32, JobError> { // one generic, checked once (L12)
let sum: u64 = xs.iter().map(|&x| x.into()).sum();
u32::try_from(sum).map_err(|_| JobError::Overflow(sum))
}
fn price(item: &str, prices: &HashMap<&str, u32>) -> Result<String, JobError> {
let p = prices.get(item).ok_or_else(|| JobError::Unknown(item.to_string()))?; // ? (L10)
Ok(format!("{item} costs {p}"))
}
fn main() {
let prices: HashMap<&str, u32> = HashMap::from([("apple", 3), ("bread", 4), ("milk", 2)]);
let stats = Arc::new(Mutex::new(Stats::default()));
let _report = Report(Arc::clone(&stats)); // declared first, so dropped last (L03)
let on_done: Box<dyn Fn(bool) + Send + Sync> = Box::new(move |ok| { // a handler value (L13–14)
let mut s = stats.lock().unwrap(); // one writer at a time (L17)
if ok { s.ok += 1 } else { s.failed += 1 }
});
let (job_tx, job_rx) = mpsc::channel();
let job_rx = Mutex::new(job_rx); // one queue, three takers: in turns (L17)
let (out_tx, out_rx) = mpsc::channel();
thread::scope(|s| { // workers borrow prices/on_done; joined here (L17)
for _ in 0..3 {
let (prices, job_rx, on_done, out_tx) = (&prices, &job_rx, &on_done, out_tx.clone());
s.spawn(move || loop {
let job = job_rx.lock().unwrap().recv().unwrap(); // the guard dies at this `;`
let result = match job {
Job::Word(item) => price(&item, prices),
Job::Sum(xs) => total(&xs).map(|t| format!("sum of {} is {t}", xs.len())),
Job::Stop => break,
};
on_done(result.is_ok());
out_tx.send(result).unwrap(); // the result moves to main (L17)
});
}
let jobs = [Job::Word("bread".into()), Job::Sum(vec![1, 2, 3]), Job::Word("cake".into()),
Job::Sum(vec![u32::MAX, 1]), Job::Word("milk".into())];
for job in jobs { job_tx.send(job).unwrap(); }
for _ in 0..3 { job_tx.send(Job::Stop).unwrap(); }
}); // every worker is joined here
drop(out_tx);
let mut lines: Vec<String> = out_rx.iter()
.map(|r| r.unwrap_or_else(|e| format!("error: {e}")))
.collect();
lines.sort();
for line in &lines { println!("{line}"); }
}
bread costs 4 error: total 4294967296 overflows u32 error: unknown item "cake" milk costs 2 sum of 3 is 6 shutdown: 3 ok, 2 failed
The Sum of [u32::MAX, 1] sums into a u64 and then fails u32::try_from, so overflow is a returned JobError, not a panic (Lesson 02: overflow is defined). The three workers race to pull from one queue, finish in any order, and the sort makes the print order a fact. Now break it by one line — drop the Mutex around the receiver, so three workers share a bare Receiver:
use std::sync::mpsc;
use std::thread;
fn main() {
let (tx, rx) = mpsc::channel::<u32>(); // the service wraps rx in a Mutex; drop that,
thread::scope(|s| { // and three workers share one bare Receiver
for _ in 0..3 {
let rx = ℞
s.spawn(move || while let Ok(n) = rx.recv() { println!("{n}"); });
}
for n in 0..3 { tx.send(n).unwrap(); }
});
}
error[E0277]: `std::sync::mpsc::Receiver<u32>` cannot be shared between threads safely
= help: the trait `Sync` is not implemented for `std::sync::mpsc::Receiver<u32>`
= note: required for `&std::sync::mpsc::Receiver<u32>` to implement `Send`
Run the triage from Lesson 21. E0277 is on its local list, because most of them are one missing trait on one line. This one names Sync, which puts it on the law side (Lesson 16): the standard library marks Receiver not Sync because a receiver belongs to one thread, and sharing &rx would let several threads take from one queue at once. The compiler cannot know which of Lesson 17's regimes you meant. A scoped borrow needs a Sync value (§4), so either one thread owns the receiver and fans the jobs out (§2), or the takers take turns behind a Mutex (§3), the service's choice. A third copy is not on offer: a Receiver does not clone. The message names the missing trait, not the design that would supply it.2 · The five questions, one column each
Every value in the service earned its type by answering Lesson 00's five questions. Read the table as the design being derived: for each value, who ends it, who else reaches it, what the signature promises, whether the proof is paid now or later, and where it stops. Each answer links the lesson that made it.
| Value | 1 · owns it / ends it | 2 · who else reaches it | 3 · signature promises | 4 · proven now, or checked later | 5 · proof stops at |
|---|---|---|---|---|---|
prices table | main; dropped at the end (L03) | 3 workers, shared &prices (L05) | price(&str, &HashMap) reads only | now: scope proves it outlives the threads — 0 ns (L17) | the standard library's own audited unsafe (L19) |
stats counter | the Arc: several owners, last drop frees (L15) | the handler, exclusive in turns via the lock | Mutex<Stats> is Sync when Stats: Send (L16) | later: the lock + atomics — a few ns each (§4) | Arc/Mutex unsafe impls |
a Job / its String | one name at a time; send moves it (L04) | nobody in parallel — the move ends the sender's name | Sender<Job>: Send when Job: Send | now: the move rule; price is a queue op (§4) | nowhere — pure safe code |
on_done handler | main owns the Box; dropped at the end (L13) | 3 workers, shared &on_done | dyn Fn(bool) + Send + Sync (L13, L16) | later: one indirect call, plus the lock it takes | the closure body |
_report guard | main; declared first, dropped last (L03) | no other name — a lone owner | Drop runs the shutdown print | now: drop order is static — 0 ns beyond the work | local to main |
Give each worker its own prices instead of lending one and you pay a clone per worker; drop the Mutex around stats and the += 1 becomes an assignment through a shared Arc, which the compiler refuses (E0594; Lesson 15 is why the Mutex is there); take the guard out and the shutdown line is gone. Every piece is the cheapest answer the law left, and the next section prices it.
3 · What does the bargain cost at your workload?
The widget is the service's ledger, driven by numbers measured on this machine (§4). You set the request rate and the operations per request; it computes the nanoseconds per second each mechanism adds, as a share of one CPU core, and states the verdict. The proofs that cost nothing at run time sit in their own band at zero. Change the rate and predict the bill before you read it.
What to try. Three worked workloads, each computed by the engine above and pasted here unchanged. §1’s service does about 55.1 ns of priced work per request (two locks, two allocations, two channel hops, one dyn call, one push); at 10,000 requests/s that is 0.055% of one core, none of it the safety proofs, and the biggest single line is Box::new at half the bill. Push it to a million requests/s and it is still only 5.5% of a core. Table lookups — a million bounds-checked indexes per request — puts the bounds check at 1.2% of a core at 100 requests/s and 12% at 1,000: the one check the promise cannot do for free (Lesson 02), now visible. Arc fan-out — sixteen Arc::clones a request — costs 0.59% of a core at 100,000 requests/s, 90% of it the atomic counts inside the clones; lend with & inside a scope instead of cloning and the same workload drops to 5.80 ns a request.
4 · The honest bill
The bill has two halves, and the split is the whole thesis. One half is paid before the program runs and costs nothing after; the other is paid every time, in cycles you can measure and name.
What costs 0 ns: proofs, shown in assembly
Borrow checking, lifetimes, Send/Sync and exhaustiveness are compile-time proofs: they change whether the program is accepted, never what it does. The evidence is that turning the proof off leaves the machine code identical. Three pairs from the service — each differs only by a proof — compiled at full optimization:
pub struct Stats { pub ok: u32, pub failed: u32 }
pub enum Job { Word(String), Sum(Vec<u32>), Stop }
#[inline(never)] // the borrow checker proves: s is valid and unaliased
pub fn record(s: &mut Stats, ok: bool) { if ok { s.ok += 1 } else { s.failed += 1 } }
#[inline(never)] // raw pointers: nobody proves anything
pub unsafe fn record_raw(s: *mut Stats, ok: bool) { unsafe { if ok { (*s).ok += 1 } else { (*s).failed += 1 } } }
#[inline(never)] // Send + Sync bound: this handler may cross threads
pub fn notify(on_done: &(dyn Fn(bool) + Send + Sync), ok: bool) { on_done(ok) }
#[inline(never)] // no bound: it may not
pub fn notify_local(on_done: &dyn Fn(bool), ok: bool) { on_done(ok) }
#[inline(never)] // exhaustive match: a new variant is a compile error here
pub fn size(job: &Job) -> usize { match job { Job::Word(w) => w.len(), Job::Sum(xs) => xs.len(), Job::Stop => 0 } }
#[inline(never)] // a wildcard arm instead
pub fn size_wild(job: &Job) -> usize { match job { Job::Word(w) => w.len(), Job::Sum(xs) => xs.len(), _ => 0 } }
$ rustc --edition 2024 -C opt-level=3 --crate-type=lib --emit asm -o proofs.s proofs.rs
$ # record (borrow-checked) vs record_raw (raw pointers): diff the two bodies, names normalized
$ diff <(body proofs.s record) <(body proofs.s record_raw) && echo identical
identical
$ grep ' = ' proofs.s # the other two pairs never got their own code at all:
_..._notify = _..._notify_local # the Send + Sync bound emitted nothing extra
_..._size_wild = _..._size # exhaustive match and wildcard compiled to one function
record: record_raw: ; byte-for-byte identical
tbz w1, #0, LBB tbz w1, #0, LBB ; if !ok jump
ldr w8, [x0] ldr w8, [x0] ; s.ok
add w8, w8, #1 add w8, w8, #1
str w8, [x0] str w8, [x0]
ret ret
LBB: LBB:
ldr w8, [x0, #4] ldr w8, [x0, #4] ; s.failed
add w8, w8, #1 add w8, w8, #1
str w8, [x0, #4] str w8, [x0, #4]
ret ret
The borrow checker, the Send + Sync bound and the exhaustiveness proof left no trace in the output: record and record_raw are the same instructions, and the other two pairs were folded into one function each. That is "the price is paid at compile time, in the shape of your code" (Lesson 00) made literal — and lifetimes join them, since Lesson 06 showed four functions differing only in their annotations compiling identically.
What costs real nanoseconds: checks, measured
The other half is the run-time price this track named at each step, now measured. A benchmark program times each operation — median of 31 timed runs after warm-up, every input and result through std::hint::black_box so nothing is optimized away — and the widget above reads its numbers. The method:
const RUNS: usize = 31;
fn median_ns_per_op(ops: usize, mut run: impl FnMut()) -> f64 {
for _ in 0..WARMUP { run(); } // warm the caches and the clock
let mut t: Vec<f64> = (0..RUNS)
.map(|_| { let s = Instant::now(); run(); s.elapsed().as_nanos() as f64 / ops as f64 })
.collect();
t.sort_by(|a, b| a.partial_cmp(b).unwrap());
t[RUNS / 2] // the median, not the mean
}
// e.g. one uncontended lock/unlock: *black_box(&mutex).lock().unwrap() += 1;
On one machine — Apple M5 (aarch64), macOS 26.5.2, rustc 1.98.1, -C opt-level=3, 2026-09-30, uncontended, single thread — the ledger reads: an extra bounds check costs about 0.12 ns per indexed access, a RefCell borrow about 0.19 ns, an Rc::clone+drop 0.91 ns against an Arc's 3.33 ns (the atomic is the difference), a Mutex lock/unlock 4.11 ns, an AtomicUsize::fetch_add 1.69 ns, a dyn call about 0.42 ns over an inlined generic, a Box::new+drop 13.7 ns, and a channel send+recv 8.57 ns. The machine was shared with other jobs while these ran; the published figure for each is the median of three independent program invocations, and its spread is recorded in tools/rust_verify/22_bench.json. Debug-build overflow checks are the one cost that vanishes in release, where arithmetic wraps instead of panicking (Lesson 02).
dyn call measured 0.42 ns here, in a loop whose iterations do not wait on each other; Lesson 13 measured 0.14 ns in a loop that sums into one running total, which hides part of a call, and several times more once the types were mixed at random. Read the ledger as "these operations are cheap, in this order, and here is how to measure yours" — never as "Arc costs 3.33 ns" stated as a fact about Rust.5 · What the promise does not cover
The ledger prices what Rust checks. It cannot price what Rust never promised. The promise is exactly "safe code has no undefined behavior"; everything below is defined behavior, so safe Rust allows all of it — the honest list, one line each with the lesson that owns it:
| Still yours to prevent | Why the promise does not reach it |
|---|---|
| Deadlock | a lock order is a whole-program property; no local rule sees it (L17). |
| Race conditions | an order-dependent wrong answer with no data race is defined; load-then-store loses updates (L17). |
| Leaks | leaking is safe: an Rc cycle or mem::forget skips a destructor and nothing is undefined (L03, L15). |
| Panics & overflow in release | a panic is defined — it unwinds, or aborts if so configured; release overflow wraps to a wrong-but-defined number (L10, L02). |
| Logic errors | a wrong answer is a bug a test finds, not UB; the compiler never promised correctness (L00). |
| Resource exhaustion | a thread per idle connection, an unbounded queue: defined, and still fatal (L17, L18). |
Three more bills sit underneath the promise, and honesty requires naming them. The compiler is a program with bugs. As of 2026-09 a type-system soundness hole (rust-lang/rust #25860), open since May 2015, lets safe code stretch a reference's lifetime through a specially shaped function pointer; the original example is now rejected, but a variant still compiled on rustc 1.98.1 when this was checked, and the Rust project's "Project Zero" goal aims to close such holes. The same 1.98 series shipped a rustc miscompilation — a null pointer where a vtable function belongs — that was in stable for two weeks before the 1.98.1 fix. You inherit your dependencies' proofs. Safe Rust's guarantee is conditional on the soundness of the unsafe code beneath it (Lessons 19–20), and a study of crates.io as of September 2018 (Evans et al.) found that fewer than 30% of crates used unsafe themselves, yet over half could not be fully checked by the compiler because unsafe hid somewhere in their call chain. And the supply chain — a crate you did not audit runs its build script on your machine — is a trust boundary the language does not police at all.
The largest bill is paid by people. The measured weak link is the counterfactual: in Brown's Ownership Inventory, 36 learners who had finished the Book could say why the compiler rejects a program about 64% of the time, but only 31% could write a program that would misbehave if it were accepted — which is why this track is built on counterexamples. The Book's own quiz data showed chapter 4 (ownership) as a drop-off point where readers leave, with only 2% reaching chapter 19; and in the 2020 Rust survey, 61.4% of respondents rated lifetimes tricky or very difficult, the hardest topic named. Compile time is a standing cost: this 77-line service took, on the machine above, about 59 ms to type-check, 145 ms for a debug build and 372 ms for a release build — trivial here, but in the 2024 survey slow compilation was the most-cited productivity obstacle. Async is close to a second dialect (Lesson 18), and the checker will reject some safe programs until you restructure them (Lesson 07). None of these is undefined behavior; all of them are real, and they go on the same ledger as the nanoseconds.
6 · Where it is the wrong tool, and how to choose
The five questions are a decision procedure, not just a reading habit. Ask them of a project and the currencies fall out: run time (question 4 — what is checked later, and how often), safety (questions 1, 2, 5 — whose proof keeps the contract, and where it stops), and people (the learning curve and compile times of §5). Weigh those three, not a slogan.
- A collector is right when the scarce resource is the team's attention, not the machine's cycles. The Go track's bargain is simplicity as the tool: a small language and a run-time system, so that many people can change a large program for years, at the price of a pause you can measure. Deeply cyclic, aliased, shared-mutable graphs fight Rust's law (Lesson 15) and are exactly where a collector is easiest.
- C++ is right when the codebase, the libraries and the team are already C++ — rewriting a working system to change languages is a cost with its own risks, and interop at the boundary is often the better move (C++ track).
- Long-lived and concurrent — code that will be changed for years by people who did not write it, where proving there is no data race is worth the up-front checking (Lesson 17).
- Security-relevant, no runtime — where memory-safety bugs are the threat and a garbage collector is not an option: a kernel module, a browser engine, a sandbox. Safe Rust rules out the class of bug — memory unsafety — that was about 70% of the CVEs Microsoft fixed in its own products over 2006–2018 (one vendor's figure, not an industry-wide one), at C++'s run-time budget.
That is the whole series in one judgment. Lesson 00 put three languages side by side: C++ trusts the programmer to keep the safety contract, Go constrains the program with a run-time system, Rust verifies the contract with a proof at compile time. None is the right answer everywhere; each spends a different scarce thing — the programmer's attention, the machine's cycles, or the compiler's, and yours, up front. You now know what the trade is, priced in three currencies, and the question left is not which language to use next.
Common mistakes / failure modes
dyn). It is competitive with C++'s budget, not magic.Arc clone ~3.33 ns, a lock ~4.11 ns — each spelled in the source where you asked for it (§4).unsafe makes Rust as dangerous as C"unsafe surface is small and auditable, bounded by privacy to a module (Lesson 20). But you inherit your dependencies' unsafe, which is why the 2018 study's "over half of crates cannot be fully checked" is the honest caveat (§5).Checkpoint exercise
Box::new and drop) or removing the one dyn call — before you try the first. Finally, take the service program, change one line so it no longer compiles, and classify the error with Lesson 21’s triage: local, law, or boundary? (Check: 1% of a core is 10,000,000 ns/s and Arc fan-out costs about 59 ns a request, so the bill crosses 1% near 170,000 requests/s, between the slider’s 100,000 and 300,000 steps, about 90% of it the Arc clones’ atomic counts. In the service the two allocations are about half of the 55.1 ns and the dyn call is 0.42 ns: allocation dominates.)Where this points next
There is no next lesson. The track derived Rust from one promise and one budget, and every rule arrived as the only move left: an owner because something must end each value once, a move because a copied owner double-frees, a borrow because moving to read is wasteful, a lifetime because a borrow must not outlive its owner, traits and generics and dyn because abstraction must not cost the guarantee, Send and Sync and scopes because the law must cross threads, unsafe and privacy because a human sometimes takes the proof back. You have now seen the whole thing assembled and priced. What remains is the habit: ask of every line who owns it, who else can reach it and how, what the signature promises, whether it is proven now or checked later and at what price, and where the proof stops. Trust, constrain, verify — decide, deliberately, which belongs where in your own work, and what you will now ask of every line of code.
unsafe, and every piece is the cheapest answer the five questions left. The bill has two halves. Compile-time proofs cost 0 ns: borrow checking, lifetimes, Send/Sync and exhaustiveness compile to assembly identical to the unchecked version. Run-time checks cost a measured few nanoseconds each, spelled in the source: a bounds check, an Arc atomic, a lock, a dyn call. The promise does not cover deadlock, races, leaks, panics, logic errors or exhaustion, rests on sound unsafe beneath you and on a compiler with known holes, and is bought with a real learning curve and compile time. So choose in three currencies — run time, safety, people: a collector when its bounded bill buys correctness, C++ when the ecosystem is there, Rust when code is long-lived, concurrent, security-relevant and cannot afford a runtime. C++ trusts, Go constrains, Rust verifies; which belongs where is now your call.Interview prompts
- What does "zero-cost abstraction" mean in Rust, and where is it false? (§4 — compile-time proofs (borrow check, lifetimes,
Send/Sync, exhaustiveness) leave assembly identical, so they are genuinely 0 ns; but bounds checks,Arcatomics, locks,RefCellflags anddyncalls are real run-time costs you asked for by name.) - Rust vs C++ vs Go — when would you reach for each? (§6 — C++ trusts and fits an existing C++ ecosystem; Go constrains with a small bounded runtime that buys team correctness; Rust verifies at compile time and pays off for long-lived, concurrent, security-relevant code with no runtime budget.)
- What is the cost of the borrow checker? (§4, §5 — 0 ns at run time (it is erased), paid instead in compile time and in a learning curve: the counterexample is the measured hard part, and the project calls the checker a beginner problem that fades.)
- What does safe Rust not prevent? (§5 — deadlock, race conditions, leaks, panics, release-build overflow, logic errors and resource exhaustion: all defined behavior. It also rests on sound
unsafebeneath it and a compiler with known soundness holes.) - How would you review an
unsafeblock, or a dependency that contains one? (§5, §2 — check the invariant it relies on and that privacy bounds every line that can break it to one module (Lesson 20); for a dependency you inherit its proof, so audit the crate or isolate it — in the 2018 study over half of crates could not be fully checked by the compiler.) - How would you migrate a C++ module to Rust? (§6 — by the same ledger: port first the piece where the bill is worth paying — self-contained, long-lived, security-relevant — and do not rewrite a working system just to change languages. A judgment, not a measured fact.)
- Walk the five questions over one shared value in a concurrent program. (§2 — e.g. the
Arc<Mutex<Stats>>counter: theArcowns and last-drop frees; the handler reaches it exclusively in turns;Mutex<Stats>isSyncwhenStats: Send; the check is paid at run time as a lock; the proof stops atArc/Mutex'sunsafe impls.) - Why does dropping the
Mutexaround oneReceiverfail to compile, and what does that teach about reading errors? (§1 —Receiveris notSync, so sharing&rxacross threads is rejected (E0277); the message points at one line but the fix is a Lesson-17 design choice — a lock or one consumer — and the message names the missing trait, not the design.)
Companion reads: Go · 17 Capstone (the same exercise, one language over, with Go's bill), Go · 16 Idiomatic Go (complexity as the currency), C++ · 19 Undefined behavior (the bargain Rust re-made), and C++ · 00 Orientation (C++ makes the bill itemized).