Lua runner with rich builtin stdlib
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
a/docs/concurrency.md

5.4 KiB

Concurrency

a runs every script on an async core (tokio). The async stdlib calls — os.sleep, the http client, sqlite queries — don't block the OS thread while they wait: they suspend and let other work run. This page covers how that interacts with Lua coroutines and how to run several pieces of work at once with task.join.

Plain Lua coroutines behave exactly as in stock Lua. The moment a coroutine needs to wait on something async — a sleep, a request, a query — run it through task.join rather than driving it yourself, for the reason in Self-driven coroutines.

-- Three requests that would take ~3s back-to-back finish in ~1s.
local a, b, c = task.join(
    function() return http.get("https://httpbingo.org/delay/1").status end,
    function() return http.get("https://httpbingo.org/delay/1").status end,
    function() return http.get("https://httpbingo.org/delay/1").status end
)
print(a, b, c) --> 200   200   200

Plain coroutines

Stock Lua coroutines are pure VM machinery and behave normally:

local function squares(n)
    for i = 1, n do
        coroutine.yield(i * i)
    end
end

local gen = coroutine.wrap(squares)
print(gen(3), gen(), gen()) --> 1   4   9

This is ordinary cooperative scheduling: nothing runs concurrently, and the tokio runtime is never involved.

Async calls suspend, they don't block

An async stdlib call — at the top level of your script, or inside a task.join coroutine — suspends until it is ready without tying up the thread:

local t0 = os.microtime()
os.sleep(0.10)
print(string.format("waited %.3fs", os.microtime() - t0)) --> waited ~0.100s

While that sleep is pending, any sibling tasks keep making progress.

Running work concurrently

task.join(fn1, fn2, ...)

Run each function as its own coroutine, drive them concurrently on the runtime, and return each one's first result positionally once all have finished. If a task raises an error, task.join re-raises the first one.

Because async calls suspend instead of blocking, the tasks overlap — the wall-clock time is the longest task, not the sum:

local function worker(name, secs)
    return function()
        os.sleep(secs)          -- suspends; siblings run meanwhile
        return name
    end
end

local t0 = os.microtime()
local a, b, c = task.join(
    worker("slow", 0.30),
    worker("med",  0.20),
    worker("fast", 0.10)
)
print(a, b, c)                                      --> slow   med   fast
print(string.format("%.3fs", os.microtime() - t0)) --> ~0.300s, not 0.600s

Returning results

Each task's first return value lands in the matching slot, so several results come back together:

local me, repos = task.join(
    function() return http.getJSON("https://api.github.com/users/torvalds") end,
    function() return http.getJSON("https://api.github.com/users/torvalds/repos") end
)
print(me.name, #repos) -- both fetched concurrently

Nesting

task.join suspends like any other async call, so a task may itself call task.join:

local total = task.join(
    function()
        local x, y = task.join(
            function() os.sleep(0.05); return 21 end,
            function() os.sleep(0.05); return 21 end
        )
        return x + y          -- 42
    end,
    function() os.sleep(0.10); return "sibling" end
)
print(total) --> 42

Self-driven coroutines

Async stdlib functions suspend correctly only when the runtime is driving the coroutine — at the top level of your script, or inside a task.join coroutine. If you drive a coroutine yourself with coroutine.resume or coroutine.wrap, an async call does not wait:

-- DON'T do this:
local co = coroutine.wrap(function()
    os.sleep(0.50)
    return "awoke"
end)

local v = co()   -- returns immediately with an opaque value; the 0.5s wait never
                 -- happens, because coroutine.wrap can't drive the runtime.

Under the hood an async call yields a private marker that only the runtime's scheduler understands; a hand-written resume/wrap loop just receives that marker and moves on.

Rule of thumb: use plain coroutine.* for pure-Lua generators; the moment a coroutine needs to os.sleep, hit the network, or touch the database, run it through task.join instead.

How it works

task.join wraps each function in a Lua coroutine and polls them all on the tokio runtime. The concurrency comes from the reactor — timers and I/O yielding control while they wait — not from extra threads.

  • Concurrency, not parallelism. Everything runs on one thread; there are simply many operations in flight at once.
  • I/O-bound work overlaps. Sleeps, HTTP requests, and database queries all wait at the same time.
  • CPU-bound work does not yield. A tight compute loop with no async calls blocks its siblings until it finishes or reaches an async call.

Full example

-- Fetch several resources at once, then combine them.
local function get(url)
    return function() return http.getJSON(url) end
end

local user, repos = task.join(
    get("https://api.github.com/users/torvalds"),
    get("https://api.github.com/users/torvalds/repos")
)

print(user.name)
print(#repos .. " public repos")
-- Both requests ran concurrently: ~1 round-trip of latency, not 2.

A runnable version of these patterns ships in lua/coroutines-demo.lua — run it with a lua/coroutines-demo.lua.