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@Tare 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); andload(order),store(v, order).cas(expected, desired, success, failure, weak = false) -> (bool, T)answers whether it exchanged and the value observed;failuremay not bereleaseoracq_reland may not be stronger thansuccess; withweak = trueit may fail spuriously.wait(expected)blocks while the value equalsexpected;wake(count)wakes up tocountwaiters,wake(0)all. TheOrderingcases arerelaxed,acquire,release,acq_rel,seq_cst, and an omittedorderisseq_cst.atomic.fence(order)is a standalone fence, andatomic.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&counterof a@u64is typed; a diagnostic spells it so, and a cast names it the same way,((@u64)*)raw.- Every
@Tis 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 0thread.spawn(entry: func(void*?) -> unit, context: void*?, stack: usize = 0, name: str = "") -> thread.Handle!starts a thread that runsentry(context); a thread that needs no context is givennone. and ends when it returns;stack = 0meansthread.default_stack, 8 MiB on every host unless the program sets it (macOS would give 512 KiB, Windows 1 MiB).Handleis an integer-shaped zeroable value.handle.join() -> !waits for the thread and is notmutating;handle.detach()lets it run unjoined. AHandlethat 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(), andthread.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
@Toperations,atomic.fence,thread.spawn(everything before the spawn is visible to the new thread),join(everything the thread did is visible after it), and thesyncmodule'sMutex(lock,unlock,try_lock, sincetryis 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)), andSemaphore(acquire,release), all zeroable so thatvar lock: sync.Mutexis a valid unlocked mutex, and built over@Twithwaitandwake. local var(§6.3) is one variable per thread.memory.allocator(§12.3) is thread-local: a new thread starts withmemory.heap, not the spawning thread's current allocator; a thread that should allocate elsewhere receives the allocator through its context and useswithorin.- 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.