Rust Operational Guide
Concise operational pointers for deep Rust troubleshooting and tuning.
Assumes you already know ownership, traits, Result/Option, basic async/.await. This skill covers the operational layer — borrow-checker corners, lifetime variance, async state machines, unsafe invariants, build/runtime profiling, allocators.
When to use
Load when the question is about:
- Advanced borrow-checker patterns: split borrows, indexed mutation,
CellvsRefCellvsMutex - Lifetime variance / subtyping (
&'a mut Tinvariance, fn arg contravariance) - NLL / Polonius / 2024-edition borrow-checker behavior
Pin/Unpin, self-referential futures,pin!vsBox::pinSend/Syncauto-trait failures,PhantomData<*const ()>opt-out- tokio scheduler:
spawn_blocking,block_in_place, runtime flavor, task starvation unsafe+ UB: aliasing rules,MaybeUninit,ptr::read/ptr::write- miri: what it catches, what it can't
- Macro debugging:
cargo expand,trace_macros! - Release-profile tuning:
lto,codegen-units,panic = "abort" - Profiling:
cargo flamegraph,samply,tracy,[profile.release] debug = true - Allocator swap: jemalloc, mimalloc, arena allocators
- Build performance: sccache, mold,
-Zshare-generics,cargo build --timings - Supply-chain:
cargo audit,cargo deny,cargo vet no_std/alloc/ embedded;#[panic_handler],#[global_allocator]
Do NOT load for: idiomatic Rust style, beginner ownership errors, basic Result/Option chaining, choosing between Vec/HashMap, simple lifetime elision questions — those don't need this skill.
Borrow checker — advanced workarounds
- Split borrows on disjoint fields:
&mut selfblocks all other borrows, but&mut self.a+&self.bis fine on a struct field path. Field-disjointness is checked syntactically — through agetter()method it fails. Workaround: expose(&mut a, &b)tuple accessors, or refactor methods to take individual fields. - Two-phase borrows:
vec.push(vec.len())compiles since 2018 because the reservation phase of&mut vecis suspended while the argument is evaluated. Won't help if the argument calls a method that needs&mut vecitself. - Indexed mutation pattern: when you need mutable access to two
Vecelements, useslice::split_at_mut(idx)to obtain two non-overlapping&mut [T]. For arbitrary index pairs, use[T]::get_disjoint_mut([i, j])(stable 1.83, formerlyget_many_mut). Index-based loops withfor i in 0..len { vec[i].mutate() }sidestep iterator-invalidation entirely. Cell<T>— single-threaded interior mutability forCopy(or move-in/move-out) only. No&to inner. Zero runtime cost. Use forCell<u32>counters, parent pointers, anywhere you'd reach for&mutbut the borrow checker says no.RefCell<T>— single-threaded, runtime-checked. Multipleborrow()OR oneborrow_mut(); conflict panics (already borrowed), does not deadlock. Cost: one word per cell + a runtime check. Use sparingly — a panic in production is rarely better than a refactor.Mutex<T>— thread-safe. On the same-thread re-lock the behavior is unspecified (may deadlock or panic). Poisons on panic-while-held: subsequentlock()returnsErr(PoisonError); recover withinto_inner()orclear_poison()(stable 1.77). For most cases,parking_lot::Mutexis faster and does not poison.OnceCell<T>/OnceLock<T>(stable 1.70) — set-once interior mutability.OnceLockis the thread-safe variant; replaces thelazy_static!andonce_cell::sync::Lazypatterns in std.
Lifetime variance
Variance determines what subtype substitutions are sound. Wrong variance = unsoundness.
| Construct | Variance |
|---|---|
&'a T |
covariant in 'a and T |
&'a mut T |
covariant in 'a, invariant in T |
*const T |
covariant in T |
*mut T |
invariant in T |
fn(T) -> () |
contravariant in T |
fn() -> T |
covariant in T |
Cell<T> / UnsafeCell<T> |
invariant in T |
PhantomData<T> |
covariant in T |
PhantomData<*const T> / PhantomData<fn() -> T> |
covariant, but opts out of Send/Sync differently |
- Why
&mut Tis invariant inT: a&mut Vec<&'static str>cannot subtype to&mut Vec<&'a str>, otherwise the callee could overwrite with shorter-lived strings then the caller reads them as'static→ UB. &'static mut Trule of thumb: extremely restrictive; you can have at most one ever, and once handed out you never get it back. Most APIs that look like they want&'static mutactually wantBox::leak()or aOnceLock.- Signaling variance with
PhantomData: lifetime-only generics (struct Foo<'a>with no'ause in fields) needPhantomData<&'a ()>to be covariant in'a. To opt out ofSend/Syncwhile keeping a type generic, usePhantomData<*const T>(sometimes spelledPhantomData<Cell<T>>for invariance + !Sync).
Borrow-checker generations: NLL, edition 2024, Polonius
- NLL (lexical → flow-sensitive) is the default since Rust 2018; ends a borrow at the last use not the end of scope.
- Edition 2024 (Rust 1.85, Feb 2025) introduces
if lettemporary scope changes andlet ... elsecleanup paths that close several "the borrow ends after the brace" papercuts. - Polonius is the next-gen borrow checker; on track to land but not stable. Accepts NLL Problem Case #3 — the conditional return of a mutable reference:
if let Some(v) = map.get_mut(&k) { return v; } map.insert(...);— currently rejected by NLL because the&mutfromget_mutis held across theelsearm. Try withRUSTFLAGS="-Zpolonius=next"on nightly. - Workarounds while waiting:
entry()API for the map case;unsafe { &mut *(ptr as *mut _) }after.get_mut().is_some()(correct, but unsafe); or split into two passes.
Pin/Unpin and async state machines
async fndesugars to a struct holding all locals across.awaitpoints; locals that hold&to other locals make the future self-referential, hence !Unpin.Pin<P>is a wrapper guaranteeing the pointee won't move. Library contract — compiler does not enforce;unsafecode (executors,pin-project) upholds it.Unpinis an auto-trait: types wherePin<&mut T>provides no extra guarantee over&mut T. Most types areUnpin. Hand-rolled futures usually are not.pin!(stable 1.68) — stack-pins a value:let fut = pin!(async { ... });returnsPin<&mut F>. Cheaper thanBox::pinbut tied to current scope.Box::pin— heap-pins. Use when the future must outlive the current frame (returning, storing in a struct field, sending totokio::spawn).PhantomPinned— zero-sized marker that opts a struct out ofUnpin.- Pin projection — going from
Pin<&mut Outer>toPin<&mut Inner>. Two rules: a field is structurally pinned if you project pin-to-pin; otherwise it's not. Mixing both correctly requiresunsafeor — preferred — thepin-project/pin-project-litecrates. - The landmine: implementing
Futureby hand and callinginner.poll(cx)without projecting pin → won't compile, but workarounds viaPin::new_uncheckedare easy to get wrong (moving the inner field after first poll = UB). Pin<&mut T>to&mut T: only safe ifT: Unpin. Otherwiseunsafe { Pin::get_unchecked_mut() }and you must not move out.
Send/Sync auto-traits
Send: safe to transfer ownership across threads.Sync:&TisSend(i.e., shareable). Both auto-derived if all fields are.- Not
Sendand notSync:Rc<T>,Cell<T>,RefCell<T>,UnsafeCell<T>,*const T/*mut T(raw pointers).MutexGuard<T>isSyncbut notSend(must be released by the locking thread on some platforms). - Holding a non-
Sendacross.awaitmakes the future non-Send— the most common spawn-error.tokio::spawnrequiresSend. Common culprits:Rc,RefCell,MutexGuardfromstd::sync::Mutex(usetokio::sync::Mutexor release before await). - Opt-in
unsafe impl Send for T {}when you've manually verified safety (e.g., wrapping a raw pointer to thread-safe C state). - Opt out: stable way is
PhantomData<*const ()>field, orPhantomData<Cell<()>>for !Sync only. Negative impls (impl !Send for T {}) are unstable.
Tokio scheduler
- Runtime flavors:
multi_thread(default, work-stealing across worker threads =available_parallelism()) andcurrent_thread(single-threaded; futures only progress while youblock_on). tokio::spawnschedules onto worker threads; requiresFuture: Send + 'staticon multi_thread.spawn_blocking(f): runsfon a separate blocking thread pool. Defaultmax_blocking_threads = 512; idle threads time out afterthread_keep_alive(10s). For sync I/O, CPU-heavy work, third-party blocking libs. ReturnedJoinHandleis not abortable.block_in_place(f): runsfon the current worker thread but signals tokio to migrate other tasks to peers. Multi-thread only — panics oncurrent_thread. Suspends concurrent code in the same task (e.g., other arms ofjoin!). Use sparingly; preferspawn_blocking.- Task starvation symptom: long synchronous computation inside an async task delays every other task on that worker. Tokio cooperatively yields only at
.awaitpoints. Fix: chunk work withtokio::task::yield_now().await, or move tospawn_blocking. - Async vs sync
Mutex:tokio::sync::Mutexallows the lock to be held across.await(its guard isSend).std::sync::Mutexguard held across.awaitmakes future !Send and risks priority inversion under contention. For brief critical sections, preferstd::sync::Mutexand release before any.await. tokio-console(subscribe viaconsole-subscribercrate,RUSTFLAGS="--cfg tokio_unstable") — instrumented runtime view: idle/poll times, blocked tasks, lock contention.
Unsafe and undefined behavior
- Aliasing rules (the Rust abstract machine, enforced by the compiler's noalias annotations to LLVM):
- Any number of
&Tmay coexist; while a&Texists, no&mut Tto overlapping memory anywhere. - A
&mut Tis exclusive: no other&or&mutto the same memory may exist or be derivable. - Raw pointers don't carry these rules but converting back to
&/&mutreinstates them.
- Any number of
- Stacked Borrows / Tree Borrows: the (still experimental) operational model that defines exactly when raw-pointer/reference interleavings are UB. Tree Borrows (newer, more permissive) is what miri runs by default since 2024. Run with
MIRIFLAGS="-Zmiri-tree-borrows"to be explicit. MaybeUninit<T>: the only sound way to hold uninitializedT. Plainmem::uninitialized()is deprecated and instant-UB for almost anyT. EvenMaybeUninit::<bool>::uninit().assume_init()is UB (boolmust be 0 or 1; reading uninitialized = invalid value).ptr::write(p, val)vs*p = val:*p = valdrops the old value first. For uninitialized memory you must useptr::write(orMaybeUninit::write) —*p = valwould drop garbage.ptr::read(p): bitwise copy; original memory becomes logically uninitialized. Afterptr::read, do not let the original be dropped.#[repr(C)]vs default#[repr(Rust)]: onlyrepr(C),repr(transparent),repr(u*)give a stable layout. Default Rust layout is unspecified — never assume field order inunsafe.- Provenance: a pointer carries identity; casting
usize-back-to-pointer may lose it on strict-provenance targets. Preferptr.with_addr(new_addr)over(ptr as usize) | flag.
miri
- Runs:
rustup +nightly component add miri && cargo +nightly miri test(ormiri run). - Detects: out-of-bounds, use-after-free, misaligned access, data races, uninitialized reads, invalid enum discriminants, alignment violations of
*const T, leaks, Stacked/Tree Borrows violations,unreachable_uncheckedreached, overlappingcopy_nonoverlapping. - Cannot detect: FFI calls (skipped or stubbed),
std::processinteractions, real network/file I/O (useMIRIFLAGS=-Zmiri-disable-isolationto allow some), platform-specific syscalls, anything that depends on actual memory addresses. - Slow: 10–100x. Run on a tight unit-test subset; not feasible for full integration suites.
- Tip:
MIRIFLAGS="-Zmiri-strict-provenance"to catch sloppyusize↔pointer conversions.
Macro debugging
cargo expand(cargo subcommand) — expands all macros in a crate to the final tokens the compiler sees. Install:cargo install cargo-expand. Requires nightly toolchain installed (uses-Zunpretty=expanded).trace_macros!(true)— built-in nightly-onlymacro_rules!tracer; prints each expansion as the compiler matches.log_syntax!— nightly-only; prints the tokens it receives during expansion. Useful when amacro_rules!arm matches the wrong branch.- proc-macro debugging:
cargo install cargo-expand; for panics in proc macros,RUST_BACKTRACE=1 cargo buildand addeprintln!to the proc-macro source — they print at compile time.
Build profiles and codegen
- Defaults (release):
opt-level = 3,debug = false,lto = false,codegen-units = 16,panic = "unwind",incremental = false. Defaults (dev):opt-level = 0,debug = true,codegen-units = 256,incremental = true. lto = "fat": whole-program LTO across all crates. 5–20% perf, large binary-size win, 3–10x slower link.lto = "thin": ~80% of fat's win, much faster.lto = falsestill does ThinLocal LTO unlesscodegen-units = 1.codegen-units = 1+lto = "fat"is the small-binary recipe. Disables parallel codegen; build time blows up.panic = "abort": dropscatch_unwind, removes unwinding tables, ~10% binary-size win, killsResult-pretending-via-panic crates. Required forno_stdtypically. Set per-profile:[profile.release] panic = "abort". Note: tests/benches forceunwindregardless.[profile.release] debug = true: keep DWARF symbols without losing optimizations — required for useful flamegraphs/perf reports. Adds ~50% binary size; ship stripped.strip = "symbols"/"debuginfo": strip in cargo (stable 1.59) without externalstrip(1).-C target-cpu=native(RUSTFLAGS=-Ctarget-cpu=native): emits SIMD/AVX for your CPU. Binary won't run on older chips. For distributed binaries, target a baseline (x86-64-v3is a common 2026 choice).
Profiling and observability
cargo flamegraph(cargo subcommand, installcargo install flamegraph): wrapsperf(Linux),xctrace(macOS), ordtrace. Default sample rate 997 Hz. Needs[profile.release] debug = truefor symbols. Flag--no-inlineif everything inlines intomain.samply— modern alternative; opens results in Firefox Profiler (richer flame view than svg).cargo install samply && samply record ./target/release/bin.tracy— frame-oriented, real-time profiler; instrumentation viatracing-tracyor thetracy-clientcrate. Best for game/render/realtime work.pprof-rs— in-process CPU profiler usable inside HTTP services; emits pprof or flamegraph on demand.- Always pair with:
RUSTFLAGS="-C force-frame-pointers=yes"for accurate stacks on optimized code.
Allocators
- Default allocator is the system allocator (glibc malloc on Linux, libSystem on macOS, HeapAlloc on Windows). Long-running services see fragmentation and tail-latency spikes under multithreaded churn.
- jemalloc swap (
tikv-jemallocatorcrate, 0.6+):
Better fragmentation, per-thread arenas, ~5–15% latency improvements on contended workloads.#[global_allocator] static GLOBAL: tikv_jemallocator::Jemalloc = tikv_jemallocator::Jemalloc; - mimalloc (
mimalloccrate): smaller code, often best on Windows; competitive on Linux. - Arena allocators (
bumpalo,typed-arena): single-bump-pointer, drop-everything-at-once. Massive wins for request-scoped allocations in servers, parser ASTs, ECS frame allocations. Caveat: items aren't dropped individually — careful withDroptypes. - Detecting it's worth it: RSS growth over 24h with steady workload, or
jemalloc'sstats.printshowing high external fragmentation. Don't swap without a baseline.
no_std and embedded
#![no_std]: links againstcoreonly, notstd. No heap, no threads, noVec, noString, noprintln!.extern crate alloc;+ a#[global_allocator]re-enablesBox/Vec/String/BTreeMapwithout fullstd. Common embedded allocators:embedded-alloc,linked_list_allocator,talc.#[panic_handler]is mandatory inno_stdbinaries — no default exists. Common crates:panic-halt,panic-abort,panic-semihosting,panic-probe(defmt).#[global_allocator]mandatory if youextern crate alloc;inno_std.- Floats:
core::f32lacks transcendentals (sin,cos,exp) — uselibmcrate. #![no_main]+#[entry]fromcortex-m-rt(or equivalent) replace the standardmain.
Build performance
cargo checkis 2–3x faster thancargo build— runs frontend + borrow check, skips codegen. Use in editors/CI for fast feedback.cargo build --timingsgeneratestarget/cargo-timings/cargo-timing.html— Gantt of dep parallelism. Look for sequential bottlenecks (build-script crates that block everything).moldlinker (Linux):RUSTFLAGS="-C link-arg=-fuse-ld=mold"or[target.x86_64-unknown-linux-gnu] linker = "clang"; rustflags = ["-C", "link-arg=-fuse-ld=mold"]. 5–10x faster link on big binaries.lld: cross-platform LLVM linker.-C link-arg=-fuse-ld=lld. Slower than mold on Linux; fine on macOS via-C link-arg=-fuse-ld=lldif installed.sccache: shared compilation cache, best for CI and multi-project workstations.RUSTC_WRAPPER=sccache cargo build. Less useful for incremental local builds (cargo's own incremental is faster).-Z share-generics(nightly): share monomorphizations across crates.RUSTFLAGS="-Zshare-generics=y". ~10–20% rebuild win on big workspaces.-Z threads=N(nightly): parallel frontend.RUSTFLAGS="-Zthreads=8". Up to 50% wall-clock win on cold builds.- Incremental gotcha: incremental compilation caches in
target/debug/incrementalcan corrupt and produce baffling errors.cargo clean -p <crate>if a single crate misbehaves;rm -rf target/debug/incrementalfor full reset. - Per-file build-script optimization:
[profile.dev.build-override] opt-level = 3— proc-macros and build scripts run optimized even in dev.
Supply-chain
cargo audit(cargo install cargo-audit): scansCargo.lockagainst the RustSec advisory database. CI-friendly, exits non-zero on findings.cargo deny(cargo install --locked cargo-deny): broader than audit. Four checks:licenses(allow/deny SPDX list),bans(forbid specific crates, detect duplicates),advisories(RustSec),sources(only registry crates from approved registries / git URLs). Singledeny.tomlconfig.cargo vet(Mozilla): per-dependency human review attestations, audit imports from other orgs (e.g., import Mozilla's audits). Heavier thanaudit/deny; intended for orgs that ship to end users.cargo update --precise X.Y.Z -p crate: pin a single transitive dep to dodge an advisory without bumping everything else.
Authoritative references
Official docs (doc.rust-lang.org):
- The Rustonomicon —
unsafe, aliasing, drop check, variance - Rust Reference: Subtyping & Variance
std::pinstd::mem::MaybeUninitstd::sync::Mutex- Async Book
- Embedded Rust Book
- Cargo profiles
- Edition 2024 guide
Tooling:
- tokio docs —
spawn_blocking,block_in_place, runtime flavors - miri — UB detection
- pin-project — sound pin projection
- cargo-deny book / RustSec advisory DB
- flamegraph-rs, samply
Authoritative blogs:
- Niko Matsakis — smallcultfollowing.com/babysteps (Polonius, NLL, lang-team rationale)
- Amos / fasterthanlime — pin, async, ergonomic deep-dives
- without.boats — async fundamentals, Pin design history
- Yoshua Wuyts — async ecosystem, traits
- Ralf Jung — UB, Stacked/Tree Borrows, miri internals
- Nicholas Nethercote — The Rust Performance Book
- corrode.dev (Matthias Endler) — compile-time and ergonomics
Guardrails
Before recommending a non-trivial operational change (allocator swap, panic strategy, codegen-units, RUSTFLAGS, unsafe code):
- Quote the specific flag/setting and its default
- Cite the official Rust doc / Reference / Nomicon / Async Book section
- Make the recommendation conditional on observed evidence — never blanket-tune
Tuning without measurement is worse than defaults. And: every unsafe block must list the invariants it upholds in a // SAFETY: comment.