XCO_MT — multi-threaded extension
Status: implemented — gated behind XCO_MT in xco.h/xco.c, tested
in tests/test_mt.c (inbox mechanics, fire routing, deferred waker,
pthread-worker job round-trip, MPSC stress; the ST suite also runs
against the MT build to prove no-threads-attached equivalence). The
build produces both libxco.a and libxco_mt.a; the flag changes
struct layouts, so a whole program must agree on it.
Extends xco from one runtime to N pinned threads communicating through
per-thread MPSC inboxes. Single-threaded fast paths are unchanged; the
extension is gated behind XCO_MT.
The unit of the extension is xco_thread_t — a freestanding thread
abstraction, not a runtime attachment. It holds the atomic inbox, a host
wakeup hook, and the dedup flag, and nothing else. A runtime may have
one attached (that's how cross-thread waiter fires find it), but a
thread without any runtime — a worker executing jobs — is equally
first-class. Everything OS-specific lives behind two host-supplied
points: the wakeup fn pointer (how to rouse this thread) and the
host's own blocking call (how this thread sleeps). After thread
creation, the pthread implementation — or eventfd, or kqueue user
events, or WFE/SEV on bare metal — is entirely behind the abstraction.
xco itself gains a dependency on <stdatomic.h> and nothing more: no
pthread, no syscalls, no TLS beyond one _Thread_local pointer pair.
Model
- One
xco_thread_tper participating thread. All non-atomic runtime/event/timer state is touched only by its owning thread. - A runtime pinned to a thread attaches that thread's
xco_thread_t. Without one, the runtime is single-threaded — today's behavior. - The waiter is the message. It already carries a
firefn pointer; we add a value slot and ahomepointer. No allocation, no boxing. - Each waiter has a home thread (where its
firemust run). Each event has an owner runtime (whose thread mutates its waitlist). The two are independent — that's how cross-thread waits work. - A job for a worker is just a waiter whose
homeis the worker's thread: posting work and routing completions are the same primitive.
The thread abstraction
typedef struct xco_thread xco_thread_t;
struct xco_thread {
/* Vyukov MPSC inbox. */
xco_waiter_t inbox_stub; /* sentinel */
_Atomic(xco_waiter_t *) inbox_tail; /* producers xchg */
xco_waiter_t *inbox_head; /* consumer-only */
/* Wake dedup. */
_Atomic bool wakeup_pending;
/* Host hook: rouse this thread. Called from arbitrary threads
* (and, being lock-free upstream, safe from signal handlers if the
* host's implementation is). */
void (*wakeup)(xco_thread_t *);
void *wakeup_ud;
/* The runtime pinned to this thread, or NULL for a bare worker.
* Set by xco_rt_attach_thread. */
xco_runtime_t *rt;
};
void xco_thread_init(xco_thread_t *t,
void (*wakeup)(xco_thread_t *), void *ud);
/* Producer side, any thread: push w onto t's inbox and wake t on the
* empty -> non-empty edge. The primitive under cross-thread fires; also
* called directly to hand a job to a worker. */
void xco_thread_post(xco_thread_t *t, xco_waiter_t *w);
/* Consumer side, owner thread only: pop and run every inboxed waiter's
* fire. Installs/restores the current-thread TLS around the fires so
* nested fires and event mutations route correctly. */
void xco_thread_drain(xco_thread_t *t);
/* Consumer side, owner thread only: the lost-wake handshake. Clears
* wakeup_pending, then re-checks the inbox. True = inbox empty, safe to
* block on the host primitive; false = drain again first. */
bool xco_thread_try_park(xco_thread_t *t);
/* Attach to a runtime (same shape as xco_rt_attach_timers); sets both
* rt->thread and t->rt. */
static inline void xco_rt_attach_thread(xco_runtime_t *rt, xco_thread_t *t);
A general xco_waiter_init(w, fire) helper (both builds) initializes a
detached waiter and, under XCO_MT, clears home/value — every
library init path routes through it, and hand-built waiters should too,
setting home explicitly afterward when routing is wanted.
What binds it to an actual OS thread is host code only:
| Thread | wakeup impl |
blocking call |
|---|---|---|
| event-loop thread | write eventfd / kevent EVFILT_USER / self-pipe |
epoll_wait / kevent |
| pthread worker | pthread_cond_signal |
pthread_cond_wait |
| bare-metal / ISR target | SEV (or nothing) |
WFE |
xco never sees any of these; it calls t->wakeup(t) and returns.
The fire seam
xco_waiter_fire becomes the routing point:
static inline void xco_waiter_fire(xco_waiter_t *w, uintptr_t v) {
w->prev = NULL; w->next = NULL;
if (!w->home || w->home == xco__thread_current) {
w->fire(w, v); /* fast path, unchanged */
return;
}
w->value = v;
xco_thread_post(w->home, w); /* Vyukov + dedup wake */
}
xco__thread_current is a thread-local set for the duration of
xco_rt_run and xco_thread_drain (save-and-restore, so nesting is
fine). NULL when neither is active. One TLS read on the fast path;
compiles to nothing when XCO_MT is off.
Setting TLS inside the library (rather than asking the host to do it) is
forced by the inbox drain: drain's fire callbacks would otherwise
re-route into the same inbox they were just popped from. Same-thread
fires between drains are rare in practice — host IO callbacks running
on the owner thread typically call event mutators (xco_latch_set,
etc.) directly rather than going through the fire path.
Required additions
Waiter
struct xco_waiter {
xco_waiter_t *next, *prev;
void (*fire)(xco_waiter_t *, uintptr_t);
/* MT (XCO_MT only): */
xco_thread_t *home; /* NULL = fire-anywhere */
uintptr_t value; /* set by remote firer; also waker's resume value */
};
value subsumes xco_waker_t.resume_value — the same "stash payload
between fire and resume" slot, generalized.
Runtime
The runtime gains a single xco_thread_t *thread field; everything else
lives in the thread struct.
Waker after the change
The waker keeps its rt and resume_value fields in both builds; only
home is new (set from rt->thread at init, NULL when rt is NULL or
has no thread attached). Fire uses sw->rt directly — it always runs
on the home thread by construction, where touching rt's ready queue is
legal — so no TLS-runtime recovery is needed, and a bare-thread drain
(a worker with no runtime) can fire arbitrary waiters without any
runtime existing on that thread. The base's value slot is inbox
transit only; resume_value remains the fire→step handoff.
static inline void xco_waker_init(xco_waker_t *sw, xco_runtime_t *rt, xco_mach_t *m) {
xco_waiter_init(&sw->base, xco__waker_fire);
#ifdef XCO_MT
sw->base.home = rt ? rt->thread : NULL;
#endif
sw->rt = rt;
sw->mach = m;
sw->resume_value = 0;
}
void xco__waker_fire(xco_waiter_t *w, uintptr_t v) {
/* Always on home thread by construction (xco_waiter_fire routed us). */
xco_waker_t *sw = (xco_waker_t *)w;
sw->resume_value = v;
xco_rt_enqueue(sw->rt, w);
}
Inbox mechanics — reusing next
The waiter's list memberships (event waitlist / ready queue / inbox)
remain disjoint in time, so next does triple duty. The inbox path
casts to _Atomic at the access site:
void xco_thread_post(xco_thread_t *t, xco_waiter_t *w) {
_Atomic(xco_waiter_t *) *w_next = (_Atomic(xco_waiter_t *) *)&w->next;
atomic_store_explicit(w_next, NULL, memory_order_relaxed);
xco_waiter_t *prev = atomic_exchange_explicit(
&t->inbox_tail, w, memory_order_acq_rel);
_Atomic(xco_waiter_t *) *prev_next = (_Atomic(xco_waiter_t *) *)&prev->next;
atomic_store_explicit(prev_next, w, memory_order_release);
/* Dedup: only the empty -> non-empty transition triggers the wake.
* seq_cst pairs with the fence in xco_thread_try_park. */
if (!atomic_exchange_explicit(&t->wakeup_pending, true, memory_order_seq_cst)) {
if (t->wakeup) t->wakeup(t);
}
}
Caveat on the cast. The C standard does not guarantee T * and
_Atomic(T *) share representation; in practice on every C11 platform
that matters they do (same size and alignment for pointers). We take
that bet to keep the waiter at one next field and the ST hot path
untouched.
Pop (consumer-only, owner thread):
xco_waiter_t *xco__inbox_pop(xco_thread_t *t) {
xco_waiter_t *head = t->inbox_head;
_Atomic(xco_waiter_t *) *head_next = (_Atomic(xco_waiter_t *) *)&head->next;
xco_waiter_t *next = atomic_load_explicit(head_next, memory_order_acquire);
if (head == &t->inbox_stub) {
if (!next) return NULL;
t->inbox_head = next;
head = next;
head_next = (_Atomic(xco_waiter_t *) *)&head->next;
next = atomic_load_explicit(head_next, memory_order_acquire);
}
if (next) { t->inbox_head = next; return head; }
/* Single-element: re-link stub at tail. Standard Vyukov dance. */
/* ... */
}
Safe to re-park inside fire because next is rewritten on each
membership transition.
Parking protocol
wakeup_pending is set on post (atomic exchange returns the old value;
only the false→true edge calls wakeup). The consumer clears it just
before blocking, then re-checks the inbox to close the lost-wake window
— that pair is xco_thread_try_park:
bool xco_thread_try_park(xco_thread_t *t) {
atomic_store_explicit(&t->wakeup_pending, false, memory_order_seq_cst);
/* Order the flag store before the inbox re-check; pairs with the
* producer's seq_cst flag exchange in xco_thread_post. Either the
* producer sees our false and wakes, or we see its push and don't
* park. */
atomic_thread_fence(memory_order_seq_cst);
if (xco__inbox_nonempty(t)) {
atomic_store_explicit(&t->wakeup_pending, true, memory_order_relaxed);
return false; /* drain again, don't block */
}
return true; /* safe to block on the host primitive */
}
Net cost: one wake per quiescent → busy transition, regardless of how many producers pile in.
Workers: threads without runtimes
A worker owns an xco_thread_t and nothing else — no runtime, no
events. Its job queue is the inbox; a job is a waiter with
home = &worker->t whose fire executes the work on the worker
thread. Completion is the same move in reverse: fire a waiter whose
home is the submitter's thread.
/* Worker loop — the only pthread code is the host's blocking call and
* the condvar behind t.wakeup: */
for (;;) {
xco_thread_drain(&self->t); /* runs job fires, TLS installed */
if (xco_thread_try_park(&self->t))
host_cond_wait(self); /* woken by t.wakeup's signal */
}
The inbox is MPSC, so each worker has its own and the producer picks one (round-robin or affinity — a host policy). There is no shared queue and no stealing; pinned means pinned, for jobs as for coroutines.
Affinity rules
- Mutators of an event (
xco_latch_set,xco_notify_*,xco_semaphore_release,xco_queue_send_poll,xco_queue_close,xco_broadcast_publish,xco_op_cancel,xco_op_complete,xco_timer_*) must run on the event's owner thread. Cross-thread invocation goes through a trampoline waiter (next section). - A waiter parked on event E may have any
home. On fire, it's detached locally on E's owner thread, then routed home. - Waiters never migrate
homewhile parked or inboxed.
Trampoline waiters
A trampoline is a waiter whose fire performs the local mutation you
wanted to do from a foreign thread. The waiter itself is the message;
the mutation runs on the destination thread.
/* Long-lived trampoline owned by a producer thread, used to publish to a
* broadcast that lives on rt B. */
typedef struct {
xco_waiter_t base;
xco_broadcast_t *bcast;
uintptr_t value;
} bcast_publish_trampoline_t;
static void bcast_publish_fire(xco_waiter_t *w, uintptr_t _v) {
bcast_publish_trampoline_t *t = (bcast_publish_trampoline_t *)w;
xco_broadcast_publish(t->bcast, t->value); /* runs on B */
}
/* Init once: */
bcast_publish_trampoline_t tr = {
.base = { .fire = bcast_publish_fire, .home = rt_B->thread },
.bcast = b,
};
/* Per cross-thread publish (any thread): */
tr.value = the_value;
xco_waiter_fire(&tr.base, 0); /* routes; B runs publish on drain */
Reuse rule: a trampoline can't be re-fired while still on an inbox. Either allocate one per pending message, or coalesce on the producer side (e.g., "latest-value" semantics line up naturally with broadcast). For one-shot operations the trampoline is typically embedded in the same struct as the operation it completes (see xco_op below).
Op round-trip: complete on the home thread
xco_op_complete and xco_op_cancel stay owner-thread-only — no MT
branch, no hidden routing, no extra fields on xco_op_t. A worker that
finishes an op does not complete it; it posts the op back to its home
thread, and completion runs there. The canonical shape embeds one job
waiter in the op struct and rides it both ways — out to the worker as
the job, back home as the completion notice:
typedef struct {
xco_op_t base; /* done latch lives here */
xco_waiter_t job; /* leg 1: worker inbox; leg 2: home inbox */
xco_thread_t *home; /* where complete must run */
int fd; void *buf; size_t len; /* request */
ssize_t result; int err; /* reply, filled by worker */
} read_op_t;
/* Leg 2 — home thread (via drain): */
static void read_op_completed(xco_waiter_t *w, uintptr_t _v) {
read_op_t *op = container_of(w, read_op_t, job);
xco_op_complete(&op->base, XCO_OP_COMPLETED); /* owner thread: legal */
}
/* Leg 1 — worker thread (via drain): */
static void read_op_execute(xco_waiter_t *w, uintptr_t _v) {
read_op_t *op = container_of(w, read_op_t, job);
op->result = host_read(op->fd, op->buf, op->len, &op->err);
w->fire = read_op_completed;
w->home = op->home;
xco_waiter_fire(w, 0); /* routes home; completion runs there */
}
/* Submitter, home thread, after xco_rt_take_ops: */
op->job.fire = read_op_execute;
op->job.home = &worker->t;
xco_waiter_fire(&op->job, 0); /* routes to the worker */
Both legs are ordinary xco_waiter_fire routing; the reuse rule holds
because the waiter's inbox memberships are disjoint in time. Foreign-
thread cancel is the same move: post a trampoline home and call
xco_op_cancel there (mid-flight, the advisory cancel_requested flag
is the only thing an executor reads off-home).
Atomics and freestanding inventory
Atomic:
xco_thread_t.inbox_tailxco_thread_t.wakeup_pendingxco_waiter.nextat the inbox post/pop access sites only
Not atomic: every other field. Event waitlists, ready queue, timer heap, op pending list, latch state, semaphore permits, queue buffers — all stay plain, governed by the owner-thread rule.
Freestanding: XCO_MT adds <stdatomic.h> and one _Thread_local
pointer pair. It adds no pthread usage, no allocation, no syscalls, and
no knowledge of how any thread was created, blocks, or wakes — those
exist only in host code, behind wakeup and the host's blocking call.
Host main loop
xco_rt_run absorbs both the inbox drain and the TLS install/restore,
so a runtime thread's loop is just:
for (;;) {
xco_rt_run(rt, host_now()); /* drains inbox + ready + timers */
if (no_io_pending(rt) && xco_thread_try_park(rt->thread))
host_block_on_io(rt->thread); /* eventfd / kevent / self-pipe */
}
Internally:
void xco_rt_run(xco_runtime_t *rt, uint64_t now) {
xco_thread_t *prev_t = xco__current_thread();
xco_runtime_t *prev_rt = xco__current_rt();
xco__current_thread_set(rt->thread);
xco__current_rt_set(rt);
for (;;) {
if (rt->thread) xco__drain_inbox(rt->thread); /* TLS already set */
drain_ready_queue(rt);
if (rt->timers) xco_timers_advance(rt->timers, now);
if (quiescent(rt)) break; /* fixpoint over all three */
}
xco__current_rt_set(prev_rt);
xco__current_thread_set(prev_t);
}
Canonical example: interrupt handler
A signal handler (or hardware ISR analog) resumes a coroutine waiting on
a hardware event. Setup runs on the runtime's thread; the handler holds
only the waiter pointer. On bare metal the same shape works with
wakeup = SEV (or nothing) and the idle loop blocking in WFE.
typedef struct {
xco_waiter_t base;
xco_coro_t *co;
} irq_waiter_t;
static void irq_resume(xco_waiter_t *w, uintptr_t v) {
irq_waiter_t *iw = (irq_waiter_t *)w;
xco_step(&iw->co->base, v); /* on home thread */
}
/* On rt's thread: */
irq_waiter_t iw = {
.base = { .fire = irq_resume, .home = rt->thread },
.co = xco_self(),
};
atomic_store(&g_irq_pending, &iw.base); /* publish to ISR */
xco_suspend(0);
/* In the signal handler — async-signal-safe: */
void on_sigio(int sig, siginfo_t *si, void *ctx) {
xco_waiter_t *w = atomic_exchange(&g_irq_pending, NULL);
if (w) xco_waiter_fire(w, (uintptr_t)si->si_value.sival_int);
}
The handler never allocates, never locks, never touches event internals. Net cost: one atomic exchange (inbox tail), one release store (prev->next), and — only on the empty-inbox edge — one wakeup call. The coroutine resumes on its own thread with the ISR's value.
What stays out of scope
- Thread creation, joining, blocking, waking. All host bindings; xco defines only the mailbox and the handshake.
- Work stealing / coroutine or job migration. Pinned means pinned.
- Bounded inbox / backpressure. The natural bound is parked-op count; unbounded growth is a host-design problem, not an xco one.
- Lock-free events. Every primitive remains owner-thread-only; cross-thread use goes through trampoline waiters. This keeps primitives tiny and the ST build identical to today.
Build
XCO_MT toggles the new waiter fields, the xco_thread_t API, and the
TLS install/restore and inbox drain in xco_rt_run/xco_thread_drain.
The op layer is untouched in both modes — completion is always an
owner-thread call. Without XCO_MT, xco compiles to exactly today's
library — no atomics, no TLS, no extra bytes per waiter, no home field
on the base, and xco_rt_run reduces to just the ready-queue + timers
fixpoint it is today.