Go's default concurrency performance is surprisingly hard for Rust to match
Default Tokio and its channels lag considerably behind Go. Tuned Tokio with kanal beat it, at a cost.
So this week I had a thought about how Rust would perform against Golang in concurrency and CSP.
For this benchmark, I used Claude Code to write the entire benchmark suite.
Turns out that default Tokio and its channels lag considerably behind Golang in many cases.
Default Tokio vs Crossbeam vs Go
Crossbeam is included mainly as a channel baseline. It uses OS threads rather than Tokio’s async runtime, so this isn’t an apples-to-apples runtime comparison. I wanted to see how much of the difference was coming from the channel implementation itself.
| Test | Tokio | Crossbeam | Go |
|---|---|---|---|
| 1 sender → 1 receiver, capacity 1024 | 13.2M | 38.3M | 29.6M |
| 1 sender → 1 receiver, capacity 1 | 5.13M | 6.76M | 11.3M |
| 4 senders → 1 receiver, capacity 1024 | 8.81M | 35.4M | 25.9M |
| 4 senders → 1 receiver, capacity 1 | 5.00M | 5.74M | 10.1M |
| 4 senders → 4 receivers, capacity 1024 | 9.32M | 23.3M | 18.7M |
| 4 senders → 4 receivers, capacity 1 | 2.03M | 5.17M | 6.87M |
| Spawn and join, 1M tasks | 4.05M | — | 6.48M |
| Lock contention, 8 workers | 9.78M | 31.9M | 25.6M |
Messages/s for channel tests, tasks/s for spawning, increments/s for lock contention. Tokio doesn’t have a channel with multiple receivers, so the 4 → 4 tests use async-channel.
I expected Rust to lose because of cooperative scheduling, but not THIS MUCH. There aren’t enough state machines here for cooperative scheduling to really explain the difference either.
That made me wonder whether the channel was the problem, or whether Tokio just needed some tuning.
I asked Claude to tune it too.
And well, Rust did beat Golang eventually.
Tuned Tokio
| Test | Tokio | Tuned Tokio + Tokio channels | Tuned Tokio + kanal | Go |
|---|---|---|---|---|
| 1 → 1, capacity 1024 | 13.2M | 23.8M | 137M | 29.6M |
| 1 → 1, capacity 1 | 5.13M | 5.12M | 20.5M | 11.3M |
| 4 → 1, capacity 1024 | 8.81M | 7.07M | 104M | 25.9M |
| 4 → 4, capacity 1 | 2.03M | 1.93M | 19.4M | 6.87M |
The results were surprising, ngl.
137M messages/s compared to Go’s 29.6M is honestly fucking insane.
But the throughput came at a cost. kanal’s receive operation isn’t cancel-safe when used in patterns like select! and timeouts.
I have read that the creator is going to add this in an upcoming version, but they generally consider this kind of cancellation an anti-pattern for Rust, which is fair.
But I come from Golang, and select and timeout-based cancellation are basically bread and butter over here.
There were some other interesting results too.
| Test | Tuned Tokio | Go |
|---|---|---|
| Ping-pong latency p50 | 80 ns | 241 ns |
| Ping-pong latency p99 | 2.13 µs | 391 ns |
| Select over 2 channels, capacity 1024 | 40.6M | 13.9M |
| CPU-heavy hashing | 115M | 108M |
| Lock contention, 8 workers | 80.0M | 25.6M |
| Memory per idle task, 1M tasks | 258 B | 2.69 KiB |
(Only ping-pong here uses kanal. Select stays on Tokio’s own channel because of the cancel-safety thing, and lock contention uses parking_lot.)
I even generated flame graphs to see why there was such a big difference.
And boy oh boy, the Rust and Golang flame graphs look very different. The Rust stacks go way deeper.
I know it’s not really the topic of this benchmark, but it’s a really interesting observation IMO.