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

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`)