Back to Writing
10 min read

Rust vs Go Threading Models: A Performance Deep Dive

Rust Go Performance Threading

Most Go-versus-Rust comparisons stay on memory safety, compile times, and syntax. That misses the part that shows up under load: the scheduler.

When a service is carrying 10k+ concurrent connections, throughput depends on where work is parked, resumed, copied, and woken. That is where Go and Rust stop being abstract language choices and become different operating models.

Threading Models Benchmark

Go’s M:N Scheduling (Goroutines)

Go’s runtime takes a highly opinionated approach with its M:N scheduler. It multiplexes M lightweight goroutines onto N OS threads.

Advantages

  • Developer Ergonomics: The code reads like synchronous code. The runtime handles parking and resuming work.
  • Preemption: Go’s scheduler is preemptive (cooperatively preemptive via function calls, and fully preemptive since Go 1.14 via signals). A tight loop won’t permanently starve the system.

Drawbacks

  • Stack Growth: Each goroutine starts with a small stack (typically 2KB) that grows dynamically. This growth requires copying the stack and adjusting pointers, which can cause micro-stalls during high-concurrency spikes.

Rust’s Async/Await + OS Threads

Rust offers OS threads for heavy computational workloads and async/await (typically powered by Tokio or async-std) for I/O-bound concurrency.

Async Advantages

  • Compiled State Machines: Rust’s async code compiles into state machines, so the executor can schedule compact task state instead of growing stacks.
  • Memory Footprint: Because the exact size of the state machine is known at compile time, memory allocation is statically verifiable and significantly smaller than even Go’s 2KB minimum.

Async Drawbacks

  • Cooperative Preemption: If a Rust task blocks the thread (e.g., executing a long CPU-bound operation without yielding), it blocks the entire executor.
  • Ecosystem Fragmentation: Choosing a runtime binds you tightly to its ecosystem, unlike Go’s unified standard library.

Memory Profile: 10k Concurrent Tasks

MetricGo (Goroutines)Rust (Async Tasks)Delta
Idle Memory~20 MB (2KB/gr)~0.8 MB (80B/task)-96%
Peak Memory (I/O)~85 MB~12 MB-85.8%
Context Switch Overhead0.2µs - 1.5µs0.1µs - 0.5µs-60%
Stack ManagementDynamic (Copying)Static (State Machine)N/A

Technical Deep Dive: Zero-Copy and State Machines

The gap is not only scheduling. It is also memory layout. Rust’s async functions compile into anonymous state machines. Because their size is known at compile time, the executor can allocate the needed task state directly instead of starting with a small stack and growing it later.

Zero-Copy Performance

In high-throughput services, avoidable copies show up quickly. Rust’s Bytes crate gives reference-counted slices, so headers and bodies can move through an async pipeline without copying the underlying buffer.

use bytes::Bytes;

// Slicing doesn't copy - just increments refcount
let data = Bytes::from("huge_payload_from_network");
let header = data.slice(0..16);  // Zero-copy!
let body = data.slice(16..);     // Zero-copy!

Rigorous Benchmarking with Criterion

For repeatable measurement, I used criterion on the Rust side with warmup and outlier detection.

use criterion::{black_box, criterion_group, criterion_main, Criterion};

fn bench_actor_latency(c: &mut Criterion) {
    c.bench_function("10k_actors_ping_pong", |b| {
        b.iter(|| {
            // Task execution logic here
            black_box(run_actor_cycle())
        })
    });
}

Benchmark Analysis: 1:1 Actor Ping-Pong

To test the raw overhead of the schedulers, I built a 1:1 actor ping-pong microbenchmark (1 million messages passed back and forth) comparing Go’s native channels against Rust’s tokio::sync::mpsc.

MetricGo (Native Channels)Rust (Tokio MPSC)Delta
Time per Operation238 ns/op~5,300 ns/op (5.3 µs)Go is ~22x Faster
Scheduler IntegrationNative / DeepUser-land (Tokio)

Methodology

This is the part that surprised me: Go’s scheduler and channels are very strong for synchronous actor-to-actor message passing. Tokio’s mpsc path carries more user-land runtime overhead because each yield goes through reactor and wake-up machinery.

To reproduce:

  1. Clone the Benchmark Suite:
    git clone https://gist.github.com/omar391/841886c96cb449c252c6384c3de33502 actor-bench
    cd actor-bench
  2. Run Benchmarks:
    go test -bench=BenchmarkPingPong
    cargo bench

Conclusion

If the workload is mostly message passing and the team values simple concurrency primitives, Go remains hard to beat. If memory layout, static task size, and explicit async boundaries matter more, Rust gives tighter control. The interesting decision is not which language is better; it is which runtime shape matches the failure mode you are trying to avoid.

Thanks for reading. If you found this useful, feel free to DM me on X/LinkedIn.