sysl

Module

sysl.threads

Threads of execution, and the two things a program does with one: start it, and wait for it – with the lock, the condition variable and the channel threads share things through.

Import import sysl.threads
Requires
requires { os }

This module asks for os and nothing more. A thread is something an operating system’s scheduler gives a program, and what starts one is whatever answers the hooks of sysl.threads.sys – pthreads on a hosted target, a kernel’s own @exports over clone and futex on a bare one, which needs neither POSIX nor a C library. sysl.sync stays below it, requiring nothing: an atomic word is something a bare machine has whether or not anything schedules threads on it.

sysl.posix.threads is the same API under its older name, kept for the programs that import it.

Answered by whatever is underneath

Every thread is started, joined and named through the hooks – sysl_thread_spawn, sysl_thread_join, sysl_thread_self and sysl_thread_yield – and every wait sleeps on the futex pair, sysl_futex_wait and sysl_futex_wake. SleepLock, Mutex[T], Condvar and Channel[T] are written once, here, over sysl.sync.Atomic[u32] and those two hooks; nothing in them is a C type.

The library owns the stack, and the compiler owns the thread-local storage

spawn makes one allocation per thread holding the thread’s id word, its block of @thread_local storage and its stack, and join gives it back. A program with no heap starts a thread on storage of its own with spawn_on. The thread-local block is laid out from the program’s own template – the .tdata image and the .tbss size the linker made, which the compiler’s link names – so a kernel answering sysl_thread_spawn only writes the thread pointer. On a hosted target the C library lays each thread’s storage itself and spawn passes the system no stack, pthreads taking its own.

A domain is a thread (reference/memory.md § Crossing a concurrency domain), so everything this module starts is a new one. spawn hands the new thread an address, and what is at that address is then shared by two threads and needs a Mutex[T] beside it, or an Atomic[T] below. spawn is marked @crossing(arg), so every count the state reaches has to be atomic and the compiler says so at the call (reference/memory.md § @crossing).

Index

DEFAULT_STACK channel current spawn spawn_on tls_place tls_room yield_now Channel Condvar Mutex SleepLock Thread Lock for SleepLock

Constants

DEFAULT_STACK

const DEFAULT_STACK: usize = 65536

The stack spawn gives a thread where nothing else is asked for: 64 KiB.

Functions

channel

channel[T](slots: []T) -> Channel[T]

Builds an empty channel over storage the caller supplies.

A function rather than an associated new, because the element type is inferred from the slots: channel(slots[..]) says everything, where Channel[int].new(…) would say the element type twice.

The slice is read here and not kept: what is stored is its address and its length, so that the channel may cross a domain. The storage has to outlive every thread that holds the channel, which is the same contract spawn(&body, &state) already has with whatever state points at.

current

current() -> Thread

The calling thread’s own handle, which is what a body compares against to learn it is not the thread that spawned it. It cannot be joined: a thread waiting for itself would wait for ever.

spawn

spawn[T](body: *extern(*T) -> unit, arg: *T, stack: usize = DEFAULT_STACK) -> Option[Thread]

Starts body on a new thread, with arg as the address it is handed.

On a bare target the library makes the thread’s storage: one allocation of stack bytes plus the thread’s id word and its block of @thread_local storage, given back by join. On a hosted target nothing is allocated and stack is not used: pthreads makes the thread’s stack, and the C library its thread-local storage.

