Skip to content

Concurrency

StarLang has built-in cooperative multitasking. Lightweight tasks run on a single-threaded VM with a round-robin scheduler — no OS threads, no locks, no data races.

go

The go keyword spawns a new task from a function call:

fn worker():void {
    console.log("running in background")
}

fn main():void {
    go worker()
    runtime.keepAlive()
}

Arguments are passed normally:

fn adder(a: number, b: number):void {
    console.log(a + b)
}

fn main():void {
    go adder(10, 20)
    runtime.keepAlive()
}

runtime.keepAlive() enters the event loop and waits for all spawned tasks to complete. Without it, the program exits immediately after main returns.

Restrictions

  • go only works with user-defined functions. Using go with a native function is a compile error.
  • Spawned tasks cannot return values directly. Use channels or globals to communicate results.

Channels

Channels are the primary communication mechanism between tasks.

Creating Channels

var ch: chan = chan(4)    # buffered channel, capacity 4
var ch0: chan = chan(0)   # unbuffered channel (synchronous)

send and recv

ch.send(42)              # put a value into the channel
var v: number = ch.recv()  # take a value from the channel

Buffered Channels

A buffered channel has internal storage. send succeeds immediately if the buffer has space. recv succeeds immediately if the buffer has data.

var ch: chan = chan(2)
ch.send(10)    # buffer: [10]
ch.send(20)    # buffer: [10, 20]
var a: number = ch.recv()   # a = 10, buffer: [20]
var b: number = ch.recv()   # b = 20, buffer: []

When the buffer is full, send parks the current task until another task recvs. When the buffer is empty, recv parks the current task until another task sends.

Unbuffered Channels

An unbuffered channel (chan(0)) has no internal storage. Every send blocks until a receiver is ready, and every recv blocks until a sender is ready. This is a synchronous rendezvous — the value transfers directly from sender to receiver.

fn producer(ch: chan):void {
    ch.send(42)
}

fn consumer(ch: chan):void {
    var v: number = ch.recv()
    assert(v == 42)
}

fn main():void {
    var ch: chan = chan(0)
    go producer(ch)
    go consumer(ch)
    runtime.keepAlive()
}

Producer/Consumer Pattern

package main

var result: number = 0

fn producer(ch: chan):void {
    ch.send(42)
    ch.send(100)
}

fn consumer(ch: chan):void {
    var a: number = ch.recv()
    var b: number = ch.recv()
    result = a + b
}

fn main():void {
    var ch: chan = chan(2)
    go producer(ch)
    go consumer(ch)
    runtime.keepAlive()
    assert(result == 142)
}

select

select waits on multiple channels simultaneously. The first ready case executes.

select {
    ch1.recv() -> val {
        console.log("received from ch1")
    }
    ch2.recv() -> val {
        console.log("received from ch2")
    }
}

Recv Cases

Bind the received value with ->:

select {
    ch.recv() -> msg {
        console.log(msg)
    }
}

If the received value is not needed, omit the binding:

select {
    ch.recv() {
        console.log("got something")
    }
}

Send Cases

select {
    ch.send(42) {
        console.log("sent")
    }
}

default

The default case runs when no channel is ready. Without default, the task yields and retries until a case becomes ready.

select {
    ch.recv() -> val {
        result = val
    }
    default {
        console.log("nothing ready, moving on")
    }
}

default must be the last case in the select block.

Multiple Cases

Cases are checked in order. The first ready case wins:

var ch1: chan = chan(1)
var ch2: chan = chan(1)
ch2.send(88)

select {
    ch1.recv() -> val {
        console.log("from ch1")
    }
    ch2.recv() -> val {
        console.log("from ch2")   # this executes
    }
}

select with Goroutines

fn ping(ch: chan):void {
    ch.send(1)
}

fn pong(ch: chan):void {
    ch.send(2)
}

fn main():void {
    var ch1: chan = chan(0)
    var ch2: chan = chan(0)
    go ping(ch1)

    select {
        ch1.recv() -> v {
            console.log("ping won")
        }
        ch2.recv() -> v {
            console.log("pong won")
        }
    }
}

runtime.yield()

Manually yield the current task's time slice. Other ready tasks get to run before this task resumes.

fn step_a():void {
    console.log("a1")
    runtime.yield()
    console.log("a2")
}

fn step_b():void {
    console.log("b1")
    runtime.yield()
    console.log("b2")
}

fn main():void {
    go step_a()
    go step_b()
    runtime.keepAlive()
}

Output: a1, b1, a2, b2 — tasks interleave at yield points.

runtime.setQuantum(n)

Set the instruction budget per task before the scheduler switches. Default is 4096 instructions on PC, 512 on ESP32.

runtime.setQuantum(128)    # switch tasks every 128 instructions

Lower values give finer interleaving (fairer scheduling). Higher values reduce context switch overhead. The value must be a positive number.

Scheduling Model

StarLang uses cooperative multitasking with an instruction budget:

  • Each task runs for up to quantum VM instructions before yielding
  • The scheduler picks the next READY task in round-robin order
  • Tasks can also yield explicitly (runtime.yield()) or implicitly (channel park)
  • There are no OS threads — everything runs on a single thread

Task States

State Description
READY Eligible to run
RUNNING Currently executing
PARKED Waiting — on a channel, a full queue.push, a blocking native, or a full event ring
DONE Finished execution
FAILED Terminated with error

Task Lifecycle

  1. go fn() creates a new task in READY state
  2. Scheduler picks the next READY task, sets it to RUNNING
  3. Task runs until: quantum exhausted (-> READY), yield (-> READY), blocked (-> PARKED), or returns (-> DONE)
  4. Scheduler picks next READY task
  5. Parked tasks wake when their blocking operation can proceed (-> READY)

Backpressure (parking instead of dropping)

Parking is the single mechanism behind StarLang's zero-loss guarantee. Whenever a producer outruns a consumer, the producer parks rather than spinning or dropping:

Operation Parks when Woken by
ch.send / ch.recv buffer full / empty matching recv / send
queue.push (block mode) queue full consumer pop/popBatch/clear
blocking native (e.g. socket I/O) would block the runtime when the fd is ready
emit from a spawned task event ring full dispatcher drains a slot

A parked producer's instruction is rewound and re-run on wake, so it transparently retries. If a producer parks but no other task can ever wake it, the runtime traps (channel/queue deadlock fault code 13, or event storm fault code 12) instead of hanging forever.

Limits

Limit Value
Max concurrent tasks 32
Default quantum (PC) 4096
Default quantum (ESP32) 512
Max channel waiters 16

Exceeding the task limit is a runtime error.

Notes

  • All concurrency is single-threaded — no locks needed, no data races possible
  • Globals are shared between all tasks
  • Tasks implicitly park/wake on channels, full queue.push (block mode), blocking natives, and full event rings
  • go with native functions is not supported (compile error)
  • GC scans all task stacks and channel buffers as roots
  • Use runtime.guard() to set hard limits on instruction count and memory — protects against runaway tasks