Parallel Lua VMs, copied results, shared maps, and cancellation.
Worker tasks and shared data
Task.fork executes a Lua function on a reusable worker thread with its own Lua VM. The gameplay VM keeps processing input, updates and rendering. There is no instruction slicing, main-thread task scheduler, or required async/await wrapper.
Fork a function and read its result
local Task = require("demi.task")
function Game:on_start()
self.work = Task.fork(function(input)
local sum = 0
for i = 1, input.count do sum = sum + i end
return sum
end, { count = 10000000 })
end
function Game:on_update(dt)
if self.work and self.work:done() then
local result, err = self.work:result()
if self.work:status() == "completed" then
self.total = result -- Main-thread game state.
else
print(err)
end
self.work = nil
end
end
function Game:on_destroy()
if self.work then self.work:cancel() end
end
Inline and named Lua functions are supported. fork(function, ...) copies its arguments before submission; result() copies the return values into the calling VM, preserving multiple returns and nils. done() means execution has ended, not necessarily that it succeeded. Status is queued, running, cancelling, completed, failed, or cancelled. Pending or unsuccessful result() calls return nil and a message; error() exposes the diagnostic.
What crosses the VM boundary
- Arguments/results can contain nil, booleans, integers, numbers, binary-safe strings, and plain Lua tables. Table cycles and aliases are preserved within a transfer. Data array/object/null markers are supported.
- Captured primitive locals are copied snapshots. Explicit
Shared.maphandles remain shared native objects. - Captured ordinary tables such as
self, captured functions, native C functions, threads, unknown userdata and custom metatables are rejected. Pass plain table inputs explicitly instead of silently copying a captured game object. - The function's normal global environment is rebound to a fresh worker environment. Workers can import
demi.database,demi.network.http,demi.network.tcp,demi.shared,demi.task, vector/scalar math anddemi.regex. The standard base, math, string, table and UTF-8 facilities are also available.demi.datasupports value helpers and YAML parsing; live asset-store queries belong on the game thread.
Workers reuse their VMs, but jobs do not share global or library-table mutations. Functions with a custom _ENV are rejected. Worker table GC finalizers are not available: deferred Lua callbacks could otherwise write shared state after a job had finished. Use deterministic close handlers for job cleanup. Completion order depends on OS scheduling; do not use it as an authoritative simulation clock.
Share a native map deliberately
local Task = require("demi.task")
local Shared = require("demi.shared")
local counters = assert(Shared.map({ completed = 0 }))
local job = Task.fork(function()
-- Capturing this native handle is supported; it is not an ordinary table.
assert(counters:add("completed", 1))
return true
end)
Each map owns native copied values behind a mutex. Keys are strings. get, set, add, and snapshot synchronize individual operations. Returned tables are independent copies; editing one does not update the map. Map values cannot contain other shared handles, preventing native ownership cycles.
Critical sections
with_lock acquires the map's lock, calls your function with the map, then unlocks automatically. There is no separate lock() or unlock() call. Use it when several operations must happen together without another thread changing the map between them:
local Shared = require("demi.shared")
local shared = assert(Shared.map({ health = 100 }))
local ok, err = shared:with_lock(function(map)
local health = map:get("health")
map:set("health", health - 10)
end)
if not ok then
print(err) -- "busy" or "cancelled"; the callback did not run.
end
The lock covers both the read and the write. It is released when the callback returns, including when the callback raises an error. Separate get and set calls outside this callback each lock individually, so another worker could change health between them.
On a worker, with_lock waits for the lock and checks cancellation while waiting. On the game thread it never waits: if the lock is busy, it returns false, "busy" without calling your function. try_lock uses that non-blocking behavior on every thread:
local ok, previous = counters:try_lock(function(map)
local value = map:get("completed")
map:set("completed", value + 1)
return value
end)
if not ok then
-- Another worker owns the lock; retry during a later update.
end
Both methods return true followed by the callback's return values, or false and "busy"/"cancelled" if acquisition fails. Callback errors are rethrown after unlocking. Callbacks cannot yield; attempting to yield also releases the lock and raises an error. Writes before an error remain applied.
Keep locked callbacks short. Reentering the same map is supported; when locking several maps, all code must follow the same lock order to avoid deadlock. Individual map operations also never wait on the game thread, returning nil and "busy" on contention.
Cancellation and pool size
self.work = Task.fork(function()
local Task = require("demi.task")
local total = 0
while not Task.cancelled() do
for i = 1, 100000 do total = total + i end
end
return total
end)
cancel() immediately cancels queued work. Running work enters cancelling and must observe Task.cancelled() or call Task.check_cancelled() between useful chunks. Its handle becomes done only after execution and cleanup stop. Dropping a handle does not cancel its job. Scene unload requests cancellation; engine shutdown joins the workers. An infinite loop that ignores cancellation can occupy a worker indefinitely and prevent shutdown—threads are never forcibly killed.
The default pool has two threads, created lazily. Before the first fork, Task.configure({workers = 4}) changes its size. VMs are reused rather than created per job. Batch small calculations when transfer and submission costs outweigh useful computation.
Database and network operations
The database, TCP and HTTP services work directly inside tasks. Their operation handles provide wait(timeout_ms): the worker sleeps until completion, cancellation or an optional timeout. No busy-polling loop is needed. On the game thread, keep using the non-blocking submission and polling APIs; waiting there is rejected.
local work = Task.fork(function()
local Database = require("demi.database")
local db = assert(Database.open({ driver = "sqlite", path = "scores.db" }))
assert(db:execute("CREATE TABLE IF NOT EXISTS scores(points INTEGER)"):wait())
local result = assert(db:query("SELECT COUNT(*) FROM scores"):wait())
assert(db:close():wait())
return result.rows[1][1]
end)
Create connections inside the task; connection and operation userdata cannot be passed between Lua VMs. Database requests execute on a separate native pool, and network requests use their own I/O services, so sleeping Lua workers do not prevent those requests from completing. A sleeping task still occupies one Lua worker slot. Use the asynchronous main-thread APIs when you need many independent requests without occupying those slots.
A timed-out or cancelled wait returns nil and "timeout" or "cancelled". It does not cancel the underlying operation; keep its handle and call cancel() explicitly if required. Already committed database writes cannot be undone by cancellation.
Work with the live engine
Use Task.main(callback, ...) to read or modify entities, HUDs, assets or other live engine state. It queues the callback for the game thread and sleeps the worker until the callback returns. Arguments and results are copied using the same rules as fork.
local work = Task.fork(function(entity_id)
local Task = require("demi.task")
local position = assert(Task.main(function(id)
local Transform3D = require("demi.transform3d")
local x, y, z = Transform3D.get_position(id)
assert(x, "Entity has no Transform3D")
return { x, y, z }
end, entity_id))
-- Perform expensive calculations here, on the worker.
return position
end, "player")
Import engine services inside the callback rather than capturing a service table or self. Keep these callbacks short: their work runs during a game update, not in parallel with the world. Cancellation skips callbacks that have not started, but cannot roll back an executing callback. Errors return nil and an error message to the worker. Do not hold shared locks while waiting for main-thread code that needs those locks. Scene unload invalidates queued calls.
Run demi run --project examples/async_tasks to move a marker and trigger HUD actions while a worker calculates and publishes progress through a shared map.