Blocking work and I/O
Files, sockets, timers, and blocking C libraries -- without ever stalling the loop.
---- 1. Suspending I/O the loop already understands
- 2. Async file I/O
- 3. The blocking pool: escaping to a thread, on purpose
- 4. Attached compute: GPUs and NPUs
- Scaling across cores: the executor
- The other direction: bridging INTO the runtime
- Determinism: testing the whole thing
- What you have learned
The event loop’s one rule is: never block the thread that runs it.
A fiber that calls a slow synchronous function – read() on a cold
file, a CPU-bound compression pass, a legacy C library that does its own
blocking I/O – stalls every other fiber on that loop until it
returns. This chapter is about the three ways libxtc lets you do slow
work without breaking that rule.
1. Suspending I/O the loop already understands
For sockets and timers, libxtc suspends the fiber and lets the OS poller wake it. You do not manage readiness yourself:
- Timers:
xtc_proc_sleep(ns)parks the fiber; the loop wakes it when the timer fires (see chapter 2). - Sockets: the
xtc_net_*API (xtc_net(3)) does non-blocking connect / accept / read / write and suspends the fiber until the socket is ready, driven by io_uring / epoll / kqueue / IOCP under the hood. - Arbitrary fds:
xtc_proc_wait_fd(fd, events, timeout, &revents)parks the fiber untilfdis readable/writable – the building block for wrapping any fd-based protocol.
2. Async file I/O
File reads and writes do not have a clean readiness model on every OS,
so libxtc presents one portable async file API, xtc_aio_*
(xtc_aio(3)):
xtc_aio_pread, xtc_aio_pwrite, xtc_aio_fsync, xtc_aio_fdatasync.
Where the platform has a native completion mechanism (io_uring on Linux,
IOCP on Windows, kqueue AIO on the BSDs, event-port AIO on illumos) the
operation completes with zero extra threads. Everywhere else it
transparently falls back to the blocking pool (below) – so you write
storage code once, as if async file I/O is always available, and it is
always correct.
3. The blocking pool: escaping to a thread, on purpose
When you must call something that will block and has no async form – a
third-party library, a CPU-bound kernel – hand it to the blocking
pool with xtc_blocking_run(fn, arg, &result)
(xtc_blocking(3)).
libxtc runs fn(arg) on a dedicated worker thread, suspends the
calling fiber (so the loop keeps serving everyone else), and resumes
the fiber with the result when the worker finishes. The blocking is real
– but it happens on a pool thread, not the loop thread.
/* inside a fiber: compute a slow hash without stalling the loop */
int result;
xtc_blocking_run(hash_a_big_buffer, buf, &result);
/* the fiber resumes here once the pool thread is done */
Why an explicit pool call instead of auto-detecting blocking? A library cannot know which of your function calls will block. Making the escape hatch explicit means the one place a thread hop happens is visible in the code and in a profile – you can see, and budget, every departure from the single-threaded model. It is the same philosophy as
unsafein Rust: not forbidden, but marked.
4. Attached compute: GPUs and NPUs
An accelerator – a GPU, or an NPU (the neural/vision engine on a
modern laptop or server) – is, from the loop’s point of view, just
another async device: you submit opaque work, it runs on a coprocessor,
and it signals completion through a fence. On Linux a fence is a
pollable file descriptor (a sync_file / dma-fence), which is exactly
the kind of readiness event the loop already waits on for sockets. So
libxtc parks a fiber on a GPU/NPU completion the same way it parks one
on a socket read
(xtc_accel(3)):
/* inside a fiber: the consumer's runtime submitted work and gave us a
* completion fence fd; park until the device is done -- no OS thread
* held while we wait. */
int fence_fd = submit_inference(model, input); /* your runtime */
if (xtc_accel_wait_fence(fence_fd, -1) == XTC_OK)
use_result();
xtc_accel_probe enumerates the GPUs and NPUs present; GPU and NPU are
the same abstraction here (both DRM devices whose completions are
the same kind of fence fd), so one API covers both and the device kind
is just a tag. For a runtime that only offers a blocking
submit-and-wait, xtc_accel_run_blocking routes it through the pool
above instead.
The abstraction stops at the fence. libxtc links no GPU/NPU runtime – not Level Zero, CUDA, Vulkan, or OpenVINO – and owns no tensors, no device memory, no model format. Your code (or the vendor runtime it links) submits the work and produces the fence; libxtc’s job is only to park a fiber on that fence and wake it, deterministically-testable under the simulator. That boundary is what keeps this a concurrency primitive rather than a compute-runtime shim. Built only where the platform has the DRM/accel subsystem (auto-detected;
--without-accelto force off).
Scaling across cores: the executor
One loop uses one core. To use all of them, run an executor – N
loops, one per core, with work-stealing between them
(xtc_exec(3)).
A process spawned on a busy loop can be stolen and run on an idle one;
messages and completions cross loops safely. Your process code does not
change – it is still spawn / send / recv – it just runs on more cores.
A thread pool per subsystem. The mainstream C server design gives each subsystem its own thread pool and synchronizes with locks and queues between them. It works, but the thread count grows with the number of subsystems, context switches dominate under load, and the lock graph becomes the system’s hardest correctness problem. The executor model uses one thread per core regardless of how many subsystems you have; concurrency comes from fibers, not threads, and the only threads are the N loop threads plus the blocking pool. Fewer threads, no cross-subsystem lock graph.
The other direction: bridging INTO the runtime
The blocking pool sends work out of a loop. Sometimes you need the
inverse: something outside the runtime – a C library’s completion
callback, a signal handler’s follow-up, an embedder’s own I/O thread –
needs to run an effect on a loop and get its result back. That is the
dispatcher
(xtc_dispatch(3)),
libxtc’s answer to Cats Effect’s Dispatcher.
xtc_dispatch(loop, fn, arg, &fut, &handle) spawns a fiber on loop
that runs fn(arg), and hands back a future for the result plus an
optional cancel handle. It is safe to call from any OS thread,
including one libxtc knows nothing about – the effect body itself:
/* The effect to run on the runtime. Returns a result the future
* carries back to the caller. */
static int
compute(void *arg)
{
return (int)(intptr_t)arg * 2;
}
Tested source: docs/_includes/snippets/09_dispatch.c
and the submit-and-await, from a foreign thread:
int
main(void)
{
pthread_t th;
xtc_future_t *fut = NULL;
intptr_t out = 0;
if (xtc_exec_init(&g_exec, 2) != XTC_OK)
return 1;
xtc_exec_set_service_mode(g_exec, 1);
if (pthread_create(&th, NULL, run_exec, NULL) != 0)
return 1;
usleep(5000); /* let the workers spin up */
/* From this foreign thread, submit compute(21) to run on a loop
* and await the doubled result. */
if (xtc_dispatch(xtc_exec_loop(g_exec, 0), compute,
(void *)(intptr_t)21, &fut, NULL) != XTC_OK)
return 1;
if (xtc_future_wait(fut, &out, -1) != XTC_OK)
return 1;
printf("dispatch result: %ld\n", (long)out); /* 42 */
(void)xtc_exec_stop(g_exec);
(void)pthread_join(th, NULL);
(void)xtc_exec_fini(g_exec);
return out == 42 ? 0 : 1;
}
Tested source: docs/_includes/snippets/09_dispatch.c
The future always resolves exactly once: with fn’s value on a
normal return, or XTC_E_ABORTED if you xtc_dispatch_cancel the
handle or the fiber crashes – never lost, never doubled, never a hang.
Cancellation is cooperative and composes with
xtc_uncancelable(3)
and
xtc_scope(3),
so a resource acquired under a scope is still released on the
cancellation path.
Why a blessed one-call front door? Nothing here is new machinery: a cross-thread
xtc_proc_spawnalready posts to the target loop’s MPSC inbox and pings its poller, andxtc_promise_setis already safe from any thread. Consumers kept re-assembling exactly that pair by hand at every callback boundary.xtc_dispatchis that composition, packaged with the cancellation core so the common bridge is one call that falls into the pit of success.
Determinism: testing the whole thing
Because all concurrency flows through the loop, libxtc can replace the
real scheduler and I/O with a deterministic simulation driven by a
seed – the FoundationDB / TigerBeetle approach. The same process code
runs, but scheduling, timers, message ordering, and injected faults
(partitions, latency, disk errors, crashes) are reproducible from the
seed. This is how libxtc finds concurrency bugs before they ship; see
the test/sim/ suite and the deterministic-testing notes.
What you have learned
- Never block the loop thread; suspend instead.
- Sockets/timers/fds suspend via the OS poller; files via
xtc_aio_*; everything else via the explicitxtc_blocking_runpool. - The executor scales the same process code across cores with work-stealing.
- Deterministic simulation replays the whole concurrent system from a seed.
That completes the guide. For the mental-model shift from threads-and-locks thinking, read Thinking in libxtc; to see it all assembled into real programs, read the Examples.
← Links, monitors, and supervisors · Next: Resource limits →