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
goonly works with user-defined functions. Usinggowith 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 ->:
If the received value is not needed, omit the binding:
Send Cases
default
The default case runs when no channel is ready. Without default, the task yields and retries until a case becomes ready.
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.
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
quantumVM instructions before yielding - The scheduler picks the next
READYtask 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
go fn()creates a new task inREADYstate- Scheduler picks the next
READYtask, sets it toRUNNING - Task runs until: quantum exhausted (-> READY),
yield(-> READY), blocked (-> PARKED), or returns (-> DONE) - Scheduler picks next
READYtask - 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 gowith 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