5.2 KiB
Concurrency: coroutines and task
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
explains how that interacts with Lua coroutines, and how to run several pieces of
work concurrently with task.join.
TL;DR
- Plain Lua coroutines work exactly as in stock Lua.
- Async stdlib calls (
os.sleep,http.*,sqlite…) suspend on the runtime — they don't block the thread. - Use
task.jointo run multiple coroutines concurrently. N tasks that each wait T seconds finish in ~T, not ~N·T. - Don't call async stdlib functions inside a coroutine you drive yourself
with
coroutine.resume/coroutine.wrap— see the gotcha.
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 next = coroutine.wrap(squares)
print(next(3), next(3), next(3)) --> 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
coroutine) suspends until it's 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 (see below) keep making progress.
task.join — run work concurrently
task.join(fn1, fn2, ...) -> r1, r2, ...
Runs each function as its own coroutine, drives them concurrently on the
runtime, and returns each one's first result positionally once all have
finished. If any task raises an error, task.join re-raises the first one.
Because async calls suspend instead of blocking, the tasks overlap:
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
The three sleeps run at the same time, so the wall-clock time is the longest single task, not the sum.
Real-world example: concurrent HTTP fetches
local function fetch(url)
return function()
return http.get(url).status
end
end
local s1, s2, s3 = task.join(
fetch("https://httpbingo.org/delay/1"),
fetch("https://httpbingo.org/delay/1"),
fetch("https://httpbingo.org/delay/1")
)
print(s1, s2, s3) --> 200 200 200, in ~1s total instead of ~3s
Passing results back
Each task returns its first value to the corresponding slot:
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 can 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
The gotcha
Async stdlib functions only suspend correctly when the runtime is driving the
coroutine — i.e. at the top level of your script, or inside a task.join
coroutine. If you drive a coroutine yourself, the 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. The rule of thumb:
Use plain
coroutine.*for pure-Lua generators. The moment a coroutine needs toos.sleep, hit the network, or touch the database, run it throughtask.joininstead.
How it works (and its limits)
task.join wraps each function in a Lua coroutine and polls them all
concurrently on the tokio runtime. The concurrency comes from the reactor —
timers and I/O yielding control while they wait — not from extra threads:
- Great for I/O-bound work: sleeps, HTTP requests, database queries all overlap.
- A CPU-bound task (a tight compute loop with no async calls) will not yield, so it blocks its siblings until it finishes or hits an async call.
In other words this is concurrency, not parallelism: one thread, many in-flight operations.
See also
- Runnable demo:
lua/coroutines-demo.lua(a lua/coroutines-demo.lua)