Lesson 5 — Rust: lifetimes, Box, Arc, dyn Trait
Question
Reading Reth source requires deeper Rust than dapp work. Lifetimes (<'a>), Box<dyn Trait>, Arc<dyn Trait + Send + Sync> — what they mean, when each is right.
Short answer.
| Need | Use |
|---|---|
| Borrow a trait object for one call | &dyn Trait |
Own one (single owner, a Vec of mixed types) | Box<dyn Trait> |
| Share one across threads / Tokio tasks | Arc<dyn Trait + Send + Sync> |
| One concrete type known at compile time, hot path | generics / impl Trait |
Why Arc<dyn Trait + Send + Sync>? tokio::spawn needs a Send + 'static future, and Arc<T> is Send only when T: Send + Sync, so the trait object has to carry + Send + Sync. The 'static bound means "holds no short-lived borrows", not "lives forever"; owned values and Arc clones satisfy it.
Principle (minimum model)
- Lifetimes (
<'a>). Compile-time guarantee that a reference never outlives the value it points to. Most uses are inferred; rarely-written. Box<dyn Trait>. Owned trait object on the heap. Used when you need polymorphism but ownership.Arc<dyn Trait>. Shared trait object across tasks. Add+ Send + Syncfor Tokio. Reth itself mostly wires components through generics (Node: FullNodeComponents, with associated typesPool,Evm,Consensus,Network);Arc<dyn …>shows up at a few seams, such asArc<dyn Consensus<B>>in the downloaders.&dyn Trait/&mut dyn Trait. Borrowed; no heap allocation (the call costs the same as throughBox<dyn>). Use when you don't need ownership.impl Trait. In argument position, an anonymous generic. In return position, one concrete type the function picks but does not name:fn foo() -> impl Future<...>. Since edition 2024 a returnedimpl Traitcaptures all in-scope lifetimes unless you writeuse<..>.- Generics vs
dyn Traittrade-off. Generics monomorphise (compile-time); each concrete type generates its own code.dyndispatches at runtime via vtable; smaller binary; slightly slower (mainly because calls through the vtable can't be inlined). - Reth conventions. Node components are generic type parameters (
FullNodeComponents); trait objects (Arc<dyn …>,Box<dyn …>) appear where types must be erased, such as downloaders, network handshakes and errors.
Worked example + steps
Rust: lifetimes, Box, Arc, dyn Trait
Open Reth's ExEx code and you quickly meet Arc<>, 'static bounds, dyn Trait and Box<>. Four "advanced but actually simple" Rust features — and you need every one to read the code Reth ships with. This lesson tests you, not teaches you — if you stumble on the predict prompts, the gap is real and worth closing.
1. Lifetimes 'a
A lifetime annotation tells the compiler how long a borrow lives.
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
if x.len() >= y.len() { x } else { y }
}
'ais a label for "some lifetime"- Both inputs and the output share the same
'a→ "the returned reference is valid only while both inputs are", i.e. no longer than the shorter-lived one - Often the compiler infers them — most signatures don't need explicit lifetimes
'static
A &'static T reference points at data that lives for the entire program. String literals are &'static str:
let s: &'static str = "hello";
A T: 'static bound is weaker: T holds no borrows shorter than 'static. Owned values such as String or an Arc<…> satisfy it and can still be dropped at any time. Long-running tasks (like an ExEx, or anything passed to tokio::spawn) require it because they can outlive any local scope, so you move owned data or Arc clones into them.
2. Box<T> — heap allocation
When you want a value on the heap rather than the stack, wrap it in Box<T>:
let boxed: Box<i64> = Box::new(42);
println!("{}", *boxed); // 42
Common reasons:
- Recursive types (linked lists) need a fixed-size pointer
- Holding a dynamically-sized value (
dyn Trait) - Move large values cheaply instead of copying
3. Rc<T> and Arc<T> — shared ownership
Rust's "one owner" rule sometimes gets in the way: you want multiple parts of the program to hold the same value.
| Type | Use |
|---|---|
Rc<T> | single-threaded reference counting |
Arc<T> | thread-safe (atomic reference counting) |
use std::sync::Arc;
let shared = Arc::new(String::from("hello"));
let clone1 = Arc::clone(&shared); // refcount += 1
let clone2 = Arc::clone(&shared); // refcount += 1
// Send to another thread: Arc<T> is Send because String is Send + Sync
std::thread::spawn(move || println!("{}", clone1)).join().unwrap();
Reth and ExEx code is full of Arc<...> — multiple async tasks need to read the same component.
4. Mutex / RwLock — shared mutability
Arc<T> only hands out &T. To mutate shared state, put interior mutability inside: Mutex or RwLock (or an atomic such as AtomicU64 for a counter):
use std::sync::{Arc, Mutex};
let counter = Arc::new(Mutex::new(0));
let c = Arc::clone(&counter);
std::thread::spawn(move || {
let mut n = c.lock().unwrap();
*n += 1;
}).join().unwrap();
| Type | Use |
|---|---|
Mutex | exclusive read/write |
RwLock | many readers OR one writer |
5. dyn Trait — dynamic dispatch
A trait object — like a TypeScript / Java interface, but you opt into runtime method resolution explicitly.
trait Greet {
fn greet(&self);
}
struct En;
struct Ja;
impl Greet for En { fn greet(&self) { println!("Hello"); } }
impl Greet for Ja { fn greet(&self) { println!("Konnichiwa"); } }
let g: Box<dyn Greet> = if std::env::var("LANG").unwrap_or_default().starts_with("ja") {
Box::new(Ja)
} else {
Box::new(En)
};
g.greet();
impl Trait vs dyn Trait
| Form | Meaning |
|---|---|
impl Trait | static dispatch (concrete type at compile time) |
dyn Trait | dynamic dispatch (resolved at runtime; unsized, so behind a pointer: &, Box, Arc, Rc) |
impl is faster, but dyn lets you have heterogeneous collections like Vec<Box<dyn Trait>>.
6. What you'll see in ExEx code
use futures::TryStreamExt;
async fn my_exex<Node: FullNodeComponents>(
mut ctx: ExExContext<Node>,
) -> eyre::Result<()> {
while let Some(notification) = ctx.notifications.try_next().await? {
// ...
}
Ok(())
}
ctx.notifications is a Stream of eyre::Result<ExExNotification>, not a channel, so there is no .recv(): read it with TryStreamExt::try_next (or StreamExt::next).
Node: FullNodeComponents— trait boundExExContext<Node>— generic over the node bundleNodeis the node's component bundle as type parameters; shared handles inside it are oftenArcs- Lifetime annotations are elided but
'staticis required
Cheat sheet
| Feature | One-liner |
|---|---|
'a / 'static | how long borrows live |
Box<T> | heap allocation |
Rc<T> / Arc<T> | shared ownership (Arc is thread-safe) |
Mutex<T> | safely mutate shared data |
dyn Trait | runtime method dispatch |
Final check: close this tab. Write the ExEx
my_exexsignature from memory. If you can't, you don't yet own the vocabulary — open it back up. The next lesson reads ExEx in detail; you'll need each of these without the cheat sheet.
Summary (3 lines)
- Lifetimes (
<'a>) + Box<dyn Trait> + Arc<dyn Trait + Send + Sync> + &dyn Trait + impl Trait. Each has a specific use case. - Reth conventions: node components as generics (
FullNodeComponents); trait objects only where types must be erased. Share across tasks withArc<dyn Trait + Send + Sync>. - Reading Reth needs this fluency. Next: ExEx buildup.