Synchronization Primitives

SafeC provides six synchronization modules in std/sync/ for both hosted (OS-based) and freestanding (bare-metal) environments — plus bare_spawn.sc, the reference "Hook" backend for the language-level spawn/join keywords on freestanding targets (see thread).

c
#include <std/sync/spinlock.h>
#include <std/sync/lockfree.h>     // LFQueue<T>
#include <std/sync/channel.h>      // Chan<T>
#include <std/sync/mpsc.h>         // MpscQueue<T>
#include <std/sync/task.h>         // TaskScheduler, the TaskRunner trait, run_rounds
#include <std/sync/thread_bare.h>  // ThreadSched
#include <std/sync/parallel.h>     // parallel_for (thread pool)

For communicating with a different process rather than another thread, see IPC (std/ipc/pipe.h, std/ipc/uds.h).

TaskScheduler (from task.h) is also the foundation the real-time I/O reactor drives — see Real-Time Scheduler for the hosted (macOS/Linux) event loop that lets tasks block on file/socket readiness without stalling the whole scheduler.


Spinlock

A busy-wait mutual exclusion lock built on __sync_lock_test_and_set. Suitable for short critical sections where sleeping is impossible or undesirable (interrupt handlers, bare-metal drivers).

Struct

c
struct Spinlock {
    volatile int locked;

    void lock();
    int  trylock();
    void unlock();
    int  is_locked() const;
}

Constructor

c
static struct Spinlock Spinlock::new();  // an unlocked spinlock: std::Spinlock::new()

Methods

Method Description
lock() Spin until the lock is acquired.
trylock() Attempt to acquire; return 1 if successful, 0 if already locked.
unlock() Release the lock.
is_locked() const Return 1 if currently locked (advisory only).

Example

c
#include <std/sync/spinlock.h>
#include <std/io.h>

static struct Spinlock g_lock;   // zero-initialized == unlocked
static int g_counter = 0;

void increment() {
    g_lock.lock();
    g_counter = g_counter + 1;
    g_lock.unlock();
}

int main() {
    g_lock = std::Spinlock::new();
    increment();
    increment();
    std::println_int(g_counter);  // 2
    return 0;
}

WARNING

Do not hold a spinlock across blocking calls, sleep, or any operation that may take variable time. Use a mutex from thread.h for those cases.


LFQueue<T> — Lock-Free SPSC Ring Buffer

A bounded single-producer / single-consumer queue. head/tail are monotonically increasing element counters updated with atomic stores (the element is written before head is published, and read before tail releases the slot); only the slot index is counter & (cap - 1), so all cap slots are usable and "full" is told apart from "empty" without a sentinel slot.

c
generic<T>
struct LFQueue {
    static struct LFQueue<T> new(unsigned long cap);                    // cap rounded UP to a power of two
    static struct LFQueue<T> over([]T buffer);   // caller's buffer; buffer.len rounded DOWN

    int           enqueue(T val);   // producer; 1 on success, 0 if full
    ?T            dequeue();        // consumer; null if empty
    int           is_empty() const;
    int           is_full() const;
    unsigned long length() const;
    void          destroy() consume;// frees the buffer if LFQueue::new allocated it
};
c
struct LFQueue<long long> q = std::LFQueue::new(64UL);   // T from the declared variable
q.enqueue(5LL);                               // producer thread
match (q.dequeue()) {                         // consumer thread
    case some(v): use(v);
    case none:    { }                         // empty right now
}
q.destroy();

Exactly one producer and one consumer; for several producers use MpscQueue.

LFQueue, MpscQueue and Chan share the queue protocol of std/collections/traits.h: all three are a Fifo (enqueue / dequeue), and the two non-blocking queues are also a CountedFifo (plus length() / is_empty()). Code written against those traits — std::enqueue_all, std::drain_into, or your own generic<Q: Fifo> function — takes any of them, or a std::Queue / RingBuffer.


Chan<T> — Bounded Blocking Channel

The language's chan_create/chan_send/chan_recv/chan_close built-ins are untyped: the runtime (channel.sc, a mutex + condvar MPMC queue) carries fixed 8-byte slots. Chan<T> fixes the element type and pads each value into a full slot, so nothing reads or writes past a smaller T; sizeof(T) <= 8 is a compile-time static_assert. Unlike the queues on this page, send/recv block while the channel is full/empty.

c
generic<T>
struct Chan {
    static struct Chan<T> new(int cap);   // cap < 1 is clamped to 1

    int  send(T val);   // blocks while full; 0 once closed
    ?T   recv();        // blocks while empty; null once closed *and* drained
    void close();       // wakes every blocked sender/receiver

    int  enqueue(T val);   // send under the Fifo name (blocks too)
    ?T   dequeue();        // recv under the Fifo name (blocks too)
};
c
#include <std/sync/channel.h>
#include <std/thread.h>

static struct Chan<int> g_ch;

