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.

TestTokioCrossbeamGo
1 sender → 1 receiver, capacity 102413.2M38.3M29.6M
1 sender → 1 receiver, capacity 15.13M6.76M11.3M
4 senders → 1 receiver, capacity 10248.81M35.4M25.9M
4 senders → 1 receiver, capacity 15.00M5.74M10.1M
4 senders → 4 receivers, capacity 10249.32M23.3M18.7M
4 senders → 4 receivers, capacity 12.03M5.17M6.87M
Spawn and join, 1M tasks4.05M—6.48M
Lock contention, 8 workers9.78M31.9M25.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

TestTokioTuned Tokio + Tokio channelsTuned Tokio + kanalGo
1 → 1, capacity 102413.2M23.8M137M29.6M
1 → 1, capacity 15.13M5.12M20.5M11.3M
4 → 1, capacity 10248.81M7.07M104M25.9M
4 → 4, capacity 12.03M1.93M19.4M6.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.

TestTuned TokioGo
Ping-pong latency p5080 ns241 ns
Ping-pong latency p992.13 µs391 ns
Select over 2 channels, capacity 102440.6M13.9M
CPU-heavy hashing115M108M
Lock contention, 8 workers80.0M25.6M
Memory per idle task, 1M tasks258 B2.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.

← All posts