async runtime
revo runs fibers cooperatively. when a fiber hits i/o, it goes on to do more important things than waiting,
on other fibers. the scheduler picks another ready fiber. when i/o completes, the fiber wakes up
it’s important to me that your async code looks blocking but isn’t, because it makes simple scripts faster for free
this lets you spawn hundreds of fibers without threads, callbacks or other nasty things
fibers
a fiber is just a lightweight task the scheduler managws. you spawn them like this:
when s:send() would block, that fiber parks. the scheduler runs server fiber instead. once data is ready, the client fiber resumes with the result
concurrent networking for dummies
run it, open another terminal, type nc localhost 6767 and you’ll have yourself a shell
you can then open infinity more terminals and run the same command and have them be handled at once
the scheduler
the coolest thing here is that async ops operate on generic tokens
the scehduler, src/vm/scheduler.zig,
- tracks fiber state (ready, waiting, running)
- maintains the runqueue
- tracks which fibers are waiting on i/o
the key type is WaitEntry:
`fiber_id`:
which fiber to wake
`wait_id`:
file descriptor / socket handle
`intent`:
read, write, or both
`token`:
opaque state from the i/o layer
`on_ready`:
callback when data arrives
`on_deinit`:
cleanupi/o polling
pollIoWaiters() in src/baselib/net.zig uses std.posix.poll() on posix. when a file descriptor is ready:
on_readycallback fires- callback does the syscall (send/recv/accept)
- if done, calls
completeWaiter()to wake the fiber - fiber goes back on runqueue
fn onRecvReady(vm: *VM, waiter: *Scheduler.WaitEntry, events: i16) !Scheduler.IoDispatchResult {
const token = @as(*RecvWaitToken, @ptrFromInt(waiter.token));
const rc = std.c.recv(waiter.wait_id, buffer.ptr, buffer.len, flags);
deinitToken(RecvWaitToken, vm.runtime.alloc, waiter.token);
waiter.token = 0;
return try completeWaiter(vm, waiter, .ok, data);
}the io driver
all socket io is readiness-driven, we dont do worker threads anymore
pollIoWaiters() in src/baselib/net.zig snapshots the waiter list under lock
(each waiter carries a generation stamp),
poll()s the snapshot with no locks held,
then revalidates every ready fd against the live list before dispatching.
ready entries are claimed (removed) before their callback runs,
so later appends, removals, or array growth can’t invalidate them;
still-pending entries go back with a fresh generation.
completions and callbacks run with the vm gil held
socket:send(data)
send_fncalledSendWaitTokenallocated with message ID and offset- fast nonblocking send first; on
AGAINthe fiber parks withonSendReady - socket becomes writable,
onSendReadyfires - sends bytes, updates offset if needed
- when all sent, wakes fiber with
(:ok, bytes_sent)
socket:recv(opts)
recv_fncalled with options (read_some/read_line/read_all, max_bytes, delimiter)RecvWaitTokenallocated- socket parks with
onRecvReady - when data arrives,
onRecvReadyreads into buffer - for
read_some: wakes immediately - for
read_line: keeps buffering instream.pendinguntil delimiter - for
read_all: keeps buffering until close
socket:accept()
accept_fnchecks socket is a server- nonblocking
acceptonce; onAGAINthe fiber parks withonAcceptReady - connection arrives,
onAcceptReadycallsstd.c.accept() - wraps socket in
SocketEntry - wakes fiber with
(:ok, new_socket_table)
extending to other i/o
to wait on a new fd source, park with a WaitEntry and handle it in a readiness callback:
-
vm.sched.parkCurrentForIo(wait_id, intent, token, on_ready, on_deinit)
appends the waiter (stamped with a fresh generation) and parks the fiber -
pollIoWaitersclaims ready entries before dispatch;
return.{ .completed = true }to drop yours,
or.{}to be re-queued with a fresh generation -
tokens are yours: allocate on park, free in
on_deinitor on completionthe driver calls
on_deinitexactly once per claimed entry that leaves
the list with a nonzero token, so zero it after freeing it yourself -
match new completions by
(wait_id, fiber_id, intent, generation);
anything else is stale (closed, recycled fd) and must be skipped
see onRecvReady above and src/baselib/net.zig
performance
poll runs with zero timeout per scheduler cycle
recv buffers in stream.pending to handle partial reads. watch your allocation overhead. fibers allocate stack, so memory bounds your fiber count, not file descriptors
gotchas
- don’t store buffer pointers
once you hand a buffer to a pending entry or token, it owns it. don’t free it yourself later - check fiber IDs before waking
a fiber might have died, so validate the id - completions aren’t ordered
readiness may fire out-of-order. don’t assume fifo