static ?&int producer(?&int unused) {
    int i = 1;
    while (i <= 100) { g_ch.send(i); i = i + 1; }
    g_ch.close();
    return null;
}

int main() {
    g_ch = std::Chan::new(8);                           // T from the assignment target
    unsigned long long tid = 0ULL;
    std::thread_create(&tid, producer, null);
    long long total = 0LL;
    int more = 1;
    while (more) {
        match (g_ch.recv()) {
            case some(v): total = total + (long long)v;
            case none:    more = 0;                     // closed and drained
        }
    }
    std::thread_join(tid);
    return total == 5050LL ? 0 : 1;
}

For a T larger than 8 bytes, send a ?&T to it (a reference fits on every supported target) and keep the referent alive until it has been received.


MpscQueue<T> — Multi-Producer / Single-Consumer Queue

The multi-producer sibling of LFQueue: still bounded and non-blocking (enqueue/dequeue return at once), but correct with any number of concurrent producers because a Spinlock guards every mutation. "Single consumer" is a documented contract, not enforced (concurrent dequeue() calls would still be serialized correctly).

c
generic<T>
struct MpscQueue {
    static struct MpscQueue<T> new(unsigned long cap);   // any cap (not just powers of two)

    int           enqueue(T val);   // any thread; 1 on success, 0 if full
    ?T            dequeue();        // null if empty
    int           is_empty() const;
    int           is_full() const;
    unsigned long length() const;
    void          destroy() consume;
};
c
static struct MpscQueue<int> g_q;

static ?&int producer(?&int base) {
    int b = base.default(0);
    int i = 0;
    while (i < 1000) {
        while (!g_q.enqueue(b + i)) { std::thread_yield(); }   // full: back off
        i = i + 1;
    }
    return null;
}

TaskScheduler — Cooperative Task Scheduler

A stackless cooperative (non-preemptive) scheduler. Tasks are resumable functions: each takes its argument plus a resume_point and returns where to resume next time, or 0 when done, so a task can yield mid-work without an OS stack of its own. tick() runs one round — every non-blocked, non-done task gets one turn.

c
struct TaskScheduler {
    struct Task tasks[TASK_MAX];   // TASK_MAX = 1024; finished slots are reused
    int         count;
    int         current;

    static struct TaskScheduler new();   // no tasks

    // Spawn func(arg, resume_point). Returns the task index, or -1 if full.
    generic<T> int spawn(fn int(?&T, int) func, ?&T arg);

    int  tick();                    // one round; returns the number of still-active tasks
    void run_all();                 // round-robin until every task is done
    int  active_count() const;

    // I/O-readiness blocking — see std/sched/reactor.h
    void await_fd(int fd, int filter);   // from *within* the running task's turn
    int  unblock_fd(int fd, int filter); // called by the reactor
    int  has_blocked() const;
    int  has_ready() const;
};

spawn is a method with its own type parameter: a task's argument type is whatever its function takes, and spawn infers T from the function, so a mismatched argument is a compile error rather than a bad cast at run time. A null argument is fine — T still comes from the function.

c
#include <std/sync/task.h>
#include <std/io.h>

struct Blinker { int led; int times; };

static int blink(?&Blinker b, int step) {
    match (b) {
        case some(x): { x.times = x.times + 1; }
        case null:    return 0;
    }
    return step < 2 ? step + 1 : 0;          // yield twice, then finish
}

static int hello(?&int unused, int step) {
    std::println("hello");
    return 0;
}

int main() {
    struct TaskScheduler sched = std::TaskScheduler::new();
    struct Blinker b;
    b.led = 13;
    b.times = 0;
    unsafe { sched.spawn(blink, (?&Blinker)&b); }
    sched.spawn(hello, null);
    sched.run_all();
    return b.times == 3 ? 0 : 1;
}

There is no preemption: a task that never returns stalls the whole round. A task that would block on I/O calls await_fd(fd, filter) instead; tick() then skips it until unblock_fd() — normally std::Reactor — reports the fd ready. See Real-Time Scheduler. Without a reactor, run_all() spins forever on a task that awaits and is never unblocked.


Task states: TASK_READY (0), TASK_RUNNING (1), TASK_DONE (2). A scheduler holds up to TASK_MAX (1024) tasks.

ThreadSched — Priority-Based Freestanding Threads

thread_bare.h adds a per-slot priority to TaskScheduler, with the same resumable-function protocol. It is still cooperative: a higher priority only means "served earlier within a tick".

c
#include <std/sync/thread_bare.h>

newtype Thread = int;   // index into the table; THREAD_NONE (-1) = spawn failed

struct ThreadSched {
    struct TaskScheduler inner;
    int                  priority[THREAD_MAX];   // THREAD_MAX = TASK_MAX

    static struct ThreadSched new();   // all slots empty

    // Same protocol as TaskScheduler::spawn; priority >= 0, higher runs earlier
    // in each tick. Returns the Thread, or THREAD_NONE if the table is full.
    generic<T> Thread spawn(fn int(?&T, int) func, ?&T arg, int priority);

