Fibers and the event loop
What a fiber really is, how the loop schedules it, and why a fiber is not a thread.
---- A fiber is a stack you can put down
- The loop’s job
- Suspending on time, not just yielding
- Awaiting a value across fibers
- What you have learned
In Getting started you ran a coroutine that
yielded once and returned a value. This chapter explains what a fiber
is, how the loop schedules it, and how to suspend on real work (a
timer, I/O) instead of a bare xtc_yield.
A fiber is a stack you can put down
An ordinary C function owns the CPU from the moment it is called until it
returns; its local variables live on the one call stack. A fiber
gives a function its own small call stack that libxtc can switch away
from and back to. When a fiber calls xtc_yield(), libxtc saves its
registers, restores the loop’s registers, and returns to the loop – the
fiber’s stack is untouched, frozen mid-function. Later the loop switches
back, and the function continues on the next line as if nothing
happened.
That switch is a few dozen instructions (a register save/restore written
in per-architecture assembly, or ucontext where assembly is not
available) – no kernel involvement. Compare a thread context switch,
which traps into the scheduler. This is why one loop on one thread can
interleave hundreds of thousands of fibers.
Why hand-written
fcontextassembly? The switch is the hottest path in the whole library. The x86-64 System V switch measures about 7.6 ns.ucontext(the portable fallback) also saves the signal mask with a syscall on every switch – roughly an order of magnitude slower. libxtc ships assembly for x86-64, AArch64, ppc64le, riscv64, arm, s390x, and sparc64, and falls back toucontext(and Win32 fibers on Windows) only where it must. See Architecture for the substrate matrix.
The loop’s job
xtc_loop_run is a scheduler. Its cycle is:
flowchart TD
A["run queue<br/>has a ready fiber?"] -->|yes| B["switch into fiber"]
B --> C{"fiber<br/>suspends or<br/>finishes?"}
C -->|suspends: yield / await / recv| A
C -->|finishes| A
A -->|no| D{"anything parked<br/>on I/O or timers?"}
D -->|yes| E["ask the OS poller<br/>(io_uring / epoll /<br/>kqueue / IOCP)<br/>wait for the next event"]
E --> F["wake the fiber<br/>the event belongs to"]
F --> A
D -->|no| G(["return from<br/>xtc_loop_run"])
- Pop a ready fiber from the run queue and switch into it.
- The fiber runs until it suspends (yields, awaits I/O, receives a message that has not arrived) or finishes.
- If the run queue is empty but fibers are parked on I/O or timers, ask the OS poller (io_uring / epoll / kqueue / IOCP) to wait for the next event, wake the fiber it belongs to, and loop.
- When nothing is runnable and nothing is parked, return.
You do not call the scheduler; you arm work (spawn fibers, start
timers, issue I/O) and hand it the thread with xtc_loop_run.
Suspending on time, not just yielding
xtc_yield gives the CPU back but asks to be rescheduled immediately.
The more useful suspension waits for something. The simplest is a timer:
inside a fiber, xtc_proc_sleep(ns) parks this fiber for ns
nanoseconds while the loop keeps running everything else.
Do not call
xtc_sleep_nsinside a fiber – it sleeps the whole OS thread and stalls every other fiber on the loop.xtc_sleep_nsis for ordinary thread code;xtc_proc_sleepis the fiber-friendly form. This distinction – a blocking call vs. a suspending call – is the single most important habit in libxtc, and Blocking work and I/O is entirely about it.
Awaiting a value across fibers
xtc_await (from chapter 1) blocks the caller until a task finishes.
Called from ordinary thread code after xtc_loop_run returns, it simply
reads the already-computed result. Called from inside another fiber,
it suspends that fiber until the awaited task completes – letting you
fan work out and join it back without threads or callbacks.
What you have learned
- A fiber is a suspendable call stack; switching is a user-space register swap.
- The loop schedules ready fibers and parks the rest on the OS poller.
xtc_yieldreschedules immediately;xtc_proc_sleepsuspends on a timer;xtc_awaitsuspends on another task – all without blocking the thread.
The bare coroutine is the foundation. The next layer up gives each unit of work an identity and a mailbox, so units can be addressed and can fail independently: processes.
← Getting started · Next: Processes and messages →