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.
174 lines
5.2 KiB
174 lines
5.2 KiB
# Concurrency: coroutines and `task`
|
|
|
|
`a` runs every script on an async core ([tokio](https://tokio.rs/)). 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.join`** to 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](#the-gotcha).
|
|
|
|
## Plain coroutines
|
|
|
|
Stock Lua coroutines are pure VM machinery and behave normally:
|
|
|
|
```lua
|
|
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:
|
|
|
|
```lua
|
|
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:
|
|
|
|
```lua
|
|
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
|
|
|
|
```lua
|
|
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:
|
|
|
|
```lua
|
|
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`:
|
|
|
|
```lua
|
|
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:
|
|
|
|
```lua
|
|
-- 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
|
|
> to `os.sleep`, hit the network, or touch the database, run it through
|
|
> `task.join` instead.
|
|
|
|
## 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`](../lua/coroutines-demo.lua)
|
|
(`a lua/coroutines-demo.lua`)
|
|
|