Skip to content
This is the documentation for xc 0.86. The current documentation describes the newest version.

Threading

Threading is available on arm64, x86_64, win64, arm9 and wasm32 (each thread a Web Worker over shared memory; see Runtime for what a page needs). On xt6502 and m68k every one of these classes is a hard #error, with no stub, so a threaded program cannot build and then run single-threaded.

The API is small: Thread, Mutex (+ Guard), Cond, Sem, Atomic, ThreadLocal, and Pool.forRange.

For a loop whose iterations are independent, a par block is simpler: it splits the loop over every thread, or runs it on the GPU, with no threads to manage. For units of work that depend on one another, an OperationQueue runs them on worker threads in dependency order.

Two threads that use one object race on its ARC refcount. A non-atomic increment that loses the race under-counts, and the object is freed while still in use. The crash can then appear far from the cause.

The back ends therefore emit the refcount update as an atomic read-modify-write wherever two threads could meet. You do not need to request it:

  • On arm64 with the LSE atomics (Apple silicon), always. They cost nothing measurable there.
  • Elsewhere, per module: when the module spawns a thread; when it has no main (a library or a plugin, whose caller may have threads of its own); and when it loads a plugin, whose threads may share its objects. A program that does none of these keeps plain counts, which on x86-64 are several times cheaper for allocation-heavy code.

-fthread-safe-arc and -fno-thread-safe-arc force it on or off. A program whose objects reach threads it did not start (a C library calling back into it from its own threads) needs the flag on.

A thread body is a bound method (a callback), so the thread’s state is the object the method belongs to. There is no void* context and no cast on entry.

class Worker : Object
{
i32 id;
i32 result;
void run(void) { … } // this runs on the new thread
}
Worker* a = Worker.withId(2);
Thread* ta = Thread.spawn(&a.run);
ta.join();
MethodEffect
Thread.spawn(&obj.body)Start a thread running body. Returns a Thread*.
join()Block until the thread finishes. Returns false if it could not be joined.
detach()Let it run unjoined; its resources are released when it exits.
isValid()false if the thread failed to start.
Thread.yield()Hint to the scheduler.
Thread.sleepMs(u32)Sleep the calling thread.

Guard.on(m) locks immediately and unlocks when the guard is released at the end of the scope. This includes an early return or a throw, which a bare lock()/unlock() pair does not handle.

class Ledger : Object
{
Mutex* lock;
i32 balance;
void init(void) { lock = new Mutex(); balance = 0; }
void deposit(i32 amount)
{
Guard* g = Guard.on(lock);
balance = balance + amount;
} // unlocked here, whatever the exit
}

Mutex also has lock(), unlock() and tryLock() for when the scope does not match the critical section.

When the shared state is one word, use an Atomic: it needs no lock or scope, and there is no unlock to forget.

Atomic* hits = Atomic.withValue(0);
hits.add(5);
hits.load();
hits.store(0);
hits.compareAndSwap(12, 100); // true if it was 12

Cond is a condition variable paired with a Mutex. It waits for a predicate to become true without spinning. Sem is a counting semaphore, for limiting use of a resource.

Pool.forRange(from, to, &obj.body) calls the body once per index, spread across the available cores, and blocks until every index is done. The body is a callback, so it can accumulate into the object it belongs to:

class Squares : Object
{
Atomic* total;
void init(void) { total = Atomic.withValue(0); }
void one(i32 i) { total.add(i * i); }
}
Squares* sq = new Squares();
Pool.forRange(1, 11, &sq.one); // 385

Pool.forRangeWithThreads(from, to, body, n) sets the thread count.

