Concurrency models compared
The previous article in this track measured a workload with no waiting in it: parse JSON, sum a column, done. Most real backends are the opposite — a request spends most of its life waiting for an upstream call to come back — and that is exactly where the two languages’ concurrency models are supposed to differ. This article puts the difference under the same kind of measurement: the same fan-out program written three times (Go goroutines, Rust OS threads, Rust async tasks), run on the same machine, with wall time and memory for each.
The short version: for pure waiting, Go and Rust’s async finish in the same time and neither is complicated to write; OS threads also finish, but you pay for them in spawn time and resident memory. The real difference is not speed. It is that Go has one concurrency model, and Rust has two — and picking the wrong one is not a compile error, it is a 60× slowdown that the code gives you no hint about.
The workload
Section titled “The workload”One task simulates one upstream call: sleep for 40 ms, then send its index over a channel. The main side receives all 10,000 results and sums them. Sleeping is the honest stand-in for I/O — what the benchmark measures is what each model does while a task is not running: how much it costs to park 10,000 of them and get them all back.
Every program prints the same three lines: the task count and wait, the wall-clock time to
complete the whole wave, and a checksum — 49995000, the sum of 0..9,999 — which is how you know
all 10,000 results actually made it through the channel. Memory is the process’s maximum resident
set size, reported by /usr/bin/time -l on macOS around the whole run.
Everything here ran on an Apple M2 Max with rustc 1.99.0 and go1.27.3 darwin/arm64. One run each, shown as it came out; the numbers move a little from run to run and will move more on your machine.
The Go version
Section titled “The Go version”Go’s model needs no library, no setup, and no decisions. go starts a goroutine, a channel
carries the results back, and a blocking time.Sleep is the correct way to wait — the runtime
parks the goroutine and reuses the thread for someone else:
package main
import ( "fmt" "time")
func main() { const tasks = 10000 started := time.Now() results := make(chan int, tasks) for i := 0; i < tasks; i++ { go func(i int) { time.Sleep(40 * time.Millisecond) results <- i }(i) } total := 0 for n := 0; n < tasks; n++ { total += <-results } fmt.Printf("tasks %d, wait 40ms\n", tasks) fmt.Printf("wall %.2fs\n", time.Since(started).Seconds()) fmt.Printf("checksum %d\n", total)}tasks 10000, wait 40mswall 0.05schecksum 49995000maximum resident set size 349306880.05 s for 10,000 concurrent tasks: the wave completes at the speed of the sleep plus a few
milliseconds of scheduling, because goroutines are not threads. Each one starts with a small stack
that grows on demand, parked goroutines cost no thread, and the Go runtime multiplexes all 10,000
onto a pool of OS threads sized to the CPU. Nothing here was tuned; this is go run out of the
box.
Rust with OS threads
Section titled “Rust with OS threads”The direct translation uses std::thread::spawn and the standard library’s mpsc channel — no
dependencies at all:
use std::sync::mpsc;use std::thread;use std::time::{Duration, Instant};
fn main() { const TASKS: usize = 10_000; let started = Instant::now(); let (tx, rx) = mpsc::channel(); for i in 0..TASKS { let tx = tx.clone(); thread::spawn(move || { thread::sleep(Duration::from_millis(40)); tx.send(i as u64).expect("receiver alive"); }); } drop(tx); let total: u64 = rx.iter().sum(); let wall = started.elapsed().as_secs_f64(); println!("tasks {TASKS}, wait 40ms"); println!("wall {wall:.2}s"); println!("checksum {total}");}tasks 10000, wait 40mswall 0.45schecksum 49995000maximum resident set size 28672000It works, it is idiomatic, and it finishes nine times slower than Go. The reason is visible in the
code: thread::spawn creates a real OS thread — ask the kernel, wait for it — 10,000 times. Each
thread also reserves its own stack. The 0.45 s is mostly the spawning, not the sleeping; RSS sits
at 27.3 MB. For tens of concurrent tasks none of this matters, and OS threads are the simplest
correct answer in Rust. This workload is deliberately outside that range, because the question the
article is answering is what happens past it.
One detail worth reading twice: drop(tx). The loop clones the sender for every thread, so the
receiver’s iterator (rx.iter()) only ends when every clone is gone — including the original.
Forgetting the drop is the Rust equivalent of forgetting close(ch) in Go: the receiver waits
forever.
Rust with async
Section titled “Rust with async”The third version moves the fan-out onto tokio, the async runtime most of the Rust web ecosystem is built on:
[package]name = "conc-tokio"version = "0.1.0"edition = "2024"
[dependencies]tokio = { version = "1", features = ["rt-multi-thread", "macros", "time", "sync"] }use std::time::{Duration, Instant};
#[tokio::main]async fn main() { const TASKS: usize = 10_000; let started = Instant::now(); let (tx, mut rx) = tokio::sync::mpsc::channel(1024); for i in 0..TASKS { let tx = tx.clone(); tokio::spawn(async move { tokio::time::sleep(Duration::from_millis(40)).await; tx.send(i as u64).await.expect("receiver alive"); }); } drop(tx); let mut total = 0u64; while let Some(i) = rx.recv().await { total += i; } let wall = started.elapsed().as_secs_f64(); println!("tasks {TASKS}, wait 40ms"); println!("wall {wall:.2}s"); println!("checksum {total}");}tasks 10000, wait 40mswall 0.05schecksum 49995000maximum resident set size 7864320Same wall time as Go — 0.05 s, the sleep is the workload and both runtimes are doing the same
trick — and the smallest footprint of the three: 7.5 MB of RSS against Go’s 33.3 MB. A tokio task
is a state machine the compiler builds into your future; a goroutine carries its own allocated
stack even while parked. Cheap in both languages, cheap in different currencies. What you pay for
tokio instead is visible above the code block: a dependency, a runtime to start, and .await on
every wait.
Reading the numbers
Section titled “Reading the numbers”| Go, goroutines | Rust, OS threads | Rust, tokio | |
|---|---|---|---|
| wall time | 0.05 s | 0.45 s | 0.05 s |
| max RSS | 33.3 MB | 27.3 MB | 7.5 MB |
What the table says, and what it does not:
- For waiting, the two M:N runtimes tie. 10,000 parked tasks re-woken after 40 ms is table stakes for both Go and tokio. The performance gap between the languages in the last article came from per-request allocation, not from scheduling.
- OS threads scale worse than either — 9× the wall time here, all of it spawn cost — but the per-task numbers are arithmetic on whole-process RSS, which includes the runtime’s own memory; dividing 7.5 MB by 10,000 tasks and calling it “750 bytes per task” would be pretending to more precision than the measurement has. The direction is solid: threads cost the most, tokio tasks the least, and none of them is expensive enough to matter below a few thousand tasks.
- Go’s goroutines are cheap but not free. 33.3 MB against tokio’s 7.5 MB is the honest surprise in this table: each goroutine starts with its own stack, and 10,000 of them cost real memory even while parked. “Goroutine” is not a synonym for “free”.
Where the models actually bite
Section titled “Where the models actually bite”The wave above is the flattering case for every model. The difference that changes how you write code shows up when the waiting is done by the wrong primitive — and the two languages treat that mistake very differently.
In Go it is nearly impossible to make. Blocking a goroutine is the design; the runtime finds
something else for the thread to do. The first program in this article is blocking code — plain
sequential-looking time.Sleep calls — and it is also the fastest thing in the article.
In Rust, the same-looking call inside an async task blocks a whole worker thread, and the
runtime cannot relocate your task while a thread is stuck in std::thread::sleep. Same program
shape, one word changed, measured:
use std::time::{Duration, Instant};
#[tokio::main]async fn main() { const TASKS: usize = 1_000; const WAIT: u64 = 20;
let started = Instant::now(); let mut handles = Vec::with_capacity(TASKS); for _ in 0..TASKS { handles.push(tokio::spawn(async move { tokio::time::sleep(Duration::from_millis(WAIT)).await; })); } for h in handles { h.await.unwrap(); } println!("tasks {TASKS}, wait {WAIT}ms"); println!("async sleep wall {:.2}s", started.elapsed().as_secs_f64());
let started = Instant::now(); let mut handles = Vec::with_capacity(TASKS); for _ in 0..TASKS { handles.push(tokio::spawn(async move { std::thread::sleep(Duration::from_millis(WAIT)); })); } for h in handles { h.await.unwrap(); } println!("blocking sleep wall {:.2}s", started.elapsed().as_secs_f64());}tasks 1000, wait 20msasync sleep wall 0.03sblocking sleep wall 1.94s60× slower, for the lack of one .await. With 1,000 tasks of 20 ms each, the async version
overlaps all of them and finishes in one wait; the blocking version queues them through the
default worker pool — one thread per core, twelve on this machine — for roughly
1,000 × 20 ms ÷ 12 ≈ 1.7 s of pure waiting in line. Nothing warned about it: the code compiles,
looks natural, and is wrong at any real scale. The standard fixes are tokio::time::sleep for
timers, tokio::task::spawn_blocking for genuinely blocking work, and async-capable clients for
I/O.
This is the general shape of the difference. Go has one model, so there is nothing to choose and nowhere to stumble. Rust has two — blocking and async — and the compiler does most of the bookkeeping for you (it will not let a future escape its function by accident), but the runtime boundary between “blocking” and “async” is a convention, and crossing it by mistake is silent.
The choice also propagates. An async fn does not run when you call it — it returns a future,
which is the desugared signature underneath:
use std::future::Future;
fn fetch() -> impl Future<Output = u32> { async { 42 }}Call it and you have a future, not a u32; someone upstream has to .await it, which means
being async themselves. Async is viral up the call stack — the “function colouring” problem —
while goroutines have no colour at all: the same plain function is callable from either world. In
exchange, Rust’s compiler makes the concurrency visible in the types: what is running
concurrently, what crosses an .await, what must stay on one thread — all of it is checked before
the program ever runs. Go discovers those facts at run time, with a race detector if you remember
to turn it on.
Channels, side by side
Section titled “Channels, side by side”Both models above carry their results over a channel, and the two channel libraries share one
subtle difference worth spelling out. Go channels are closed explicitly — close(results) — and
a for v := range results loop ends when it sees the close. Rust’s mpsc has no close call: the
channel closes when the last sender is dropped, which is why both Rust versions drop(tx) before
iterating or receiving. The receive loop then reads naturally:
use std::sync::mpsc;
fn drain() { let (tx, rx) = mpsc::channel::<u64>(); drop(tx); // receivers see the channel close here for v in rx { // runs until every sender is gone let _ = v; }}Ownership replaces the close protocol: a sender cannot outlive its scope, so “is anyone still sending?” is a compile-time fact instead of a runtime convention.
What this does not show
Section titled “What this does not show”The usual limits apply, plus two specific ones. There is no real network here — a sleep does not exercise the socket, TLS, or connection pools, and a real upstream adds queueing that can dwarf everything measured above. And Go was not given its best weapon in this workload: for a mix of waiting and per-task CPU work, Go’s scheduler preempting long-running goroutines across all cores is genuinely good, while naive async Rust can starve a worker; that comparison needs a different workload than a sleep. As before, one machine, one run each — treat the table as shape, not as a spec.
Run it yourself
Section titled “Run it yourself”Rust with threads — standard library only:
cargo new conc-threads && cd conc-threads# paste src/main.rs from the OS-threads section abovecargo run --releaseRust with async — paste the Cargo.toml dependencies from the tokio section first:
cargo new conc-tokio && cd conc-tokiocargo run --releaseGo — no module setup needed, the program is standard library only:
go run main.goOn macOS, prefix any of the release binaries with /usr/bin/time -l to see the maximum resident
set size the article reports.
The next article compares the other thing every backend is made of: what happens when a call
fails. if err != nil versus Result and ?, in the same written-twice style. The overview on
the track page lists everything that is coming.