SCUA

News

Non-blocking I/O with no async, no await

September 20, 2026

Overlapping I/O usually costs you the colour of every function that touches it. One call goes async, so its caller must await, so that caller goes async too, and the keyword climbs your call stack until half the codebase is a different kind of function from the other half. SCUA overlaps I/O without any of it. There is no async keyword and nothing to await.

#What colouring actually costs

The tax is not the two keywords. It is that your functions divide into two incompatible sets, and a synchronous one cannot call an asynchronous one without becoming asynchronous itself. Utility code gets duplicated. A library that picked the wrong colour is unusable. Refactoring a leaf function to do I/O rewrites everything above it.

Languages that have it mostly cannot remove it, because the colour is how the compiler knows where to split the function into a state machine.

#How SCUA avoids it

By default SCUA I/O blocks, which is the right default for a script or a tool: simple, predictable, finished. A host that wants overlap installs an I/O platform, or you pass --io=async.

Then a capability call inside a message handler parks the handler rather than blocking the thread. The host performs the operation however it likes, completes it by token, and the handler resumes with an ordinary value. The function that made the call is an ordinary function, its caller is an ordinary function, and nothing above it changed.

The embedding example in the repository runs the whole loop:

$ zig build embed-async
SCUA async-I/O embedding demo (scua 0.27.0-dev.b13ad50)
  [host] script parked: http GET https://api.example/data (token 1)
  [host] completed token 1 with HTTP 200 "pong"; polling...
handler resumed: http.get -> status=200, body="pong"
PASS: script parked on http.get, host completed it by token, poll resumed the handler

The script side of that is http.get(url) returning a value. There is no callback, no promise and no await, because the parking happens underneath the call rather than in the type of the function that made it.

#Fan-out, which is the gap uncoloured I/O usually leaves

Removing await tends to cost you the thing Promise.all is for: many independent operations that should run together. SCUA has wait_all and map_all built in.

let results = wait_all([
    fn() return 1 + 1 end,
    fn() return "two" end,
    fn() return [3, 3, 3] end,
  ])
$ scua --fast examples/wait_all.scua
first arm: 2
second arm: two
arm 1 failed: fault=true (boom)
doubled: 20, 40, 60, 80

Results come back in order, and it is all-settled: one arm failing never cancels the others, so its error sits in its slot and the rest still run. Under --io=async, arms that do real I/O park independently and the waits overlap. map_all does the same across a list under a concurrency cap.

The third line of that output is an arm that faulted rather than failed, and it comes back carrying fault = true so you can tell a bug apart from an ordinary failure before deciding whether to retry.

#Background work

spawn do ... end starts a task alongside a partition's message handling. The handler that spawned it returns immediately.

partition Greeter
  on Greet(name)
    print(`hi {name}, checking back soon`)
    spawn do
      wait(1s)
      print(`still here, {name}?`)
    end
  end
end

While that task is parked the partition keeps handling other messages. None of this uses threads or locks; it rides the same one-handler-at-a-time model partitions already give you, so a handler never has to reason about another one running underneath it.

#The catch

Only handler turns park. A top-level scua_eval blocks regardless of whether an I/O platform is installed.

That is a real constraint rather than a detail. If you want overlap, the work has to be inside a handler, which in practice means structuring the program around partitions and messages rather than as a top-level script that calls out. For a server or an embedding host that is the shape you wanted anyway. For a one-shot tool it is not, and blocking is the better fit there. Hence the default.

Uninstalling the platform with scua_set_io_platform(p, NULL, NULL) puts everything back to blocking.