Threads
std::thread is an ordinary library. spawn starts an OS thread, a handle joins it, a mutex<T> sleeps, once runs a function once. There is no type named future and no function named async. A value comes back through a mutex or a task<T>.
WASI preview 1 has no OS threads. std::thread is not part of a --target-os wasi build.
use std::thread;
#[atomic]
class Counter
{
atomic<int32> $hits;
constructor()
{
$this->hits = atomic<int32>(0);
}
function bump() : void
{
$this->hits->add(1);
}
function get() : int32
{
return $this->hits->load();
}
}
Counter $c = Counter();
thread::handle $w = guard thread::spawn(function() : void {
$c->bump();
}) else ($e) {
die($e->message());
}
$c->bump();
$w->join();
echo $c->get(); // 2spawn takes function<void()> and returns result<handle, error>. guard it, or you have not started a thread. The class is marked #[atomic], and the field is an atomic<int32>, because the handle is copied onto another thread and both sides write the same word. An unmarked class with a plain int32 would compile. It would also be a data race.
use std::thread; binds the prefix, so you write thread::spawn. Same shape as use std::math; and math::sqrt.
Join
Last drop of the handle joins. Forgetting the handle is still a join, not a leak of a running thread:
use std::thread;
#[atomic]
class Flag
{
atomic<int32> $done;
constructor()
{
$this->done = atomic<int32>(0);
}
}
Flag $f = Flag();
{
thread::handle $w = guard thread::spawn(function() : void {
$f->done->store(1);
}) else ($e) {
die($e->message());
}
}
echo $f->done->load(); // 1$w dropped at the inner brace, so the echo cannot run until the worker has stored. join() is what you write when you want that moment named.
detach() is how you decline. After it, dropping the handle does nothing, and nobody waits:
use std::thread;
thread::handle $w = guard thread::spawn(function() : void {
}) else ($e) {
die($e->message());
}
$w->detach();A second join or detach on the same handle is a no-op. The first one wins.
Tasks
spawn(function<T()>) is the other overload. It returns result<task<T>, error>. wait() joins, then copies the slot:
use std::thread;
thread::task<int32> $t = guard thread::spawn(function() : int32 {
return 40 + 2;
}) else ($e) {
die($e->message());
}
echo $t->wait(); // 42There is no task<void>. That is a handle. Failure of spawn is "the thread never started". What $work itself returns is T. A failing computation is a T that is itself a result, not a second error channel here.
&name dest-types as function<R(P...)>, so a named function needs no wrapper:
use std::thread;
function answer() : int32
{
return 40 + 2;
}
thread::task<int32> $t = guard thread::spawn(&answer) else ($e) {
die($e->message());
}
echo $t->wait(); // 42Dropping a task you never wait()ed still waits. The destructor joins before the slot dies, so an owning T the worker is still writing cannot race that drop:
use std::thread;
thread::task<string> $t = guard thread::spawn(function() : string {
thread::sleep(20);
return 'owned';
}) else ($e) {
die($e->message());
}That program prints nothing and still joins. The string is destroyed after the worker has seated it, not while it is being written.
Channels
task<T> is one shot. For several values, produce starts a worker with a sender as a parameter. When the worker returns the sender drops, the stream closes, and recv returns null:
use std::thread;
thread::receiver<int32> $rx = guard thread::produce(function(thread::sender<int32> $tx) : void {
$tx->send(1);
$tx->send(2);
}) else ($e) {
die($e->message());
}
while (true) {
int32 $v = guard $rx->recv() else {
break;
}
echo $v;
}channel<T>() is the pair of ends when you want to send from more than one place, or from the parent as well. tx() and rx() take the ends off the pair, so bind them to locals before spawning: a leftover sender on $ch would keep recv waiting after the worker had already dropped. Copying a sender is another retain of the same object, which is how several producers share one stream. Last drop of the sender closes; close() is the named form of the same thing. send returns false when no receiver is left, and the value is destroyed with that call.
use std::thread;
thread::channel<int32> $ch = thread::channel<int32>();
thread::sender<int32> $tx = $ch->tx();
thread::receiver<int32> $rx = $ch->rx();
thread::handle $w = guard thread::spawn(function[mv $tx]() : void {
$tx->send(1);
$tx->send(2);
}) else ($e) {
die($e->message());
}
$w->join();
while (true) {
int32 $v = guard $rx->recv() else {
break;
}
echo $v;
}channel<T>($cap) is bounded. send waits while the queue is full. Capacity 0 is refused rather than becoming a rendezvous.
try_recv() answers a poll<T>: .item($v), .empty, or .closed. Blocking recv never returns empty, so T? is enough there.
Locks
mutex<T> keeps T inside the object. There is no way to name it without going through lock(). $held->value is a T&: a place, a method receiver, an index. An array behind a mutex is still an array:
use std::thread;
thread::mutex<array<int32>> $m = thread::mutex<array<int32>>(array<int32>());
{
thread::locked<array<int32>> $held = $m->lock();
$held->value->push(7);
$held->value->push(9);
echo $held->value[0]; // 7
}
thread::locked<array<int32>> $held = $m->lock();
echo $held->value->count(); // 2A scalar is the same borrow, just assigned:
use std::thread;
thread::mutex<int32> $m = thread::mutex<int32>(0);
{
thread::locked<int32> $g = $m->lock();
$g->value = 7;
}
thread::locked<int32> $g = $m->lock();
echo $g->value; // 7The inner block unlocks when $g drops. Unlock is the destructor. locked<T> is #[unique] so it cannot be copied: that would unlock twice.
Why would lock() be const? Because the mutex is how you write T. The object you called it on stays const. try_lock() is the same. A const method can keep a cache:
use std::thread;
class Hits
{
private thread::mutex<int32> $n = thread::mutex<int32>(0);
public const function bump() : int32
{
thread::locked<int32> $g = $this->n->lock();
$g->value = $g->value + 1;
return $g->value;
}
}
Hits $h = Hits();
echo $h->bump(); // 1Copying the address out of $held->value and reading it after the guard dies is the same class of rule as a slice outliving its array. The type will not stop you. The lock will not be held.
Sharing the mutex across threads is the point. mutex is itself #[atomic], so the handle can cross:
use std::thread;
thread::mutex<int32> $m = thread::mutex<int32>(0);
thread::handle $w = guard thread::spawn(function() : void {
thread::locked<int32> $g = $m->lock();
$g->value = $g->value + 1;
}) else ($e) {
die($e->message());
}
{
thread::locked<int32> $g = $m->lock();
$g->value = $g->value + 1;
}
$w->join();
thread::locked<int32> $done = $m->lock();
echo $done->value; // 2try_lock answers locked<T>?. null means busy. Anything else from the platform is a die, the same as lock() failing:
use std::thread;
thread::mutex<int32> $m = thread::mutex<int32>(1);
thread::locked<int32> $held = $m->lock();
thread::locked<int32>? $again = $m->try_lock();
echo $again == null; // 1once
Every caller waits, losers included. $work runs with the gate dropped, so it may take other locks:
use std::thread;
#[atomic]
class Cell
{
int32 $n;
constructor()
{
$this->n = 0;
}
function bump() : void
{
$this->n = $this->n + 1;
}
}
Cell $cell = Cell();
thread::once $o = thread::once();
$o->run(function() : void { $cell->bump(); });
$o->run(function() : void { $cell->bump(); });
echo $cell->n; // 1It must not call run on this same once. That waits for itself.
Conditions
condition is the primitive a channel is built on. wait takes the guard, releases the mutex, sleeps, and reacquires. A wakeup is not a promise that the predicate is true. Loop on it:
use std::thread;
thread::mutex<int32> $m = thread::mutex<int32>(0);
thread::condition $cv = thread::condition();
thread::handle $w = guard thread::spawn(function() : void {
thread::locked<int32> $g = $m->lock();
while ($g->value == 0) {
$cv->wait($g);
}
}) else ($e) {
die($e->message());
}
{
thread::locked<int32> $g = $m->lock();
$g->value = 1;
}
$cv->notify();
$w->join();Calling wait without holding is the same class of rule as reading $held->value after the guard dies. The type will not stop you.
Sharing
You have a blob. A second thread should look at it. You do not want to copy the bytes, and you do not want to mv them away so this thread cannot name them.
There is no function[&$blob](). Capture copies the variable. For a class, that variable is a handle: capturing it retains. The payload stays on the heap. Both threads name the same object:
use std::thread;
#[atomic]
class Blob
{
array<int32> $words;
const function sum() : int32
{
int32 $n = 0;
int32 $i = 0;
while ($i < $this->words->count()) {
$n = $n + $this->words[$i];
$i = $i + 1;
}
return $n;
}
}
Blob $blob = Blob([1, 2, 3, 4]);
thread::handle $w = guard thread::spawn(function() : void {
echo $blob->sum();
}) else ($e) {
die($e->message());
}
$w->join();$blob is still alive on this thread after spawn. Nothing was moved. #[atomic] is the count, not the fields. Two threads writing $words without a lock still race. Read-only analysis while the parent does not mutate is the contract you are making. Echo will not check it. There is no race detector.
An unmarked class handle copied onto another thread compiles. It is also a data race on the count. Mark it.
A struct copies. Capture a borrow instead, and join before the struct dies:
use std::thread;
struct Sample
{
int32 $a;
int32 $b;
}
Sample $blob = Sample(20, 22);
const Sample& $view = &$blob;
thread::handle $w = guard thread::spawn(function() : void {
echo $view->a + $view->b;
}) else ($e) {
die($e->message());
}
$w->join();The type will not keep $view from outliving $blob. Same class of rule as a slice outliving its array. Join first.
A string copy is fine. str::buf is already marked. A closure environment is minted marked, which is why a captured local can be spawned at all.
A #[unique] value still needs function[mv $x](): there is nothing to copy. atomic<T> is unique too. Put the word on a marked class, as the first example on this page does, and capture the handle. Or mv it into the closure.
The rest
thread::id is an identifier, not a handle. You cannot join yourself. current() is this thread. == compares two:
use std::thread;
thread::id $here = thread::current();
echo $here == thread::current(); // 1yield() asks the scheduler to run someone else. sleep($ms) is std::time::sleep in milliseconds. concurrency() is how many processors the platform reports, and 1 when it declines:
use std::thread;
thread::yield();
thread::sleep(1);
echo thread::concurrency() >= 1; // 1error is why a thread could not be started: an errno and the sentence you print. exhausted() is EAGAIN, the platform saying it will not give you another one.
use std::thread;
function start() : result<thread::handle, thread::error>
{
return thread::spawn(function() : void {});
}
thread::handle $w = guard start() else ($e) {
die($e->message());
}
$w->join();What it is not
No async. No future. No select. No rwlock. No thread_local. What is missing keeps the list.
This is OS threads, a sleeping lock, a condition, and a queue. That is the whole of it, and it is enough to write the programs that need a second core.
Next
- Atomics for
#[atomic]andatomic<T>. - Closures for what a
function<void()>actually captures. - Results for
guardandresult<T, E>. - What is missing for the rest of the concurrency surface.