batteries/async.lua

256 lines
5.9 KiB
Lua
Raw Normal View History

2020-01-31 00:55:50 +00:00
--[[
simple kernel for async tasks running in the background
can "stall" a task by yielding the string "stall"
this will suspend the coroutine until the rest of
the queue has been processed or stalled
and can early-out update_for_time
todo:
2020-02-01 08:39:51 +00:00
multiple types of callbacks
finish, error, step
getting a reference to the task for manipulation
attaching multiple callbacks
cancelling
2023-12-21 02:59:38 +00:00
proper error traces for coroutines with async:add, additional wrapper?
2020-01-31 00:55:50 +00:00
]]
local path = (...):gsub("async", "")
local assert = require(path .. "assert")
local class = require(path .. "class")
local tablex = require(path .. "tablex")
local async = class({
name = "async",
})
2020-01-31 00:55:50 +00:00
function async:new()
self.tasks = {}
self.tasks_stalled = {}
2020-01-31 00:55:50 +00:00
end
async: Skip wrapping with xpcall under lovejs See Davidobot/love.js#54. lovejs crashes love with "attempt to yield across metamethod/C-call boundary" when you try to yield in a coroutine, so we skip wrapping. We lose the coroutine-local callstack, but at least it works. Here's my repro: local color = { purple = {0.25, 0.09, 0.28, 1}, white = {0.89, 0.91, 0.90, 1}, } local ball = { x = 100, y = 100, r = 20, } local S = {} local input = { vanilla = 'v', async = 'c', quit = 'escape', } function love.load() S.coro = async() end local function coro_fn() S.async_running = true for i=1,100 do color.purple[4] = i / 100 coroutine.yield() end color.purple[4] = 1 S.async_running = false return true end function love.update(dt) S.coro:update(dt) if S.vanilla then local success, result = coroutine.resume(S.vanilla) if result then print("coroutine cleared") S.vanilla = nil end end if not S.async_running then if love.keyboard.isDown(input.vanilla) then S.vanilla = coroutine.create(coro_fn) print("raw coroutine started") elseif love.keyboard.isDown(input.async) then print("Starting async call coroutine from update") S.coro:call(coro_fn) elseif love.keyboard.isDown(input.quit) then love.event.quit() end end end function love.draw() love.graphics.setColor(color.purple) love.graphics.circle("fill", ball.x, ball.y, ball.r) love.graphics.setColor(color.white) local str = "To start a coroutine:" for key,val in pairs(input) do str = ("%s\n%s: %s"):format(str, key, val) end love.graphics.printf(str, 5,5, 200, "left") end
2022-03-06 03:55:32 +00:00
local capture_callstacks
if love and love.system and love.system.getOS() == 'Web' then
--do no extra wrapping under lovejs because using xpcall
-- causes a yield across a c call boundary
async: Skip wrapping with xpcall under lovejs See Davidobot/love.js#54. lovejs crashes love with "attempt to yield across metamethod/C-call boundary" when you try to yield in a coroutine, so we skip wrapping. We lose the coroutine-local callstack, but at least it works. Here's my repro: local color = { purple = {0.25, 0.09, 0.28, 1}, white = {0.89, 0.91, 0.90, 1}, } local ball = { x = 100, y = 100, r = 20, } local S = {} local input = { vanilla = 'v', async = 'c', quit = 'escape', } function love.load() S.coro = async() end local function coro_fn() S.async_running = true for i=1,100 do color.purple[4] = i / 100 coroutine.yield() end color.purple[4] = 1 S.async_running = false return true end function love.update(dt) S.coro:update(dt) if S.vanilla then local success, result = coroutine.resume(S.vanilla) if result then print("coroutine cleared") S.vanilla = nil end end if not S.async_running then if love.keyboard.isDown(input.vanilla) then S.vanilla = coroutine.create(coro_fn) print("raw coroutine started") elseif love.keyboard.isDown(input.async) then print("Starting async call coroutine from update") S.coro:call(coro_fn) elseif love.keyboard.isDown(input.quit) then love.event.quit() end end end function love.draw() love.graphics.setColor(color.purple) love.graphics.circle("fill", ball.x, ball.y, ball.r) love.graphics.setColor(color.white) local str = "To start a coroutine:" for key,val in pairs(input) do str = ("%s\n%s: %s"):format(str, key, val) end love.graphics.printf(str, 5,5, 200, "left") end
2022-03-06 03:55:32 +00:00
capture_callstacks = function(f)
return f
end
else
capture_callstacks = function(f)
--report errors with the coroutine's callstack instead of one coming
-- from async:update
async: Skip wrapping with xpcall under lovejs See Davidobot/love.js#54. lovejs crashes love with "attempt to yield across metamethod/C-call boundary" when you try to yield in a coroutine, so we skip wrapping. We lose the coroutine-local callstack, but at least it works. Here's my repro: local color = { purple = {0.25, 0.09, 0.28, 1}, white = {0.89, 0.91, 0.90, 1}, } local ball = { x = 100, y = 100, r = 20, } local S = {} local input = { vanilla = 'v', async = 'c', quit = 'escape', } function love.load() S.coro = async() end local function coro_fn() S.async_running = true for i=1,100 do color.purple[4] = i / 100 coroutine.yield() end color.purple[4] = 1 S.async_running = false return true end function love.update(dt) S.coro:update(dt) if S.vanilla then local success, result = coroutine.resume(S.vanilla) if result then print("coroutine cleared") S.vanilla = nil end end if not S.async_running then if love.keyboard.isDown(input.vanilla) then S.vanilla = coroutine.create(coro_fn) print("raw coroutine started") elseif love.keyboard.isDown(input.async) then print("Starting async call coroutine from update") S.coro:call(coro_fn) elseif love.keyboard.isDown(input.quit) then love.event.quit() end end end function love.draw() love.graphics.setColor(color.purple) love.graphics.circle("fill", ball.x, ball.y, ball.r) love.graphics.setColor(color.white) local str = "To start a coroutine:" for key,val in pairs(input) do str = ("%s\n%s: %s"):format(str, key, val) end love.graphics.printf(str, 5,5, 200, "left") end
2022-03-06 03:55:32 +00:00
return function(...)
local results = {xpcall(f, debug.traceback, ...)}
local success = table.remove(results, 1)
if not success then
error(table.remove(results, 1))
end
return unpack(results)
end
end
end
2020-01-31 00:55:50 +00:00
--add a task to the kernel
function async:call(f, args, callback, error_callback)
assert:type_or_nil(args, "table", "async:call - args", 1)
async: Skip wrapping with xpcall under lovejs See Davidobot/love.js#54. lovejs crashes love with "attempt to yield across metamethod/C-call boundary" when you try to yield in a coroutine, so we skip wrapping. We lose the coroutine-local callstack, but at least it works. Here's my repro: local color = { purple = {0.25, 0.09, 0.28, 1}, white = {0.89, 0.91, 0.90, 1}, } local ball = { x = 100, y = 100, r = 20, } local S = {} local input = { vanilla = 'v', async = 'c', quit = 'escape', } function love.load() S.coro = async() end local function coro_fn() S.async_running = true for i=1,100 do color.purple[4] = i / 100 coroutine.yield() end color.purple[4] = 1 S.async_running = false return true end function love.update(dt) S.coro:update(dt) if S.vanilla then local success, result = coroutine.resume(S.vanilla) if result then print("coroutine cleared") S.vanilla = nil end end if not S.async_running then if love.keyboard.isDown(input.vanilla) then S.vanilla = coroutine.create(coro_fn) print("raw coroutine started") elseif love.keyboard.isDown(input.async) then print("Starting async call coroutine from update") S.coro:call(coro_fn) elseif love.keyboard.isDown(input.quit) then love.event.quit() end end end function love.draw() love.graphics.setColor(color.purple) love.graphics.circle("fill", ball.x, ball.y, ball.r) love.graphics.setColor(color.white) local str = "To start a coroutine:" for key,val in pairs(input) do str = ("%s\n%s: %s"):format(str, key, val) end love.graphics.printf(str, 5,5, 200, "left") end
2022-03-06 03:55:32 +00:00
f = capture_callstacks(f)
return self:add(coroutine.create(f), args, callback, error_callback)
end
--add an already-existing coroutine to the kernel
function async:add(co, args, callback, error_callback)
local task = {
co,
args or {},
callback or false,
error_callback or false,
}
table.insert(self.tasks, task)
return task
2020-01-31 00:55:50 +00:00
end
--remove a running task based on the reference we got earlier
function async:remove(task)
task.remove = true
if coroutine.status(task[1]) == "running" then
--removed the current running task
return true
else
--remove from the queues
return tablex.remove_value(self.tasks, task)
or tablex.remove_value(self.tasks_stalled, task)
end
end
--separate local for processing a resume;
-- because the results come as varargs this way
local function process_resume(self, task, success, msg, ...)
local co, args, cb, error_cb = unpack(task)
2020-01-31 00:55:50 +00:00
--error?
if not success then
if error_cb then
error_cb(msg)
2020-01-31 00:55:50 +00:00
else
local err = ("failure in async task:\n\n\t%s\n")
:format(tostring(msg))
error(err)
2020-01-31 00:55:50 +00:00
end
end
--check done
if coroutine.status(co) == "dead" or task.remove then
2020-01-31 00:55:50 +00:00
--done? run callback with result
if cb then
cb(msg, ...)
end
2020-01-31 00:55:50 +00:00
else
--if not completed, re-add to the appropriate queue
if msg == "stall" then
--add to stalled queue as signalled stall
table.insert(self.tasks_stalled, task)
else
table.insert(self.tasks, task)
end
2020-01-31 00:55:50 +00:00
end
end
--update some task in the kernel
function async:update()
--grab task definition
local task = table.remove(self.tasks, 1)
if not task then
--have we got stalled tasks to re-try?
if #self.tasks_stalled > 0 then
--swap queues rather than churning elements
self.tasks_stalled, self.tasks = self.tasks, self.tasks_stalled
return self:update()
else
return false
end
end
--run a step
--(using unpack because coroutine is also nyi and it's core to this async model)
local co, args = unpack(task)
process_resume(self, task, coroutine.resume(co, unpack(args)))
2020-01-31 00:55:50 +00:00
return true
end
--update tasks for some amount of time
function async:update_for_time(t, early_out_stalls)
2020-01-31 00:55:50 +00:00
local now = love.timer.getTime()
while love.timer.getTime() - now < t do
if not self:update() then
break
end
--all stalled?
if early_out_stalls and #self.tasks == 0 then
break
end
2020-01-31 00:55:50 +00:00
end
end
--add a function to run after a certain delay (in seconds)
function async:add_timeout(f, delay)
self:call(function()
async.wait(delay)
f()
end)
end
--add a function to run repeatedly every delay (in seconds)
--note: not super useful currently unless you plan to destroy the whole async kernel
-- as there's no way to remove tasks :)
function async:add_interval(f, delay)
self:call(function()
while true do
async.wait(delay)
f()
end
end)
end
2023-12-18 05:18:28 +00:00
--await the result of a function or set of functions
--return the results
function async:await(to_call, args)
local single_call = false
if type(to_call) == "function" then
to_call = {to_call}
single_call = true
end
local awaiting = #to_call
local results = {}
for i, v in ipairs(to_call) do
self:call(function(...)
table.insert(results, {v(...)})
awaiting = awaiting - 1
end, args)
end
while awaiting > 0 do
async.stall()
end
--unwrap
if single_call then
results = results[1]
end
return results
end
--static async operation helpers
-- these are not methods on the async object, but are
-- intended to be called with dot syntax on the class itself
--stall the current coroutine
function async.stall()
return coroutine.yield("stall")
end
--make the current coroutine wait
function async.wait(time)
if not coroutine.running() then
error("attempt to wait in main thread, this will block forever")
end
local now = love.timer.getTime()
2021-11-18 04:18:58 +00:00
while love.timer.getTime() - now < time do
async.stall()
end
end
--eventually get a result, inline
-- repeatedly calls the provided function until it returns something,
-- stalling each time it doesn't, returning the result in the end
function async.value(f)
local r = f()
while not r do
async.stall()
r = f()
end
return r
end
--make an iterator or search function asynchronous, stalling every n (or 1) iterations
--can be useful with functional queries as well, if they are done in a coroutine.
function async.wrap_iterator(f, stall, n)
stall = stall or false
n = n or 1
local count = 0
return function(...)
count = count + 1
if count >= n then
count = 0
if stall then
async.stall()
else
coroutine.yield()
end
end
return f(...)
end
end
return async