    int  tick();                      // one pass in descending-priority order
    void run_all();
    int  is_active(Thread t) const;
    void join_thread(Thread t);       // cooperative join: tick() until t is done
    int  active_count() const;
};
c
static int urgent(?&int unused, int step)     { return 0; }
static int background(?&int unused, int step) { return 0; }

void run() {
    struct ThreadSched s = std::ThreadSched::new();
    Thread hi = s.spawn(urgent, null, 10);
    s.spawn(background, null, 1);
    s.join_thread(hi);
    s.run_all();
}

A ThreadSched holds TASK_MAX task slots inline (tens of KB); on small targets keep it in static storage.


TaskRunner — Driving Either Scheduler

TaskScheduler and ThreadSched have the same driving interface, named by the TaskRunner trait in task.h:

c
trait TaskRunner {
    int  tick();                 // one round; returns the still-active count
    void run_all();              // until every task is done
    int  active_count() const;
}

// At most max_rounds rounds of s (a bounded run_all); returns the tasks still active.
generic<S: TaskRunner> int run_rounds(&S s, int max_rounds);
Type Conforms Round order
TaskScheduler TaskRunner spawn order
ThreadSched TaskRunner descending priority

A main loop written once against TaskRunner — std::run_rounds, or your own generic<S: TaskRunner> function (see Combining bounds) — runs either scheduler. Spawning is deliberately not part of the trait: ThreadSched::spawn takes a priority that TaskScheduler::spawn has no place for. (The trait isn't called Scheduler because that name is std/kernel/scheduler.h's preemptive scheduler struct.)

c
#include <std/sync/task.h>
#include <std/sync/thread_bare.h>

static int countdown(?&int left, int step) {
    match (left) {
        case some(n): { *n = *n - 1; return *n > 0 ? step + 1 : 0; }
        case null:    return 0;
    }
    return 0;
}

// One watchdog-fed main loop for any scheduler: a few rounds per kick.
generic<S: TaskRunner>
int drive(&S s) {
    int kicks = 0;
    while (std::run_rounds(s, 4) > 0) { kicks = kicks + 1; }   // feed the watchdog here
    return kicks;
}

int main() {
    int a = 10;
    int b = 10;
    struct TaskScheduler ts = std::TaskScheduler::new();
    unsafe { ts.spawn(countdown, (?&int)&a); }
    struct ThreadSched th = std::ThreadSched::new();
    unsafe { th.spawn(countdown, (?&int)&b, 5); }
    int k1 = drive(&ts);
    int k2 = drive(&th);
    return a == 0 && b == 0 && k1 == 2 && k2 == 2 ? 0 : 1;
}

parallel_for — Data-Parallel Loops

c
#include <std/sync/parallel.h>

Splits [0, n) into chunks of at least grain indices and runs body(ctx, lo, hi) on each, on the calling thread plus a persistent pool of worker threads. It returns once every chunk has finished. Chunks are handed out from an atomic counter, so uneven chunks balance out.

The workers start on the first call and are then kept, parked on a condition variable, so later calls cost a wake-up (a few microseconds) instead of a thread spawn. std's own CPU kernels use the pool: sgemm (and through it tensor_matmul and Conv2D::forward), the large-FFT and batched-FFT paths, fft2d and stft_forward.

c
generic<T>
void parallel_for(unsigned long n, unsigned long grain,
                  fn void(&T, unsigned long, unsigned long) body, &T ctx);

unsigned long parallel_threads();               // threads a call uses, counting the caller
void          parallel_set_threads(unsigned long n);   // 1 .. PARALLEL_MAX_THREADS (64)
Situation Behaviour
n <= grain, or one thread configured body(ctx, 0, n) inline
A parallel_for already running (nested call from a body, or a concurrent call from another thread) inline, on the calling thread
Freestanding / bare-metal, WebAssembly inline (no threads)

The thread count is SAFEC_NUM_THREADS if set, else the number of online CPUs (capped at 64), or whatever parallel_set_threads last set.

Choose grain so a chunk does at least ~10–50 µs of work. Each index range belongs to exactly one call of body, so chunks can write disjoint parts of the output without locking.

c
#include <std/sync/parallel.h>

struct Scale { &float data; float k; };

static void scale_range(&Scale s, unsigned long lo, unsigned long hi) {
    unsigned long i = lo;
    while (i < hi) {
        unsafe { s.data[i] = s.data[i] * s.k; }
        i = i + 1UL;
    }
}

void scale_all(&float data, unsigned long n, float k) {
    struct Scale s;
    s.data = data;
    s.k = k;
    std::parallel_for(n, 65536UL, scale_range, &s);
}

Not for real-time code

A call blocks until its slowest chunk finishes, and it wakes threads through the OS scheduler. Audio callbacks and other hard-deadline code should stay on one thread: use sgemm_serial, fft_serial and friends there.