Threads and shared data
The async module starts and manages operating-system threads. Pepper keeps
threads safe by controlling what they can share: only thread-safe values
can be reached from more than one thread, and the compiler checks the types
that claim to be.
On WebAssembly (--target wasm32-wasip1) a program has one thread:
Thread.spawn panics. Mutexes, channels and atomics still work, as long as
nothing waits for another thread (see WebAssembly).
Starting and joining threads
import async;
const finished = async.Atomic<int>(0);
# A thread body is a function that takes nothing and returns nothing.
count_to_a_million() {
var me = async.Thread.current();
assert(me.name() == "counter", "a spawned thread sees its own Thread");
var total: long = 0;
var i = 0;
while (i < 1000000) {
total = total + 1;
i = i + 1;
}
finished.fetch_add(1);
}
main(): int {
var main_thread = async.Thread.current();
assert(main_thread.name() == "main", "the main thread is named main");
var first = async.Thread.spawn(count_to_a_million, "counter");
var second = async.Thread.spawn(count_to_a_million, "counter");
assert(first.id() != second.id(), "every thread has its own id");
first.join();
second.join();
assert(first.is_finished(), "join waits for the body to return");
assert(finished.load() == 2, "both bodies ran");
# How many threads can run at once, and on how many cores.
assert(async.logical_cpus() >= async.physical_cpus(), "hyperthreads count once in physical_cpus");
return 0;
}
Thread.spawn(body, name)starts a new thread that runsbody, a function of typeThreadBody(an alias forshared () => void), and returns itsThread. The name is optional.join()waits for the body to return. Afterjoin(), the joining thread sees everything the other thread did. A thread can be joined once.Thread.current()returns the calling thread’sThread. For a spawned thread it is the same object thatspawnreturned. The main thread’s is namedmain.id()returns the thread’sThreadId, a number that no other thread in the program has. Ids come from the runtime, starting at 1, and are not the operating system’s thread ids. AThreadIdcan be compared and used as aHashMapkey.is_finished()reports whether the body has returned.is_joinable()reports whetherjoin()may be called.Thread.sleep(milliseconds)pauses the calling thread (in a task, only the task: see Tasks).- Dropping every reference to a
Threadwithout joining it detaches the thread. It keeps running, and its resources are freed when it finishes. - A
panicor failedasserton any thread ends the whole program.
The body can be a named function or a closure. A closure passes data to the thread through its captures:
var total = async.Atomic<long>(0);
var step: long = 5;
var worker = async.Thread.spawn(() => { total.fetch_add(step); });
worker.join();
The body is a shared function value,
so the closure may capture only thread-safe values (numbers, Atomic<T>,
[Shared] structs, shared function values). The compiler checks each
capture and reports one that is not thread-safe where the closure uses it:
main.pep:5:46: error: closure passed as 'shared () => void' captures 'name' of type 'String', which is not thread-safe
5 | var t = async.Thread.spawn(() => { print(name); });
| ^~~~
main.pep:4:9: note: capture a thread-safe value instead, such as an 'async.Atomic<T>' or a '[Shared]' struct
4 | var name = "worker";
| ^~~~
Every module-scope variable is const and thread-safe, so any thread may
use one (see below).
The Thread reference documents every method.
Processor counts
async.logical_cpus()returns how many threads the machine can run at the same moment. A core with hyperthreading (SMT) counts two or more times. This is a good default size for a pool of CPU-bound worker threads. In a Linux container with a CPU quota (docker run --cpus), it is the quota rounded up, when that is fewer (see Containers).async.physical_cpus()counts each physical core once. On Linux it reads the kernel’s CPU topology, on macOShw.physicalcpu, and on WindowsGetLogicalProcessorInformationEx. Inside a virtual machine it counts the cores the hypervisor presents, because the guest cannot see the host’s hardware.
Thread-local variables
A module-scope var marked [ThreadLocal] has a separate copy on every
thread, like C++ thread_local or a Rust #[thread_local] static:
import async;
import [Thread] from async;
# A separate `depth` on every thread, each starting at 0.
[ThreadLocal]
var depth: int = 0;
const deepest = async.Atomic<int>(0);
descend(levels: int) {
depth += 1;
if (depth > deepest.load()) {
deepest.store(depth);
}
if (levels > 1) {
descend(levels - 1);
}
depth -= 1;
}
work() {
descend(5);
}
main(): int {
depth = 100; # changes only the main thread's copy
var a = Thread.spawn(work);
var b = Thread.spawn(work);
a.join();
b.join();
assert(deepest.load() == 5, "each thread counted from its own 0");
assert(depth == 100, "the main thread's copy is its own");
return 0;
}
- Every thread’s copy starts from the initializer, which must be a
constant: a number,
boolorcharliteral (negated, or cast), an enum case, or nothing, for the type’s default (zero, or null for a struct). To start from anything else, leave the initializer out and set the value on the thread’s first use. - Reading or assigning one touches only the calling thread’s copy, so it is
a plain
var: it need not beconstlike other module-scope variables, and needs no atomics or locks. A read is a single load from the thread’s storage (a%fs-relative load on x86-64 Linux), not a call. - Its type must be thread-safe (see What threads can share):
a number, a raw pointer, a
[Shared]struct, asharedfunction type, or a tuple or[Value]struct of them. A value of any other type, such as aString, goes in aThreadLocal<T>. - When a thread started with
Thread.spawnexits (theasyncruntime’s workers and blocking threads included), each of its thread-locals is set to its default and the struct it held is released, last declared first. The main thread’s are never released, like every other module-scope variable, and a thread started by C code does not release its own. - A task sees the thread-locals of the worker thread it runs on, as with a
ThreadLocal<T>.
[ThreadLocal] applies only to a module-scope variable. A variable whose
type is not thread-safe, or whose initializer is not a constant, is
reported at the type or the initializer:
main.pep:3:14: error: thread-local variable 'scratch' has type 'Plain', which is not thread-safe
3 | var scratch: Plain;
| ^~~~~
note: a per-thread value of any type is an 'async.ThreadLocal<T>' (after 'import async')
main.pep:1:8: note: 'Plain' is not declared [Shared]
1 | struct Plain { var x: int; }
| ^~~~~
main.pep:3:18: error: thread-local variable 'depth' must start from a constant
3 | var depth: int = start();
| ^~~~~
note: every thread starts from this value; leave it out and assign the value on the thread's first use instead
Per-thread values with ThreadLocal
ThreadLocal<T> is a variable with a separate value on every thread, of
any type:
import async;
import [Thread, ThreadLocal] from async;
# One variable, a separate value on every thread.
const calls = ThreadLocal<int>();
const totals = async.Atomic<long>(0);
record_call() {
calls.set(calls.get() + 1);
}
work() {
record_call();
record_call();
record_call();
totals.fetch_add(calls.get()); # 3 on every thread
}
main(): int {
record_call();
var a = Thread.spawn(work);
var b = Thread.spawn(work);
a.join();
b.join();
assert(totals.load() == 6, "each thread counted its own calls");
assert(calls.get() == 1, "the main thread's value is its own");
return 0;
}
get()returns the calling thread’s value, or the default ofT(zero, or null for a struct) until that thread callsset.set(value)replaces only the calling thread’s value.is_set()andclear()test for and remove the calling thread’s value.- When a thread started with
Thread.spawnexits, its value is released, even if theThreadLocalitself was destroyed first. As with[ThreadLocal]variables, the main thread’s value is released only byclear(), and a thread started by C code leaks the values it did not clear. - Each thread keeps its values in a table of its own, held in
[ThreadLocal]variables, soget()costs a few loads and no call, and a program can have any number ofThreadLocals.
A ThreadLocal is usually a module-scope variable. Its T does not need to
be thread-safe, because each value stays on the thread that stored it. For a
T that is not thread-safe, such as a String, the value is reached like a
Mutex’s (see What a lock body may do):
with (var value = local.enter()) { ... }binds this thread’s value; theelseblock runs when the thread has none;get()is a compile error;set(value)takes a new value: a constructor call, a string literal,copy(x), ormove(x).
Otherwise a function called while a Mutex is locked could keep part of the
locked value in a thread-local, and a later get() would hand it out after
the lock was released. The
ThreadLocal reference has the details.
Tasks that take turns on one worker thread share its ThreadLocal values;
a TaskLocal<T> gives each task its own.
What threads can share
Every struct value is a counted reference (see Memory and reference counting). Updating a count from two threads at once can free an object too early or never, so by default a struct’s counts are plain, fast increments, and its objects must stay on one thread.
A struct declared [Shared] has atomic reference counts, so references
to one object can be copied and dropped on any thread:
import async;
# A struct that threads can share: declared [Shared], every field const and
# thread-safe.
[Shared]
struct Settings {
const port: int;
const retries: int;
construct(this.port = 0, this.retries = 0) {}
}
# async.Atomic<T> of a [Shared] struct: threads read the current settings, and one
# of them can replace them.
const current = async.Atomic<Settings>(Settings(8080, 3));
const seen = async.Atomic<long>(0);
read_settings() {
var settings = current.load();
seen.fetch_add(settings.port);
}
main(): int {
var readers = Array<async.Thread>();
var i = 0;
while (i < 4) {
readers.append(async.Thread.spawn(read_settings));
i = i + 1;
}
for (var reader in readers) {
reader.join();
}
assert(seen.load() == 4 * 8080, "every reader saw the settings");
current.store(Settings(9090, 5));
assert(current.load().port == 9090, "replaced");
return 0;
}
The compiler checks every [Shared] struct:
-
Every field must be
const. It is set once, by the constructor, and never assigned again, so threads cannot race on it. To change shared state, give the struct a thread-safe member such as anAtomic<T>. -
Every field must have a thread-safe type. These are all the thread-safe types, the same ones the constraint
T: Sharedaccepts:- every number type (
int,long,byte,char,short,float,doubleand the rest),bool, andvoid; - enums whose cases carry no values, which are stored as integers;
[Shared]structs, and enums whose cases carry values that are declared[Shared];- weak references to thread-safe types;
- raw pointers (
__ptr<T>, andvoid*) to thread-safe types; sharedfunction types, such asshared () => void(see Function types), and C function pointer types (extern (int) => int);- tuples whose elements are all thread-safe;
- inside a generic, a type parameter constrained
T: Shared.
Nothing else is. A struct is never thread-safe on its own, even when all its fields are numbers: a struct value is a reference to a reference-counted object, and only a
[Shared]struct counts those references atomically. SoString,Arrayand other structs that are not[Shared]are not thread-safe, and neither is a plain function type such as() => void:main.pep:3:14: error: field 'F' of shared struct 'H' has type '() => void', which is not thread-safe 3 | const F: () => void; | ^~~~~~~~~~ note: declare the function type 'shared () => void' to share it between threads - every number type (
A generic [Shared] struct whose parameter has no constraint is checked
for each set of type arguments, and the error points at the code that asked
for the specialization:
[Shared]
struct Cell<T> {
const value: T;
}
make<T>(): Cell<T> { return Cell<T>(); }
main(): int {
var c = make<String>();
return 0;
}
main.pep:9:13: error: 'make<String>' requires thread-safe type arguments; 'String' is not thread-safe
9 | var c = make<String>();
| ^~~~~~~~~~~~
main.pep:6:29: note: 'Cell<String>' is required here, in 'make<String>'
6 | make<T>(): Cell<T> { return Cell<T>(); }
| ^~~~~~~
main.pep:3:18: note: required by field 'value' declared here
3 | const value: T;
| ^
runtime/string.pep:54:15: note: 'String' is not declared [Shared]
A generic can state the rule up front with the built-in constraint
T: Shared: [Shared] struct Cell<T: Shared> accepts exactly the type arguments above, and a wrong one
is reported where it is written ('String' does not conform to 'Shared', required by 'T' in 'Cell'). The standard library’s Atomic<T>, spawn,
run_blocking and Lazy<T> do this, so Atomic<String> is reported at
String.
A field that is not const, or that has a type that is not thread-safe, is
reported at the field:
main.pep:3:9: error: field 'Count' of shared struct 'Counter' must be 'const'
3 | int Count;
| ^~~~~
note: threads change a shared struct's state through a thread-safe member such as 'async.Atomic<T>' (after 'import async')
Thread, ThreadLocal<T> and Atomic<T> are [Shared] themselves, and so
are the synchronization types below: Channel<T>, Mutex<T>,
Condition<T>, Once and Lazy<T>.
Module-scope variables
Every module-scope variable is implicitly shared, exported or not: any
function can use it, and any function can run on any thread. So it follows
the same two rules as a field of a [Shared] struct:
- It must be
const. To change the value, give it a thread-safe type such asAtomic<T>, or aThreadLocal<T>for a value per thread. The one exception is a[ThreadLocal]variable, which every thread has its own copy of. - It must have a thread-safe type from the list above. A
String, anArray, a struct that is not[Shared], a trait value, or a plain function value cannot be a module-scope variable; keep it in a local, or in aThreadLocal<T>.
const MAX_WORKERS = 8; # a number
const jobs_done = async.Atomic<long>(0); # changed with fetch_add
const settings = Settings(8080, 3); # a [Shared] struct
const scratch = ThreadLocal<String>(); # a String per thread
const double: shared (int) => int = (x: int) => x * 2; # a shared function
[ThreadLocal]
var depth: int = 0; # a copy per thread
A variable that breaks a rule is reported at its name or type:
main.pep:1:5: error: module-scope variable 'hits' must be 'const'
1 | var hits = 0;
| ^~~~
note: every thread can reach a module-scope variable; change its state through a thread-safe type such as 'async.Atomic<T>' (after 'import async'), or declare it '[ThreadLocal]' to give each thread its own copy
main.pep:2:15: error: module-scope variable 'origin' has type 'Plain', which is not thread-safe
2 | const origin: Plain = Plain();
| ^~~~~
main.pep:1:8: note: 'Plain' is not declared [Shared]
1 | struct Plain { var x: int; }
| ^~~~~
Module-scope variables are initialized before main runs, in the order they
are declared, and each file’s after those of the files it imports and of the
runtime. So an initializer may use the variables of the files it imports.
Channels
A Channel<T> carries values from threads that send to threads that
receive, with any number of each, like a Go channel. (In a
task, a wait parks only the
task, and so do the waits of Mutex and Condition below.)
import async;
# The main thread sends numbers to a pool of workers, and the workers send
# their squares back on a second channel.
const jobs = async.Channel<long>(16); # bounded: send waits while 16 are queued
const results = async.Channel<long>(); # unbounded: send never waits
square_numbers() {
# The loop ends once `jobs` is closed and empty.
for (var n in jobs) {
results.send(n * n);
}
}
# A `shared` function value is thread-safe, so a channel can carry work.
const tasks = async.Channel<async.ThreadBody>();
const ran = async.Atomic<long>(0);
run_tasks() {
for (var task in tasks) {
task();
}
}
main(): int {
var workers = Array<async.Thread>();
var i = 0;
while (i < 4) {
workers.append(async.Thread.spawn(square_numbers));
i = i + 1;
}
var n: long = 1;
while (n <= 100) {
jobs.send(n);
n = n + 1;
}
jobs.close(); # no more work: each worker's loop ends
for (var worker in workers) {
worker.join();
}
results.close();
var total: long = 0;
for (var square in results) {
total = total + square;
}
assert(total == 338350, "the sum of the first 100 squares");
# receive() returns a Result: an error once the channel is closed and
# empty. A closed channel refuses new values.
assert(results.receive().is_err(), "nothing left");
assert(!results.send(1), "closed");
var runner = async.Thread.spawn(run_tasks);
tasks.send(() => { ran.fetch_add(1); });
tasks.send(() => { ran.fetch_add(10); });
tasks.close();
runner.join();
assert(ran.load() == 11, "the runner ran both tasks");
return 0;
}
Channel<T>(capacity)makes a channel that holds at mostcapacityvalues;sendwaits while it is full.Channel<T>()(capacity 0) is unbounded, andsendnever waits.send(value)queues a value and returnstrue, or returnsfalseif the channel is closed.try_send(value)does not wait: it also returnsfalsewhen a bounded channel is full.receive()waits for the next value and returns aResult<T>: the value, or an error once the channel is closed and empty.try_receive()returns an error right away when nothing is waiting, andreceive_for(milliseconds)when the time runs out.close()ends the stream and wakes every waiting sender and receiver. Values already queued are still received. Onlyclose()closes a channel: dropping it, or ending the threads that use it, does not.for (var v in channel)receives until the channel is closed and empty. Several threads can loop over one channel; each value goes to one of them.- Values from one sender arrive in the order they were sent.
- The
countproperty andis_closed()report the channel’s state, which other threads may change right after.
send and try_send take a new value, which nothing on the sending
thread references, and hand it to the receiver without copying it: a
constructor call, a literal, a call that returns a fresh value,
move(x) (a variable that
holds the only reference) or copy(x)
(when the sender keeps using x). A String, an Array or a struct of them
can be sent, and changing the original after sending a copy does not change
what was sent:
import async;
# A plain struct: no destructor and no raw pointer, so `copy` copies it field
# by field. Its Strings and Array are not thread-safe, but a Channel takes
# only new values, and a copy is one.
struct Order {
String customer;
Array<String> items;
Order next;
construct(this.customer = "") {
items = Array<String>();
}
}
const orders = async.Channel<Order>();
const letters = async.Atomic<long>(0);
count_letters() {
for (var order in orders) {
for (var item in order.items) {
letters.fetch_add(item.length as long);
}
}
}
main(): int {
# copy() makes a new object for everything that is not thread-safe.
var tea = Order("ada");
tea.items.append("tea");
var copied = copy(tea);
copied.items[0].append(" and cake");
assert(tea.items[0] == "tea", "the original is unchanged");
# Objects referenced twice stay shared in the copy, and cycles are kept.
var a = Order("a");
var b = Order("b");
a.next = b;
b.next = a;
var pair = copy(a);
assert(pair.next.next === pair, "the copy has the same cycle");
a.next = __null;
pair.next = __null;
# send() takes a new value: send a copy to keep changing the order.
var worker = async.Thread.spawn(count_letters);
var order = Order("grace");
order.items.append("coffee");
orders.send(copy(order));
order.items.append("biscuits");
# Or hand the value itself over with `move`; `last` is null afterwards.
var last = Order("hopper");
last.items.append("tea");
orders.send(move(last));
orders.close();
worker.join();
assert(letters.load() == 9, "the worker saw only what was sent");
return 0;
}
Sending a variable that something else may still reference is an error, and the editor offers both fixes:
main.pep:7:16: error: 'line' is not a new value, but parameter 'value' takes a value that nothing else references
7 | lines.send(line);
| ^~~~
| move(line)
runtime/async/channel.pep:173:17: note: 'value' is declared [Move] here
173 | send([Move] value: T): bool {
| ^~~~~
note: hand it over with 'move(line)', or pass a new value, such as 'copy(line)'
A thread-safe value (a number, a [Shared] struct, a shared function value
such as a ThreadBody) passes freely, which makes a channel of ThreadBody
values a work queue. Since nothing is copied, a channel can carry any type,
trait values and values that cannot be copied (an io.File) included.
The Channel reference documents every method.
Waiting on several channels with Select
A Select<E> waits on several channels at once and takes the first value
any of them receives (or, with a send arm, sends to the first with room),
like Go’s select. Each channel is added with a
function that turns its values into an E, usually a case of an
enum of your own that carries the value, so the channels may
carry different types and a match handles the result:
import async;
# Every value the select can return: one case per channel.
enum Event {
Line(text: String),
Reading(value: long),
Stop,
}
const lines = async.Channel<String>();
const readings = async.Channel<long>();
const stop = async.Channel<bool>();
read_lines() {
lines.send("first");
lines.send("second");
lines.close();
}
read_sensor() {
var i: long = 1;
while (i <= 3) {
readings.send(i * 10);
i = i + 1;
}
readings.close();
}
main(): int {
var select = async.Select<Event>();
select.receive(lines, (text: String): Event => Event.Line(text));
select.receive(readings, (value: long): Event => Event.Reading(value));
select.receive(stop, (unused: bool): Event => Event.Stop);
var threads = [async.Thread.spawn(read_lines), async.Thread.spawn(read_sensor)];
var text = 0;
var total: long = 0;
var running = true;
while (running) {
match (select.wait()) {
Ok(event) => {
match (event) {
Line(line) => {
text = text + 1;
}
Reading(value) => {
total = total + value;
}
Stop => {
running = false;
}
}
}
Err(error) => {
# Every channel is closed and empty (`stop` included, below).
running = false;
}
}
if (text == 2 && total == 60) {
stop.close();
}
}
for (var thread in threads) {
thread.join();
}
assert(text == 2 && total == 60, "every value arrived once");
return 0;
}
receive(channel, wrap)adds a channel.wrapruns on the waiting thread, with the value received.wait()waits until a channel has a value and returns it as anE, in aResult<E>.wait_for(milliseconds)gives up after that long, andtry_wait()does not wait at all.- The value is taken under that channel’s own lock, so no other receiver can take it between “ready” and “take”.
- When several channels have values, each wait starts just after the channel that fired last, so one busy channel cannot starve the others.
- A channel that is closed and empty is skipped. Once every channel is,
wait()returns an error (every channel is closed). A timeout’s error isthe time ran out. - A
Selectbelongs to the thread (or task) that made it; it is not[Shared]. In a task, waiting parks the task, and a cancel or the task’stimeoutends the wait.
A Select can also wait to send, like a case ch <- v in Go’s select.
send(channel, value, sent) adds a send arm: wait() fires it once
channel has room (a bounded channel that is full waits), and returns what
sent returns. Here a loop keeps receiving jobs while each result waits for
room in a bounded channel:
import async;
# What one wait did: a job arrived, or a result went out.
enum Step {
Job(value: long),
Sent,
}
const jobs = async.Channel<long>();
# Holds one result at a time, so sending can wait.
const results = async.Channel<long>(1);
feed() {
var i: long = 1;
while (i <= 5) {
jobs.send(i);
i = i + 1;
}
jobs.close();
}
main(): int {
var select = async.Select<Step>();
select.receive(jobs, (value: long): Step => Step.Job(value));
var feeder = async.Thread.spawn(feed);
var received = 0;
var waiting = 0;
var total: long = 0;
while (received < 5 || waiting > 0) {
match (select.wait()) {
Ok(step) => {
match (step) {
Job(value) => {
received = received + 1;
# Goes into `results` once it has room; until then
# the select keeps receiving jobs.
select.send(results, value * value, (): Step => Step.Sent);
waiting = waiting + 1;
}
Sent => {
waiting = waiting - 1;
# Take the result back out, so the next send has room.
total = total + results.receive().value();
}
}
}
Err(error) => {
panic(error.message);
}
}
}
feeder.join();
assert(total == 55, "every square was sent once");
return 0;
}
valuemust be a new value, as forChannel.send(move(x),copy(x), a constructor call, …). The select holds it and moves it into the channel only when that arm fires, under the channel’s lock, so it is never sent when another arm fires first.- A send arm fires once: after its value is sent it leaves the select, so
the next
wait()does not send it again. Add another withsendfor the next value. - A send arm whose channel is closed is skipped like a closed receive channel, and its value is dropped with the select.
The Select reference documents every method.
Mutexes and conditions
A Mutex<T> owns a value that one thread at a time can use. The way to
reach the value is a with block:
import async;
# Each balance is an Atomic, but moving money changes both, and no thread
# may see it in neither account. A Mutex makes the pair of updates one step.
[Shared]
struct Accounts {
const checking: async.Atomic<long>;
const savings: async.Atomic<long>;
construct(long checking = 0, long savings = 0) {
this.checking = async.Atomic<long>(checking);
this.savings = async.Atomic<long>(savings);
}
}
const accounts = async.Mutex<Accounts>(Accounts(1000, 0));
const bad_totals = async.Atomic<long>(0);
move_to_savings() {
var i = 0;
while (i < 1000) {
with (var a = accounts.lock()) {
a.checking.fetch_sub(1);
a.savings.fetch_add(1);
} # unlocked here, on every path out of the block
i = i + 1;
}
}
audit() {
var i = 0;
while (i < 1000) {
with (var a = accounts.lock()) {
if (a.checking.load() + a.savings.load() != 1000) {
bad_totals.fetch_add(1);
}
}
i = i + 1;
}
}
main(): int {
var mover = async.Thread.spawn(move_to_savings);
var auditor = async.Thread.spawn(audit);
mover.join();
auditor.join();
assert(bad_totals.load() == 0, "the auditor never saw money missing");
# try_lock does not wait: the body runs if the lock was free, and the
# else block runs if another thread held it.
with (var a = accounts.try_lock()) {
assert(a.savings.load() == 1000, "everything was moved");
} else {
assert(false, "no other thread holds the lock now");
}
return 0;
}
with (var v = mutex.lock()) { ... }waits until no other thread holds the lock, binds the value, and unlocks when the block ends by any path: the end of the body,return,breakorcontinue. Everything a thread did while it held the lock is visible to the next thread that takes it.try_lock()does not wait. Withwith (var v = mutex.try_lock()) { ... } else { ... }, the body runs if the lock was free and theelseblock runs if it was not.- A Mutex is not re-entrant: calling
lock()again on the thread that holds it panics instead of deadlocking.is_held()reports whether the calling thread holds it. - A
panicwhile a Mutex is held ends the program, so a Mutex is never left “poisoned”. - Taking a free lock is one atomic instruction, and a thread that finds it
held spins briefly before it sleeps. Locks sleep on the operating system’s
wait-on-address call (
futexon Linux,__ulock_waiton macOS,WaitOnAddresson Windows), as Rust’s do.
T is a struct type: a struct of your own, an Array<String>, or a
[Shared] struct whose Atomic fields must change as a unit.
Mutex<int> is an error (use async.Atomic<int>). A Mutex of values that are not
thread-safe is kept apart from the rest of the program by the compiler; see
What a lock body may do.
Mutex<T>(value)andreplace(value)take a new value: a constructor call, a string literal, a call that returns a fresh value,copy(x), ormove(x), never a variable something else still references. The caller hands its reference over, so nothing is copied.replace(value)swaps the whole value under the lock and returns the old one, which the caller then owns outright.
A value built in a local variable goes in with
move, which hands over the
variable’s reference instead of copying the value:
import async;
main(): int {
# Build the list here, then hand it to the Mutex: `move` gives up
# `lines`' reference, so nothing is copied and `lines` is empty after.
var lines = Array<String>();
lines.append("first");
var log = async.Mutex<Array<String>>(move(lines));
var line = "second";
with (var list = log.lock()) {
# `line` holds the only reference to its String, so it can move in.
list.append(move(line));
}
# Swap in a new list; the old one comes back without a copy either.
var next = Array<String>();
next.append("third");
var old = log.replace(move(next));
print($"{old.count}"); # 2
print(old[1]); # second
return 0;
}
A Condition<T> lets a thread that holds a Mutex sleep until another thread
changes the value. Get one with mutex.condition():
import async;
# A baker makes pies and eaters take them. The eaters sleep on a Condition
# until there is a pie, or until the bakery closes.
[Shared]
struct Bakery {
const pies: async.Atomic<long>;
const closed: async.Atomic<int>;
construct() {
pies = async.Atomic<long>(0);
closed = async.Atomic<int>(0);
}
}
const bakery = async.Mutex<Bakery>(Bakery());
const changed = bakery.condition();
const eaten = async.Atomic<long>(0);
eat() {
while (true) {
with (var b = bakery.lock()) {
# Wait in a loop: a wakeup can be spurious, and another eater
# may take the pie first.
while (b.pies.load() == 0 && b.closed.load() == 0) {
changed.wait(); # unlocks, sleeps, locks again
}
if (b.pies.load() == 0) {
return; # closed and nothing left; the block unlocks
}
b.pies.fetch_sub(1);
}
eaten.fetch_add(1);
}
}
main(): int {
var eaters = Array<async.Thread>();
var i = 0;
while (i < 3) {
eaters.append(async.Thread.spawn(eat));
i = i + 1;
}
i = 0;
while (i < 50) {
with (var b = bakery.lock()) {
b.pies.fetch_add(1);
changed.notify_one();
}
i = i + 1;
}
with (var b = bakery.lock()) {
b.closed.store(1);
changed.notify_all();
}
for (var eater in eaters) {
eater.join();
}
assert(eaten.load() == 50, "every pie was eaten once");
return 0;
}
wait()must run while the caller holds the Mutex, normally inside itswithblock. It unlocks, sleeps until notified, and locks again before returning. It panics if the caller does not hold the Mutex.- A thread can wake without a notification (a spurious wakeup), and another thread may get to the value first, so always wait in a loop that re-checks what it waits for.
wait_for(milliseconds)gives up after that long and returnsfalse.notify_one()wakes one waiting thread, andnotify_all()wakes them all.
wait_while(predicate) writes that loop for you: it waits while
predicate returns true for the locked value, asking again after every
wakeup, and returns with the Mutex still locked. wait_while_for(milliseconds, predicate) gives up after that long and returns false:
import async;
# A worker takes jobs from a queue, sleeping while it is empty.
const jobs = async.Mutex<Array<long>>(Array<long>());
const changed = jobs.condition();
const done = async.Atomic<long>(0);
work() {
var taken = 0;
while (taken < 5) {
with (var queue = jobs.lock()) {
# Waits while the predicate holds; spurious wakeups are handled.
# The predicate is given the locked value: it cannot capture it.
changed.wait_while((waiting: Array<long>): bool => waiting.count == 0);
done.fetch_add(queue.remove_at(0));
taken = taken + 1;
}
}
}
main(): int {
var worker = async.Thread.spawn(work);
var i: long = 1;
while (i <= 5) {
with (var queue = jobs.lock()) {
queue.append(i);
changed.notify_all();
}
i = i + 1;
}
worker.join();
assert(done.load() == 15, "every job was taken once");
with (var queue = jobs.lock()) {
# Gives up after 10 milliseconds: nothing is queued.
var ready = changed.wait_while_for(10, (waiting: Array<long>): bool => waiting.count == 0);
assert(!ready, "the time ran out");
}
return 0;
}
The predicate is given the locked value as its parameter, since a closure cannot capture part of a locked value. It must be a closure written at the call, and the compiler checks its body as it checks a lock body (see What a lock body may do): it cannot keep any part of the value, or bring in a value from outside.
main.pep:10:26: error: 'queue[0]' is part of the value locked by 'queue' and cannot outlive the lock
10 | saved.append(queue[0]);
| ^~~~~~~~
main.pep:9:27: note: locked here
9 | ready.wait_while((queue: Array<String>): bool => {
| ^~~~~
note: 'String' is not thread-safe, so no part of the locked value may be kept outside the block; take 'copy(queue[0])'
The Mutex reference documents Mutex,
MutexGuard and Condition.
What a lock body may do
A value that is not thread-safe counts its references plainly, so no thread
may hold a reference into a Mutex’s value while the Mutex is unlocked. The
compiler checks each with block over a lock so that none can:
import async;
# A Mutex of values that are not thread-safe: a list of Strings that
# several threads add to.
const names = async.Mutex<Array<String>>(Array<String>());
add_names(prefix: String) {
var i = 0;
while (i < 3) {
with (var list = names.lock()) {
list.append("worker"); # a new String: fine
list.append(prefix + "!"); # `+` returns a new String: fine
list.append(copy(prefix)); # a copy of an outside value: fine
var n = list.count; # a number leaves freely
if (list.contains(prefix)) { # comparing keeps nothing
n = n + 1;
}
}
i = i + 1;
}
}
# Part of the locked value leaves the block only as a copy.
first_name(): String {
with (var list = names.lock()) {
if (list.count > 0) {
return list[0].copy();
}
}
return "";
}
main(): int {
var a = async.Thread.spawn(() => { add_names("a"); });
var b = async.Thread.spawn(() => { add_names("b"); });
a.join();
b.join();
var first = first_name();
first.append(" (mine)"); # changing the copy leaves the list alone
with (var list = names.lock()) {
assert(list.count == 18, "every name was added");
assert(list[0] == "worker", "the locked String is unchanged");
}
# replace swaps in a new value and hands back the old one, which the
# caller then owns outright.
var old = names.replace(Array<String>());
assert(old.count == 18, "the old list");
# A thread's own value that is not thread-safe is reached through `with`.
var scratch = async.ThreadLocal<String>();
scratch.set("draft");
with (var text = scratch.enter()) {
text.append(" 2");
}
with (var text = scratch.enter()) {
assert(text == "draft 2", "this thread's value");
}
return 0;
}
Inside the block, every value that is not thread-safe is either part of
the locked value (the binding, its fields and elements, and what calls on
them return), from outside (a parameter, this, or a variable declared
before the block), or new. A new value may join either side; the other
two must never meet:
-
Moving in. A call, constructor or assignment that would link an outside value with the locked one is an error. Pass a new value instead:
copy(line), a literal, or the result of a call that returns a fresh value. A variable that holds the only reference to its value can hand it over withmove(line).main.pep:8:21: error: 'line' cannot move into the value locked by 'names' 8 | list.append(line); | ^~~~ main.pep:7:28: note: locked here 7 | with (var list = names.lock()) { | ^~~~ note: 'String' is not thread-safe, so the locked value cannot share it with the rest of the program; pass 'copy(line)' -
Moving out. Storing part of the locked value outside the block, or returning it from the block, is an error; take a copy (
copy(list[0]), orlist[0].copy(), which returns a fresh value).main.pep:8:17: error: 'list[0]' is part of the value locked by 'names' and cannot outlive the lock 8 | first = list[0]; | ^~~~~~~ -
Closures. A closure written in the block cannot capture part of the locked value, since a function value could carry it out.
-
Two locks. Values locked by two nested blocks stay apart as well.
-
Numbers pass freely, as do every thread-safe value and every new one.
Which calls link which values comes from what the called function does: the
compiler works out, for every function, whether it keeps an argument (as
append keeps its element) and whether its result shares anything with its
arguments. A result that shares nothing, such as that of +, substr,
copy() or a constructor, is fresh, and may cross in either direction.
The language server shows “Returns a fresh value” when hovering over such a
function. A call through a function value, a value an extern function
hands back, and a value read through raw memory that a [Shared] struct
holds are never fresh: something else may reference them. The runtime takes
the few values it hands out that nothing else references out of raw memory
with move(p[i]), a promise the compiler cannot check and allows only in the
runtime: a channel’s receive() (the value send handed over) and a Mutex’s
replace().
A function that returns a tuple has one such fact per element:
after var (fresh, same) = split(line);, fresh may move into a locked value
while same, which split returned from its argument, may not.
The guard a lock() returns can only be the scope of a with header: storing
it in a variable or field is an error, so no lock outlives its block.
Two attributes carry these rules, and code outside the runtime may use them too:
[Isolated]on a struct makes it a guard: it can only be the scope of awithheader, and the block is checked as above.MutexGuard<T>andThreadLocalGuard<T>are[Isolated].[Move]on a parameter (construct([Move] T value)) requires a new value for it at every call: a constructor call, a literal, a fresh result,copy(x),move(x), or any thread-safe value. The caller hands its reference over, so the function receives it without a count step.
Checking confinement at run time
Raw memory (__ptr<T>) and extern code are promises the compiler cannot
check, so a program that uses them can still keep a reference into a locked
value. Set the environment variable PEP_CHECK_SYNC when running a program
to check at every unlock that nothing outside the locked value refers into
it:
$ PEP_CHECK_SYNC=1 ./main
PEP_CHECK_SYNC: an object in the value locked by a 'Mutex<Box>' is still referenced from outside it
The check copies the locked value to count the references inside it, so it
costs time and memory in proportion to the value’s size at each unlock; it
is off unless the variable is set. It checks a Mutex<T> whose T is not
thread-safe and can be copied.
Reader/writer locks
An RwLock<T> lets many threads read a value at once, or one thread
write it:
import async;
# A price list that many threads read and one thread now and then updates.
# A writer changes both prices together, so a reader never sees one updated
# and the other not.
[Shared]
struct Prices {
const coffee: async.Atomic<long>;
const tea: async.Atomic<long>;
construct() {
coffee = async.Atomic<long>(300);
tea = async.Atomic<long>(250);
}
}
const prices = async.RwLock<Prices>(Prices());
const mismatches = async.Atomic<long>(0);
check_prices() {
var i = 0;
while (i < 1000) {
with (var p = prices.read()) { # many readers at once
if (p.coffee.load() - p.tea.load() != 50) {
mismatches.fetch_add(1);
}
}
i = i + 1;
}
}
raise_prices() {
var i = 0;
while (i < 100) {
with (var p = prices.write()) { # no reader sees the middle
p.coffee.fetch_add(10);
p.tea.fetch_add(10);
}
i = i + 1;
}
}
main(): int {
var threads = Array<async.Thread>();
var i = 0;
while (i < 3) {
threads.append(async.Thread.spawn(check_prices));
i = i + 1;
}
threads.append(async.Thread.spawn(raise_prices));
for (var thread in threads) {
thread.join();
}
assert(mismatches.load() == 0, "no reader saw a half-done update");
with (var p = prices.read()) {
assert(p.coffee.load() == 1300, "every raise landed");
}
return 0;
}
with (var v = lock.read()) { ... }waits until no writer holds or waits for the lock; any number of threads may read at the same time.with (var v = lock.write()) { ... }waits until no other thread holds the lock at all.try_read()andtry_write()do not wait: theelseblock runs when the lock is not free.replace(value)swaps the whole value under the write lock and returns the old one.- Writers come first: while a writer waits, new readers wait behind it, so readers cannot keep it out forever. A thread that reads must therefore not ask to read again before its block ends, and asking to write while holding the lock panics instead of deadlocking.
T must be thread-safe, as for Atomic<T>: typically a [Shared] struct
whose Atomic fields a writer changes together. Readers that hold the
value at once each copy a reference to it, so a value that is not
thread-safe, such as an Array<String>, would have its reference counts
changed by several threads at a time; it is a compile error at the type
argument. Put such a value in a Mutex<T>. When readers only need the
latest value and writers replace it whole, an Atomic<T> holding a
[Shared] struct does the same job without a lock.
Semaphores, barriers and latches
Three types coordinate threads by counting. Each is [Shared], so keep it
in a module-scope variable or pass it to threads:
- A
Semaphore(permits)lets at mostpermitsthreads hold a permit at once.acquire()waits for a free permit and takes it,try_acquire()takes one only if one is free, andrelease()gives one back.with (semaphore.permit()) { ... }holds a permit for the block and gives it back on every path out. - A
Barrier(count)makes groups ofcountthreads meet:wait()sleeps untilcountthreads have called it, then all go on together. It returnstrueon exactly one of them, the leader, and resets for the next round. - A
Latch(count)opens oncecount_down()has been calledcounttimes.wait()sleeps until it is open, andis_ready()asks without waiting. It is one-shot: counting down an open latch panics.
import async;
const WORKERS = 4;
# At most two workers use the printer at once.
const printer = async.Semaphore(2);
const printing = async.Atomic<long>(0);
const most_printing = async.Atomic<long>(0);
# Every worker finishes a step before any starts the next.
const step_done = async.Barrier(WORKERS);
const steps_led = async.Atomic<long>(0);
# The main thread waits until every worker has set up.
const ready = async.Latch(WORKERS);
work() {
ready.count_down();
var step = 0;
while (step < 3) {
with (printer.permit()) {
var now = printing.fetch_add(1) + 1;
if (now > most_printing.load()) {
most_printing.store(now);
}
printing.fetch_sub(1);
} # the permit is given back here
if (step_done.wait()) {
steps_led.fetch_add(1); # one leader per step
}
step = step + 1;
}
}
main(): int {
var workers = Array<async.Thread>();
var i = 0;
while (i < WORKERS) {
workers.append(async.Thread.spawn(work));
i = i + 1;
}
ready.wait();
assert(ready.is_ready(), "every worker counted down");
for (var worker in workers) {
worker.join();
}
assert(most_printing.load() <= 2, "the semaphore let two in at most");
assert(steps_led.load() == 3, "each step had exactly one leader");
return 0;
}
Each is a Mutex over its counts with a Condition to sleep on, so in a
task their waits park the task,
not its worker.
Once and Lazy
Once runs a function exactly once, however many threads ask, and Lazy<T>
builds a value the first time a thread asks for it:
import async;
[Shared]
struct Settings {
const workers: long;
construct(this.workers = 1) {}
}
const builds = async.Atomic<long>(0);
load_settings(): Settings {
builds.fetch_add(1);
return Settings(async.logical_cpus() as long);
}
# Built by the first thread that calls get(), not before main runs.
const settings = async.Lazy<Settings>(load_settings);
# Runs initialize() once, however many threads call it.
const setup = async.Once();
const setups = async.Atomic<long>(0);
initialize() {
setups.fetch_add(1);
}
work() {
setup.call(initialize);
assert(settings.get().workers >= 1, "every thread sees the settings");
}
main(): int {
assert(!settings.is_built(), "nothing is built before first use");
var workers = Array<async.Thread>();
var i = 0;
while (i < 4) {
workers.append(async.Thread.spawn(work));
i = i + 1;
}
for (var worker in workers) {
worker.join();
}
assert(builds.load() == 1, "the settings were built once");
assert(setups.load() == 1, "initialize ran once");
assert(setup.is_done(), "done");
return 0;
}
once.call(body)runsbodyif no call has run one yet. Threads that call it while the body runs wait for it, and every caller sees what the body did.is_done()reports whether it has run.Lazy<T>(initializer)takes ashared () => Tfunction. The firstget()runs it; later calls, from any thread, return the same value.is_built()reports whether it has run.- A
Lazy<T>is the usual way to give a module-scope variable a value that is expensive to build, or that cannot be built beforemainruns. ItsTmust be thread-safe, since every thread gets the same value.
The Once reference has the details.
Limitations
Lazy<T>holds only thread-safe values.- A value received from a channel moves into a locked value directly
(
list.append(jobs.receive().value())), but not through a variable declared outside the block: such a variable is an outside value, whatever it holds, so it needscopy, ormovewhen it holds the only reference (list.append(move(job))). - There is no reader-writer lock, and no barrier or semaphore. A Mutex is not re-entrant.
- The value a
Select’swait()returns is not a new value for a lock body, sincewrapis a function value the compiler cannot follow;copyit to move it into a locked value. - Deadlocks are not detected, except locking a Mutex twice on one thread, which panics.