// threading.xc — Thread, Mutex, Guard, Atomic and Pool.
#import "Stdio.xc"
#import "Foundation.xc"
#import "Thread.xc"
#import "Mutex.xc"
#import "Atomic.xc"
#import "Pool.xc"
class Worker : Object
{
i32 id;
i32 result;
void init(void) { id = 0; result = 0; }
static Worker* withId(i32 n) { Worker* w = new Worker(); w.id = n; return w; }
void run(void)
{
i32 acc = 0;
for (i32 i = 1; i <= 1000; i = i + 1) acc = acc + i * id;
result = acc;
}
}
class Ledger : Object
{
Mutex* lock;
i32 balance;
void init(void) { lock = new Mutex(); balance = 0; }
void deposit(i32 amount)
{
Guard* g = Guard.on(lock);
balance = balance + amount;
}
}
class Summer : Object
{
Ledger* ledger;
void init(void) { ledger = 0; }
void addMany(void)
{
for (i32 i = 0; i < 500; i = i + 1) ledger.deposit(2);
}
}
class Squares : Object
{
Atomic* total;
void init(void) { total = Atomic.withValue(0); }
void one(i32 i) { total.add(i * i); }
}
i32 main(void)
{
// ---- spawn and join ----
Worker* a = Worker.withId(2);
Worker* b = Worker.withId(3);
Thread* ta = Thread.spawn(&a.run);
Thread* tb = Thread.spawn(&b.run);
ta.join();
tb.join();
Stdio.printf("workers %ld %ld\n", a.result, b.result);
// ---- a mutex around shared state ----
Ledger* led = new Ledger();
Summer* s1 = new Summer(); s1.ledger = led;
Summer* s2 = new Summer(); s2.ledger = led;
Thread* t1 = Thread.spawn(&s1.addMany);
Thread* t2 = Thread.spawn(&s2.addMany);
t1.join();
t2.join();
Stdio.printf("balance %ld\n", led.balance);
// ---- atomics without a lock ----
Atomic* hits = Atomic.withValue(0);
hits.add(5);
hits.add(7);
Stdio.printf("atomic %ld, cas ok %d, after %ld\n",
hits.load(),
(i16)(hits.compareAndSwap(12, 100) ? 1 : 0),
hits.load());
// ---- data parallelism ----
Squares* sq = new Squares();
Pool.forRange(1, 11, &sq.one);
Stdio.printf("sum of squares 1..10 = %ld\n", sq.total.load());
return 0;
}
workers 1001000 1501500
balance 2000
atomic 12, cas ok 1, after 100
sum of squares 1..10 = 385

The balance is deterministic because deposit takes the lock: two threads each make 500 deposits of 2.

The runtime uses pthreads on macOS, raw clone + futex (Linux) or kernel32 (Windows) on the freestanding targets, XTOS threads on arm9, and Web Workers on wasm32. Both macOS runtimes share one runtime source.

On wasm32 a program that spawns a thread is built differently from one that does not: its memory is shared, its refcounts and allocator are atomic, and each thread is the same module started again in a Worker (Node’s worker_threads under Node) on the same memory. Waiting is not allowed on a page’s main thread, so the program’s main runs in a Worker too, and the page keeps the DOM. A browser shares memory between Workers only on a cross-origin isolated page, so serve it with these two headers:

Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp

Without them the loader stops with a message naming the headers. On a host that cannot set headers, a service worker can add them (the coi-serviceworker approach). A program that never spawns a thread needs neither, and par and OperationQueue do not spawn threads on wasm32: a library does not make a page need isolation.

Any thread may start threads, and as many may run at once as the program needs: in a browser the page keeps a pool of Workers and adds one whenever a thread is waiting to start. A program that starts threads cannot be, or link, a wasm32 library (--emit-lib, or #use of one): each thread is an instance of one module, and a library is a module of its own, so the compiler refuses the combination rather than build threads that never run.

On arm9 the kernel provides only thread lifecycle and a futex. Mutex, Cond and Sem are built on it in user space, so an uncontended lock is ldrex/strex and never enters the kernel. Two behaviours there differ from a host:

  • A faulting thread ends its whole process. The thread may have held shared locks and left shared state half-updated, so its siblings stop too.
  • cpuCount() returns 1, because XTOS owns one A9 core. A plain Pool.forRange therefore runs one worker. Set the count with Pool.forRangeWithThreads when a workload needs more (the limit is 128 threads per process).

Known gap: the static-initialiser guard (__sinit_<Class>) is a check-then-act, so two threads that touch the same class’s statics for the first time at once can both run the initialiser.