A message handler runs to completion and then stops. But games and servers need things that take time: a timer that fires in five seconds, an animation that plays over a frame, a task that polls in the background. SCUA handles this with background tasks, a clock you can fast-forward, and a few ways to wait. None of it uses threads or locks. It all rides on the same one-handler-at-a-time model that partitions already give you.
#Background tasks with spawn
spawn do ... end starts a task that runs alongside a
partition's message handling. The handler that spawned it returns right
away; the spawned task carries on in the background.
partition Greeter
on Greet(name)
print(`hi {name}, checking back soon`)
spawn do
wait(1s)
print(`still here, {name}?`)
end
end
end
let g = Greeter()
tell g.Greet("Sam")
Run it with --fast so the wait completes instantly (more
on that below):
$ scua --fast greeter.scua
hi Sam, checking back soon
still here, Sam?
The Greet handler prints its line, kicks off the
background task, and returns. The task waits a second, then prints.
While it's parked, the partition is free to handle other messages.
There's a standalone form too, spawn(fn() ... end), for
top-level background work that isn't tied to a partition:
spawn(fn()
let start = now()
wait(2s)
print(`slow timer fired at +{now() - start}ms`)
end)
spawn(fn()
let start = now()
wait(500ms)
print(`fast timer fired at +{now() - start}ms`)
end)
print("two timers running")
$ scua --fast timers.scua
two timers running
fast timer fired at +500ms
slow timer fired at +2000ms
Both timers start together. The 500ms one fires first because its wake time comes sooner. The scheduler interleaves tasks by when they're due to wake, not by the order you spawned them.
#Durations
A duration is written as a number with a unit suffix:
200ms, 5s, 2min, 1h.
You can do arithmetic on them; under the hood a duration is a count of
milliseconds, so now() - start is a plain number of
milliseconds.
wait(200ms)
wait(5s)
wait(2min)
wait(1h)
#wait, now, and dt
wait(d) parks the current task until d has
elapsed, then resumes it. While parked, the task uses no CPU and the
partition can do other work.
now() is the current time, in milliseconds. It's stamped
once per turn and frozen for that turn, so two now() calls
in the same handler return the same value. Measure elapsed time by
taking a now() at the start and subtracting:
let start = now()
wait(2s)
print(`waited {now() - start}ms`) -- waited 2000ms
dt() is the time since the task last ran, also in
milliseconds. The first time a task runs it's 0; after a
wait(2s) it's 2000. It's the convenient form for per-step
updates, where you want "how much time passed since my last tick"
without tracking a timestamp yourself.
#Two clock modes
The same program can run against two different clocks, and the flag you pass decides which.
By default, the clock is real. wait(5s) really blocks
for five seconds of wall-clock time. This is what you want when a server
is actually serving, or when a delay should feel like a delay.
With --fast, the clock is virtual and fast-forwards.
Instead of sleeping, the scheduler jumps straight to the next task's
wake time. A simulation that describes an hour of game time finishes in
milliseconds, and a test doesn't have to wait around. The output is
identical; only the wall-clock time it takes to produce it changes.
Here's a two-timeline simulation:
spawn(fn()
let start = now()
print(`[patrol] move to A (t+{now() - start}ms)`)
wait(3s)
print(`[patrol] move to B (t+{now() - start}ms)`)
wait(2s)
print(`[patrol] back to A (t+{now() - start}ms)`)
end)
spawn(fn()
let start = now()
wait(1s)
print(`[regen] +10 hp (t+{now() - start}ms)`)
wait(1s)
print(`[regen] +10 hp (t+{now() - start}ms)`)
end)
print("world: spawned 2 tasks")
Run it normally and the lines appear spread over about five real seconds:
$ scua scheduling.scua
world: spawned 2 tasks
[patrol] move to A (t+0ms)
[regen] +10 hp (t+1000ms)
[regen] +10 hp (t+2000ms)
[patrol] move to B (t+3000ms)
[patrol] back to A (t+5000ms)
Run it with --fast and you get the exact same lines,
instantly:
$ scua --fast scheduling.scua
world: spawned 2 tasks
[patrol] move to A (t+0ms)
[regen] +10 hp (t+1000ms)
[regen] +10 hp (t+2000ms)
[patrol] move to B (t+3000ms)
[patrol] back to A (t+5000ms)
The t+ numbers are virtual game time in both runs. What
changes is how long you sit there waiting for them.
#Selective receive with wait_for
A partition's mailbox is a single queue, but inside a handler you
don't have to take messages in arrival order. wait_for(Tag)
parks the handler until a message of that exact tag arrives, then
consumes it, skipping over anything else that's waiting. The skipped
messages stay queued for their normal handlers.
partition Inbox
on Open()
print("[inbox] opened; holding until the Priority message arrives")
let p = wait_for(Priority) -- skip anything that isn't Priority
print(`[inbox] handled priority first: {p[0]}`)
end
on Mail(subject)
print(`[inbox] mail: {subject}`)
end
end
let box = Inbox()
tell box.Open()
tell box.Mail("newsletter")
tell box.Mail("receipt")
tell box.Priority("server on fire") -- sent last, consumed first
$ scua --fast wait_for.scua
[inbox] opened; holding until the Priority message arrives
[inbox] handled priority first: server on fire
[inbox] mail: newsletter
[inbox] mail: receipt
Open parks on wait_for(Priority). The two
Mail messages arrive and wait their turn.
Priority arrives last but the parked handler was waiting
for exactly it, so it's handled first. wait_for returns the
message's arguments as a list, so p[0] is the first
argument.
#Waiting on several things with select
select waits on more than one thing at once and takes
the first arm that's ready. It has three kinds of arm:
wait_for(Tag) -> ...is ready when a message of that tag is queued.wait(d) -> ...is a timeout: ready oncedelapses.default -> ...runs immediately if nothing else is ready, turningselectinto a non-blocking poll.
partition Worker
on Job(name)
select
wait_for(Done) -> print(`worker: {name} finished`)
wait(10s) -> print(`worker: {name} timed out`)
end
end
end
partition Pinger
on Check()
select
wait_for(Pong) -> print("pinger: got a pong")
default -> print("pinger: nothing waiting")
end
end
end
let busy = Worker()
tell busy.Job("render")
tell busy.Done() -- the Done arrives, so wait_for(Done) wins
let stuck = Worker()
tell stuck.Job("upload") -- no Done ever arrives, so wait(10s) wins
let p = Pinger()
tell p.Check() -- no Pong queued, so default wins
$ scua --fast select.scua
worker: render finished
pinger: nothing waiting
worker: upload timed out
The first worker gets its Done and finishes. The second
never does, so after ten seconds (instant under --fast) the
timeout arm fires. The pinger has nothing queued, so its
default arm wins without blocking. A select
with a default never parks; one without a
default parks until an arm is ready.
#What can wait where
This is the sharp edge to learn early. Suspending splits into two worlds that don't currently overlap.
A message handler suspends through the mailbox. Inside
on/ask, you may suspend with ask,
wait_for, or a select. You may not call bare
wait(d) to sleep. Try it and the partition faults:
scua: file.scua: actor fault: a handler may only suspend via ask
If you want a delay inside a handler, use it as a select
arm, select wait(1s) -> ... end, not a standalone
wait.
A spawned task suspends through the clock. Inside
spawn do ... end (and spawn(fn() ... end)),
you may wait(d). You may not currently ask,
wait_for, or select from a spawned task:
scua: file.scua: actor fault: a spawned coroutine may only suspend via wait (ask/wait_for/select not yet supported there)
So the division of labour is: handlers talk to other partitions and
react to messages; spawned tasks do timed background work. To get a
result from a spawned task back into your state, have it
tell your partition a message, the same way any other code
would. Sending those two worlds together is a known limit, not a
permanent design; for now, structure around it.
#Fan-out with wait_all and map_all
The scheduler above is about work spread over time. A
different need is work spread over a list: twenty URLs to
fetch, a hundred files to read, a batch of inputs to process. For that,
SCUA has two built-ins — wait_all and map_all
— with no import and no async/await
coloring.
wait_all takes a list of zero-argument
functions, runs them, and returns their outcomes in
order:
let results = wait_all([
fn() return 1 + 1 end,
fn() return "two" end,
])
print(results[0], results[1]) -- 2 two
map_all is the same idea over a list, applying one
function to each element, at most concurrency at a time
(default 8):
let doubled = map_all([10, 20, 30], fn(x) return x * 2 end, { concurrency = 4 })
print(doubled[0], doubled[1], doubled[2]) -- 20 40 60
Two properties define both:
- Ordered. Slot
iholds armi's outcome, regardless of which finished first. - All-settled. One arm failing never cancels the rest. You always get a slot for every arm — there's no "first error wins" that throws the others away.
Each slot holds that arm's value as-is: a plain
value when the arm just computes one, or the
Ok/Error an I/O arm returned. And if an arm
faults — a real bug, like an out-of-range index — that lands in
its slot as an Error carrying a tagged fault record, so one
broken arm can't take down the batch. Test e.fault to tell
a bug apart from an ordinary failure:
let mixed = wait_all([
fn() return "ok" end,
fn() return error("boom") end,
])
match mixed[1]
Ok(v) -> print(`ok: {v}`)
Error(e) -> print(`arm 1 faulted={e.fault}: {e.message}`)
end
Two things to keep straight. First, don't put ?
on it — wait_all and map_all never
fail "as a whole", there's no outer Ok/Error
to short-circuit; the failures live in the slots. Second, each arm must
be a function you hand over uncalled:
wait_all([fn() return http.get(u) end]), not
wait_all([http.get(u)]) (which would run the call before
wait_all ever sees it). By default the arms run one after
another; add --io=async and the
fs/http waits genuinely overlap, for the same
results in less wall-clock time. The dedicated how-to walks through all
of this: Run many things
at once.
#Coroutines
Underneath spawn, wait, and
select is a lower-level building block: coroutines. A
coroutine is a function you can suspend and resume by hand.
coroutine(fn) creates one (suspended), resume
runs it until the next yield, yield(v) hands a
value back to the resumer, and status tells you whether
it's suspended or done.
let counter = coroutine(fn()
let i = 0
while i < 1000000 do
i = i + 1
yield(i)
end
end)
print(resume(counter)) -- 1
print(resume(counter)) -- 2
print(status(counter)) -- suspended
$ scua coroutines.scua
1
2
suspended
yield is an expression: the value passed to the next
resume becomes its result, so a coroutine can receive as
well as produce. Most code reaches for spawn and
select rather than coroutines directly, but they're there
when you want manual control. The coroutines.scua example
in the examples folder walks through generators, two-way communication,
and what happens when a coroutine runs off the end.
#Related pages
- Partitions and the actor model for the handlers and messages this page builds on.
- Run many things at
once for the full
wait_all/map_allwalkthrough, including--io=async. - Logging for watching what your tasks are doing as they run.