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
- Suspending from inside a foreign call chain
- 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.
Suspending from inside a foreign call chain
Here is the property that a callback runtime cannot match. Because a libxtc fiber is stackful – it owns a real C call stack – a suspension point can live arbitrarily deep in a chain of ordinary C calls, including inside a library function that knows nothing about libxtc.
The classic shape is a library routine that calls back into your code:
qsort calling your comparator, bsearch, a parser calling a callback
per token. If that callback yields, libxtc freezes the entire stack –
the library’s frame included – and restores it intact on resume. The
library never notices.
#include <stdio.h>
#include <stdlib.h>
#include <stdint.h>
#include "xtc.h"
#include "xtc_loop.h"
#include "xtc_async.h"
static int g_yields; /* how many times the comparator suspended */
/* An ordinary qsort comparator -- except it cooperatively yields. It
* could just as well await an async read or a message here; the point
* is that the suspension happens INSIDE qsort's call frame. */
static int
cmp_yielding(const void *a, const void *b)
{
int x = *(const int *)a;
int y = *(const int *)b;
xtc_yield(); /* hand the loop control from deep inside qsort() */
g_yields++;
return (x > y) - (x < y);
}
static intptr_t
sorter(void *arg)
{
int *v = arg;
int n = 8, i;
/* qsort is plain libc: it has no idea it is running on a fiber.
* Our comparator suspends the whole fiber (qsort frame included)
* on each compare, and qsort resumes correctly every time. */
qsort(v, (size_t)n, sizeof v[0], cmp_yielding);
for (i = 1; i < n; i++)
if (v[i - 1] > v[i])
return -1; /* not sorted -> the pattern broke */
return 0; /* sorted correctly across all the yields */
}
int
main(void)
{
static int data[8] = { 5, 2, 8, 1, 9, 3, 7, 4 };
xtc_loop_t *loop;
xtc_task_t *task;
intptr_t ok = -1;
if (xtc_loop_init(&loop) != XTC_OK)
return 1;
if (xtc_async(loop, sorter, data, &task) != XTC_OK)
return 1;
(void)xtc_loop_run(loop);
(void)xtc_await(task, &ok);
printf("sorted correctly through %d in-qsort yields: %s\n",
g_yields, ok == 0 ? "yes" : "no");
(void)xtc_loop_fini(loop);
return ok == 0 ? 0 : 1;
}
Tested source: docs/_includes/snippets/06_qsort_yield.c
sorted correctly through 20 in-qsort yields: yes
Why this needs a stackful fiber. When the comparator yields,
qsort’s frame is mid-partition on the fiber’s stack. A register swap saves the whole stack and returns to the loop; the resume lands right back inside the comparator andqsortcontinues. A callback or stackless runtime has no stack to put down at that point, so it cannot suspend from insideqsortat all – you would have to rewrite the sort as a state machine. This is the concrete payoff of the fiber-over-callbacks choice.
The switch is cheap (a register swap), but note the cost model: a comparator that yields on every compare turns an O(n log n) sort into O(n log n) scheduler round-trips, and the fiber holds
qsort’s stack for the whole sort. That is correct and fine – just be deliberate about whether a given callback should yield. A pure CPU comparator that never yields runs at full speed with zero runtime overhead.
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 →