Learn
Concurrency
Two shapes, and they answer different questions.
parallel runs a fixed set of jobs and joins; concurrent
runs tasks that talk over channels, and 1,000,000 of them run in
22 s on a compiled build. Both are deterministic: the same program
prints the same bytes on every run and on every engine, which is the property
the whole gate suite is built on.
parallel and spawn
fn main() / {io, async} {
var log = ""
parallel {
spawn { log += "a" }
spawn { log += "b" }
}
print(log) // "ab", every time, on every engine
}
The block records which spawn sites fired, in order, and the
bodies run afterwards — which is what makes the order deterministic and the
scope joined. A spawn body sees the enclosing scope rather
than capturing it, which is the opposite of how a closure behaves and is
documented as such.
A compiled build uses several cores here only where that cannot be observed
([E-NOT-PARALLEL]): every task has to be v = e with
e pure, each v its own and read by no other task. Four
fib(36) tasks run on four workers; anything that could tell the
difference runs in order instead. The language does not have a rule saying
“this is parallel” — it has a rule saying parallelism may never
change an answer.
concurrent, go and channels
fn main() / {io, async} {
let ch = chan_new(0) // unbuffered: a send waits for a receive
concurrent {
go {
for i in 0..1000 {
chan_send(ch, i)
}
chan_close(ch)
}
go {
var total = 0
while true {
match chan_recv(ch) {
Some(v) -> total += v
None -> break // drained, and closed
}
}
print("{total}")
}
}
}
A task sees its surroundings by value, as a closure does, and may not write a
binding outside it or reach a ref from outside: tasks share
nothing mutable. chan_select(chs) gives the index of the
first channel a receive would not wait on, and waits until there is one.
Why the ordering is fixed, and what it cost to make it cheap
A task runs alone until it ends or a channel operation has to wait; then the
next runnable task takes over. A send wakes the task that has waited
longest for room, a receive wakes the one that has waited longest for a value,
and closing wakes everything waiting on that channel
([E-SCHEDULE]). That is a total order, so the output is the same on
every run.
It was also quadratic until recently: the scheduler resumed every live task every round, so 40,000 tasks took 199 s. A wait queue per channel and per side — so a send wakes a receiver rather than another sender — made a round cost what is runnable rather than what is alive, and 1,000,000 tasks now run in 22 s. The measurement, and the fix that looked obvious and was worth nothing →
The interpreter stops near 285,000 tasks and is allowed to: an
interpreter frame costs about 10 KB where a compiled one costs 30 bytes, and
[E-RECURSION-DEPTH] guarantees 10,000 nested calls to every frame
it reserves.
Actors: shared state without a lock
actor Counter(start: i64) {
var n: i64 = start
fn bump(inout self) { self.n += 1 }
fn read(inout self) -> i64 { self.n }
}
An actor is the one thing tasks may share. Every copy of the handle reaches
the same state, which only its methods reach. A method may not perform
async, so it runs to its end before another task runs —
exclusive without a lock, by the effect row rather than by a
mutex somebody has to remember to take.
Many conversations on one thread
net_wait(handles, ms) returns the handles that can be read
without waiting, which is what lets one thread hold many connections. Every
other network call blocks, and a task does not carry net, so
without it a server serves one client at a time.
Data-parallel methods
let doubled = xs.par_map(|v| v * 2)
let evens = xs.par_filter(|v| v % 2 == 0)
Both keep input order, and both require the closure to be pure — which the
effect system can prove rather than assume. A closure that prints will
not typecheck here. Order-preserving means a par_map is
interchangeable with a map in every observable way, including on
one core, so the number of cores is never part of an answer. Measured at 3.68×
on eight cores over boxed strings.
Testing a schedule other than the one you got
A deterministic scheduler means the order you see is the order you always
see, which is a problem of its own: a race that another order would expose
never runs. grasm test --schedules N reruns a test under
N other orders, each named by a seed that
--schedule-seed replays, so a failure found that way is a failure
you can get back.