xtc_async(3)

---

xtc_async(3)

L2 stackful coroutines and protothreads

XTC_ASYNC(3) Library Functions Manual XTC_ASYNC(3)

xtc_async, xtc_await, xtc_yield, xtc_yield_set_budget, xtc_yield_check, xtc_yield_if_due, xtc_yield_due_count, xtc_stack_size, xtc_set_stack_size, XTC_COOP_REGION, XTC_PT_THREAD, XTC_PT_BEGIN, XTC_PT_END, XTC_PT_YIELD, XTC_PT_WAIT_UNTIL — L2 stackful coroutines and protothreads

#include <xtc_async.h>

int
xtc_async(xtc_loop_t *loop, xtc_coro_fn fn, void *arg, xtc_task_t **out_task);

int
xtc_await(xtc_task_t *t, intptr_t *result);

void
xtc_yield(void);

void
xtc_yield_set_budget(xtc_loop_t *loop, int64_t budget_ns);

int
xtc_yield_check(void);

int
xtc_yield_if_due(void);

uint64_t
xtc_yield_due_count(const xtc_loop_t *loop);

size_t
xtc_stack_size(void);

int
xtc_set_stack_size(size_t bytes);

Macros:

XTC_COOP_REGION { ... }

XTC_PT_THREAD(name(args)) { XTC_PT_BEGIN(pt); ...; XTC_PT_END(pt); }
XTC_PT_YIELD(pt)
XTC_PT_WAIT_UNTIL(pt, condition)

xtc_async spawns fn(arg) as a stackful coroutine attached to loop. The coroutine runs on its own stack (default 6400a0KiB; configurable via ()) guarded by an unmapped page so stack overflow surfaces as SIGSEGV rather than silent memory corruption.

A coroutine may freely use stack-resident locals; they are preserved across () and xtc_await(). This is the headline contract that distinguishes fibers from protothreads.

() blocks the caller until the awaited coroutine has returned, then writes the coroutine's intptr_t return value to *result. When called from the main thread, it drives the loop until the target completes; when called from inside another coroutine, it parks the caller and resumes it once the target is done.

() returns control to the loop. The next loop step resumes the coroutine at the line after the call.

xtc has no forcible preemption, so a long compute loop must yield to be fair to the rest of the loop. () arms a per-loop run-quantum time budget in nanoseconds (0, the default, disables it). With a budget set, () returns 1 once the current coroutine's quantum has run past the budget without yielding, and () yields in that case (returning 1 if it did). Sprinkle xtc_yield_if_due() in a compute loop and a co-resident coroutine keeps making progress. xtc_yield_check() is also the queryable "over budget" signal an embedder can wire to a cancellation token -- fire an xtc_abort_source when over budget and poll the token in xtc_svr_call_abortable(3). () reports how many times a coroutine went over budget on the loop (telemetry). All four are no-ops returning 0 off a loop.

The XTC_COOP_REGION block marks a region that runs to completion without scheduler interleaving. In M4 (single-thread, non-preemptive) it is a documentation marker. In M5 (multi-loop) it sets a “do not steal” flag for the duration of the block.

For platforms without a fiber substrate, xtc ships a Duff's-device protothread implementation as a fallback. The price is that protothread bodies cannot use stack-resident locals across yield points: lift them into a state struct or variables. This restriction is enforced by example, not by the compiler.

A protothread function returns XTC_PT_DONE when it falls through XTC_PT_END and XTC_PT_YIELDED when it yields. The caller drives the protothread by calling it repeatedly until it returns XTC_PT_DONE.

() returns the default per-coroutine stack size in bytes. xtc_set_stack_size() overrides it for subsequent () calls; the value is rounded up to a page multiple, and a sub-1600a0KiB request returns XTC_E_INVAL.

XTC_OK on success; XTC_E_INVAL on bad argument; XTC_E_NOMEM on stack-allocation failure, including exhaustion of the process mapping limit (Linux vm.max_map_count: each fiber stack is two mappings, so the stock 65530 admits about 32K live fibers); XTC_E_INTERNAL on any other platform failure (getcontext / makecontext / mprotect). Releases before 1.50 returned XTC_E_INTERNAL when the guard-page mprotect hit the mapping limit.

M4 is single-thread. A coroutine spawned on a loop must run only on that loop's thread.

static intptr_t multiply(void *arg) {
        int v = *(int *)arg;
        return (intptr_t)(v * 2);
}

int main(void) {
        xtc_loop_t *loop;
        xtc_task_t *t;
        int v = 21;
        intptr_t r;
        xtc_loop_init(&loop);
        xtc_async(loop, multiply, &v, &t);
        xtc_await(t, &r);     /* r == 42 */
        xtc_loop_fini(loop);
        return 0;
}

xtc_loop(3), xtc_io(3), xtc(7)

First appeared in xtc 0.0.1 (M4). The ucontext substrate is the M4 default; per-architecture fiber asm () arrives in M4.5 for ~3000a0ns context switches, and became the default on x86-64 and aarch64 Linux with glibc in 1.59 (ucontext stays selectable with --with-coro=ucontext).

May 25, 2026 Debian

View the mdoc source