Luce Base
LuceSourceLuciaOS

15. Atomics, volatile, and threads

15.1 Atomic types#

import atomic

var ready: @bool
var hits: @u64
var head: @Node*?

hits += 1                                       # atomic add, seq_cst: the instruction, wrapping as C11 does
ready = true                                    # store, seq_cst
if ready: ...                                   # load, seq_cst
let previous = hits.add(1, .relaxed)
ready.store(true, .release)
while not ready.load(.acquire): ...
let (exchanged, observed) = head.cas(expected, replacement, .acq_rel, .acquire)
atomic.fence(.release)
  • Plain reads and = are sequentially consistent loads and stores.
  • +=, -=, |=, &=, ^= on an @T are the sequentially consistent read-modify-write instructions, and they wrap as C11's do. Checked arithmetic does not apply to atomics: the @ on the type is the marker that the instruction is what is wanted.
  • The methods, each returning the previous value: add(v, order), sub(v, order), set(mask, order) (or), clear(mask, order) (and-not), flip(mask, order) (xor), max(v, order), min(v, order), swap(v, order); and load(order), store(v, order). cas(expected, desired, success, failure, weak = false) -> (bool, T) answers whether it exchanged and the value observed; failure may not be release or acq_rel and may not be stronger than success; with weak = true it may fail spuriously. wait(expected) blocks while the value equals expected; wake(count) wakes up to count waiters, wake(0) all. The Ordering cases are relaxed, acquire, release, acq_rel, seq_cst, and an omitted order is seq_cst. atomic.fence(order) is a standalone fence, and atomic.fence(.signal) is a compiler-only barrier for signal handlers.
  • @ covers the whole type after it, suffixes included: @Node*? is an atomic nullable pointer and @u64* an atomic pointer. A pointer to an atomic is written with parentheses, (@u64)*, as &counter of a @u64 is typed; a diagnostic spells it so, and a cast names it the same way, ((@u64)*)raw.
  • Every @T is lock-free by construction; the double-width form of §5.9 exists only where the target has the instruction.
  • Semantics are the C11 memory model.

Why on the type. Atomicity is a property of the location. If the marker were on the access, forgetting it once would be a plain access to the same location, which is a data race by definition; with it on the type, every access is atomic and the compiler can prove no mixed access exists. Refusing atomic structs refuses the lock that C11 uses inside _Atomic struct.

Why += is the instruction. A counter written hits += 1 must compile to the atomic add; a checked loop would spin under contention where C does not. The type already says the programmer has left checked arithmetic; +? remains available for a checked atomic update written as a cas loop.

15.2 volatile#

A load or store through volatile T* is an observable effect: never elided, merged, reordered with another volatile access or an asm block, or widened. It says nothing about other threads. Hardware registers use volatile; shared memory uses @.

15.3 Threads#

Base has no thread syntax. Threads are the standard thread module over the host's threads: POSIX threads and Windows threads.

import thread

struct Job:
    var input: const u8[]
    var checksum: u32

func run(context: void*):
    let job = (Job*)context
    job.checksum = checksum(job.input)

pub func main(arguments: str[]) -> i32!:
    var jobs: Job[4]
    var handles: thread.Handle[4]
    for index in 0..<4:
        jobs[index] = Job(input = part(index), checksum = 0)
        handles[index] = try thread.spawn(run, &jobs[index])
    for handle in handles:
        try handle.join()
    return 0
  • thread.spawn(entry: func(void*?) -> unit, context: void*?, stack: usize = 0, name: str = "") -> thread.Handle! starts a thread that runs entry(context); a thread that needs no context is given none. and ends when it returns; stack = 0 means thread.default_stack, 8 MiB on every host unless the program sets it (macOS would give 512 KiB, Windows 1 MiB). Handle is an integer-shaped zeroable value. handle.join() -> ! waits for the thread and is not mutating; handle.detach() lets it run unjoined. A Handle that is neither joined nor detached when its scope ends is a linter diagnostic; the thread keeps running. thread.current(), thread.pause() (the CPU spin hint), thread.yield(), and thread.sleep(milliseconds) exist.
  • The memory model is C11's. Two threads that access one non-atomic location, where at least one writes, with no ordering between them, are a data race, and a data race is undefined (§12.6). Ordering is established by @T operations, atomic.fence, thread.spawn (everything before the spawn is visible to the new thread), join (everything the thread did is visible after it), and the sync module's Mutex (lock, unlock, try_lock, since try is a keyword), Condition (wait(mutex), wait_for(mutex, milliseconds), which may return early and so is rechecked against a deadline, signal, broadcast), Once (run(function)), and Semaphore (acquire, release), all zeroable so that var lock: sync.Mutex is a valid unlocked mutex, and built over @T with wait and wake.
  • local var (§6.3) is one variable per thread. memory.allocator (§12.3) is thread-local: a new thread starts with memory.heap, not the spawning thread's current allocator; a thread that should allocate elsewhere receives the allocator through its context and uses with or in.
  • A trap on any thread stops the whole process, with the trace of the thread that trapped.
  • A thread started by Base may not call into full Luce (§18.7). Inside a full Luce program, full Luce's workers are built above this module.

Why a module and not syntax. Everything a thread needs from the language is already present: @T, local, function pointers, and a void* for its argument. C's threads are a library, and Base's are too. Full Luce's isolated workers, which copy values and forbid sharing, are the safer design for application code, and they are built on this layer.