Sometimes you have a pile of independent work — fetch twenty URLs,
read a hundred files, hit a dozen APIs — and doing them one at a time is
pure waiting. SCUA has two built-ins for this fan-out:
wait_all and map_all. No import, no
async/await coloring, no future-wrangling —
you hand them a list of things to do and they hand back the results.
#wait_all: run a list, wait for all of it
wait_all takes a list of zero-argument
functions and returns their outcomes in
order:
let results = wait_all([
fn() return 1 + 1 end,
fn() return "two" end,
fn() return [3, 3, 3] end,
])
print(`first arm: {results[0]}`) -- 2
print(`second arm: {results[1]}`) -- two
Two properties matter:
- Ordered.
results[0]is the first arm's outcome,results[1]the second — the result list lines up with the arm list, regardless of which finished first. - All-settled. One arm failing never cancels the others. You always get a slot for every arm; there's no "first error wins" that throws the rest away.
#The result is the arm's value, as-is
Each slot holds whatever that arm produced, unchanged:
- A plain arm gives a plain value — read it directly.
- An arm that does I/O (like
http.getorfs.read) gives back that call'sOk/Error— match it as you always would. - An arm that faults (a bug — an index out of range,
a nil field) gives an
Errorcarrying a tagged fault record.
So you match Ok/Error when your arms return
it, and read the value directly when they don't. There's no extra
wrapping layer to peel off.
#Telling a bug apart from a failure
Because a faulting arm lands as an Error, you can catch
a bug in one arm without it taking down the batch. The fault
Error carries a fault tag (and a
message) so you can tell a genuine bug apart from an
ordinary failure before you decide what to do:
let mixed = wait_all([
fn() return "ok" end,
fn() return error("boom") end,
])
match mixed[1]
Ok(v) -> print(`unexpected ok: {v}`)
Error(e) -> print(`arm 1 failed: fault={e.fault} ({e.message})`)
end
#map_all: the same thing over a list
When every arm does the same thing to a different input,
map_all is the natural spelling. It maps a function over a
list, running at most concurrency at a time (default 8),
waits for all, and aligns the results with the input:
let doubled = map_all([10, 20, 30, 40], fn(x) return x * 2 end, { concurrency = 4 })
print(`doubled: {doubled[0]}, {doubled[1]}, {doubled[2]}, {doubled[3]}`)
Same semantics as wait_all: ordered, all-settled, and
each slot is that call's value as-is. Iterate the results like any
list:
let lengths = map_all(["a", "bb", "ccc"], fn(s) return s.len() end)
for i in 0:lengths.len() do
print(`{i}: length {lengths[i]}`)
end
The concurrency cap is the throttle:
{ concurrency = 8 } keeps at most eight requests in flight,
so you can fan out over a thousand URLs without opening a thousand
sockets at once.
#Two gotchas
Don't put ? on it.
wait_all and map_all never fail "as a whole" —
there is no outer Ok/Error to short-circuit. A
per-arm failure lives in that arm's slot, and the caller always
waits for every arm. wait_all([...])? is a mistake.
Each arm must be a function — don't call it eagerly.
wait_all runs the functions you give it; it can't run a
value you already computed. Writing wait_all([http.get(u)])
calls http.get first, before wait_all
ever sees it, so nothing overlaps — SCUA catches this and raises a loud
error naming the arm. Wrap each call in fn() ... end:
wait_all([ fn() return http.get(a) end, fn() return http.get(b) end ]) -- right
wait_all([ http.get(a), http.get(b) ]) -- wrong: calls run eagerly
#Making the waits actually overlap
By default SCUA's I/O is blocking, so on the default platform the
arms run one after another — you still get the same ordered, all-settled
results, but no wall-clock saving. Add --io=async and a
call that has to wait parks instead: fs and
exec calls run on an offload pool, net reads
and writes and plain http:// requests wait for their
sockets on a network poller, and https:// requests run on
the pool, so the arms' waits overlap. That holds at the top level of a
script, inside a task started there with spawn, and inside
a partition's handler:
let pages = map_all(urls, fn(u) return http.get(u) end, { concurrency = 8 })
print(len(pages))
scua --io=async --allow-net=api.example.com fetch_all.scua
Three arms that each wait 0.3 s take about 0.3 s under
--io=async and about 0.9 s without it. Plain
spawned tasks overlap the same way: three tasks that each
make one 0.3 s round trip finish in about 0.3 s together.
A few places still wait in place, running their calls one after
another exactly as they would without the flag: a wait_all
nested inside another wait_all's arm, and a task spawned
inside a partition's handler.
An arm may pause for wait(d) or for one of those I/O
calls. Inside a partition's handler it may also ask, which
is how a handler asks several partitions at once (see Asking
several partitions at once). A wait_for or
select in an arm doesn't run: that arm's slot holds an
Error that says so and gives the arm's line, and the other
arms carry on. Do those in the handler itself, before or after the
map_all/wait_all.
--io=async is an optimization flag, not a
requirement: the values are identical either way — ordered,
all-settled — and only the wall-clock differs, along with the order in
which lines printed by tasks running at the same time come out. Write
the code once; add the flag when overlap pays off.
See the runnable examples/wait_all.scua
for all of the above in one file.