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.