Threads

Threads

OS threads and the lock that guards what they share live in the standard library's std.thread module, so a threaded program starts with:

using std.thread;
 
manifest { libraries: ["stdlib.moon"] }

Spawning Threads

spawn takes a lambda and whatever that lambda's parameters need, and runs it on a new OS thread:

function main() i32 {
    var t = spawn(() => i32 { return 42; });
    return t.join();  // Wait for thread, returns 42
}

It hands back a Thread<T> handle, where T is what the function returns.

spawn is an ordinary generic function, not a keyword:

public function spawn<F: _Callable>(fn: F, args...: _params_of<F>)
    Thread<_return_type_of<F>>

_Callable accepts a lambda or a named function passed as a value, so spawn(work, 5) runs the named function work on the new thread. _params_of<F> is the parameter list of whatever you passed, so the arguments you supply are checked against it, and _return_type_of<F> is what it returns. Both are resolved when the call is compiled, so a wrong argument is a compile error like any other.

A spawned function can return a result enum. join() transfers that result to the joining thread, which handles it with match or propagates it with try. Errors have the same explicit ownership across threads as other values.

Every spawned thread gets an 8 MiB stack, whatever libc the binary links. Libc defaults vary wildly (glibc ~8 MiB, musl 128 KiB, macOS 512 KiB), so without this, recursion that works on the main thread could overflow on a worker on some targets.

Arguments

Give the thread what it should work on as arguments, and they move into it:

function main() i32 {
    var t = spawn((a: i32, b: i32) => i32 { return a + b; }, 40, 2);
    return t.join();  // 42
}

Because the work arrives as arguments, a thread body does not have to close over the frame that started it. It can be an ordinary lambda value — including one at global scope, which captures nothing and can be spawned as often as you like:

var work = (n: i32) => i32 { return n * 2; };
 
function main() i32 {
    var a = spawn(work, 20);
    var b = spawn(work, 1);
    return a.join() + b.join();  // 42
}

A class or payload enum argument moves, so the thread owns it — the spawner cannot use it again, and the thread releases it when it finishes:

var consume = (r: Report) => i32 {
    return r.rows;        // r is the thread's, and is dropped when it ends
};

Thread Handles

Thread<T>

A handle to a running OS thread. The type parameter T matches the return type of the spawned lambda.

MethodDescription
join()Block until the thread completes, returns the result of type T
function main() i32 {
    var t: Thread<i64> = spawn(() => i64 { return 100; });
    var result: i64 = t.join();
    return 0;
}

Threads Are Scoped

A thread never outlives the scope that spawned it. The handle spawn returns owns the running thread, and dropping it joins: when the handle goes out of scope — at the end of the block, on a return, a break, or an exception unwinding past it — the thread is joined right there, unless join() already ran.

function main() i32 {
    var x: i32 = 0;
    if (true) {
        var t = spawn([ref x]() => i32 { x = 7; return 0; });
    }   // t is dropped here, which joins the thread
    return x;  // 7 — the thread has finished
}

That is what makes captures safe: the lambda's captured environment lives in the frame that spawned the thread, so the thread must finish while that frame is still standing.

