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

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.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.

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