The body is a *extern, not a callable (reference/ffi.md § A function's address): it is what every system’s thread start takes, and a closure would have to be boxed for the new thread to reach it. A package with an allocator is free to take a &sync Fn and call it from a body of its own – sh.sysl.libuv‘s thread pool does, and the capture check holds such a closure to the crossing rule capture by capture.

T is inferred from the body, so spawn(&work, &state) is the whole of the call. An address is always written, null included – and null is the one thing that cannot be, since it takes its type from its context and the context here is the T being inferred.

@crossing(arg) is what holds a caller to the rule about what may reach another domain. A *T is on the crossable list because it carries no count, which says nothing about the object at the far end – and the object at the far end is what the new thread gets. The annotation is what asks the compiler to look through it, so a state holding a plain &T is refused here rather than racing later.

spawn_on

spawn_on[T](stack: []u8, body: *extern(*T) -> unit, arg: *T) -> Option[Thread]

Starts body on a new thread whose storage is stack, which the program owns.

What a program with no heap writes: the slice holds the thread’s id word, its block of @thread_local storage and, in what is left, its stack – so it has to outlive the thread, which is the contract spawn(&body, &state) already has with whatever state points at. A module-level static var of bytes, or a local of a main that joins before it returns, is the usual owner.

It answers None where the slice leaves no stack once the word and the thread-local block are laid in it, and where the system would not start the thread. On a hosted target the stack is handed to pthreads, which asks for at least its PTHREAD_STACK_MIN – 16 KiB on macOS, 128 KiB under glibc on aarch64 – and is trimmed to whole 16 KiB pages first.

tls_place

tls_place(room: []u8) -> Option[*u8]

Lays one thread’s block of @thread_local storage out in room – the template’s initialized bytes copied, the rest zeroed – and answers the value the thread pointer holds while that thread runs.

Some(null) where there is nothing to lay: the program has no thread-local storage, or the target is hosted and the C library lays it. None where room is smaller than tls_room().

The layout is the processor’s ABI for local-exec access, at the offsets the linker computed: on AArch64 the pointer is the start of a 16-byte control block the storage follows at its alignment; on x86-64 the storage ends at the pointer, whose first word holds its own address; elsewhere the storage starts at the pointer. The pointer is aligned to the storage’s alignment in each.

tls_room

tls_room() -> usize

How many bytes one thread’s block of @thread_local storage needs, slack for its alignment included – zero where the program has none, and always zero on a hosted target, where the C library lays each thread’s block down itself.

It is what a program running a thread spawn did not start – its first one, on a bare target – sets aside before laying the block with tls_place.

yield_now

yield_now() -> bool

Offers the processor to whatever else is ready to run, and answers whether the system took it.

This is a hint, not a wait: a thread that yields is still runnable and may be given the processor straight back.

Types

Channel

struct Channel[T]
    private lock: SleepLock
    private has_room: Condvar
    private has_value: Condvar
    private ring: Ring[T]
    private closed: i32

A bounded queue two threads hand values across, and the one place the language’s rule about what may leave a concurrency domain is enforced on a value rather than on an address (library/sync.md, reference/memory.md § Crossing a concurrency domain).

It is what Thread.join says it does not have. Everything else in this module shares by address: spawn hands the new thread a *T, Mutex[T] hands a holder a *T, and what crosses is a pointer whose far end both threads are looking at. A channel is the other shape — a value goes in on one thread and comes out on another, and the two are never looking at one object.

The storage is the caller’s, which is this module’s house style rather than a limitation. Mutex[T] wraps a value the caller built and spawn takes an address the caller has; a channel takes the slots. What that buys is that the type needs no allocator, so a channel is available to a program that has given the heap up, and its capacity is a number the program chose.

var slots: [8]int = [0; 8]
var ch = channel(slots[..])

spawn(&producer, &ch)

loop
    ch.receive() match
        Some(v) -> print(v)
        None -> break

A waiter sleeps: a full send waits on one Condvar and an empty receive on the other, each woken by the transfer that makes room or brings a value, and both woken by close.

The copying half of the crossing rule is NOT built. reference/memory.md § Crossing a concurrency domain allows a channel to take a heap-backed view because it copies the bytes, and this one does not copy: a slot is assigned, which is the same share the sender held. So a view is refused here exactly as it is at spawn, and the relaxation waits on something that copies.

MemberSignatureDescription
capacitycapacity(*self) -> usizeHow many values the channel can hold at once, which is the storage it was given.
lenlen(*self) -> usizeHow many values are waiting to be taken.
is_closedis_closed(*self) -> boolWhether close has been called.
closeclose(*self)Stops the channel taking anything further, and lets every waiter go.
sendsend(*self, value: T) -> boolPuts a value in, waiting while the channel is full, and answers whether it went in.
try_sendtry_send(*self, value: T) -> boolPuts a value in if there is room, and answers whether it went.
receivereceive(*self) -> Option[T]Takes a value out, waiting while the channel is empty.
try_receivetry_receive(*self) -> Option[T]Takes a value out if there is one, and answers nothing where there is not.

Condvar

struct Condvar
    private seq: Atomic[u32]

A place for threads to sleep until another says something has changed: a condition variable.

It is a sequence number, bumped by every notify_one and notify_all. A waiter reads the number while it still holds the lock, lets the lock go, and sleeps in sysl_futex_wait only while the number is still the one it read – so a notification sent between the release and the sleep changes the number, the futex finds it changed, and the waiter does not sleep through it. That is the whole of what makes a condition variable correct, and it needs no queue of its own.

A wait can end with nothing having changed – a spurious wake, a notification meant for another waiter – so the condition is always re-checked in a loop, as with every condition variable:

val state = m.lock_raw()

while !state.ready
    cv.wait(&m)

m.unlock_raw()
MemberSignatureDescription
newnew() -> CondvarA condition variable nobody is waiting on.
waitwait[T](*self, m: *Mutex[T])Releases m, which the caller holds, sleeps until notified, and takes m again before answering.
wait_forwait_for[T](*self, m: *Mutex[T], ns: i64) -> boolwait, for at most ns nanoseconds: answers false where the time ran out first.
wait_rawwait_raw(*self, l: *SleepLock)wait over a bare SleepLock, which the caller holds.
notify_onenotify_one(*self)Wakes one waiter, if there is one.
notify_allnotify_all(*self)Wakes every waiter.

Mutex

struct Mutex[T]
    private inner: Guarded[SleepLock, T]

Mutual exclusion that owns what it protects, which is the difference library/threads.md § Mutex[T] draws against SpinLock.

A spinlock is a flag beside the data and what the data is stays the programmer’s to remember. This holds the T, and both of its fields are private – so there is no way to reach the value that does not go through the lock, and no way to build one that skips the free state.

with is the way in: it takes the lock, hands a closure the address of the value, and releases the lock when the closure returns. The address is lent (@lends), so the compiler holds the closure to letting it go – returning it, storing it, or passing it to something that keeps it is refused, which is the one mistake a returned address could not prevent: using it after the release.

m.with((n) -> *n += 1)

lock_raw and unlock_raw are the hold a block cannot express – taken in one function and released in another, or held across a Condvar.wait. Nothing ties their address to the hold, which is what the name says.

@guards(value) is what lets one cross into a &sync whatever it holds (reference/memory.md § A guarded value crosses with its lock): sharing the lock does not share the value, and what leaves through with or try_with is held to the &sync rule instead – an int or a .copy() of a string, not the Map or a string still sharing the guarded one’s count.

The lock is a SleepLock, written in sysl over an Atomic[u32] and the futex hooks, not a pthread_mutex_t: a caller-allocated C struct’s size is in a header and differs between libcs on one platform, and the futex is what every system that has threads has underneath its own mutex. A waiter sleeps; it neither spins nor yields.

Underneath it is a sysl.sync.Guarded[SleepLock, T] – the record-and-its-own-lock every lock shares – so the two are one implementation and lay out alike. Mutex[T] is the name a hosted program reaches for, and the one that picks the lock for it.

MemberSignatureDescription
newconst new(value: T) -> Mutex[T]Builds a free lock around a value.
withwith[R](*self, body: *T -> R) -> RTakes the lock, waiting until it is free, runs body with the address of what it protects, releases the lock, and answers what body answered.
try_withtry_with[R](*self, body: *T -> R) -> Option[R]The same, if the lock is free: Some of what body answered, or None without waiting.
lock_rawlock_raw(*self) -> *TTakes the lock, waiting until it is free, and answers the address of what it protects.
try_lock_rawtry_lock_raw(*self) -> Option[*T]Takes the lock if it is free, and answers what it protects where it did.
unlock_rawunlock_raw(*self)Releases the lock a lock_raw or try_lock_raw took.

SleepLock

struct SleepLock
    private state: Atomic[u32]

A lock that sleeps, and guards nothing of its own: the classic three-state futex lock.

The word is zero when free, one when held, and two when held with a thread asleep on it, so an uncontended lock and unlock are one atomic operation each and never reach the system, and only an unlock that may have a sleeper to wake calls sysl_futex_wake. A waiter sleeps in sysl_futex_wait rather than spinning or yielding, so a contended lock costs nothing while it waits.

It is what Mutex[T] and Channel[T] are built on, and it is public for the hold neither of them expresses: a lock beside data the program names itself, as SpinLock is, for code that may sleep. It implements sysl.sync.Lock, so Guarded[SleepLock, T] is a record with a sleeping lock of its own.

MemberSignatureDescription
newconst new() -> SleepLockA free lock.
locklock(*self)Takes the lock, sleeping until it is free.
try_locktry_lock(*self) -> boolTakes the lock if it is free, and answers whether it did.
unlockunlock(*self)Releases the lock, waking one sleeper where there may be one.

Thread

struct Thread
    id: u64
    private word: *u64
    private room: *Room

A thread that has been started, and may be waited for.

It is a handle rather than the thread: copying one copies the handle, and joining either copy joins the one thread. Joining twice is not checked here – the second join would give the thread’s storage back a second time – for the reason SpinLock.unlock gives about the releasing thread: the word it would take to notice is paid by every correct program. A thread never joined keeps its storage for the rest of the program.

MemberSignatureDescription
joinjoin(self) -> boolWaits for the thread to finish, and answers whether it was waited for.

Implementations

Lock for SleepLock

impl Lock for SleepLock

Search

Esc
↑↓ to navigate ↵ to open Esc to close