Grasm v1.0.0 Get started

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.

What is missing, and it is half of a north-star goal. A task refuses every effect: it cannot read a file, open a socket or print. 1,000,000 tasks run, which is the count G6 asks for, and uncolored is the word in the goal that is not met — concurrency that cannot do I/O is not the thing the goal is about. There is also no async/await and no timer library. Where that sits on the scorecard →