For the same reason, the handle of a thread spawned over a capture-list lambda — or over a bound method, which holds its receiver by reference — is bound to that frame. It can move between locals, and it joins when it drops, but it cannot leave: returning it, storing it in a field, a container element, an indexed slot or a global, or passing it to a by-value parameter (the callee could keep it, and nothing in the parameter's type says the handle is frame-bound) would let the thread outlive the variables it borrows. Passing it by ref is fine — a borrow cannot be kept.

class Worker {
    var t: Thread<i32>;
    init() {
        var x = 3;
        // error: the field could outlive x, which the thread reads
        this.t = spawn([ref x]() => i32 { return x; });
    }
}

A thread that should outlive the frame takes what it needs as spawn arguments instead of captures. Arguments move into the thread, which owns them from then on, so a handle over a capture-free lambda may live anywhere — a constructor can start a worker and store the handle in a field, and the object joins the thread when it drops:

class Worker {
    var t: Thread<i32>;
    init(cfg: Config) {
        this.t = spawn((c: Config) => i32 { return c.limit; }, cfg);
    }
}   // dropping a Worker joins its thread

The Thread's _Result

join() hands you the value the thread returned, and you own it from then on: if it is a class or a payload enum, it is dropped at the end of your scope, like any other value you own.

class Report {
    public var rows: i32;
    init(rows: i32) { this.rows = rows; }
    deinit() { /* release what the report holds */ }
}
 
function main() i32 {
    var t = spawn(() => Report { return Report(12); });
    var r = t.join();  // r owns the report
    return r.rows;     // r is dropped here
}

A thread joined at scope exit hands its result to nobody, so the result is dropped right there instead.

Capturing Variables

Spawned lambdas can capture variables from the enclosing scope. A variable the lambda simply uses is captured by value — a copy the thread can read but not mutate:

function main() i32 {
    var x: i32 = 10;
    var t = spawn(() => i32 { return x * 2; });
    return t.join();  // Returns 20
}

Naming a variable in the capture list says which of three things you mean. [ref x] borrows it mutably, [const ref x] borrows it read-only, and an entry with no ref gives the value to the closure: a class or payload enum moves in, and a scalar is copied. What the closure owns is the closure's, so it may change it, and it is dropped when the closure's scope ends:

function main() i32 {
    var p = Point(3, 4);
    var t = spawn([p]() => i32 {
        return p.x + p.y;   // the thread owns p now
    });
    // p cannot be used here any more — it was moved into the lambda
    return t.join();
}

A compound value is never picked up implicitly; you have to say which one you meant.

To share state with the thread, capture it by reference with [ref x]() => .... Both the thread and the parent then work on the same value, so guard it with a Mutex:

using std;
using std.thread;
 
class Tally {
    public var counter: i64;
    public var m: Mutex;
    init() { this.counter = 0; this.m = Mutex(); }
}
 
function main() i32 {
    var s = Tally();
    var t = spawn([ref s]() => i64 {
        var i: i64 = 0;
        while (i < 100000) {
            s.m.lock(); s.counter = s.counter + 1; s.m.unlock();
            i = i + 1;
        }
        return 0;
    });
 
    var i: i64 = 0;
    while (i < 100000) {
        s.m.lock(); s.counter = s.counter + 1; s.m.unlock();
        i = i + 1;
    }
 
    var r = t.join();
    println(s.counter);  // 200000
    return 0;
}
 
manifest { libraries: ["stdlib.moon"] }

Sharing one value with several threads

A [ref x] capture is a mutable borrow, and mutable borrows are exclusive, so only one thread can capture a given variable that way. A thread that only reads the value captures it with [const ref x] — a shared borrow, which any number of threads can hold at once:

class Config {
    public var limit: i32;
    init(limit: i32) { this.limit = limit; }
}
 
function main() i32 {
    var cfg = Config(20);
    var t1 = spawn([const ref cfg]() => i32 { return cfg.limit; });
    var t2 = spawn([const ref cfg]() => i32 { return cfg.limit; });
    return t1.join() + t2.join();  // 40
}

Everything reached through a const ref capture is read-only: assigning a field or calling a method that is not a const method is a compile error.

For two threads that both write one value, use Shared<T> — see below.

Sharing Mutable State: Shared<T>

Shared<T> is the shared-ownership counterpart to Unique<T>: Unique has one owner, Shared has as many as you like. It puts the value in a heap box next to a Mutex and an atomic count of the live handles. clone() adds an owner, lock() waits for the lock and hands back a guard, and the last handle to go drops the value and frees the box.

Because Sun never copies implicitly, every extra owner is a visible clone(), so you can see the count change in the source.

using std;
using std.thread;
 
class Counter {
    public var n: i64;
    init() { this.n = 0; }
}
 
// One body, spawned twice. The clone is this thread's, and released when
// the thread ends.
var bump = (h: Shared<Counter>) => i32 {
    var i: i64 = 0;
    while (i < 100000) {
        var g = h.lock();
        g.get().n = g.get().n + 1;
        i = i + 1;
    }
    return 0;
};
 
function main() i32 {
    var alloc = make_heap_allocator();
    var s = Shared<Counter>(alloc, Counter());
 
    var t1 = spawn(bump, s.clone());
    var t2 = spawn(bump, s.clone());
 
    t1.join();
    t2.join();
 
    var g = s.lock();
    println(g.get().n);  // 200000
    return 0;
}
 
manifest { libraries: ["stdlib.moon"] }

Each thread gets its own handle, moved in as an argument. A capture list works too — spawn([c1]() => i32 { … }) — but passing the handle keeps the body independent of the frame that started it, so the same lambda serves both threads. Several threads can also share one handle read-only with [const ref s] and lock through that: lock() is a const method, because taking the lock does not change the handle.

The guard

lock() returns a SharedGuard<T> that holds the lock for as long as it is alive. get() gives you a reference to the value, and dropping the guard unlocks — at the end of the block, on an early return, or while an exception unwinds past it. There is no way to reach the value without a guard, and no way to make a guard except by locking.

Hold a guard for as short a span as you can: while you have it, every other thread that wants the value is waiting.

Handles are values

A Shared<T> handle follows the usual rules: binding it to a second name moves it, and the count does not change. Only clone() adds an owner.

MethodDescription
clone()Another owner of the same value
lock()Wait for the lock, return a SharedGuard<T> that holds it
count()How many owners there are right now
is_null()Whether the handle has been moved out of
SharedGuard<T> methodDescription
get()The shared value, as a reference

Thread Handles Are Values

A handle is an ordinary owned value, so the usual rules apply: binding it to a second name moves it, and the old name can no longer be joined. Overwriting a handle joins the thread it held first.

function main() i32 {
    var t = spawn(() => i32 { return 11; });
    var t2 = t;
    return t2.join();  // t has been moved out of; t.join() would not compile
}

Multiple Threads

You can spawn multiple threads and join them independently:

function main() i32 {
    var t1 = spawn(() => i32 { return 10; });
    var t2 = spawn(() => i32 { return 20; });
    var t3 = spawn(() => i32 { return 30; });
    return t1.join() + t2.join() + t3.join();  // 60
}

Synchronization with Mutex

For shared mutable state between threads, use Mutex from std.thread:

using std.thread;
 
function main() i32 {
    var m = Mutex();
    
    m.lock();
    // Critical section - only one thread can be here at a time
    m.unlock();
    
    return 0;
}
 
manifest {
    libraries: ["stdlib.moon"]
}

The Mutex implementation uses atomic compare-and-swap with kernel wait-on-address blocking (Linux futex, macOS __ulock_wait) for efficient contention handling.

MethodDescription
lock()Acquire the mutex (blocks if held by another thread)
unlock()Release the mutex

Mutex uses a fast path (single atomic CAS) for uncontended locks and falls back to kernel wait-on-address blocking only when contention is detected.

Low-Level Atomics

For counters, publication flags, and queue indices that do not need a whole critical section, Sun provides unsafe atomic integer intrinsics for i32, i64, and u64. Loads use acquire ordering, stores use release ordering, and compare-and-exchange and fetch-add/subtract use acquire-release ordering. Explicit acquire and release fences are also available.

These primitives take raw pointers, so they are intentionally lower-level than Mutex and Shared<T>. The caller must keep the pointed-to value alive and aligned, and every concurrent access to that location must be atomic. See Atomic Intrinsics for the complete API and ordering rules.

Constraints

  • spawn takes anything callable: a lambda (a literal, a variable holding one, or a global) or a named function passed as a value — the _Callable constraint. Fallible workers return a result enum
  • The arguments must match the callee's parameters exactly, in number and type
  • A thread is joined when its handle goes out of scope, so a thread spawned inside a loop body is joined at the end of that iteration. To run several threads at once, spawn them all in the same scope
  • Several threads can capture the same variable with [const ref x], but only one can capture it with [ref x]. For two threads that both write one value, pass each a Shared<T> handle

Example: Parallel Computation

function main() i32 {
    // Compute two independent values in parallel
    var t1 = spawn(() => i32 {
        var sum: i32 = 0;
        for (var i: i32 = 0; i < 1000; i = i + 1) {
            sum = sum + i;
        }
        return sum;
    });
 
    var t2 = spawn(() => i32 {
        var product: i32 = 1;
        for (var i: i32 = 1; i < 10; i = i + 1) {
            product = product * i;
        }
        return product;
    });
 
    var sum = t1.join();
    var product = t2.join();
    return sum + product;
}