Lake

State transitions

There are three primary control-flow operations in Lake: self(args) for in-place state changes, machine(args) for spawning a new process, and calling a pid to send a message. All three look like calls; what they do depends on the callee.

§self(args) — internal transition

self(args) re-enters the current machine's branch dispatch with new arguments. It does not allocate, does not return, and does not push a stack frame.

@rt(rt_write)

counter is {
  0 i64 -> { rt_write(1 "done\n" 5) }
  n i64 -> { self(n-1) }
}

main is {
  _ -> { counter(5) }
}

self(n-1) does not recurse on the call stack — it transitions the process to a new state and the scheduler re-enters the machine. Each self consumes one reduction.

Argument expressions are arbitrary:

lake
sum is {
  0 i64 acc i64 -> { rt_write(1 "done\n" 5) }
  n i64 acc i64 -> { self(n-1 acc+n) }
}

§machine(args) — spawn

Calling any machine other than self spawns a new concurrent process.

@rt(rt_write)

worker is {
  0 i64 -> { rt_write(1 ".\n" 2) }
  n i64 -> { self(n-1) }
}

main is {
  _ -> {
    worker(500)
    worker(500)
    worker(500)
  }
}

Each worker(...) allocates a process, registers it with the scheduler, and returns a pid immediately. The spawning process never blocks on the spawnee — they continue in parallel.

The pid returned by a spawn can be discarded (as above) or stored:

lake
let p pid = worker(500)

§Cooperative scheduling

All spawned processes share one OS thread (in this MVP). The scheduler is round-robin with a fixed quantum of 256 reductions per process. A reduction is roughly one CPS block — a self, a wait, a runtime call, the entry into a branch.

This means concurrent processes make interleaved progress without preemption. There are no priorities and no fairness guarantees beyond round-robin.

§Sending a message — pid(args)

If the callee is a pid, the call is a send, not a spawn.

lake
let p pid = receiver()
p(42)                     // queue 42 in receiver's mailbox

Messages are placed in the receiver's mailbox (a 256-slot ring buffer). If the receiver is currently in a wait { ... }, sending wakes it up and moves it back into the active queue.

The send itself returns immediately and is non-blocking. If the mailbox is full, the message is dropped. (This will become configurable.)

§Runtime calls — inlined

Calls to names declared with @rt(...) are not state transitions — they are inlined and run synchronously in the current process.

lake
rt_write(1 "hello\n" 6)   // direct call, no scheduling

See Directives for the available runtime functions.