For the past several months, I've been writing low-level network services in Jai. Every time you step into asynchronous programming in systems languages, you run straight into an ideological wall. You are forced to choose between two warring camps: Rust's zero-cost but viral async/await state machines, or Go's clean but memory-hungry goroutine stacks.
It took me five iterations of throwing away runtimes before I stopped trying to force my code into one camp or the other. What if we looked at the problem through a different lens? What if we gave framework listeners a stackless model to keep idle memory negligible, while giving user-facing handlers stackful fibers so developers can write clean, synchronous-looking code?
"It took five complete rewrites to settle on this architecture. Believe me, my Git commit log is a graveyard of dead experiments."
Today, I want to share Feather: an experimental hybrid asynchronous runtime module I've been building for the Jai programming language that explores unifying both models on the exact same thread pool.
Here is what we are covering:
Futures and stackful Fibers using a zero-offset struct layout trick.async or await.fiber_swap) and the multi-threaded fiber migration trap.To understand what Feather is exploring, let's look at the compromise every backend engineer currently navigates.
When you write async fn in Rust, the compiler transforms your function into a state machine.
async. Now every function that calls it must become async. The keyword spreads like an infection through your entire codebase. You end up maintaining two separate ecosystems: a sync world and an async world that cannot talk to each other.Go rejects colored functions. Every goroutine is allocated its own stack. You write straightforward, synchronous-looking code.
net.Conn.Read() anywhere in your code, ten levels deep in your business logic, and the runtime automatically pauses the goroutine and swaps the OS thread.| Approach | Developer Ergonomics | Memory per Idle Task | Function Coloring? |
|---|---|---|---|
| Stackless (Rust) | High friction (async/await everywhere) | Tiny (~16-64 bytes) | Yes (Viral) |
| Stackful (Go) | Great (Linear blocking code) | Heavy (2 KB to 512 KB) | No |
| Feather (Hybrid) | Linear for handlers, lightweight for listeners | Dynamic (Fibers or Futures) | No |
"Is pairing them together the ultimate answer? We cannot say that yet. But when an acceptor only needs to poll a socket once in a while, while a handler needs to query a database several layers deep, forcing the exact same memory structure on both felt worth questioning."
In Feather, the core engine (the Executor and the Reactor) knows absolutely nothing about CPU registers, call stacks, or assembly. It only understands one primitive: a stackless Future.
A Future in Feather is brutally simple:
Future :: struct {
poll: (*Future) -> Poll_State;
schedule_state: Future_Schedule_State;
executor: *Executor;
}It carries a single function pointer (poll). When an operation cannot finish, poll returns .PENDING. When it finishes, it returns .READY. There is no vtable, no fat pointer, and no dynamic dispatch.
So how do we run a stackful, register-swapping coroutine (Fiber) on this exact same engine?
We wrap it in a bridge struct:
Fiber_Future :: struct {
#as using base: Future; // MUST be at byte offset 0
fiber: *Fiber;
}Jai's #as using base: Future directive does two critical things:
base at byte offset 0. A pointer to a Fiber_Future is numerically identical to a pointer to a Future.*Future, you can pass *Fiber_Future.Memory Layout of Fiber_Future:
+-----------------------------------+-------------------+
| Future.poll | schedule_state | *Fiber pointer |
| (8 bytes) | (4 bytes + pad) | (8 bytes) |
+-----------------------------------+-------------------+
<----------- offset 0 ------------->When the Executor picks up a Fiber_Future, it calls fut.poll(fut). That function executes fiber_future_poll:
fiber_future_poll :: (base: *Future) -> Poll_State {
self := cast(*Fiber_Future) base;
// Physically switch the CPU context onto the fiber's private stack!
fiber_resume(self.fiber);
if self.fiber.finished {
fiber_destroy(self.fiber);
return .READY;
}
// The fiber paused mid-execution waiting on I/O!
return .PENDING;
}The executor doesn't know it just swapped CPU stacks. It thinks it ran a normal poll step.
One of my biggest pet peeves in modern systems is having two distinct networking APIs: std::net for synchronous code and tokio::net for asynchronous code.
In Feather, there is one TcpStream, one stream_read, and one stream_write.
We achieve this using Jai's first-class context system. Every Jai thread has an implicit context passed down the call chain. Feather injects a runtime pointer into context.feather.
Here is the exact implementation of stream_read:
stream_read :: (stream: *TcpStream, buffer: [] u8) -> (bytes_read: s64, err: Error) {
// Branch 1: Called from standard thread outside Feather?
if !context.feather {
bytes := recv(stream.fd, buffer.data, cast(u64) buffer.count, 0);
if bytes == -1 return 0, ERR_STREAM_READ;
return bytes, ERR_NONE;
}
// Branch 2: Called inside a Feather Fiber!
while true {
bytes_read, status := read_async(stream, buffer);
if status == .PENDING {
// Register socket interest with epoll/kqueue and yield the stack!
fiber_yield();
continue;
} else if status == .ERROR {
return 0, ERR_STREAM_READ;
}
return bytes_read, ERR_NONE;
}
}Look at what this means for application code. When you write a business logic handler, you don't use async or await. You write plain, linear code:
// Inside a connection fiber
buf: [1024] u8;
bytes, err := stream_read(*stream, buf); // Looks blocking!If data is already buffered in the OS socket, it returns immediately with zero context switches. If the socket would block (EWOULDBLOCK), it registers the socket with the Reactor and calls fiber_yield(), freeing the OS thread to do other work.
When you build a fiber runtime, you do not use ucontext_t or OS fibers unless you enjoy issuing slow kernel transitions. Feather switches stacks using hand-rolled assembly:
fiber_swap_arm64.s for Apple Silicon and Linux ARM64fiber_swap_linux_x64.s for x86_64Because of standard ABI calling conventions, we don't have to save all 32 registers. We only preserve callee-saved registers (x19-x30, d8-d15, and SP on ARM64).
Here is a bug that took me days to diagnose.
Imagine this scenario:
stream_read, registers interest, and calls fiber_yield(). The CPU swaps back to Thread A's scheduler stack.fiber_resume.The fiber's heap stack memory is intact. But where do Jai's context pointers point?
They point to Worker Thread A's stack!
If the fiber finishes or yields again, it will read context.scheduler_context from the old thread, jump into a stale stack frame on Core 0, and crash with an incomprehensible memory fault.
Here is how we solved it in Feather/fiber.jai:
fiber_yield :: () {
fiber := context.current_fiber;
// Jump back to the LATEST scheduler context
fiber_swap(*fiber.context_buf, fiber.jai_context.scheduler_context);
// ✨ WHEN WE WAKE UP ON A NEW THREAD:
// Update local thread variables from the heap-allocated Fiber struct!
context.scheduler_context = fiber.jai_context.scheduler_context;
context.waker = fiber.jai_context.waker;
context.feather = fiber.jai_context.feather;
}Whenever Thread B resumes a fiber, it stamps its own scheduler addresses into the fiber's heap structure. The moment the fiber wakes up from fiber_swap, it resynchronizes its stack registers.
The hardest problem in asynchronous engines is the race condition between:
If the Reactor tries to enqueue the future into the thread pool while a worker is already executing it, you get double-enqueue bugs, corrupted linked lists, or tasks running concurrently on two threads at once.
Feather coordinates this with an atomic 4-state lifecycle (Future_Schedule_State):
IDLE ──(wake)──> QUEUED ──(worker picks up)──> RUNNING
│
wake() called while RUNNING │
RUNNING ──(CAS)──> RUNNING_AND_WOKEN │
│ │
worker finishes poll │ │
RUNNING_AND_WOKEN ──(CAS)──> QUEUED ──(spawn)A look at how this behaves:
IDLE.wake() atomically transitions IDLE → QUEUED via Compare-And-Swap (CAS) and pushes it to the work queue.RUNNING.wake() fires while the task is already RUNNING, the reactor does not touch the work queue! It simply CAS-flags the state to RUNNING_AND_WOKEN.RUNNING_AND_WOKEN, it re-enqueues itself.Enough theory. Let's run some code.
Here is a concurrent TCP echo server using Feather (examples/01_echo_server.jai):
#import "Basic";
#import "Socket";
#import "Feather";
client_handler :: () {
stream: TcpStream;
stream.fd = cast(Socket) context.workspace;
defer close(stream.fd);
buf: [1024] u8;
while true {
slice: [] u8 = .{ count = buf.count, data = buf.data };
bytes_read, err := stream_read(*stream, slice);
if err.kind != .NONE || bytes_read == 0 break;
out_slice: [] u8 = .{ count = bytes_read, data = buf.data };
_ = stream_write(*stream, out_slice);
}
}
main :: () {
feather_main();
defer feather_destroy();
feather_spawn(() {
listener, _ := setup_tcp_listener(8080);
defer close(listener.fd);
print("Echo server listening on 0.0.0.0:8080\n");
while true {
stream, err := stream_accept(*listener);
if err.kind != .NONE continue;
new_ctx := context;
new_ctx.workspace = cast(*void) stream.fd;
push_context new_ctx {
feather_spawn(client_handler);
}
}
});
keep_running := true;
feather_run(*keep_running);
}Compile this with Jai:
$ jai examples/01_echo_server.jaiTest it with nc:
$ nc 127.0.0.1 8080
hello feather!
hello feather!I don't believe in presenting experimental software as production-ready perfection. Feather is fast, but it has sharp edges I am actively working through:
reactor.jai currently scans a flat array under a mutex on every tick. This works fine for hundreds of timers, but will choke at 50,000. It needs to be replaced with a hierarchical hashed timing wheel.Fiber_Stack_Pool), but they don't yet have mprotect PROT_NONE guard pages. If you blow through your 512 KB stack with runaway recursion, you will silently corrupt memory instead of catching a clean trap.kqueue) and Linux (epoll). Windows IOCP is fundamentally a completion model (the kernel writes directly into your buffer) rather than a readiness model (the kernel tells you when you can read). Bridging this without compromising the API is ongoing work.For years, the systems community has viewed stackless futures and stackful coroutines as an either-or choice. You pick your runtime, buy into its trade-offs, and structure your entire architecture around its limitations.
Feather is not claiming to have solved this dilemma once and for all. It is an opinionated experiment. It gives developers the freedom to write synchronous-looking, ergonomic code in user-facing APIs without paying the stack allocation tax across the entire server lifecycle. But until it is battle-tested by many people under unpredictable production workloads, it remains just that: an experiment.
We are not presenting a definitive answer. We are simply exploring a different way to look at the dichotomy.
Whether this hybrid approach proves to be rock-solid and viable for production systems is something only real-world adoption will tell. For now, it opens up a compelling design space for Jai. And in systems programming, having another tool to rethink old trade-offs is where the real fun begins.
Feather is open source under the MIT License. Check out the repository on GitHub or look at Falcon, the companion HTTP framework built on top of it.