Threads
A program can run work on several threads. The compiler checks that safe code never has a data race: a value moves to another thread only if it is safe there, and threads share memory only through types made for it.
Spawning and joining
Thread.spawn starts a thread running a move closure, which owns what it captures. It returns a Thread<T>, and join waits for the thread and gives back its result.
fn main() {
var numbers = Vector<u64>.create();
for (i in 1..101u64) {
numbers.push(i)!;
}
let worker = Thread.spawn(move () => {
var sum: u64 = 0;
for (n in numbers[..]) {
sum += n;
}
return sum;
})!;
Console.writeLine(worker.join());
}5050- The vector is moved into the thread. The spawner cannot use it afterwards, so nothing is shared.
- Spawning can fail (no memory, no thread):
Thread.spawnreturns aResult, here unwrapped with!. - A
Thread<T>that is dropped withoutjoinis joined then: every thread ends beforemainreturns. - A panic in any thread ends the whole program.
What may cross: Send
A value may move to another thread if it is Send. Plain data, strings, vectors and boxes are. References are not, nor anything that holds one, nor raw pointers. The compiler infers it, and reports N0334 where it fails:
fn main() {
var n: u64 = 5;
let address = borrow n as *u64;
let t = Thread.spawn(move () => address.isNull())!;
}main.nova:4:26: error[N0334]: this thread's closure captures 'address', a '*u64', which is not Send: it cannot move to another thread (type-system.md §38.1)
|
4 | let t = Thread.spawn(move () => address.isNull())!;
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^Send and Share can also bound a generic function: fn run<T: Send>(value: T).
Sharing: Arc, Mutex and atomics
To share a value, give each thread an owner of it: Arc<T> counts its owners, and the last one to go frees it. An Arc gives only shared access, so a value that threads change goes in a Mutex, whose guard lends it to one thread at a time.
struct Tally {
count: u64;
}
fn main() {
let tally = Arc<Mutex<Tally>>.create(Mutex<Tally>.create(Tally { count: 0 })!)!;
var threads = Vector<Thread<void>>.create();
for (t in 0..4u64) {
let mine = tally.clone();
threads.push(Thread.spawn(move () => {
for (k in 0..250u64) {
var guard = mine.get().lock();
let data = guard.data();
data.count += 1;
}
})!)!;
}
threads.clear(); // dropping the threads joins them
var guard = tally.get().lock();
Console.writeLine(guard.data().count);
}1000For a counter or a flag, an atomic is simpler: AtomicU64, AtomicU32, AtomicUSize and AtomicBool change through a shared reference with load, store, swap, compareExchange, fetchAdd and fetchSub.
Those are sequentially consistent: every thread sees every atomic operation in one order. That is the simple rule, and on x86-64 it costs a full barrier on each store. When one thread publishes data and another reads it, release and acquire are enough, and storeWith and loadWith say so:
struct Message {
data: AtomicU64;
ready: AtomicBool;
}
fn main() {
let m = Message { data: AtomicU64.create(0), ready: AtomicBool.create(false) };
var got: u64 = 0;
Thread.both(() => {
while (!m.ready.loadWith(LoadOrdering.Acquire)) {}
got = m.data.loadWith(LoadOrdering.Relaxed);
}, () => {
m.data.storeWith(42, StoreOrdering.Relaxed);
m.ready.storeWith(true, StoreOrdering.Release);
});
Console.writeLine(got);
}42The release store makes the data written before it visible to a thread whose acquire load reads true. The …With methods take C11's orderings (Relaxed, Acquire, Release, AcquireRelease, SequentiallyConsistent). A weaker ordering never makes a data race, but a wrong one can make a thread read a stale value, and neither novac run --interp nor ThreadSanitizer will notice. Use the plain methods unless a measurement says otherwise. benchmarks/atomics has one: a ring between two threads, 5 to 9 times faster on x86-64 with release and acquire.
Borrowing: Thread.both
A spawned thread cannot borrow, since it may outlive what it borrowed. Thread.both(a, b) can: it runs a on a new thread and b on this one, and returns only when both have finished. So the two closures may use the caller's data, for example the two halves of a slice:
fn sum(values: Slice<u64>) -> u64 {
var total: u64 = 0;
for (v in values) {
total = total + v;
}
return total;
}
fn main() {
let values: [u64; 6] = [1, 2, 3, 4, 5, 6];
let halves = values[..].splitAt(3);
var low: u64 = 0;
var high: u64 = 0;
Thread.both(() => { low = sum(halves.left); }, () => { high = sum(halves.right); });
Console.writeLine(low + high);
}21The usual borrowing rules hold between the two closures: both may read a value, but if one changes it the other may not use it. A value both read must be Share, and one that a closure changes must be Send; a reference counts as its referent here. splitAtMut gives two halves that each closure may change. If a thread cannot be started, both runs a and then b on this thread, so it never fails.
Where threads run
Threads are part of the hosted profile: Linux, macOS, Windows and CosmoOS user programs. Kernel code uses the kernel's own locks instead (Kernel code). The interpreter (novac run --interp) runs threads one at a time, in a fixed order, so its output is always the same.