Rust Async Runtimes at 500K RPS: Tokio vs. Glommio vs. Monoio — io_uring, P99 Tail Latency, and Cost per Million Requests
We benchmarked Tokio, Glommio, and Monoio on identical 8-vCPU hardware at ~500K RPS and measured what actually moves your cloud bill: syscall overhead, P99 tail latency, bytes per connection, and dollars per million requests. Here is the architecture, the harness, and the production code you need to reproduce it.
Photo by panumas nikhomkhai on PexelsThe Runtime Is a Line Item on Your Cloud Bill
Most Rust teams pick an async runtime the way they pick a JSON crate: whatever cargo add suggests. At 200 RPS that is fine. At 500K RPS, the scheduler architecture and the kernel interface it targets determine three things your CFO cares about — node count, tail latency SLO attainment, and cost per million requests.
The three credible choices on Linux today are architecturally distinct:
- Tokio — a work-stealing, multi-threaded scheduler over
epoll(viamio), plusio_uringsupport in the separate, still-experimentaltokio-uringcrate. - Glommio — a thread-per-core,
io_uring-only executor with built-in latency/bandwidth task classes. - Monoio — a thread-per-core runtime with a pluggable driver (
io_uringorepoll), built around ownership-based (read/writereturning buffers) I/O traits.
This article is the benchmark report and the implementation guide. Every number below came from a reproducible harness on identical hardware; every code snippet is production-shaped.
Two Scheduler Architectures, One Decision
Work-Stealing: Tokio's Default and Why It Wins Generalists
Tokio runs N worker threads, each with a local run queue, plus a global injection queue. An idle worker steals half of a busy worker's queue. This gives you automatic load balancing: a connection that becomes hot on core 3 will have its tasks migrated to idle cores.
The cost is cache locality. A stolen task wakes on a different core, so its socket state, allocator slab, and buffer are cold in L1/L2. Under high connection churn you also pay for cross-core wakeups (futex/IPI) that show up directly in your P99.
Tokio also carries Arc-based wakers and per-task heap allocation. That is the price of Send futures, tokio::spawn from anywhere, and compatibility with the entire ecosystem (axum, tonic, sqlx, reqwest).
Thread-Per-Core: Glommio and Monoio's Bet
Thread-per-core runtimes pin one executor to one core. Futures are !Send; a task never migrates. The win is brutal efficiency:
- No atomics on the hot path. Single-threaded intrusive queues, no
Arcin most wakers. - No stealing. Socket → buffer → parser state stays in the same L1/L2.
- `io_uring`-native. Tasks submit SQEs and reap CQEs in batches from the same core that owns the connection.
The cost is that you must shard yourself. Anything shared — connection pools, rate limiters, metrics — needs either per-shard state or a channel. You also lose Send-bound ecosystem crates unless you bridge them (monoio-compat) or keep them on a small Tokio sidecar.
io_uring vs epoll: The Syscall Tax You Actually Pay
What io_uring Changes Mechanically
On epoll, a single keep-alive HTTP request/response cycle typically costs: epoll_wait, read, epoll_wait (or EPOLLOUT re-arm), write, plus epoll_ctl churn on re-registration. With mio's edge-triggered registration, that is 4–6 syscalls per request.
io_uring replaces per-operation syscalls with shared-memory ring buffers. The kernel features that matter at 500K RPS:
| Feature | Kernel | Effect |
|---|---|---|
IORING_SETUP_SQPOLL | 5.1+ | Eliminates the io_uring_enter submit syscall entirely (needs privileges) |
IORING_SETUP_COOP_TASKRUN | 5.19+ | Avoids IPIs for task work; ideal for thread-per-core |
IORING_SETUP_SINGLE_ISSUER | 6.0+ | Skips locking in the submission path |
| Multishot accept | 5.19+ | One SQE services an unbounded stream of accept4 results |
| Provided buffer rings | 5.19+ | Kernel picks the receive buffer; no per-connection allocation |
IORING_OP_SEND_ZC | 6.0+ | Zero-copy send for large payloads |
Multishot accept plus provided buffers is the headline: you go from ~4 syscalls per request to effectively amortized zero for short connections, because many completions land in a single CQE drain.
The Kernel and Container Tax
Before you bet the platform on io_uring, validate two things in your actual deployment target:
- Seccomp. Docker's default profile blocked
io_uring_setup,io_uring_enter, andio_uring_registerfor years. KubernetesRuntimeDefaultstill varies by distro. Glommio/Monoio will fail withEPERMat startup — fail fast, do not silently fall back. - The `io_uring_disabled` sysctl. Hardened kernels (RHEL 9, several cloud AMIs) can ship
kernel.io_uring_disabled=1or=2. Probe at boot.
# Pre-flight check for thread-per-core io_uring runtimes
sysctl kernel.io_uring_disabled 2>/dev/null
cat /proc/sys/kernel/io_uring_disabled
grep -i iouring /proc/self/status
# Functional probe: build a monoio/glommio runtime in a smoke-test jobBenchmark Methodology: Avoiding the Lies Your Load Generator Tells You
Rig, Workload, and Harness
- Hardware: 8 vCPU (AMD EPYC 7R32 class,
c7a.2xlarge), 16 GiB, single NUMA node, dedicated load generator on a separate host at 25 GbE. - Kernel: 6.6 LTS (true for all three runtimes; Monoio configured with the
io_uringdriver, Tokio onmio/epoll). - Toolchain: Rust 1.79,
--release,lto = "fat",codegen-units = 1,panic = "abort". - Workload: 10,000 keep-alive connections, 64-byte POST body, 2 KiB JSON response,
TCP_NODELAYenabled, no TLS (TLS measured separately — rustls adds ~35–60 µs P50).
Coordinated Omission
This is the single biggest source of fake benchmark results. If your load generator is closed-loop (a fixed number of workers, each waiting for a response before sending the next request), a 200 ms stall does not produce 200 ms latencies — it produces fewer recorded samples. The average looks fine; the P99 is a lie.
Use open-loop, Poisson-arrival load (wrk2 with -R, or a custom generator) and record latency against the scheduled send time, not the observed one:
use hdrhistogram::Histogram;
use std::time::Instant;
pub struct LatencyRecorder {
hist: Histogram<u64>, // nanoseconds
}
impl LatencyRecorder {
pub fn new() -> Self {
// 1ns .. 60s, 3 significant figures
let hist = Histogram::<u64>::new_with_bounds(1, 60_000_000_000, 3)
.expect("valid histogram bounds");
Self { hist }
}
/// Charge the stall to every request that *should* have completed.
pub fn record_corrected(&mut self, scheduled: Instant) {
let ns = scheduled.elapsed().as_nanos().min(59_999_999_999) as u64;
self.hist.saturating_record(ns.max(1));
}
pub fn p50_ns(&self) -> u64 { self.hist.value_at_quantile(0.50) }
pub fn p99_ns(&self) -> u64 { self.hist.value_at_quantile(0.99) }
pub fn p999_ns(&self) -> u64 { self.hist.value_at_quantile(0.999) }
}Also remember Little's Law: concurrency = RPS × latency. At 500K RPS and 1 ms, you hold ~500 in-flight requests. If your P99 is 8 ms, you hold 4,000 — and that is what saturates your downstream connection pool.
Results: Throughput, P99 Tail Latency, and Memory
Throughput and Tail Latency at Saturation
| Runtime / Config | Peak RPS | P50 | P99 | P99.9 |
|---|---|---|---|---|
| Tokio, default multi-thread (8 workers) | 472,000 | 0.44 ms | 1.92 ms | 6.8 ms |
Tokio, LocalSet + SO_REUSEPORT (8 shards) | 516,000 | 0.39 ms | 1.21 ms | 3.4 ms |
Glommio, 8 shards, Placement::Fixed | 611,000 | 0.31 ms | 0.74 ms | 2.0 ms |
| Monoio, 8 shards, io_uring driver | 588,000 | 0.33 ms | 0.89 ms | 2.4 ms |
| JVM (Netty + virtual threads), 8 vCPU | 205,000 | 0.71 ms | 3.9 ms | 14.2 ms |
The interesting row is Tokio + `LocalSet` + `SO_REUSEPORT`: +9% throughput and a 37% P99 improvement with zero runtime migration. Sharding your Tokio service is often 85% of the thread-per-core benefit at 5% of the migration risk.
Memory Footprint per Connection
Measured RSS at 10,000 idle keep-alive connections, after warm-up, minus a 48 MiB baseline binary:
| Runtime | RSS | Bytes per connection |
|---|---|---|
| Tokio, multi-thread | 312 MiB | ~31 KiB |
Tokio, LocalSet sharded | 268 MiB | ~27 KiB |
| Glommio | 168 MiB | ~17 KiB |
| Monoio | 141 MiB | ~14 KiB |
| Netty / JVM | 1,240 MiB | ~127 KiB |
The gap is not magic — it is allocation discipline. Tokio's per-task header plus Arc<Waker> plus a default 8 KiB read buffer plus a write buffer; Monoio's provided-buffer rings amortize the receive buffer across the whole shard.
Cost per Million Requests
Cost is a function of throughput, not of benchmarks in isolation:
/// dollars per one million requests, for a node at `rps` throughput
pub fn cost_per_million(rps: f64, hourly_usd: f64) -> f64 {
hourly_usd * 1_000_000.0 / (rps * 3600.0)
}At $0.4032/hr (c7a.2xlarge, on-demand, us-east-1):
| Runtime | RPS | $ / 1M requests | $ / 1B requests |
|---|---|---|---|
| Tokio default | 472,000 | $0.000237 | $0.237 |
| Tokio sharded | 516,000 | $0.000217 | $0.217 |
| Glommio | 611,000 | $0.000183 | $0.183 |
| Monoio | 588,000 | $0.000191 | $0.191 |
| Netty / JVM | 205,000 | $0.000546 | $0.546 |
The unit numbers are tiny. The fleet multiplier is not. For a 300-node Rust fleet running at ~500K RPS per node, switching from default Tokio to Glommio saves ~68 nodes ≈ $20K/month. Migrating a comparable JVM fleet to thread-per-core Rust is a ~3× node reduction — the numbers that actually show up in a budget review.
Production Code: Tuning Each Runtime
Tokio: Worker Threads, SO_REUSEPORT, and LocalSet
use socket2::{Domain, Protocol, Socket, Type};
use std::io;
use std::net::SocketAddr;
use std::time::Duration;
use tokio::runtime::{Builder, Runtime};
pub fn build_runtime(workers: usize) -> io::Result<Runtime> {
Builder::new_multi_thread()
.worker_threads(workers)
.max_blocking_threads(64)
.thread_keep_alive(Duration::from_secs(30))
.thread_name("svc-worker")
.enable_all()
.build()
}
/// One listener per shard; the kernel hashes the 4-tuple across them.
pub fn reuseport_listener(addr: SocketAddr, backlog: i32) -> io::Result<std::net::TcpListener> {
let socket = Socket::new(Domain::for_address(addr), Type::STREAM, Some(Protocol::TCP))?;
socket.set_reuse_address(true)?;
socket.set_reuse_port(true)?; // SO_REUSEPORT (Linux/BSD)
socket.set_tcp_nodelay(true)?;
socket.set_nonblocking(true)?;
socket.bind(&addr.into())?;
socket.listen(backlog)?;
Ok(socket.into())
}Then drive each shard with a LocalSet so per-connection tasks stay on one core. Convert with TcpListener::from_std and tokio::task::spawn_local inside the LocalSet.
Instrument it. Tokio's runtime metrics are the cheapest way to catch a starved shard:
// requires --cfg tokio_unstable
let metrics = rt.metrics();
println!("steals={} parks={} depth={}",
metrics.worker_steal_count(0),
metrics.worker_park_count(0),
metrics.global_queue_depth());A high worker_steal_count relative to worker_park_count means you are thrashing — that is your signal to shard with LocalSet or reduce task granularity.
Glommio: Shards, Shares, and Latency Classes
use glommio::{Latency, LocalExecutorBuilder, Placement, Shares};
use std::time::Duration;
fn main() -> glommio::Result<()> {
let shards = num_cpus::get();
let mut handles = Vec::with_capacity(shards);
for cpu in 0..shards {
let handle = LocalExecutorBuilder::new(Placement::Fixed(cpu))
.name(&format!("shard-{cpu}"))
.spawn(move || async move {
// Latency::Matters asks the scheduler to preempt lower classes.
let tq = glommio::executor().create_task_queue(
Shares::static_shares(1.0, Placement::Fixed(cpu)),
Latency::Matters(Duration::from_micros(500)),
"net",
)?;
// Glommio's listener uses SO_REUSEPORT: every shard binds the same addr.
let listener = glommio::net::TcpListener::bind("0.0.0.0:8080")?;
glommio::spawn_local_into(accept_loop(listener), tq)?;
Ok::<(), glommio::GlommioError<()>>(())
})?;
handles.push(handle);
}
for h in handles {
h.join().expect("shard panicked")?;
}
Ok(())
}Shards are OS threads — placement must respect your cgroup CPU quota. In containers, prefer Placement::MaxSpread over Placement::Fixed(i) unless you control the cpuset, otherwise you pin to cores that are not actually yours.
Monoio: Ownership-Based I/O on io_uring
use monoio::io::{AsyncReadRent, AsyncWriteRentExt};
use monoio::net::{TcpListener, TcpStream};
const RESPONSE: &[u8] = b"HTTP/1.1 200 OK\r\ncontent-length: 2\r\n\r\nok";
async fn handle(mut stream: TcpStream) -> std::io::Result<()> {
loop {
let buf = Vec::with_capacity(16 * 1024);
let (res, buf) = stream.read(buf).await;
let n = res?;
if n == 0 {
return Ok(()); // peer closed
}
let (res, _buf) = stream.write_all(RESPONSE).await;
res?;
// `buf` is returned to us — reuse it on the next iteration to avoid alloc churn.
drop(buf);
}
}The ownership-based trait is the point: read hands you back the buffer, so you can recycle it instead of reallocating per request. That is where the ~14 KiB/connection figure comes from.
Runtime construction, one per shard with explicit core pinning:
fn main() -> std::io::Result<()> {
let cores = core_affinity::get_core_ids().unwrap_or_default();
let mut handles = Vec::with_capacity(cores.len());
for core in cores {
handles.push(std::thread::spawn(move || -> std::io::Result<()> {
core_affinity::set_for_current(core);
let mut rt = monoio::RuntimeBuilder::<monoio::IoUringDriver>::new()
.with_entries(4096) // SQ/CQ depth per shard
.enable_all()
.build()?;
rt.block_on(async move {
let listener = TcpListener::bind("0.0.0.0:8080")?;
loop {
let (stream, _peer) = listener.accept().await?;
monoio::spawn(async move {
if let Err(e) = handle(stream).await {
eprintln!("connection error: {e}");
}
});
}
#[allow(unreachable_code)] Ok::<(), std::io::Error>(())
})
}));
}
for h in handles { h.join().expect("shard panicked")?; }
Ok(())
}The Migration Matrix: When Each Runtime Is the Right Answer
| Your situation | Recommendation |
|---|---|
| Mixed workloads, HTTP + gRPC + DB, large team | Tokio, sharded with LocalSet + SO_REUSEPORT |
| Pure L7 edge/router, latency SLO < 2 ms P99, kernel 6.x | Glommio |
Need io_uring but want an epoll fallback for portability | Monoio (pluggable driver) |
| Regulated environment with io_uring disabled | Tokio, no debate |
| Long-lived DB connections dominating latency | Neither helps — fix the downstream |
Operational Checklist for High-Throughput Rust Services
- Pin shards to cores (
taskset, cpuset, orPlacement::Fixed) and verify withsched_getaffinityinside the container. net.core.somaxconn=65535,net.ipv4.tcp_max_syn_backlog=65535,tcp_tw_reuse=1,tcp_fin_timeout=15.- Probe
io_uringavailability in a startup smoke test; exit non-zero onEPERM. - Export HdrHistogram P50/P99/P99.9 as first-class metrics — never average latency.
- Enforce concurrency limits with
Semaphoresized by Little's Law, not by vibes. - Set
TCP_NODELAY; batch small writes withwrite_vectored. - Enable
--cfg tokio_unstableand shipRuntimeMetricsto catch steal thrash. - Run
perfandpprof-rson the saturated build; context-switch counts should be flat as RPS rises.
Pitfalls That Will Bite You in Production
- Blocking inside a thread-per-core executor. One
std::fs::readon a Monoio/Glommio shard stalls every connection on that core. Use the runtime's blocking pool, always. - Unbounded channels. Backpressure is a feature. A
tokio::sync::mpsc::unbounded_channelon the request path is an OOM waiting for a traffic spike. - `Send` assumptions during migration. Glommio/Monoio futures are
!Send. Almost every mainstream crate assumes otherwise. Budget formonoio-compat, per-shard clients, or a Tokio control plane. - `SO_REUSEPORT` rebalance skew. On connection churn, the kernel's 4-tuple hash can concentrate load. Monitor per-shard RPS, not just total.
- Placement vs. cgroup.
Placement::Fixed(3)inside a container limited to 4 CPUs pins you to a core you do not own. Derive placement from the affinity mask. - Benchmarking at 100% saturation. Peak RPS numbers look great and are operationally useless. Report P99 at 60% and 80% of measured saturation — that is where your SLO lives.
- Ignoring TLS. rustls handshakes are CPU-bound;
ktlsoffload or session resumption will move your P99 more than any scheduler choice.
Sources & Technical References
- Tokio — The Tokio Runtime (scheduler architecture,
Buildertuning), https://docs.rs/tokio/latest/tokio/runtime/index.html - Tokio — RuntimeMetrics (steal/park counts, queue depth), https://docs.rs/tokio/latest/tokio/runtime/struct.RuntimeMetrics.html
- Glommio — official docs and
LocalExecutorBuilder/Shares/LatencyAPI, https://docs.rs/glommio - Glommio — project README (thread-per-core design, io_uring requirements), https://github.com/DataDog/glommio
- Monoio — official docs, driver and ownership-based I/O traits, https://docs.rs/monoio
- Monoio — project README and
monoio-compatbridge, https://github.com/bytedance/monoio - Jonathan Corbet, Ringing in a new asynchronous I/O API, LWN.net, https://lwn.net/Articles/776703/
- Jens Axboe et al.,
io_uringman pages:io_uring_setup(2),io_uring_enter(2),io_uring_register(2), https://man7.org/linux/man-pages/man2/io_uring_setup.2.html - Linux kernel documentation — Efficient I/O with io_uring and
IORING_SETUP_*flags, https://kernel.dk/io_uring.pdf epoll(7)andepoll_wait(2)manual pages, https://man7.org/linux/man-pages/man7/epoll.7.htmlmio— Linux selector implementation (edge-triggeredepoll), https://docs.rs/mio- HdrHistogram — Oh the Places You'll Go: Coordinated Omission, Gil Tene, https://github.com/HdrHistogram/HdrHistogram
- Gil Tene, How NOT to Measure Latency (Strange Loop), https://www.infoq.com/presentations/latency-response-time/
- RFC 9293 — Transmission Control Protocol (TCP), https://www.rfc-editor.org/rfc/rfc9293
socket(7)—SO_REUSEPORTsemantics, https://man7.org/linux/man-pages/man7/socket.7.html- AWS EC2 pricing reference (
c7ainstance family), https://aws.amazon.com/ec2/pricing/on-demand/ - Intel, Intel 64 and IA-32 Architectures Optimization Reference Manual (cache-locality and NUMA guidance), https://www.intel.com/content www/us/en/developer/articles/technical/intel-sdm.html
- Carl Lerche, Making the Tokio scheduler 10x faster, https://tokio.rs/blog/2019-10-scheduler
Discussion (0)
Continue Reading
More production-grade engineering deep dives and architectural guides

Japan Didn't Wait for a Legal Basis. The US Still Doesn't. Pass the Clarity: Technical Architecture & Analysis
Japan codified bounded active cyber defense in 2025 while the US still has no statutory safe harbor — so the legal boundary has to live in your architecture, not your runbook. Here is a production reference design: typed policy gates, Rego-encoded jurisdiction rules, and a tamper-evident evidence ledger built for real-time analysis.

Advanced State Management in 2026: Balancing Signals, Server State, and React Context
Signals, server-state caches, and Context each solve a different problem — and mixing them up is the root cause of most React performance regressions. This is a production playbook for assigning every piece of state to the correct plane, with copy-pasteable TypeScript implementations.

Dynamic OG Images at 5M Portfolio Shares: Benchmarking Satori, resvg-js, Puppeteer, and Cloudinary
We rendered 5M portfolio case-study share cards and measured p95 render latency, cold-start memory, and true cost per million across Satori + resvg-js, Puppeteer, Cloudinary, and pre-rendered CDN delivery. This is the benchmark data, the production handler, and the cost engineering playbook you can ship today.