12 KiB
Creating an Async Runtime Environment
Goal
Build a minimal async runtime from scratch using Linux kernel primitives. The runtime is not specific to writeonce — it is a general-purpose event loop that any project can use to multiplex I/O without threads, without tokio, and without any external async framework.
The writeonce project uses this runtime (the wo-event and wo-rt crates), but the concepts apply to any server, daemon, or event-driven application on Linux.
What Is a Runtime?
A runtime is the loop that decides what code runs next. In a synchronous program, the OS scheduler picks the next thread. In an async runtime, a single thread asks the kernel: "which of my file descriptors are ready?" — and runs the corresponding handler.
loop {
ready_fds = ask_kernel_which_fds_are_ready()
for fd in ready_fds {
run_handler(fd)
}
}
That's the entire concept. Everything else — epoll, tokens, interest flags — is implementation detail around this loop.
Explaining It with C
Before Rust, before abstractions, here is a minimal async runtime in C that watches two file descriptors on one thread.
Step 1: Create an epoll instance
#include <sys/epoll.h>
#include <unistd.h>
#include <stdio.h>
int main() {
// Create the event loop
int epoll_fd = epoll_create1(0);
// This single fd is the "runtime" — all other fds register on it
printf("epoll fd: %d\n", epoll_fd);
}
epoll_create1 returns a file descriptor. This fd is the runtime. Every other fd in the system registers itself on this one fd, and the kernel tracks readiness for all of them.
Step 2: Register file descriptors
#include <sys/epoll.h>
#include <sys/eventfd.h>
#include <sys/timerfd.h>
#include <time.h>
int main() {
int epoll_fd = epoll_create1(0);
// Create an eventfd (like a semaphore as a file descriptor)
int event_fd = eventfd(0, EFD_NONBLOCK);
// Create a timerfd (fires every 2 seconds)
int timer_fd = timerfd_create(CLOCK_MONOTONIC, TFD_NONBLOCK);
struct itimerspec spec = {
.it_interval = { .tv_sec = 2, .tv_nsec = 0 },
.it_value = { .tv_sec = 2, .tv_nsec = 0 }
};
timerfd_settime(timer_fd, 0, &spec, NULL);
// Register both on epoll
struct epoll_event ev1 = { .events = EPOLLIN, .data.fd = event_fd };
epoll_ctl(epoll_fd, EPOLL_CTL_ADD, event_fd, &ev1);
struct epoll_event ev2 = { .events = EPOLLIN, .data.fd = timer_fd };
epoll_ctl(epoll_fd, EPOLL_CTL_ADD, timer_fd, &ev2);
}
Two different kinds of fd — an event signal and a timer — both registered on the same epoll instance. The kernel will wake us when either is ready.
Step 3: The event loop
#include <sys/epoll.h>
#include <sys/eventfd.h>
#include <sys/timerfd.h>
#include <unistd.h>
#include <stdio.h>
#include <stdint.h>
#include <time.h>
int main() {
int epoll_fd = epoll_create1(0);
int event_fd = eventfd(0, EFD_NONBLOCK);
int timer_fd = timerfd_create(CLOCK_MONOTONIC, TFD_NONBLOCK);
struct itimerspec spec = {
.it_interval = { .tv_sec = 2, .tv_nsec = 0 },
.it_value = { .tv_sec = 2, .tv_nsec = 0 }
};
timerfd_settime(timer_fd, 0, &spec, NULL);
struct epoll_event ev1 = { .events = EPOLLIN, .data.fd = event_fd };
epoll_ctl(epoll_fd, EPOLL_CTL_ADD, event_fd, &ev1);
struct epoll_event ev2 = { .events = EPOLLIN, .data.fd = timer_fd };
epoll_ctl(epoll_fd, EPOLL_CTL_ADD, timer_fd, &ev2);
printf("Runtime started. Timer fires every 2s.\n");
printf("Write to eventfd to trigger it: echo 1 > /proc/%d/fd/%d\n",
getpid(), event_fd);
// The event loop — this IS the runtime
struct epoll_event events[10];
while (1) {
int n = epoll_wait(epoll_fd, events, 10, -1); // block until ready
for (int i = 0; i < n; i++) {
int fd = events[i].data.fd;
if (fd == timer_fd) {
uint64_t expirations;
read(timer_fd, &expirations, sizeof(expirations));
printf("[timer] fired (%lu expirations)\n", expirations);
}
else if (fd == event_fd) {
uint64_t val;
read(event_fd, &val, sizeof(val));
printf("[event] signaled (value: %lu)\n", val);
}
}
}
close(epoll_fd);
close(event_fd);
close(timer_fd);
return 0;
}
Compile and run:
gcc -o runtime runtime.c
./runtime
Output:
Runtime started. Timer fires every 2s.
[timer] fired (1 expirations)
[timer] fired (1 expirations)
[timer] fired (1 expirations)
...
One thread. Two fd types. One loop. The kernel does the scheduling.
Step 4: Add a TCP server to the same loop
#include <sys/epoll.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <unistd.h>
#include <stdio.h>
#include <fcntl.h>
#include <string.h>
int main() {
int epoll_fd = epoll_create1(0);
// Create a non-blocking TCP listener
int listen_fd = socket(AF_INET, SOCK_STREAM | SOCK_NONBLOCK, 0);
int opt = 1;
setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
struct sockaddr_in addr = {
.sin_family = AF_INET,
.sin_port = htons(8080),
.sin_addr.s_addr = INADDR_ANY
};
bind(listen_fd, (struct sockaddr*)&addr, sizeof(addr));
listen(listen_fd, 128);
// Register listener on epoll
struct epoll_event ev = { .events = EPOLLIN, .data.fd = listen_fd };
epoll_ctl(epoll_fd, EPOLL_CTL_ADD, listen_fd, &ev);
printf("Listening on port 8080\n");
struct epoll_event events[64];
while (1) {
int n = epoll_wait(epoll_fd, events, 64, -1);
for (int i = 0; i < n; i++) {
int fd = events[i].data.fd;
if (fd == listen_fd) {
// Accept new connection
int client_fd = accept4(listen_fd, NULL, NULL, SOCK_NONBLOCK);
if (client_fd >= 0) {
struct epoll_event cev = { .events = EPOLLIN, .data.fd = client_fd };
epoll_ctl(epoll_fd, EPOLL_CTL_ADD, client_fd, &cev);
printf("[accept] client fd=%d\n", client_fd);
}
} else {
// Read from client
char buf[4096];
int nbytes = read(fd, buf, sizeof(buf));
if (nbytes <= 0) {
// Client disconnected
epoll_ctl(epoll_fd, EPOLL_CTL_DEL, fd, NULL);
close(fd);
printf("[close] fd=%d\n", fd);
} else {
// Echo response
const char *response =
"HTTP/1.1 200 OK\r\n"
"Content-Length: 13\r\n"
"\r\n"
"Hello, world!";
write(fd, response, strlen(response));
epoll_ctl(epoll_fd, EPOLL_CTL_DEL, fd, NULL);
close(fd);
}
}
}
}
}
This is a complete HTTP server — no threads, no framework, no library. One epoll_wait drives accept, read, write, and close for every connection.
From C to Rust: The writeonce Runtime
The writeonce wo-event crate wraps these same syscalls in safe Rust:
| C syscall | Rust wrapper | Crate |
|---|---|---|
epoll_create1 |
EventLoop::new() |
wo-event |
epoll_ctl(ADD) |
EventLoop::register(fd, interest, token) |
wo-event |
epoll_ctl(MOD) |
EventLoop::modify(fd, interest, token) |
wo-event |
epoll_ctl(DEL) |
EventLoop::deregister(fd) |
wo-event |
epoll_wait |
EventLoop::poll(timeout) |
wo-event |
eventfd |
EventFd::new() |
wo-event |
timerfd_create |
TimerFd::new() |
wo-event |
signalfd |
SignalFd::new() |
wo-event |
The key difference from the C examples: instead of matching on raw fd numbers, the Rust runtime assigns a token (u64) to each fd. The event loop returns tokens, and the runtime dispatches on them:
let events = event_loop.poll(Some(Duration::from_millis(500)))?;
for event in events {
match event.token {
TOKEN_WATCHER => handle_file_change(),
TOKEN_SIGNAL => handle_shutdown(),
TOKEN_TIMER => handle_periodic_task(),
TOKEN_LISTENER => handle_new_connection(),
token if token >= 10000 => handle_http(token),
_ => {}
}
}
Why Not tokio?
tokio is a production-grade async runtime. It handles everything — epoll, thread pools, work stealing, timers, I/O drivers. So why build a custom one?
| Concern | tokio | Custom runtime |
|---|---|---|
| Binary size | Adds ~2-3 MB | Zero — just libc syscalls |
| Dependencies | 50+ transitive crates | 1 crate (libc) |
| Complexity | Work-stealing scheduler, multi-threaded executor | Single-threaded loop, ~200 lines |
| Control | Opaque — runtime internals hidden behind .await |
Every fd, every syscall, every state transition is explicit |
| Learning | Abstracts the kernel away | Forces understanding of what the kernel actually does |
For a content platform serving markdown files, the workload is: accept connection, read request, query in-memory index, render template, write response. This is microseconds of work per request. A single-threaded event loop handles thousands of concurrent connections without the complexity of a multi-threaded executor.
Reusing the Runtime in Other Projects
The wo-event crate has no dependency on writeonce. It provides:
EventLoop— epoll wrapper with register/deregister/pollEventFd— lightweight signalingTimerFd— periodic timers as fdsSignalFd— SIGINT/SIGTERM as fd eventsEvent— readable/writable/hangup statusToken— u64 identifier for dispatch
Any project that needs non-blocking I/O on Linux can use it:
use wo_event::{EventLoop, EventFd, Interest};
use std::time::Duration;
fn main() {
let eloop = EventLoop::new().unwrap();
let efd = EventFd::new().unwrap();
eloop.register(efd.fd(), Interest::Readable, 1).unwrap();
// Signal from another thread
std::thread::spawn(move || {
std::thread::sleep(Duration::from_secs(1));
efd.write(42).unwrap();
});
// Wait for the signal
let events = eloop.poll(Some(Duration::from_secs(5))).unwrap();
assert_eq!(events[0].token, 1);
println!("Event received!");
}
The Mental Model
┌─────────────────────────────────┐
│ your code │
│ (handlers, business logic) │
└──────────────┬──────────────────┘
│ dispatches on token
┌──────────────┴──────────────────┐
│ event loop │
│ epoll_wait → Vec<Event> │
└──────────────┬──────────────────┘
│ registered fds
┌──────────────┴──────────────────┐
│ Linux kernel │
│ tracks readiness for all fds │
│ inotify, sockets, timers, │
│ signals, eventfds — all fds │
└─────────────────────────────────┘
The kernel is the scheduler. The event loop is the dispatcher. Your code is the handler. Everything in the system — files, sockets, timers, signals — is a file descriptor. One loop to rule them all.