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

175 lines
5.4 KiB

# Concurrency
`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 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](#self-driven-coroutines).
```lua
-- 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:
```lua
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:
```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 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:
```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
```
### Returning results
Each task's first return value lands in the matching slot, so several results come
back together:
```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 may 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
```
## 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:
```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.
**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
```lua
-- 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`](../lua/coroutines-demo.lua) — run it with
`a lua/coroutines-demo.lua`.