xco

Concurrency for C
git clone https://git.ryansepassi.com/git/xco.git
Log | Files | Refs | README

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

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

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:

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

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.