An async runtime is not part of the Rust language — it is an ordinary program: a queue of ready tasks, a timer thread, and a way to hear about socket readiness from the kernel. This article builds that program from scratch and measures every moving part of it.
In Rust, you can write:
let count = stream.read(&mut buf).await?;
and get the impression that the language itself knows how to suspend a function, wait for a socket to become ready, and resume execution when data arrives.
But await does not monitor the network, create threads, or decide which task should run next.
The compiler handles only one part of the job: it turns an async fn into a state machine. This object stores the function's local variables and remembers the .await point at which execution stopped.
Something else must poll that state machine again when the operation it is waiting for can make progress.
Tokio usually takes care of this. It runs tasks, tracks timers, receives events from the operating system, and puts ready tasks back into the run queue. A great deal of machinery is hidden behind its convenient API, which makes it easy to see the runtime as an indivisible piece of infrastructure best left unexplored.
Let us build its core mechanics ourselves.
We will need three components:
- an executor with a queue of ready tasks;
- a timer that wakes a task after a deadline;
- a reactor that wakes it when a socket becomes ready.
On top of them, we will run a real TCP echo server. No Tokio, no async-std, and not even the futures crate: the standard library and Linux system calls exposed through libc are enough for the core runtime.
We have to begin with the contract on which all of async Rust is built.
What Happens When poll Is Called
The value returned by an async fn implements the Future trait. Its main method looks like this:
fn poll(
self: Pin<&mut Self>,
cx: &mut Context<'_>,
) -> Poll<Self::Output>;
The executor calls poll, and the Future returns one of two variants:
Poll::Ready(value)
Poll::Pending
Ready means that the asynchronous computation has completed.
Pending means that it cannot make progress yet. For example, a timer may not have expired, or a socket may not be ready for reading.
This leads to the central contract of the asynchronous model: a Future that returns Pending must arrange to be woken in the future.
The Context provides a Waker for this purpose. An asynchronous operation stores it alongside the source of the event it is waiting for. When that event occurs, something calls wake, after which the executor polls the corresponding task again.
If poll returns Pending but the Waker is not stored anywhere, the computation may never resume.
The executor is not required to scan all unfinished operations periodically. Doing so would turn asynchronous execution into busy polling: the processor would repeatedly ask timers and sockets whether anything had changed.
The correct arrangement is the reverse. Until an event occurs, the task waiting for it is absent from the ready queue.
Why Pin Is Needed
A compiled async fn becomes a structure that preserves its state across .await points.
It may contain values that refer to one another. Without additional restrictions, moving such an object in memory could invalidate its internal references.
Pin<&mut Self> makes it possible to call poll while preserving the required guarantees about the state machine's location in memory.
The executor does not need to understand the state machine's internals. It stores the object in a pinned form and calls poll whenever the task becomes ready.
wake Does Not Resume the Function
It is easy to imagine a Waker as a callback that immediately resumes execution on the line after .await.
In reality, wake does not execute user code.
Its purpose is much narrower: the task's state may have changed — put it back into the executor's queue.
In our runtime, a task looks like this:
/// One spawned future plus the sender that puts it back on the run queue.
pub struct Task {
/// The future lives behind a `Mutex` so a wake arriving from another
/// thread can never observe a poll that is still in progress.
future: Mutex<Option<BoxFuture>>,
ready: Sender<Arc<Task>>,
live: Arc<AtomicUsize>,
}
The Future lives inside Task, while the task itself is stored in an Arc: the executor's queue owns it directly, and the timer and network reactor keep it alive through stored Wakers.
Arc::clonedoes not copy the task — it adds another pointer to the same allocation and bumps the owner count. The queue holds one such owner and every storedWakerholds another, so a task that returnedPendingstays alive exactly as long as something can still wake it. When the lastArcis dropped, theTaskis freed together with itsFuture.
The asynchronous computation's state is protected by a Mutex. A wake-up may arrive from another thread while the executor is still running poll, so access to the object must be synchronized.
The wake implementation takes only a few lines:
impl Wake for Task {
fn wake(self: Arc<Self>) {
if self.ready.send(self.clone()).is_err() {
// The executor is gone; a wake aimed at it is a no-op.
}
}
}
wake clones the Arc<Task> and sends it through the ready-task channel.
Its job ends there.
Waker is often explained in terms of RawWakerVTable: a manually constructed table of operations for cloning, waking, and destroying a pointer. That is a useful abstraction layer to understand, but our executor does not need it.
It is enough to implement the safe std::task::Wake trait for Arc<Task>. The standard library constructs the required Waker itself: it uses Arc::clone for cloning, Arc::drop for cleanup, and our method for waking.
The mechanics remain the same, but without handwritten unsafe code.
An Executor Is a Queue of Ready Tasks
We use a regular std::sync::mpsc channel as the queue.
A new task created with spawn is placed into the channel immediately. After its Future returns Pending, the task disappears from the queue and can return only through wake.
The executor's main loop looks like this:
pub fn run_all(&mut self) -> Result<()> {
while self.spawner.live.load(Ordering::SeqCst) > 0 {
let task = self.queue.recv()?;
self.polls += 1;
task.poll();
}
Ok(())
}
The key operation here is not poll, but the blocking recv():
let task = self.queue.recv()?;
If there are no ready tasks, the executor thread sleeps inside the channel.
It does not iterate over timers, check sockets, or poll unfinished computations “just in case.” Only a new message in the queue—either a spawn or someone's wake—can wake it.
After receiving a task, the executor calls poll on the Future stored inside it.
If the future returns Ready, the task is complete.
If it returns Pending, the executor does nothing with it. It trusts the implementation: a Waker must have been left somewhere so the task can be returned to the queue when the event occurs.
This is how an ordinary channel becomes the foundation of a scheduler.
Testing the Queue with Our Own Future
We do not need sockets or timers to test the executor. A type that implements Future and yields once is enough:
impl Future for YieldNow {
type Output = ();
fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<()> {
if self.yielded {
return Poll::Ready(());
}
self.yielded = true;
// The contract: whoever returns Pending must arrange a wake.
cx.waker().wake_by_ref();
Poll::Pending
}
}
On the first poll, the object records that it has yielded, wakes its own task, and returns Pending.
wake_by_ref() sends the task to the back of the queue. This gives other ready tasks a chance to run before it.
On the next call to poll, it returns Ready.
Let us run three tasks, each of which prints three steps and yields between them:
$ cargo run --release --quiet
[a] step 1
[b] step 1
[c] step 1
[a] step 2
[b] step 2
[c] step 2
[a] step 3
[b] step 3
[c] step 3
[a] done
[b] done
[c] done
all tasks finished after 12 polls total
block_on returned 42 (14 polls total)
All tasks ran on a single thread.
The round-robin order did not come from parallelism, but from the queue: each task placed itself at the back.
The poll counter helps verify the mechanics. Each task returned Pending three times and then returned Ready once. That gives four calls per task and twelve calls for the whole run.
This is already cooperative multitasking. A task runs until the asynchronous computation inside it voluntarily returns control to the executor.
So far, however, a task can wake only itself. The next step is waking it from another thread.
The Timer Stores a Waker Until the Deadline
An asynchronous Sleep operation must not immediately return its task to the queue. It has to wait until a specified time.
To do this, the runtime starts a dedicated timer thread.
The thread owns a heap of deadlines and sleeps until the nearest one. Internally, it uses a BinaryHeap, while waiting is implemented with a Condvar: if a new, earlier deadline is added, the thread can be woken to recalculate how long it should sleep.
A
Condvar, or condition variable, allows a thread to sleep until another thread reports that protected state may have changed. It is used together with aMutex: callingwaitatomically releases the mutex and puts the thread to sleep, then reacquires the mutex before execution continues after a wake-up.
On its first call to poll, a Sleep object registers its deadline and stores the Waker.
When the deadline arrives, the timer thread calls:
waker.wake();
The associated task then returns to the executor queue, and Sleep receives another poll.
The normal lifecycle of such an operation has two stages:
- register the wait and return
Pending; - after being woken, return
Ready.
Avoiding a Lost Wake-Up
A race is possible between checking the state of Sleep and storing its Waker.
Imagine the following sequence:
pollchecks that the deadline has not yet been marked as fired;- the timer thread sets the
firedstate; - the timer does not find a stored
Waker; pollstores theWakerand returnsPending.
The event has already occurred, but there is now nobody left to wake the task. It will remain unfinished forever.
This is why the fired flag and the Waker are protected by the same Mutex:
let mut state = this
.state
.lock()
.unwrap_or_else(PoisonError::into_inner);
if state.fired {
return Poll::Ready(());
}
state.waker = Some(cx.waker().clone());
Only two orderings are now possible.
If poll stores the Waker first, the timer thread will find it later and wake the task.
If the timer sets fired first, the next call to poll will observe that state and complete the operation immediately.
The shared lock rules out the state in which “the event has already happened, but nobody will ever learn about it.”
Let us test three waits with different deadlines:
$ cargo run --release --quiet
[fast] woke after 100 ms
[mid] woke after 200 ms
[slow] woke after 300 ms
3 sleeps, 600 ms combined: wall 300 ms, 6 polls total
single 10 ms sleep via block_on: woke after 10 ms
The three waiting intervals overlapped because no task occupied the executor thread while it was asleep.
Each operation required two calls to poll: one to register the deadline and one to complete after the wake-up.
While the tasks were waiting, the executor slept inside recv().
The Reactor Waits for Kernel Events
A timer knows when a deadline will arrive. A socket is different: its readiness is determined by the state of the operating system's network stack.
When a nonblocking read cannot return data yet, it reports WouldBlock.
This is not a connection error. The operation is saying: no data is available right now, try again when the socket becomes ready.
But the executor does not know when that will happen.
We need a component that registers interest in a file descriptor with the kernel, stores the waiting task's Waker, and calls it after an event.
That component is called a reactor.
Rust's standard library does not provide a socket-readiness API, so we use epoll through libc.
Three system calls are enough for the basic implementation:
epoll_create1
epoll_ctl
epoll_wait
A dedicated reactor thread blocks in epoll_wait.
While there are no network events, it sleeps inside the kernel. When one of the registered file descriptors becomes ready, epoll_wait returns.
Alongside it, the reactor stores a table:
HashMap<RawFd, Slot>
For each descriptor, it can store two Wakers:
- one waiting for read readiness;
- one waiting for write readiness.
When an event arrives, the reactor removes the appropriate Waker and calls wake.
The task then enters the same queue as a task woken by the timer or by YieldNow.
The executor does not distinguish between sources of wake-ups.
An Asynchronous Operation Tries the System Call First
Asynchronous accept, read, and write operations all follow the same pattern:
loop {
match (this.op)() {
Ok(value) => return Poll::Ready(Ok(value)),
Err(error) if error.kind() == io::ErrorKind::WouldBlock => {
let parked = this
.reactor
.wait(this.fd, this.direction, cx.waker().clone());
return match parked {
Ok(()) => Poll::Pending,
Err(error) => Poll::Ready(Err(error)),
};
}
Err(error) if error.kind() == io::ErrorKind::Interrupted => continue,
Err(error) => return Poll::Ready(Err(error.into())),
}
}
The operation first performs the system call in the usual way.
If the data is already available, it immediately returns the result through Ready. The reactor is not involved at all in that case.
If the system call returns WouldBlock, the Future:
- stores its task's
Wakerin the reactor; - registers interest in read or write readiness;
- returns
Pending.
After an epoll event, the task gets another chance to run, and the operation repeats the system call.
The reactor does not read the data on the task's behalf. It only reports that trying again now makes sense.
This creates a clear separation of responsibilities:
- the kernel tracks file-descriptor readiness;
- the reactor connects readiness to a
Waker; - the executor calls
poll; - the asynchronous operation performs the system call.
The Socket May Become Ready Before Registration
There is a small gap between two actions:
read returned WouldBlock
↓
Waker registered with epoll
Data may arrive during that interval.
If the notification mechanism reported only the transition from “not ready” to “ready,” the event could be lost. The transition would have happened before registration, and there might never be another one.
The reactor uses level-triggered epoll.
It reports not only the moment when the state changes, but the state itself: the descriptor is ready now.
Therefore, even if data arrives before registration, registering new interest in an already-ready socket still produces a notification.
At the same time, the reactor uses EPOLLONESHOT.
After one event, observation of the descriptor is disabled. The next operation must store another Waker and rearm interest through epoll_ctl.
Without EPOLLONESHOT, a level-triggered descriptor could be returned by epoll_wait repeatedly for as long as it remained ready, even when no task was waiting for the event anymore.
The two modes solve different problems:
- level-triggered operation prevents readiness from being lost;
EPOLLONESHOTprevents the reactor from handling the same state forever when no task is waiting.
Asynchronous connect Requires a Separate Protocol
After a wake-up, accept, read, and write can simply be attempted again.
A nonblocking connect works differently.
The first call usually returns EINPROGRESS: the connection is still being established.
The operation then waits for write readiness through EPOLLOUT. But the event itself does not guarantee a successful connection—it only means that the connection attempt has completed.
The result must be read from SO_ERROR:
let pending = so_error(fd)?;
if pending != 0 {
return Err(io::Error::from_raw_os_error(pending));
}
A zero value means that the connection succeeded. A nonzero value contains an error code.
This is a good example of the fact that the reactor does not work with high-level actions such as “connect” or “read a message.” It knows only about file-descriptor readiness. The object implementing the specific asynchronous operation must interpret the event.
All Wake-Up Sources Converge in One Place
Before building the echo server, let us run three tasks on a single executor:
- the server accepts a connection, reads
ping, and replies withpong; - the client connects, waits on a timer, and sends data;
- a heartbeat wakes at regular intervals.
Wake-ups arrive from two service threads—the timer and the reactor:
$ cargo run --release --quiet
[server] listening on 127.0.0.1:35519
[client] connected at 0 ms
[server] accepted 127.0.0.1:46070
[beat] tick 1 at 30 ms
[server] got "ping"
[client] got "pong" at 50 ms
[beat] tick 2 at 60 ms
[beat] tick 3 at 90 ms
one thread, three tasks: 10 polls, 90 ms wall
While the client was waiting on the timer, the server was parked in the reactor.
Neither task occupied the executor, so the heartbeat continued to run.
This is what .await hides: an async function preserves its state, returns control, and resumes after an external wake-up.
An Echo Server on Top of the Runtime
Once asynchronous accept, read, and write are available, the server code looks almost the same as it would on a production runtime:
async fn handle(mut stream: Stream, stats: Arc<Stats>) -> Result<()> {
let mut buf = [0u8; 4096];
loop {
let count = stream.read(&mut buf).await?;
if count == 0 {
return Ok(());
}
stream.write_all(&buf[..count]).await?;
stats.bytes.fetch_add(count as u64, Ordering::Relaxed);
}
}
The main accept loop creates a separate handle task for every connection.
When a read receives WouldBlock, the asynchronous operation leaves a Waker in the reactor, and the task disappears from the executor queue.
When data arrives, epoll puts it back into the queue.
If the socket cannot accept the entire response immediately, the same cycle repeats for write readiness.
Where the Connection Buffer Lives
The local array:
let mut buf = [0u8; 4096];
becomes part of the handle function's state machine after compilation.
It does not live on the stack of a dedicated operating-system thread, because there is no dedicated thread for the connection.
Each active session is represented by an object that stores:
- the buffer;
- the socket;
- the current execution point;
- the intermediate state of the operation being awaited.
When the task is parked in the reactor, this object simply remains in memory without occupying the executor.
Start the server:
$ cargo build --release
$ ./target/release/echo --addr 127.0.0.1:7000
echo listening on 127.0.0.1:7000
Test it with nc:
$ printf 'hello minirt\n' | nc 127.0.0.1 7000
hello minirt
The string passed through a nonblocking read, the executor queue, and an asynchronous write before being returned to the client unchanged.
Testing Many Concurrent Waits
A single TCP session confirms that the operations work, but it says little about the runtime's ability to hold many parked tasks.
So let us run a client that creates a separate task for each connection. All clients wait at a shared barrier so the server truly holds them concurrently, then perform several rounds:
- send 64 bytes;
- receive the response;
- compare it with the original buffer.
First, open one thousand connections:
$ ./target/release/blast --addr 127.0.0.1:7000 --conns 1000 --rounds 10
1000 conns, 10 rounds x 64 B: 10000 echoes verified, 0 failed, 158 ms wall, 11990 polls
All ten thousand responses were verified byte for byte, with no errors.
Then increase the number of concurrent connections:
$ ./target/release/blast --addr 127.0.0.1:7000 --conns 5000 --rounds 2
5000 conns, 2 rounds x 64 B: 10000 echoes verified, 0 failed, 409 ms wall, 19999 polls
This is not a throughput benchmark: the client and server communicate over the same machine's loopback interface, and the messages are too small for a meaningful network-performance measurement.
It tests something else:
- the reactor can store thousands of waiting
Wakers; - a ready task returns to the executor;
- wake-ups are not lost;
- connections do not require dedicated threads.
The server confirms the results with its own counters:
echo listening on 127.0.0.1:7000
conns 0 | peak 1000 | served 1001 | echoed 640013 B
conns 4092 | peak 4092 | served 1001 | echoed 640013 B
conns 0 | peak 5000 | served 6001 | echoed 1280013 B
A total of 6,001 connections were served:
- one from
nc; - one thousand from the first test;
- five thousand from the second.
The data volume also adds up:
- 640,000 bytes in the first session;
- 640,000 bytes in the second;
- 13 bytes from
nc.
In total, the server echoed:
1,280,013 bytes
Here, the byte count acts as a correctness check: no message was lost or counted twice.
Connections Do Not Become Threads
Let us inspect the process:
$ ls /proc/$(pgrep -x echo)/task | wc -l
3
$ cat /proc/$(pgrep -x echo)/task/*/comm | sort
echo
minirt-reactor
minirt-timer
The server has three threads:
- the main executor thread;
- the reactor thread, blocked in
epoll_wait; - the timer thread, waiting for the nearest deadline.
New connections create tasks, but they do not create operating-system threads.
This is the essential difference between the asynchronous model and a “thread per connection” design. Waiting is represented by the state of an async function and a parked Waker, not by a separate stack and an operating-system scheduling object.
At this point, the complete wake-up path is in place.
An asynchronous operation attempts to do its work immediately. If it cannot make progress, it stores a Waker and returns Pending. The timer or reactor waits for an external event, calls wake, the task returns to the queue, and the executor calls poll again.
That is enough for a single thread to serve many connections while individual tasks spend most of their time waiting for the network or a timer. But a working mechanism is not yet a production-grade runtime.
What This Runtime Does Not Provide
The executor is single-threaded.
If one task performs a long computation and does not return control through .await or an explicit yield, it blocks all other tasks. A cooperative scheduler cannot forcibly preempt it.
There is no multithreaded scheduler with work stealing to distribute ready tasks across CPU cores.
There are no execution budgets. A task that continually puts itself back into the queue can reduce fairness for other tasks.
The timer uses a BinaryHeap. That is sufficient for a modest number of deadlines, but a timer wheel might be a better data structure when the number of timers becomes very large.
The reactor is tied to Linux and epoll. Other platforms would require separate implementations, such as kqueue or IOCP.
There is no io_uring, task introspection, wake-reason tracing, or many of the other features provided by production runtimes.
But all of these are layers built on top of the same basic loop:
event → wake → queue → pollA Lost wake Looks Like an Unexplained Hang
The most unpleasant bug in a small runtime is not necessarily a panic or memory corruption.
An asynchronous operation may return Pending, after which the Waker associated with its task is lost.
The executor is still behaving correctly. The queue is empty, so it sleeps inside recv().
The timer may be waiting for the next deadline. The reactor may be blocked in epoll_wait. No mutex has to be held forever.
Yet the program makes no further progress because one task has disappeared from the readiness system.
The following details protect against this failure:
- the shared
Mutexaround the state ofSleep; - level-triggered
epoll; - storing the
Wakerbefore returningPending; - rearming
EPOLLONESHOT.
These details are not optimizations. They preserve the central Future contract: every Pending must have a real path to a future wake.
The Entire Mechanism Converges on One Queue
YieldNow wakes a task immediately.
The timer does so after a deadline.
The reactor does so when the kernel reports that a file descriptor is ready.
All three call the same Waker. All three return a Task to the same queue.
The executor removes the task and calls poll again on the Future stored inside it.
It does not know what happened outside. From its perspective, only two states exist:
- the task is ready and is present in the queue;
- the task is not ready, and its
Wakerhas been left with the event source.
This is why .await does not block a thread. It allows an async function's state machine to return Pending, preserve a path for being woken, and let the executor do other work.
An async runtime does not resume functions by magic.
It merely ensures that a ready task receives another call to poll.
Appendix: Full Source Files
executor.rs — tasks, wakers and the run queue
//! The minimal executor: a run queue of tasks and a loop that polls them.
//!
//! Waker strategy: we implement `std::task::Wake` for `Arc<Task>` instead of
//! hand-rolling a `RawWakerVTable`. The trait generates exactly that vtable
//! for us (clone = `Arc::clone`, wake = `Wake::wake`, drop = `Arc::drop`),
//! so we get the same machinery without any unsafe pointer bookkeeping.
//! Rolling the vtable by hand buys nothing here but bugs.
use anyhow::{Context as _, Result, anyhow};
use std::{
future::Future,
pin::Pin,
sync::{
Arc, Mutex, PoisonError,
atomic::{AtomicUsize, Ordering},
mpsc::{Receiver, Sender, channel},
},
task::{Context, Poll, Wake, Waker},
};
type BoxFuture = Pin<Box<dyn Future<Output = ()> + Send>>;
/// One spawned future plus the sender that puts it back on the run queue.
pub struct Task {
/// The future lives behind a `Mutex` so a wake arriving from another
/// thread can never observe a poll that is still in progress.
future: Mutex<Option<BoxFuture>>,
ready: Sender<Arc<Task>>,
live: Arc<AtomicUsize>,
}
impl Wake for Task {
/// The entire waker contract in one line: to wake a task is to put it
/// back on the queue. Nothing is polled here; the executor thread will
/// pick the task up on its next loop iteration.
fn wake(self: Arc<Self>) {
if self.ready.send(self.clone()).is_err() {
// The executor is gone, so there is nothing left to schedule
// onto. A wake aimed at a dead executor is a no-op by contract.
}
}
}
impl Task {
/// Poll the stored future once, with a fresh waker pointing back at us.
fn poll(self: &Arc<Self>) {
let mut slot = self.future.lock().unwrap_or_else(PoisonError::into_inner);
// An empty slot means the future already completed; a stale wake
// that was queued twice lands here and does nothing.
let Some(mut future) = slot.take() else {
return;
};
let waker = Waker::from(self.clone());
let mut context = Context::from_waker(&waker);
match future.as_mut().poll(&mut context) {
Poll::Ready(()) => {
self.live.fetch_sub(1, Ordering::SeqCst);
}
Poll::Pending => {
*slot = Some(future);
}
}
}
}
/// A cloneable handle for putting new tasks on the queue.
#[derive(Clone)]
pub struct Spawner {
ready: Sender<Arc<Task>>,
live: Arc<AtomicUsize>,
}
impl Spawner {
pub fn spawn(&self, future: impl Future<Output = ()> + Send + 'static) -> Result<()> {
self.live.fetch_add(1, Ordering::SeqCst);
let task = Arc::new(Task {
future: Mutex::new(Some(Box::pin(future))),
ready: self.ready.clone(),
live: self.live.clone(),
});
self.ready
.send(task)
.map_err(|_| anyhow!("executor is gone, cannot spawn"))
}
}
/// The executor: pops tasks off the queue and polls them, one at a time.
pub struct Executor {
queue: Receiver<Arc<Task>>,
spawner: Spawner,
polls: u64,
}
impl Default for Executor {
fn default() -> Self {
Self::new()
}
}
impl Executor {
pub fn new() -> Self {
let (sender, receiver) = channel();
let spawner = Spawner {
ready: sender,
live: Arc::new(AtomicUsize::new(0)),
};
Self {
queue: receiver,
spawner,
polls: 0,
}
}
pub fn spawner(&self) -> Spawner {
self.spawner.clone()
}
/// Total number of `poll` calls this executor has made.
pub fn polls(&self) -> u64 {
self.polls
}
/// Run until every spawned task has completed. `recv()` blocks when the
/// queue is empty, so a runtime with sleeping tasks parks the thread
/// instead of spinning; an external wake (a timer, the reactor) is what
/// makes `recv()` return.
pub fn run_all(&mut self) -> Result<()> {
while self.spawner.live.load(Ordering::SeqCst) > 0 {
let task = self
.queue
.recv()
.context("run queue closed with live tasks remaining")?;
self.polls += 1;
task.poll();
}
Ok(())
}
/// Drive the queue until this one future completes, then return its
/// output. Other spawned tasks make progress while we wait.
pub fn block_on<F>(&mut self, future: F) -> Result<F::Output>
where
F: Future + Send + 'static,
F::Output: Send + 'static,
{
let slot = Arc::new(Mutex::new(None));
let out = slot.clone();
self.spawner.spawn(async move {
let value = future.await;
*out.lock().unwrap_or_else(PoisonError::into_inner) = Some(value);
})?;
loop {
let task = self
.queue
.recv()
.context("run queue closed before the main future finished")?;
self.polls += 1;
task.poll();
let mut guard = slot.lock().unwrap_or_else(PoisonError::into_inner);
if let Some(value) = guard.take() {
return Ok(value);
}
}
}
}timer.rs — the deadline heap on its own thread
//! The timer: one thread, one deadline heap, and `Waker::wake` called from
//! outside the executor. This is the smallest possible proof that wakes
//! cross threads: the executor sleeps inside `recv()` until the timer
//! thread pushes a task back onto the run queue.
use anyhow::{Context as _, Result};
use std::{
cmp::Ordering as CmpOrdering,
collections::BinaryHeap,
future::Future,
pin::Pin,
sync::{Arc, Condvar, Mutex, PoisonError},
task::{Context, Poll, Waker},
thread::{Builder, JoinHandle},
time::{Duration, Instant},
};
/// State shared between one `Sleep` future and the timer thread. The single
/// mutex is what makes the handoff race-free: either the poll stores its
/// waker before the deadline fires (the thread will find and wake it), or
/// the deadline fired first (the poll sees `fired` and returns Ready).
struct SleepState {
fired: bool,
waker: Option<Waker>,
}
/// A heap entry: min-ordered by deadline (via reversed `Ord`).
struct Entry {
deadline: Instant,
state: Arc<Mutex<SleepState>>,
}
impl Eq for Entry {}
impl PartialEq for Entry {
fn eq(&self, other: &Self) -> bool {
self.deadline == other.deadline
}
}
impl Ord for Entry {
fn cmp(&self, other: &Self) -> CmpOrdering {
// Reversed: BinaryHeap is a max-heap, we want the nearest deadline
// on top.
other.deadline.cmp(&self.deadline)
}
}
impl PartialOrd for Entry {
fn partial_cmp(&self, other: &Self) -> Option<CmpOrdering> {
Some(self.cmp(other))
}
}
struct Shared {
heap: Mutex<BinaryHeap<Entry>>,
cvar: Condvar,
}
/// A cloneable handle to the timer thread.
#[derive(Clone)]
pub struct Timer {
shared: Arc<Shared>,
/// Held only so the thread's lifetime is visible in the type; the
/// thread itself runs for the life of the process.
_thread: Arc<JoinHandle<()>>,
}
impl Timer {
/// Start the timer thread. It sleeps on a condvar until the nearest
/// deadline (or forever when the heap is empty) and never busy-waits.
pub fn spawn() -> Result<Timer> {
let shared = Arc::new(Shared {
heap: Mutex::new(BinaryHeap::new()),
cvar: Condvar::new(),
});
let worker = shared.clone();
let thread = Builder::new()
.name("minirt-timer".into())
.spawn(move || run(&worker))
.context("failed to spawn the timer thread")?;
Ok(Timer {
shared,
_thread: Arc::new(thread),
})
}
/// A future that completes `duration` from now.
pub fn sleep(&self, duration: Duration) -> Sleep {
Sleep {
timer: self.clone(),
deadline: Instant::now() + duration,
state: Arc::new(Mutex::new(SleepState {
fired: false,
waker: None,
})),
registered: false,
}
}
fn register(&self, deadline: Instant, state: Arc<Mutex<SleepState>>) {
let mut heap = self
.shared
.heap
.lock()
.unwrap_or_else(PoisonError::into_inner);
heap.push(Entry { deadline, state });
// Wake the thread so it can re-check the nearest deadline.
self.shared.cvar.notify_one();
}
}
/// The timer thread body: pop due entries, wake their tasks, sleep until
/// the next deadline.
fn run(shared: &Shared) {
let mut heap = shared.heap.lock().unwrap_or_else(PoisonError::into_inner);
loop {
let now = Instant::now();
while let Some(top) = heap.peek() {
if top.deadline > now {
break;
}
let Some(entry) = heap.pop() else {
break;
};
let mut state = entry.state.lock().unwrap_or_else(PoisonError::into_inner);
state.fired = true;
if let Some(waker) = state.waker.take() {
// The cross-thread moment: this re-enqueues the task on the
// executor's run queue from the timer thread.
waker.wake();
}
}
let wait = heap.peek().map(|top| top.deadline - now);
heap = match wait {
Some(timeout) => {
let (guard, _timed_out) = shared
.cvar
.wait_timeout(heap, timeout)
.unwrap_or_else(PoisonError::into_inner);
guard
}
None => shared
.cvar
.wait(heap)
.unwrap_or_else(PoisonError::into_inner),
};
}
}
/// The sleeping future. Two polls in the common case: the first stores the
/// waker and registers the deadline, the second (after the timer thread
/// fired) observes `fired` and returns Ready.
pub struct Sleep {
timer: Timer,
deadline: Instant,
state: Arc<Mutex<SleepState>>,
registered: bool,
}
impl Future for Sleep {
type Output = ();
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<()> {
let this = self.get_mut();
{
let mut state = this.state.lock().unwrap_or_else(PoisonError::into_inner);
if state.fired {
return Poll::Ready(());
}
// Store a fresh waker on every poll: the task may have moved
// and the old waker could point at a stale generation.
state.waker = Some(cx.waker().clone());
}
if !this.registered {
this.registered = true;
this.timer.register(this.deadline, this.state.clone());
}
Poll::Pending
}
}reactor.rs — epoll and the parked wakers
//! The reactor: one epoll instance and one thread blocked in `epoll_wait`.
//!
//! A future that hits `WouldBlock` parks its `Waker` here, keyed by fd and
//! direction; when the kernel reports readiness, the thread wakes exactly
//! the parked task. We use level-triggered epoll with `EPOLLONESHOT`:
//! level-triggered closes the check-then-park race (arming an interest on
//! an already-ready fd fires immediately), and oneshot means a fired fd
//! stays silent until somebody parks a waker again — no busy loops.
//!
//! Why epoll via `libc` at all: std has no readiness API. The honest
//! std-only alternatives are a thread per connection (which makes the
//! executor pointless) or a polling loop over non-blocking sockets (which
//! burns a core doing nothing). The epoll surface we need is three calls.
use anyhow::{Context as _, Result, ensure};
use libc::{
EPOLL_CLOEXEC, EPOLL_CTL_ADD, EPOLL_CTL_DEL, EPOLL_CTL_MOD, EPOLLERR, EPOLLHUP, EPOLLIN,
EPOLLONESHOT, EPOLLOUT, c_int, close, epoll_create1, epoll_ctl, epoll_event, epoll_wait,
};
use std::{
collections::HashMap,
io,
os::fd::RawFd,
ptr,
sync::{Arc, Mutex, PoisonError},
task::Waker,
thread::{Builder, JoinHandle},
};
/// Which readiness a task is waiting for.
#[derive(Clone, Copy)]
pub enum Direction {
Read,
Write,
}
/// Parked wakers for one registered fd.
#[derive(Default)]
struct Slot {
read: Option<Waker>,
write: Option<Waker>,
}
fn interest_mask(slot: &Slot) -> u32 {
let mut mask = 0;
if slot.read.is_some() {
mask |= EPOLLIN as u32;
}
if slot.write.is_some() {
mask |= EPOLLOUT as u32;
}
mask
}
/// Re-arm the fd with the given interest; oneshot keeps it silent after
/// the next event until somebody arms it again.
fn arm(epoll: RawFd, fd: RawFd, mask: u32) -> Result<()> {
let mut event = epoll_event {
events: mask | EPOLLONESHOT as u32,
u64: fd as u64,
};
let code = unsafe { epoll_ctl(epoll, EPOLL_CTL_MOD, fd, &mut event) };
ensure!(
code == 0,
"epoll_ctl(MOD) failed: {}",
io::Error::last_os_error()
);
Ok(())
}
struct Shared {
epoll: RawFd,
slots: Mutex<HashMap<RawFd, Slot>>,
}
impl Drop for Shared {
fn drop(&mut self) {
// Best effort: the reactor lives for the whole process anyway.
if unsafe { close(self.epoll) } != 0 {
eprintln!("reactor: close failed: {}", io::Error::last_os_error());
}
}
}
/// A cloneable handle to the reactor thread.
#[derive(Clone)]
pub struct Reactor {
shared: Arc<Shared>,
/// Held for lifetime clarity; the thread runs as long as the process.
_thread: Arc<JoinHandle<()>>,
}
impl Reactor {
pub fn spawn() -> Result<Reactor> {
let epoll = unsafe { epoll_create1(EPOLL_CLOEXEC) };
ensure!(
epoll >= 0,
"epoll_create1 failed: {}",
io::Error::last_os_error()
);
let shared = Arc::new(Shared {
epoll,
slots: Mutex::new(HashMap::new()),
});
let worker = shared.clone();
let thread = Builder::new()
.name("minirt-reactor".into())
.spawn(move || run(&worker))
.context("failed to spawn the reactor thread")?;
Ok(Reactor {
shared,
_thread: Arc::new(thread),
})
}
/// Add the fd to the epoll set, disarmed: nothing fires until a task
/// parks a waker via `wait`.
pub fn register(&self, fd: RawFd) -> Result<()> {
let mut slots = self
.shared
.slots
.lock()
.unwrap_or_else(PoisonError::into_inner);
ensure!(!slots.contains_key(&fd), "fd {fd} is already registered");
let mut event = epoll_event {
events: EPOLLONESHOT as u32,
u64: fd as u64,
};
let code = unsafe { epoll_ctl(self.shared.epoll, EPOLL_CTL_ADD, fd, &mut event) };
ensure!(
code == 0,
"epoll_ctl(ADD) failed: {}",
io::Error::last_os_error()
);
slots.insert(fd, Slot::default());
Ok(())
}
/// Park a waker until the fd is ready in the given direction. Called
/// by a leaf future right after an op returned `WouldBlock`.
pub fn wait(&self, fd: RawFd, direction: Direction, waker: Waker) -> Result<()> {
let mut slots = self
.shared
.slots
.lock()
.unwrap_or_else(PoisonError::into_inner);
let slot = slots
.get_mut(&fd)
.with_context(|| format!("fd {fd} is not registered with the reactor"))?;
match direction {
Direction::Read => slot.read = Some(waker),
Direction::Write => slot.write = Some(waker),
}
arm(self.shared.epoll, fd, interest_mask(slot))
}
/// Forget the fd; any still-parked task is woken so it can observe the
/// closed socket instead of hanging forever.
pub fn deregister(&self, fd: RawFd) -> Result<()> {
let mut slots = self
.shared
.slots
.lock()
.unwrap_or_else(PoisonError::into_inner);
let Some(mut slot) = slots.remove(&fd) else {
return Ok(());
};
let code = unsafe { epoll_ctl(self.shared.epoll, EPOLL_CTL_DEL, fd, ptr::null_mut()) };
ensure!(
code == 0,
"epoll_ctl(DEL) failed: {}",
io::Error::last_os_error()
);
drop(slots);
if let Some(waker) = slot.read.take() {
waker.wake();
}
if let Some(waker) = slot.write.take() {
waker.wake();
}
Ok(())
}
}
/// The reactor thread body: wait for kernel events, hand the parked wakers
/// back to the executor, re-arm whatever interest is still parked.
fn run(shared: &Shared) {
let mut events = [epoll_event { events: 0, u64: 0 }; 64];
loop {
let count =
unsafe { epoll_wait(shared.epoll, events.as_mut_ptr(), events.len() as c_int, -1) };
if count < 0 {
let error = io::Error::last_os_error();
if error.kind() == io::ErrorKind::Interrupted {
continue;
}
eprintln!("reactor: epoll_wait failed: {error}");
return;
}
let mut ready = Vec::new();
{
let mut slots = shared.slots.lock().unwrap_or_else(PoisonError::into_inner);
for event in &events[..count as usize] {
let bits = event.events;
let fd = event.u64 as RawFd;
let Some(slot) = slots.get_mut(&fd) else {
continue;
};
// Errors and hangups wake both directions: the parked task
// must retry its op and see the real error itself.
let fail = bits & (EPOLLERR | EPOLLHUP) as u32 != 0;
if fail || bits & EPOLLIN as u32 != 0 {
if let Some(waker) = slot.read.take() {
ready.push(waker);
}
}
if fail || bits & EPOLLOUT as u32 != 0 {
if let Some(waker) = slot.write.take() {
ready.push(waker);
}
}
// Oneshot disarmed the fd; keep it armed for the direction
// that is still parked (if any).
let mask = interest_mask(slot);
if mask != 0
&& let Err(error) = arm(shared.epoll, fd, mask)
{
eprintln!("reactor: re-arm of fd {fd} failed: {error:#}");
}
}
}
// Wake outside the lock: wakes go straight to the run queue and
// must never contend with a poll that is parking a new waker.
for waker in ready {
waker.wake();
}
}
}net.rs — non-blocking sockets as futures
//! Non-blocking TCP on top of the reactor. Every async operation here is
//! the same two-step dance: try the syscall; on `WouldBlock`, park the
//! waker in the reactor and return `Pending`.
use crate::reactor::{Direction, Reactor};
use anyhow::{Context as _, Result, bail, ensure};
use libc::{
AF_INET, EINPROGRESS, ENOTCONN, SO_ERROR, SOCK_CLOEXEC, SOCK_NONBLOCK, SOCK_STREAM, SOL_SOCKET,
c_int, c_void, connect, getsockopt, in_addr, sockaddr, sockaddr_in, socket, socklen_t,
};
use std::{
future::Future,
io::{self, Read as _, Write as _},
net::{SocketAddr, TcpListener, TcpStream},
os::fd::{AsRawFd, FromRawFd, RawFd},
pin::Pin,
ptr,
task::{Context, Poll},
};
/// The universal leaf future: run `op` until it stops saying `WouldBlock`.
/// Parking happens after the failed try; level-triggered epoll makes that
/// order safe (arming an already-ready fd fires immediately).
struct ReadyOp<'a, F> {
reactor: &'a Reactor,
fd: RawFd,
direction: Direction,
op: F,
}
impl<F, T> Future for ReadyOp<'_, F>
where
F: FnMut() -> io::Result<T> + Unpin,
{
type Output = Result<T>;
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Result<T>> {
let this = self.get_mut();
loop {
match (this.op)() {
Ok(value) => return Poll::Ready(Ok(value)),
Err(error) if error.kind() == io::ErrorKind::WouldBlock => {
let parked = this
.reactor
.wait(this.fd, this.direction, cx.waker().clone());
return match parked {
Ok(()) => Poll::Pending,
Err(error) => Poll::Ready(Err(error)),
};
}
Err(error) if error.kind() == io::ErrorKind::Interrupted => continue,
Err(error) => return Poll::Ready(Err(error.into())),
}
}
}
}
/// An async TCP listener: std's listener, switched to non-blocking and
/// registered with the reactor.
pub struct Listener {
reactor: Reactor,
inner: TcpListener,
}
impl Listener {
pub fn bind(reactor: &Reactor, addr: SocketAddr) -> Result<Listener> {
let inner = TcpListener::bind(addr).with_context(|| format!("failed to bind {addr}"))?;
inner.set_nonblocking(true)?;
reactor.register(inner.as_raw_fd())?;
Ok(Listener {
reactor: reactor.clone(),
inner,
})
}
pub fn local_addr(&self) -> Result<SocketAddr> {
Ok(self.inner.local_addr()?)
}
pub async fn accept(&self) -> Result<(Stream, SocketAddr)> {
let fd = self.inner.as_raw_fd();
let inner = &self.inner;
let (stream, peer) = ReadyOp {
reactor: &self.reactor,
fd,
direction: Direction::Read,
op: move || inner.accept(),
}
.await?;
Ok((Stream::from_std(&self.reactor, stream)?, peer))
}
}
impl Drop for Listener {
fn drop(&mut self) {
if let Err(error) = self.reactor.deregister(self.inner.as_raw_fd()) {
eprintln!("listener deregister failed: {error:#}");
}
}
}
/// An async TCP stream over the reactor.
pub struct Stream {
reactor: Reactor,
inner: TcpStream,
}
impl Stream {
fn from_std(reactor: &Reactor, inner: TcpStream) -> Result<Stream> {
inner.set_nonblocking(true)?;
reactor.register(inner.as_raw_fd())?;
Ok(Stream {
reactor: reactor.clone(),
inner,
})
}
/// Non-blocking connect, the classic sequence: `connect(2)` returns
/// `EINPROGRESS`, we park for writability, and the final verdict is
/// read back with `SO_ERROR`.
pub async fn connect(reactor: &Reactor, addr: SocketAddr) -> Result<Stream> {
let SocketAddr::V4(v4) = addr else {
bail!("only IPv4 addresses are supported in this demo");
};
let fd = unsafe { socket(AF_INET, SOCK_STREAM | SOCK_NONBLOCK | SOCK_CLOEXEC, 0) };
ensure!(fd >= 0, "socket(2) failed: {}", io::Error::last_os_error());
// From here the fd is owned by TcpStream: every error path below
// closes it via Drop.
let inner = unsafe { TcpStream::from_raw_fd(fd) };
let sin = sockaddr_in {
sin_family: AF_INET as u16,
sin_port: v4.port().to_be(),
sin_addr: in_addr {
s_addr: u32::from_ne_bytes(v4.ip().octets()),
},
sin_zero: [0; 8],
};
let code = unsafe {
connect(
fd,
ptr::from_ref(&sin).cast::<sockaddr>(),
size_of::<sockaddr_in>() as socklen_t,
)
};
if code != 0 {
let error = io::Error::last_os_error();
ensure!(
error.raw_os_error() == Some(EINPROGRESS),
"connect(2) to {addr} failed: {error}"
);
}
let stream = Stream::from_std(reactor, inner)?;
let probe = &stream.inner;
ReadyOp {
reactor: &stream.reactor,
fd,
direction: Direction::Write,
op: move || {
// A failed connect surfaces via SO_ERROR, not via connect(2).
let pending = so_error(fd)?;
if pending != 0 {
return Err(io::Error::from_raw_os_error(pending));
}
match probe.peer_addr() {
Ok(_) => Ok(()),
Err(error) if error.raw_os_error() == Some(ENOTCONN) => {
Err(io::ErrorKind::WouldBlock.into())
}
Err(error) => Err(error),
}
},
}
.await
.with_context(|| format!("connect to {addr} did not complete"))?;
Ok(stream)
}
pub async fn read(&mut self, buf: &mut [u8]) -> Result<usize> {
let fd = self.inner.as_raw_fd();
let mut sock = &self.inner;
ReadyOp {
reactor: &self.reactor,
fd,
direction: Direction::Read,
op: move || sock.read(buf),
}
.await
}
/// Read until `buf` is full; a peer that closes mid-message is an error.
pub async fn read_exact(&mut self, buf: &mut [u8]) -> Result<()> {
let mut filled = 0;
while filled < buf.len() {
let count = self.read(&mut buf[filled..]).await?;
ensure!(count > 0, "connection closed mid-message");
filled += count;
}
Ok(())
}
/// Write the whole buffer, parking on a full send queue.
pub async fn write_all(&mut self, data: &[u8]) -> Result<()> {
let fd = self.inner.as_raw_fd();
let mut offset = 0;
while offset < data.len() {
let rest = &data[offset..];
let mut sock = &self.inner;
let count = ReadyOp {
reactor: &self.reactor,
fd,
direction: Direction::Write,
op: move || sock.write(rest),
}
.await?;
ensure!(count > 0, "socket accepted zero bytes");
offset += count;
}
Ok(())
}
}
impl Drop for Stream {
fn drop(&mut self) {
if let Err(error) = self.reactor.deregister(self.inner.as_raw_fd()) {
eprintln!("stream deregister failed: {error:#}");
}
}
}
fn so_error(fd: RawFd) -> io::Result<i32> {
let mut value: c_int = 0;
let mut len = size_of::<c_int>() as socklen_t;
let code = unsafe {
getsockopt(
fd,
SOL_SOCKET,
SO_ERROR,
ptr::from_mut(&mut value).cast::<c_void>(),
&mut len,
)
};
if code != 0 {
return Err(io::Error::last_os_error());
}
Ok(value)
}echo.rs — the echo server
//! TCP echo server on minirt: an accept loop, one task per connection and
//! a stats heartbeat on the timer — all interleaved on a single executor
//! thread, with wakes arriving from the epoll and timer threads.
use anyhow::Result;
use clap::Parser;
use minirt::{
executor::{Executor, Spawner},
net::{Listener, Stream},
reactor::Reactor,
timer::Timer,
};
use std::{
net::SocketAddr,
sync::{
Arc,
atomic::{AtomicU64, AtomicUsize, Ordering},
},
time::Duration,
};
/// TCP echo server running on the minirt executor.
#[derive(Parser)]
struct Args {
/// Address to listen on.
#[arg(long, default_value = "127.0.0.1:7000")]
addr: SocketAddr,
}
#[derive(Default)]
struct Stats {
active: AtomicUsize,
peak: AtomicUsize,
served: AtomicUsize,
bytes: AtomicU64,
}
/// One connection: read whatever arrives, write it straight back, stop at
/// EOF. The 4 KiB buffer lives inside the task's future, not on a thread
/// stack — a thousand connections cost a thousand futures, not a thousand
/// stacks.
async fn handle(mut stream: Stream, stats: Arc<Stats>) -> Result<()> {
let mut buf = [0u8; 4096];
loop {
let count = stream.read(&mut buf).await?;
if count == 0 {
return Ok(());
}
stream.write_all(&buf[..count]).await?;
stats.bytes.fetch_add(count as u64, Ordering::Relaxed);
}
}
/// The accept loop: every new connection becomes one more task on the
/// same executor.
async fn serve(listener: Listener, spawner: Spawner, stats: Arc<Stats>) -> Result<()> {
loop {
let (stream, peer) = listener.accept().await?;
let stats = stats.clone();
stats.active.fetch_add(1, Ordering::SeqCst);
stats
.peak
.fetch_max(stats.active.load(Ordering::SeqCst), Ordering::SeqCst);
spawner.spawn(async move {
let outcome = handle(stream, stats.clone()).await;
stats.active.fetch_sub(1, Ordering::SeqCst);
stats.served.fetch_add(1, Ordering::SeqCst);
if let Err(error) = outcome {
eprintln!("[{peer}] error: {error:#}");
}
})?;
}
}
fn main() -> Result<()> {
let args = Args::parse();
let mut executor = Executor::new();
let spawner = executor.spawner();
let reactor = Reactor::spawn()?;
let timer = Timer::spawn()?;
let listener = Listener::bind(&reactor, args.addr)?;
println!("echo listening on {}", listener.local_addr()?);
// Heartbeat: once a second print the counters, but only when they
// changed — an idle server stays silent.
let stats = Arc::new(Stats::default());
let watched = stats.clone();
spawner.spawn(async move {
let mut last = (0, 0, 0, 0);
loop {
timer.sleep(Duration::from_secs(1)).await;
let snapshot = (
watched.active.load(Ordering::SeqCst),
watched.peak.load(Ordering::SeqCst),
watched.served.load(Ordering::SeqCst),
watched.bytes.load(Ordering::SeqCst),
);
if snapshot != last {
last = snapshot;
let (active, peak, served, bytes) = snapshot;
println!("conns {active} | peak {peak} | served {served} | echoed {bytes} B");
}
}
})?;
executor.block_on(serve(listener, spawner.clone(), stats))??;
Ok(())
}blast.rs — the 1000-connection client, on the same runtime
//! Concurrency probe for the echo server, itself running on minirt: open
//! all connections first (so the server really holds them at once), then
//! run echo rounds on every connection and verify each byte.
use anyhow::{Result, ensure};
use clap::Parser;
use minirt::{executor::Executor, net::Stream, reactor::Reactor, timer::Timer};
use std::{
net::SocketAddr,
sync::{
Arc,
atomic::{AtomicUsize, Ordering},
},
time::{Duration, Instant},
};
/// Echo probe: N concurrent connections, M verified rounds each.
#[derive(Parser)]
struct Args {
/// Echo server address.
#[arg(long, default_value = "127.0.0.1:7000")]
addr: SocketAddr,
/// Number of concurrent connections.
#[arg(long, default_value_t = 1000)]
conns: usize,
/// Echo rounds per connection.
#[arg(long, default_value_t = 10)]
rounds: usize,
/// Payload size per round, bytes.
#[arg(long, default_value_t = 64)]
payload: usize,
}
/// Everything one worker task needs.
struct Job {
id: usize,
addr: SocketAddr,
rounds: usize,
payload: usize,
total: usize,
reactor: Reactor,
timer: Timer,
connected: Arc<AtomicUsize>,
verified: Arc<AtomicUsize>,
}
async fn worker(job: Job) -> Result<()> {
let mut stream = Stream::connect(&job.reactor, job.addr).await?;
job.connected.fetch_add(1, Ordering::SeqCst);
// Poor man's barrier: keep the socket open until every worker is
// connected, so peak concurrency on the server really reaches N.
while job.connected.load(Ordering::SeqCst) < job.total {
job.timer.sleep(Duration::from_millis(5)).await;
}
let message: Vec<u8> = (0..job.payload)
.map(|i| ((job.id + i) % 256) as u8)
.collect();
let mut echo = vec![0u8; message.len()];
for round in 0..job.rounds {
stream.write_all(&message).await?;
stream.read_exact(&mut echo).await?;
ensure!(echo == message, "echo mismatch on round {round}");
job.verified.fetch_add(1, Ordering::SeqCst);
}
Ok(())
}
fn main() -> Result<()> {
let args = Args::parse();
let mut executor = Executor::new();
let spawner = executor.spawner();
let reactor = Reactor::spawn()?;
let timer = Timer::spawn()?;
let connected = Arc::new(AtomicUsize::new(0));
let verified = Arc::new(AtomicUsize::new(0));
let failed = Arc::new(AtomicUsize::new(0));
let start = Instant::now();
for id in 0..args.conns {
let job = Job {
id,
addr: args.addr,
rounds: args.rounds,
payload: args.payload,
total: args.conns,
reactor: reactor.clone(),
timer: timer.clone(),
connected: connected.clone(),
verified: verified.clone(),
};
let failures = failed.clone();
spawner.spawn(async move {
let id = job.id;
if let Err(error) = worker(job).await {
failures.fetch_add(1, Ordering::SeqCst);
eprintln!("[conn {id}] error: {error:#}");
}
})?;
}
executor.run_all()?;
println!(
"{} conns, {} rounds x {} B: {} echoes verified, {} failed, {} ms wall, {} polls",
args.conns,
args.rounds,
args.payload,
verified.load(Ordering::SeqCst),
failed.load(Ordering::SeqCst),
start.elapsed().as_millis(),
executor.polls()
);
ensure!(
failed.load(Ordering::SeqCst) == 0,
"{} connections failed",
failed.load(Ordering::SeqCst)
);
Ok(())
}Test environment: Intel i7-10750H (6 cores / 12 threads), Fedora 43; Rust 1.97.1, edition 2024, release build with LTO and codegen-units = 1; dependencies — anyhow and libc only (clap in the binaries). Everything runs on one machine's loopback; terminal outputs come from recorded sessions, milliseconds drift between runs while the structural numbers — polls, lines, connections, bytes — reproduce exactly.