xco

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

commit 552dff0b3b6d727f42c428080c897229f31369d7
Author: Ryan Sepassi <rsepassi@gmail.com>
Date:   Mon,  4 May 2026 19:11:59 -0700

xco initial commit

Diffstat:
A.gitignore | 1+
AMakefile | 45+++++++++++++++++++++++++++++++++++++++++++++
Aarch/arm64/xco_arch.c | 132+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aarch/arm64/xco_arch.h | 27+++++++++++++++++++++++++++
Atests/test_xco.c | 72++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Axco.c | 102+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Axco.h | 85+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Axco_platform.h | 34++++++++++++++++++++++++++++++++++
8 files changed, 498 insertions(+), 0 deletions(-)

diff --git a/.gitignore b/.gitignore @@ -0,0 +1 @@ +build/ diff --git a/Makefile b/Makefile @@ -0,0 +1,45 @@ +# xco — minimal asymmetric coroutines. +# +# Per-arch selection: ARCH names a directory under arch/. The build +# adds -Iarch/$(ARCH) (so xco_arch.h resolves to that arch's copy) and +# compiles arch/$(ARCH)/xco_arch.c. +# +# All build artifacts land under build/, mirroring the source tree. + +CC ?= cc +AR ?= ar +CFLAGS ?= -std=c11 -Wall -Wextra -O2 -g + +ARCH ?= $(shell uname -m | sed 's/aarch64/arm64/') +ARCHDIR := arch/$(ARCH) +BUILD := build + +CPPFLAGS += -I. -I$(ARCHDIR) + +SRCS := xco.c $(ARCHDIR)/xco_arch.c +OBJS := $(SRCS:%.c=$(BUILD)/%.o) +LIB := $(BUILD)/libxco.a + +TEST_SRC := tests/test_xco.c +TEST_BIN := $(BUILD)/test_xco + +all: $(LIB) + +$(LIB): $(OBJS) + $(AR) rcs $@ $^ + +$(BUILD)/%.o: %.c + @mkdir -p $(dir $@) + $(CC) $(CPPFLAGS) $(CFLAGS) -c -o $@ $< + +$(TEST_BIN): $(TEST_SRC) $(LIB) + @mkdir -p $(dir $@) + $(CC) $(CPPFLAGS) $(CFLAGS) -o $@ $< $(LIB) + +test: $(TEST_BIN) + $(TEST_BIN) + +clean: + rm -rf $(BUILD) + +.PHONY: all clean test diff --git a/arch/arm64/xco_arch.c b/arch/arm64/xco_arch.c @@ -0,0 +1,132 @@ +/* + * arch/arm64/xco_arch.c — AArch64 init + switch + trampoline thunk. + * + * The switch primitive and the trampoline thunk are written as + * file-scope global asm rather than __attribute__((naked)) functions: + * GCC silently ignores `naked` on AArch64, so naked-function inline + * asm only works under Clang. File-scope __asm__ is portable across + * both GCC and Clang and keeps everything in one .c file. + * + * Layout offsets used in the asm: + * 0 regs[0..9] x19-x28 + * 80 regs[10..11] fp (x29), lr (x30) + * 96 regs[12] sp + * 104 fp_regs[0..7] d8-d15 + * + * Symbol naming: asm labels are built with the compiler-provided + * __USER_LABEL_PREFIX__ (empty on ELF, "_" on Mach-O) so the labels + * emitted here match what the C compiler generates for references to + * xco_platform_switch / xco_platform_trampoline_thunk on either OS. + */ + +#include "xco_arch.h" +#include "xco_platform.h" + +#include <assert.h> +#include <stddef.h> +#include <stdint.h> +#include <string.h> + +/* The platform context type forward-declared in xco_platform.h. */ +struct xco_platform_ctx { + uintptr_t regs[13]; + uint64_t fp_regs[8]; +} __attribute__((aligned(16))); + +/* ---- layout checks -------------------------------------------------- + * + * xco.c sized its embedded ctx buffer using these macros — verify + * they still match the real struct, and verify the byte offsets the + * asm hardcodes are still correct. + */ +_Static_assert(sizeof(struct xco_platform_ctx) == _XCO_CTX_SIZE, + "_XCO_CTX_SIZE out of sync with struct layout"); +_Static_assert(_Alignof(struct xco_platform_ctx) == _XCO_CTX_ALIGN, + "_XCO_CTX_ALIGN out of sync with struct layout"); +_Static_assert(sizeof(uintptr_t) == 8, "AArch64"); +_Static_assert(offsetof(struct xco_platform_ctx, regs) == 0, ""); +_Static_assert(offsetof(struct xco_platform_ctx, fp_regs) == 104, ""); + +/* Forward declaration: the symbol is defined by the file-scope asm + * below. Its address is taken in xco_platform_init. */ +extern void xco_platform_trampoline_thunk(void); + +/* ---- xco_platform_init --------------------------------------------- */ + +void xco_platform_init(xco_platform_ctx_t *ctx, + void *stack_base, size_t stack_len, + void (*entry)(uintptr_t)) { + assert(((uintptr_t)stack_base & (XCO_STACK_ALIGN - 1)) == 0); + + /* AArch64 stacks grow down. Compute and align the top. */ + uintptr_t top = (uintptr_t)stack_base + stack_len; + top &= ~(uintptr_t)(XCO_STACK_ALIGN - 1); + + memset(ctx, 0, sizeof(*ctx)); + + /* The switch primitive will load these into x19, fp, lr, sp. */ + ctx->regs[0] = (uintptr_t)entry; /* x19 */ + ctx->regs[10] = 0; /* fp */ + ctx->regs[11] = (uintptr_t)xco_platform_trampoline_thunk; /* lr */ + ctx->regs[12] = top; /* sp */ +} + +/* ---- xco_platform_switch and trampoline thunk (file-scope asm) ----- + * + * xco_platform_switch(from x0, to x1, value x2) -> x0: + * saves callee-saved state into *from, restores from *to, and + * delivers `value` to the destination as x0 — which is the first-arg + * register on the trampoline thunk's first run, and the return-value + * register when resuming a previously-suspended switch. + * + * xco_platform_trampoline_thunk(): + * On entry x0 = value, x19 = entry; tail-calls entry(value); brk if + * it ever returns. + */ + +/* Stringify-after-expand so __USER_LABEL_PREFIX__ (a token, possibly + * empty) becomes a string literal we can concatenate into the asm. */ +#define XCO_STR_(x) #x +#define XCO_STR(x) XCO_STR_(x) +#define XCO_SYM(name) XCO_STR(__USER_LABEL_PREFIX__) #name + +__asm__ ( + ".text\n" + ".align 4\n" + + ".globl " XCO_SYM(xco_platform_switch) "\n" + XCO_SYM(xco_platform_switch) ":\n" + " stp x19, x20, [x0, #0]\n" + " stp x21, x22, [x0, #16]\n" + " stp x23, x24, [x0, #32]\n" + " stp x25, x26, [x0, #48]\n" + " stp x27, x28, [x0, #64]\n" + " stp fp, lr, [x0, #80]\n" + " mov x9, sp\n" + " str x9, [x0, #96]\n" + " stp d8, d9, [x0, #104]\n" + " stp d10, d11, [x0, #120]\n" + " stp d12, d13, [x0, #136]\n" + " stp d14, d15, [x0, #152]\n" + + " ldp d8, d9, [x1, #104]\n" + " ldp d10, d11, [x1, #120]\n" + " ldp d12, d13, [x1, #136]\n" + " ldp d14, d15, [x1, #152]\n" + " ldp x19, x20, [x1, #0]\n" + " ldp x21, x22, [x1, #16]\n" + " ldp x23, x24, [x1, #32]\n" + " ldp x25, x26, [x1, #48]\n" + " ldp x27, x28, [x1, #64]\n" + " ldp fp, lr, [x1, #80]\n" + " ldr x9, [x1, #96]\n" + " mov sp, x9\n" + + " mov x0, x2\n" + " ret\n" + + ".globl " XCO_SYM(xco_platform_trampoline_thunk) "\n" + XCO_SYM(xco_platform_trampoline_thunk) ":\n" + " blr x19\n" + " brk #0\n" +); diff --git a/arch/arm64/xco_arch.h b/arch/arm64/xco_arch.h @@ -0,0 +1,27 @@ +/* + * xco_arch.h (arm64) — sizing and alignment constants. + * + * Found by the build's -I path (arch/arm64) and included by xco.h. + * Only #defines: the actual platform context struct definition is + * private to arch/arm64/xco_arch.c. + */ + +#ifndef XCO_ARCH_H +#define XCO_ARCH_H + +/* Stack alignment required by AAPCS at function call boundaries. */ +#define XCO_STACK_ALIGN 16 + +/* Implementation-detail constants used by xco.c to embed the platform + * context inside xco_impl_t without seeing the struct definition. + * arch/arm64/xco_arch.c verifies these match the real layout. */ +#define _XCO_CTX_SIZE 176 /* sizeof(struct xco_platform_ctx) */ +#define _XCO_CTX_ALIGN 16 /* _Alignof(struct xco_platform_ctx) */ + +/* Size and alignment of xco_t._private. Must hold an xco_impl_t + * (defined in xco.c): a platform context + ~3 words of bookkeeping. + * xco.c verifies sufficiency with _Static_assert. */ +#define XCO_SIZE (_XCO_CTX_SIZE + 48) +#define XCO_ALIGN _XCO_CTX_ALIGN + +#endif /* XCO_ARCH_H */ diff --git a/tests/test_xco.c b/tests/test_xco.c @@ -0,0 +1,72 @@ +/* + * test_xco.c — smoke test for the xco API. + * + * Exercises: status transitions, value channel in both directions, + * multiple suspends, nested resumes, xco_self. + */ + +#include "xco.h" + +#include <assert.h> +#include <stdalign.h> +#include <stdio.h> +#include <stdint.h> + +#define STACK_BYTES (64 * 1024) + +static alignas(XCO_STACK_ALIGN) unsigned char stack_a[STACK_BYTES]; +static alignas(XCO_STACK_ALIGN) unsigned char stack_b[STACK_BYTES]; + +/* Receives n via the first resume, then yields n+1, n+2, n+3, returns n+4. */ +static uintptr_t counter(uintptr_t n) { + assert(xco_self() != NULL); + uintptr_t v = xco_suspend(n + 1); + assert(v == 100); + v = xco_suspend(n + 2); + assert(v == 200); + v = xco_suspend(n + 3); + assert(v == 300); + return n + 4; +} + +static xco_t inner; + +/* Spawns `inner` and pumps it once, demonstrating nested resumes. */ +static uintptr_t outer(uintptr_t arg) { + (void)arg; + xco_result_t r = xco_spawn(&inner, counter, stack_b, STACK_BYTES, 10); + assert(r.status == XCO_SUSPENDED && r.value == 11); + /* Yield the inner coroutine's first value back to our resumer. */ + uintptr_t v = xco_suspend(r.value); + assert(v == 42); + /* Drive inner to completion. */ + r = xco_resume(&inner, 100); + assert(r.status == XCO_SUSPENDED && r.value == 12); + r = xco_resume(&inner, 200); + assert(r.status == XCO_SUSPENDED && r.value == 13); + r = xco_resume(&inner, 300); + assert(r.status == XCO_DEAD && r.value == 14); + return 999; +} + +int main(void) { + assert(xco_self() == NULL); + + xco_t c; + xco_init(&c, outer, stack_a, STACK_BYTES); + assert(xco_status(&c) == XCO_INIT); + + xco_result_t r = xco_resume(&c, 0); + assert(xco_self() == NULL); + assert(r.status == XCO_SUSPENDED); + assert(r.value == 11); + assert(xco_status(&c) == XCO_SUSPENDED); + + r = xco_resume(&c, 42); + assert(r.status == XCO_DEAD); + assert(r.value == 999); + assert(xco_status(&c) == XCO_DEAD); + + printf("ok\n"); + return 0; +} diff --git a/xco.c b/xco.c @@ -0,0 +1,102 @@ +/* + * xco.c — minimal asymmetric coroutines, C11. + * + * The platform layer (arch/<name>/xco_arch.c) supplies the register + * save/restore primitive (xco_platform_switch) and the initial-context + * setup (xco_platform_init). Everything else — the state machine, the + * resume chain, the trampoline wrapping user fn entry/exit, and the + * xco_self() TLS pointer — lives here. + * + * This translation unit is architecture-neutral. The platform context + * type is forward-declared (in xco_platform.h) and only ever referred + * to by pointer here, so its actual size and layout never cross into + * this file. We reserve raw space for it inside xco_impl_t using + * _XCO_CTX_SIZE / _XCO_CTX_ALIGN from the arch's xco_arch.h, and cast + * to xco_platform_ctx_t * when calling into the platform layer. + */ + +#include "xco.h" +#include "xco_platform.h" + +#include <assert.h> +#include <stddef.h> +#include <stdint.h> + +typedef struct xco_impl { + _Alignas(_XCO_CTX_ALIGN) unsigned char ctx_buf[_XCO_CTX_SIZE]; + xco_status_t status; + xco_platform_ctx_t *resumer_ctx; /* where suspend/return goes */ + xco_fn fn; +} xco_impl_t; + +_Static_assert(sizeof(xco_impl_t) <= XCO_SIZE, "XCO_SIZE too small"); +_Static_assert(_Alignof(xco_impl_t) <= XCO_ALIGN, "XCO_ALIGN too small"); + +static inline xco_impl_t *impl(xco_t *c) { return (xco_impl_t *)c; } +static inline xco_platform_ctx_t *ctx_of(xco_impl_t *ci) { + return (xco_platform_ctx_t *)ci->ctx_buf; +} + +/* Per-thread state. */ +static _Thread_local xco_impl_t *t_current = NULL; +static _Thread_local _Alignas(_XCO_CTX_ALIGN) + unsigned char t_main_ctx_buf[_XCO_CTX_SIZE]; +static inline xco_platform_ctx_t *main_ctx(void) { + return (xco_platform_ctx_t *)t_main_ctx_buf; +} + +/* Trampoline: runs on the coroutine's own stack, invoked by the + * platform layer on the first switch into a fresh context. The + * argument is the value passed to that first xco_resume. The + * coroutine identifies itself via t_current, set by the resumer just + * before switching. */ +static void trampoline(uintptr_t arg) { + xco_impl_t *self = t_current; + uintptr_t ret = self->fn(arg); + + self->status = XCO_DEAD; + (void)xco_platform_switch(ctx_of(self), self->resumer_ctx, ret); + __builtin_unreachable(); +} + +void xco_init(xco_t *c, xco_fn fn, + void *stack_base, size_t stack_len) { + xco_impl_t *ci = impl(c); + ci->status = XCO_INIT; + ci->fn = fn; + ci->resumer_ctx = NULL; + xco_platform_init(ctx_of(ci), stack_base, stack_len, trampoline); +} + +xco_result_t xco_resume(xco_t *c, uintptr_t value) { + xco_impl_t *next = impl(c); + assert(next->status == XCO_INIT || next->status == XCO_SUSPENDED); + + xco_impl_t *prev = t_current; + next->resumer_ctx = prev ? ctx_of(prev) : main_ctx(); + next->status = XCO_RUNNING; + t_current = next; + + uintptr_t back = xco_platform_switch(next->resumer_ctx, + ctx_of(next), value); + + /* Coroutine has either suspended or returned; status is already + * set correctly by xco_suspend or by the trampoline. */ + t_current = prev; + return (xco_result_t){ .value = back, .status = next->status }; +} + +uintptr_t xco_suspend(uintptr_t value) { + xco_impl_t *self = t_current; + assert(self != NULL); + self->status = XCO_SUSPENDED; + return xco_platform_switch(ctx_of(self), self->resumer_ctx, value); +} + +xco_t *xco_self(void) { + return (xco_t *)t_current; +} + +xco_status_t xco_status(const xco_t *c) { + return ((const xco_impl_t *)c)->status; +} diff --git a/xco.h b/xco.h @@ -0,0 +1,85 @@ +/* + * xco.h — minimal asymmetric coroutines. C11. + * + * A coroutine is a (program counter, stack) pair that can be resumed + * and suspended. Values pass between caller and coroutine through a + * single uintptr_t channel; pack richer data behind a pointer. + * + * Asymmetric: xco_suspend always returns to the most recent resumer. + * Resumes nest like function calls. + * + * Thread affinity: a coroutine must be resumed on the thread that + * initialized it. Cross-thread migration is not supported. + * + * Teardown: this layer does not unwind a suspended coroutine's stack. + * Drive a coroutine to return (e.g. by passing a cancel sentinel it + * is expected to handle) before freeing its stack memory. + */ + +#ifndef XCO_H +#define XCO_H + +#include <stddef.h> +#include <stdint.h> + +/* Provides XCO_SIZE, XCO_ALIGN, XCO_STACK_ALIGN; resolved by the + * build to the arch-specific copy via the include path. */ +#include "xco_arch.h" + +typedef enum { + XCO_INIT, /* created, never resumed */ + XCO_RUNNING, /* executing, or in the active resume chain */ + XCO_SUSPENDED, /* yielded; resumable */ + XCO_DEAD, /* function returned */ +} xco_status_t; + +/* Coroutine entry point. The argument is the value supplied to the + * first xco_resume. The return value is delivered to the resumer as + * the final xco_resume result, with status XCO_DEAD. */ +typedef uintptr_t (*xco_fn)(uintptr_t arg); + +typedef struct { + uintptr_t value; + xco_status_t status; /* XCO_SUSPENDED or XCO_DEAD after resume */ +} xco_result_t; + +/* Coroutine control block. Allocate anywhere — on a stack, in a + * struct, on the heap. Contents are private to the implementation. */ +typedef struct xco { + _Alignas(XCO_ALIGN) unsigned char _private[XCO_SIZE]; +} xco_t; + +/* Initialize *c to run fn on [stack_base, stack_base + stack_len). + * stack_base must be XCO_STACK_ALIGN-aligned; the runtime picks the + * starting SP based on the architecture's stack growth direction. + * Status after init is XCO_INIT. */ +void xco_init(xco_t *c, xco_fn fn, + void *stack_base, size_t stack_len); + +/* Resume c, delivering value as the result of its pending xco_suspend + * (or as fn's argument, on the first resume). Returns when c suspends + * or returns. Resuming a coroutine that is not XCO_INIT or + * XCO_SUSPENDED is undefined. */ +xco_result_t xco_resume(xco_t *c, uintptr_t value); + +/* Suspend the currently running coroutine, returning value to its + * resumer. Returns the value passed by the next xco_resume. Undefined + * if called outside a coroutine. */ +uintptr_t xco_suspend(uintptr_t value); + +/* The currently running coroutine, or NULL if the caller is not in + * one. The runtime maintains this for xco_suspend. */ +xco_t *xco_self(void); + +/* Read status without resuming. */ +xco_status_t xco_status(const xco_t *c); + +/* Convenience: init then resume in one call. */ +static inline xco_result_t xco_spawn(xco_t *c, xco_fn fn, + void *stack_base, size_t stack_len, + uintptr_t arg) { + xco_init(c, fn, stack_base, stack_len); + return xco_resume(c, arg); +} + +#endif /* XCO_H */ diff --git a/xco_platform.h b/xco_platform.h @@ -0,0 +1,34 @@ +/* + * xco_platform.h — generic platform interface for xco.c. + * + * Declares the two primitives the platform layer must implement, plus + * the (forward-declared) context type. xco.c never derefs values of + * this type — it only passes pointers around — so the forward + * declaration is enough here. The full struct lives in the arch- + * specific xco_arch.c. + */ + +#ifndef XCO_PLATFORM_H +#define XCO_PLATFORM_H + +#include <stddef.h> +#include <stdint.h> + +typedef struct xco_platform_ctx xco_platform_ctx_t; + +/* Initialize *ctx so that the next switch into it begins executing + * entry(value), where value is the uintptr_t handed to the switch. + * stack_base must be XCO_STACK_ALIGN-aligned. entry must not return. */ +void xco_platform_init(xco_platform_ctx_t *ctx, + void *stack_base, size_t stack_len, + void (*entry)(uintptr_t)); + +/* Save callee-saved state into *from, restore it from *to, and hand + * `value` to *to — as the return value of *to's previous switch, or + * as entry's argument on first switch into a fresh context. Returns + * the value passed by the next switch back to *from. */ +uintptr_t xco_platform_switch(xco_platform_ctx_t *from, + xco_platform_ctx_t *to, + uintptr_t value); + +#endif /* XCO_PLATFORM_H */