9.8 KiB
Why Rust Does Not Use Fibers or Garbage Collection
The Question
Most runtime-heavy languages ship with two things: a garbage collector (Go, Java, Python, C#, Erlang) and fiber-like concurrency (Go goroutines, Erlang processes, Java virtual threads). Rust ships with neither. Why?
The answer is the same for both: Rust pushes the cost to compile time so there is zero cost at runtime.
Garbage Collection
What a GC Does
A garbage collector tracks which objects in memory are still reachable from the program. Periodically (or continuously), it scans the heap, finds objects nothing points to, and frees them.
Allocate object A
Allocate object B
A.ref = B // B is reachable through A
drop(A) // A is unreachable — GC will free A
// B is now also unreachable — GC will free B
How It Works (Simplified)
Mark-and-sweep (Go, Java):
1. Pause the program (or run concurrently)
2. Start from "roots" (stack variables, globals)
3. Mark every object reachable from roots
4. Sweep: free every object NOT marked
Reference counting (Python, Swift, Objective-C):
1. Every object has a counter
2. When a reference is created: counter++
3. When a reference is dropped: counter--
4. When counter == 0: free immediately
The Costs
| Cost | Mark-and-sweep GC | Reference counting |
|---|---|---|
| Pause time | Stop-the-world pauses (Go: ~1ms, Java: varies) | No pauses, but slower per-operation |
| Memory overhead | 2x heap needed (live objects + garbage until collected) | Counter per object (8 bytes) |
| CPU overhead | GC thread scanning heap (10-30% throughput loss) | Increment/decrement on every pointer operation |
| Predictability | Unpredictable latency spikes | Predictable but cycles leak (need cycle collector) |
| Cache impact | GC walks heap → cache pollution | Counters spread across memory → cache misses |
For a content platform that needs predictable low-latency responses, GC pauses are the enemy. Even Go's ~1ms pauses compound under load — if a GC pause hits during epoll_wait, every pending connection stalls.
What Rust Does Instead: Ownership
Rust replaces garbage collection with a compile-time ownership system:
fn main() {
let s = String::from("hello"); // s owns the string, allocated on heap
let t = s; // ownership moves to t — s is invalid
// println!("{}", s); // compile error: s was moved
println!("{}", t); // ok
} // t goes out of scope → String freed here. Deterministic. No GC.
The rules:
- Every value has exactly one owner
- When the owner goes out of scope, the value is dropped (freed)
- Ownership can be moved or borrowed, but never duplicated
The compiler enforces these rules at compile time. At runtime, there is:
- No GC thread
- No mark phase
- No sweep phase
- No reference counters
- No heap scanning
- No pauses
Memory is freed at the exact point it is no longer needed — deterministically, at the closing brace.
Lifetimes: The Compile-Time GC
References (borrows) have lifetimes — the compiler tracks how long each reference lives and ensures no reference outlives its data:
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
if x.len() > y.len() { x } else { y }
}
The 'a lifetime annotation tells the compiler: "the returned reference lives as long as both inputs." If you try to return a reference to a local variable, the compiler rejects it — at compile time, not at runtime.
fn bad() -> &str {
let s = String::from("hello");
&s // compile error: s is dropped at end of function, reference would dangle
}
This is what a GC does at runtime (detect unreachable memory). Rust does it at compile time (detect impossible references). Zero runtime cost.
The Tradeoff
| Aspect | GC languages | Rust |
|---|---|---|
| Developer effort | Low — just allocate, GC handles cleanup | Higher — must think about ownership and lifetimes |
| Compile time | Fast | Slower (borrow checker analysis) |
| Runtime cost | GC pauses, heap scanning, memory overhead | Zero — deterministic drop at scope exit |
| Latency | Unpredictable (GC can pause anytime) | Predictable — no hidden pauses |
| Memory usage | 2x+ (garbage accumulates between collections) | Tight — freed immediately when unused |
Rust trades developer convenience for runtime performance. For systems software (databases, runtimes, web servers), this is the right trade.
Fibers
Why Other Languages Use Fibers
Go has goroutines. Erlang has processes. Java 21 has virtual threads. These are all fibers — lightweight user-space threads that the runtime schedules cooperatively (or semi-preemptively in Go's case).
They exist because these languages need to:
- Handle millions of concurrent I/O tasks
- Let developers write synchronous-looking code (
result = fetch(url)) that blocks the fiber, not the OS thread - Manage scheduling without exposing the event loop
// Go: goroutine blocks on I/O — runtime suspends it and runs another
go func() {
resp, _ := http.Get("https://example.com") // blocks this goroutine, not the thread
fmt.Println(resp.Status)
}()
Why Rust Does Not Use Fibers
1. Fibers require a runtime that allocates stacks.
Each fiber needs its own stack (Go starts at 2-8 KB, grows dynamically). This means:
- A heap allocation per fiber
- Stack overflow checks on every function call
- A runtime that manages stack growth and shrinking
- Memory overhead proportional to number of concurrent tasks
Rust's goal is zero-cost abstractions. Allocating stacks at runtime is a cost.
2. Fibers are hard to optimize across FFI boundaries.
Rust interoperates with C libraries extensively. Fibers with tiny stacks can't safely call into C code (which expects a full OS stack). Go solves this by switching to a system stack for cgo calls — adding complexity and overhead.
3. Async/await achieves the same concurrency without stacks.
Rust's async/await compiles each async function into a state machine — a regular struct stored inline, no heap allocation needed:
async fn fetch_article(title: &str) -> Article {
let data = read_from_seg(title).await; // suspend point 1
let html = render_markdown(&data).await; // suspend point 2
Article { title, html }
}
The compiler transforms this into something like:
enum FetchArticle {
Start { title: String },
AfterRead { title: String, data: Vec<u8> },
AfterRender { title: String, html: String },
Done,
}
Each .await becomes a variant transition. The "stack" is just the live variables in the current variant — bytes, not kilobytes. No allocation, no stack, no runtime overhead.
Fiber vs Async/Await: Memory Per Task
Go goroutine: ~2,048 bytes minimum (stack)
Erlang process: ~2,688 bytes minimum (stack + heap + mailbox)
Rust async task: size_of::<FetchArticle>() — often 32-128 bytes
For a million concurrent connections:
- Go: ~2 GB just for goroutine stacks
- Rust: ~128 MB for state machines (and often less, since the executor batches them)
When Fibers Would Be Better
Fibers have one advantage: deeply nested call stacks that suspend at arbitrary points. If a function 20 calls deep needs to yield, a fiber just swaps the stack pointer. With async/await, every function in the chain must be async and every call must be .awaited — the "async infection" problem.
Fibers: yield anywhere in the call stack — transparent to callers
Async: yield only at .await points — every caller must be async
For database engines with complex query execution plans that suspend mid-evaluation, fibers are compelling. For an HTTP server that suspends at I/O boundaries, async/await is strictly better.
How This Applies to writeonce
writeonce uses neither fibers nor async/await. It uses a plain event loop:
loop {
events = epoll_wait();
for event in events {
handle(event); // runs to completion, no suspension
}
}
This is the simplest model — no GC, no fibers, no async state machines. Each handler reads from the .seg file, renders a template, writes to the socket, and returns. Nothing suspends mid-handler.
The memory model:
| What | How it's managed |
|---|---|
| Article data in .seg | Owned by Store, freed when Store drops |
| Template ASTs | Owned by TemplateRegistry, live for the process lifetime |
| HTTP connections | Owned by HashMap<Token, Connection>, freed on close/hangup |
| Subscription table | Owned by SubscriptionManager, entries removed on EPOLLHUP |
No garbage. No fibers. No async. Just ownership, scopes, and the kernel's event notification. The Rust compiler guarantees at compile time that every allocation is freed exactly once, at exactly the right time.
Summary
GC languages (Go, Java): runtime scans heap → frees unreachable objects
cost: pauses, memory overhead, CPU overhead
Reference counting (Python): counter per object → free at zero
cost: per-operation overhead, cycle leaks
Rust ownership: compiler tracks ownership → free at scope exit
cost: zero at runtime, developer thinks harder
Fibers (Go, Erlang): runtime manages stacks → swap on yield
cost: stack allocation, stack checks, runtime
Async/await (Rust): compiler generates state machines → no stack
cost: zero allocation, async must propagate
Event loop (writeonce): no tasks, no suspension → handlers run to completion
cost: nothing — simplest possible model
Rust's answer to both GC and fibers is the same: make the compiler do the work so the runtime doesn't have to.