Processes and messages
Addressable, mailbox-owning units with private state -- the Erlang/BEAM model, in C.
---- Spawn, send, receive
- Three things to notice
- Selective receive
- Resource scope: release on every exit path
- Cancellation masking
- What you have learned
A bare coroutine computes and returns. A process is a coroutine with
an identity (a xtc_pid_t) and a mailbox: other processes address it
by pid and communicate only by sending it messages. Nothing is shared;
there are no locks on a process’s state because no one else can touch it.
This is the Erlang/BEAM model, in C.
Spawn, send, receive
xtc_proc_spawn(loop, fn, arg, opts, &pid) starts fn(arg) as a process
and gives you back its pid. Inside a process, xtc_self() returns your
own pid, xtc_send(pid, data, size) copies size bytes into another
process’s mailbox, and xtc_recv(&buf, &size, timeout_ns) blocks
(suspends the fiber) until a message arrives or the timeout elapses.
Here is a two-process ping/pong. pong waits for a number and replies
with one more; ping kicks it off and bounces the number back until it
reaches a limit.
sequenceDiagram
participant P as ping
participant Q as pong
P->>Q: {from: ping, n: 0}
Q->>P: {from: pong, n: 1}
P->>Q: {from: ping, n: 2}
Q->>P: {from: pong, n: 3}
P->>Q: {from: ping, n: 4}
Note over Q: n >= ROUNDS, done
Each arrow is one xtc_send into the target’s mailbox; each process
sits in xtc_recv until a message arrives. No shared memory, no locks.
#include <stdio.h>
#include <stdint.h>
#include <string.h>
#include "xtc.h"
#include "xtc_loop.h"
#include "xtc_proc.h"
#define ROUNDS 4
/* Messages are plain bytes. xtc_recv gives no sender, so we carry the
* reply address in the payload -- the usual libxtc idiom. */
struct msg {
xtc_pid_t from;
int n;
};
static void
pong(void *arg)
{
void *raw;
size_t sz;
(void)arg;
for (;;) {
struct msg req, reply;
if (xtc_recv(&raw, &sz, 1000LL * 1000 * 1000) != XTC_OK)
return; /* timed out: no partner left */
if (sz != sizeof req) {
xtc_free(raw);
continue;
}
memcpy(&req, raw, sizeof req);
xtc_free(raw); /* received buffers are ours to free */
if (req.n >= ROUNDS) {
printf("pong: reached %d, done\n", req.n);
return;
}
reply.from = xtc_self();
reply.n = req.n + 1;
(void)xtc_send(req.from, &reply, sizeof reply);
}
}
static void
ping(void *arg)
{
xtc_pid_t peer = *(xtc_pid_t *)arg;
struct msg first = { xtc_self(), 0 };
void *raw;
size_t sz;
(void)xtc_send(peer, &first, sizeof first);
for (;;) {
struct msg r;
if (xtc_recv(&raw, &sz, 1000LL * 1000 * 1000) != XTC_OK)
return;
memcpy(&r, raw, sizeof r);
xtc_free(raw);
printf("ping: got %d\n", r.n);
if (r.n >= ROUNDS)
return;
r.from = xtc_self();
(void)xtc_send(peer, &r, sizeof r);
}
}
int
main(void)
{
xtc_loop_t *loop;
xtc_pid_t pong_pid;
if (xtc_loop_init(&loop) != XTC_OK)
return 1;
if (xtc_proc_spawn(loop, pong, NULL, NULL, &pong_pid) != XTC_OK)
return 1;
if (xtc_proc_spawn(loop, ping, &pong_pid, NULL, NULL) != XTC_OK)
return 1;
(void)xtc_loop_run(loop);
(void)xtc_loop_fini(loop);
return 0;
}
Tested source: docs/_includes/snippets/03_ping_pong.c
ping: got 1
ping: got 3
pong: reached 4, done
Three things to notice
Messages are copies. xtc_send copies the bytes into the
recipient’s mailbox. The sender and receiver never share the buffer, so
there is nothing to lock and no lifetime to coordinate across processes.
A received buffer is yours to free. xtc_recv hands you a
heap buffer that you own. Release it with
xtc_free
– not plain free. libxtc may be running under a custom allocator (an
embedder like PostgreSQL installs one), and freeing an
allocator-supplied buffer with the C library free is a mismatched-free
bug. Every libxtc call that returns a caller-owned buffer documents
xtc_free; that man page lists them.
There is no sender field. xtc_recv does not tell you who sent the
message. If you need to reply, put your own pid in the payload – that
is what the from field in the example does. This keeps the mailbox a
plain byte queue and lets you design your own protocols on top.
Shared state behind a mutex. The C default is a struct guarded by a
pthread_mutex. It is faster for a single hot counter, but it does not compose: every new invariant adds another lock, lock order becomes a global proof obligation, and a thread that dies holding a lock wedges everyone. The process model trades a little copy cost for the property that state has exactly one owner and failure is contained to that owner. libxtc still ships mutexes, rwlocks, RCU, and a lock manager (Locks and synchronization) for the cases that genuinely want shared memory – but the default unit of concurrency is the shared-nothing process.
Selective receive
Sometimes a process wants the next message that matches a predicate,
leaving others in the mailbox for later. xtc_recv_match(match_fn,
user_data, &buf, &size, timeout) scans the mailbox and returns the
first message for which match_fn returns non-zero, preserving the
arrival order of the rest. This is how you implement a request/response
correlation (pull the reply with your request id) without draining
unrelated traffic. See
xtc_proc(3).
Resource scope: release on every exit path
A process holds resources – a file descriptor, a buffer, a lock. The
awkward question is what releases them when the process does not exit
the way you drew on the whiteboard: an early error return, an
xtc_exit_self, an asynchronous kill from a supervisor, or a contained
fault. In plain C the answer is a maze of goto out labels, and every
new exit path is a chance to leak.
xtc_scope turns “this will be released” from a convention you have to
remember into a mechanism the runtime enforces. Open a scope, defer a
finalizer into it, and the finalizer runs in LIFO order on every exit
path while the scope is open – normal close, error, exit, abort, or a
fault-guard-contained crash. (A scope is a marker on the same per-process
recovery registry that already releases fds and locks on an unwind, so it
rides the same cleanup.)
static void
close_file(void *arg)
{
printf("finalizer: closing %s\n", (const char *)arg);
}
static void
using_a_scope(void *arg)
{
xtc_scope_t *s;
(void)arg;
s = xtc_scope_open(); /* opens on the calling process */
if (s == NULL)
return;
/* Defer finalizers; they run LIFO on close OR on any unwind. */
(void)xtc_scope_defer(s, close_file, (void *)"data.db");
(void)xtc_scope_defer(s, close_file, (void *)"index.db");
/* ... work with the resources ... */
xtc_scope_close(s); /* runs: index.db, then data.db */
}
Tested source: docs/_includes/snippets/07_resource_scope.c
Most of the time you want the acquire/use/release shape, and
xtc_bracket is the sugar for it. The acquire runs cancellation-masked
so the release is registered before an abort can ever be observed, and
the release then runs on every exit path of the use step:
static int
acquire_buf(void **res, void *ud)
{
(void)ud;
*res = xtc_malloc(64);
return (*res != NULL) ? XTC_OK : XTC_E_RESOURCE;
}
static int
use_buf(void *res, void *ud)
{
(void)ud;
((char *)res)[0] = 'x'; /* use the resource */
return XTC_OK;
}
static void
release_buf(void *res, void *ud)
{
(void)ud;
printf("bracket: releasing buffer\n");
xtc_free(res); /* runs on every exit path of use */
}
static void
using_bracket(void *arg)
{
(void)arg;
/* acquire runs abort-masked, so release is always registered. */
(void)xtc_bracket(acquire_buf, use_buf, release_buf, NULL);
}
Tested source: docs/_includes/snippets/07_resource_scope.c
The “paper door” this closes. For years, effect systems shipped resource lifecycles as a convention: the API carrots you toward acquire/use/release, but nothing stops you walking past it and leaking a socket on the cancellation path. A human respects the paper door; a coding agent barges straight through it.
xtc_bracketis a real door: the release is wired to the unwind, so there is no exit path that skips it.
Cancellation masking
Cancellation in libxtc is cooperative: a running fiber observes an
asynchronous kill (from xtc_exit_pid, a supervisor, or a deadline)
only at a park point – a xtc_yield, xtc_recv, or xtc_proc_sleep.
That is usually what you want, but it leaves one race: if a kill lands
between acquiring a resource and registering its release, the release
is never registered. xtc_uncancelable closes it. It runs a body with
cancellation masked: a kill delivered inside the region is deferred
and only observed once the region returns. xtc_bracket uses it for you
around the acquire; you can use it directly for any acquire-then-register
critical step. xtc_cancel_poll is the escape hatch that re-admits
cancellation for a sub-region, and xtc_cancel_requested lets a masked
region notice a pending kill and unwind early and cleanly. See
xtc_scope(3).
What you have learned
- A process is an addressable, mailbox-owning coroutine with private state.
xtc_sendcopies;xtc_recv/xtc_recv_matchreceive; received buffers are freed withxtc_free.- Replies carry the sender pid in the payload by convention.
xtc_scope/xtc_bracketrelease resources on every exit path;xtc_uncancelablemasks cancellation so a release is never lost to a mid-acquire abort.
Processes let things run independently. The next chapter is about what happens when one of them fails, and how to build systems that recover: links, monitors, and supervisors.
← Fibers and the event loop · Next: Links, monitors, and supervisors →