Skip to content
← Inside Reth — Sync, Extensions, and the SDK
The Reth Stack — Sync, Extensions, and the SDK
Lesson 6 of 17·CONTENT15 min30 XP
Course
Inside Reth — Sync, Extensions, and the SDK
Lesson role
CONTENT
Sequence
6 / 17

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.

NeedUse
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 tasksArc<dyn Trait + Send + Sync>
One concrete type known at compile time, hot pathgenerics / 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 + Sync for Tokio. Reth itself mostly wires components through generics (Node: FullNodeComponents, with associated types Pool, Evm, Consensus, Network); Arc<dyn …> shows up at a few seams, such as Arc<dyn Consensus<B>> in the downloaders.
  • &dyn Trait / &mut dyn Trait. Borrowed; no heap allocation (the call costs the same as through Box<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 returned impl Trait captures all in-scope lifetimes unless you write use<..>.
  • Generics vs dyn Trait trade-off. Generics monomorphise (compile-time); each concrete type generates its own code. dyn dispatches 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 }
}
  • 'a is 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.

TypeUse
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();
TypeUse
Mutexexclusive read/write
RwLockmany 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

FormMeaning
impl Traitstatic dispatch (concrete type at compile time)
dyn Traitdynamic 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 bound
  • ExExContext<Node> — generic over the node bundle
  • Node is the node's component bundle as type parameters; shared handles inside it are often Arcs
  • Lifetime annotations are elided but 'static is required

Cheat sheet

FeatureOne-liner
'a / 'statichow long borrows live
Box<T>heap allocation
Rc<T> / Arc<T>shared ownership (Arc is thread-safe)
Mutex<T>safely mutate shared data
dyn Traitruntime method dispatch

Final check: close this tab. Write the ExEx my_exex signature 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 with Arc<dyn Trait + Send + Sync>.
  • Reading Reth needs this fluency. Next: ExEx buildup.