Skip to content
Select theme

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.

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.

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:

main.go
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 40ms
wall 0.05s
checksum 49995000
maximum resident set size 34930688

0.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.

The direct translation uses std::thread::spawn and the standard library’s mpsc channel — no dependencies at all:

tasks 10000, wait 40ms
wall 0.45s
checksum 49995000
maximum resident set size 28672000

It 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.

The third version moves the fan-out onto tokio, the async runtime most of the Rust web ecosystem is built on:

Cargo.toml
[package]
name = "conc-tokio"
version = "0.1.0"
edition = "2024"
[dependencies]
tokio = { version = "1", features = ["rt-multi-thread", "macros", "time", "sync"] }
tasks 10000, wait 40ms
wall 0.05s
checksum 49995000
maximum resident set size 7864320

Same 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.

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”.

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:

tasks 1000, wait 20ms
async sleep wall 0.03s
blocking sleep wall 1.94s

60× 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:

what async fn really is
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.

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.

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.

Rust with threads — standard library only:

Terminal window
cargo new conc-threads && cd conc-threads
# paste src/main.rs from the OS-threads section above
cargo run --release

Rust with async — paste the Cargo.toml dependencies from the tokio section first:

Terminal window
cargo new conc-tokio && cd conc-tokio
cargo run --release

Go — no module setup needed, the program is standard library only:

Terminal window
go run main.go

On 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.