This article contains a short introduction to the async-await programming syntax for running asynchronous tasks and explains how it can be implemented in the Lua programming language.
Author: David Heiko Kolf, 2022-02-12.
There are tasks where you want your function to wait for an event – maybe a certain time shall pass, the user needs to enter something or some data needs to arrive over the network.
Ideally we would like to write something like
function demotask () print("The task has started.") sleep(3) print("The task has continued after waiting for 3 seconds.") local text = getinput() print("The user supplied the task with the following text: " .. text) end
However, writing it this way would pause the entire program until the function can continue, which is something we often want to avoid when there are other tasks to be done at the same time (for example in a game).
C# offers a nice syntax which enables the continuation of a function after an asynchronous operation finished, the async-await-syntax:
async Task demotask () { Console.WriteLine("The task has started."); await Task.Delay(3000); Console.WriteLine("The task has continued after waiting for 3 seconds."); var text = await getinput(); Console.WriteLine("The user supplied the task with the following text: " + text); }
This syntax has the significant advantage that it is immediately obvious which functions can delay our task as they have to be called with the "await" keyword.
In Lua we can achieve something similar by using the coroutine.wrap function and calling the created continuation with itself as its first argument:
w = coroutine.wrap(function (cont) print("The task has started.") sleep(cont, 3) print("The task has continued after waiting for 3 seconds.") local text = getinput(cont) print("The user supplied the task with the following text: " .. text) end) function sleep (cont, seconds) -- just a stub implementation, store the callback in a global variable sleepcb = cont return coroutine.yield() end function getinput (cont) -- just a stub implementation, store the callback in a global variable inputcb = cont return coroutine.yield() end w(w) -- start the coroutine while passing the wrapped resume function as argument -- the callbacks are called directly as a test print("After a few seconds the sleep callback is called.") sleepcb() print("The user has decided on a text.") inputcb("Hello world!")
The above example produces the following output:
The task has started. After a few seconds the sleep callback is called. The task has continued after waiting for 3 seconds. The user has decided on a text. The user supplied the task with the following text: Hello world!
This example already performs very similar to the C# syntax. It is again quickly obvious which functions are causing delays as only a function which received the continuation function as a parameter is able to continue the task after a delay.
While this way already works, there are two errors that can happen that would be hard to spot: If for example a callback is accidentally called a second time, the task would continue without being expected to and the only error might be that the arguments passed to the callback function are not those that are expected to be returned. The other case would be if a function that did not receive the continuation callback nonetheless calls coroutine.yield. The task would just stop without any indication as to why it happened.
In the context of a computer game, I developed a small library that can immediately report those two mistakes:
--[[ The 'asynwait' module provides async/await-style programming for Lua based on coroutines. Coroutines started with fork get a wait handle as parameter that can be passed to other functions that can use it to create a callback which will resume the coroutine once. In addition, other modules can put their own functions into the index table of the wait function so they can be called as methods: wait:time(3) Copyright (C) 2022 David Heiko Kolf Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. --]] local cocreate, coresume, costatus, coyield = coroutine.create, coroutine.resume, coroutine.status, coroutine.yield local error, type, setmetatable = error, type, setmetatable local M = {} local _ENV = nil M.index = {} -- to be filled by other modules local metatbl = { __index = M.index } function M.fork (func, msgh, ...) if msgh == nil then msgh = function (thread, msg) end end local ptype = type(func) if ptype ~= 'function' then error("bad argument #1 to 'fork' (function expected, got "..ptype..")", 2) end ptype = type(msgh) if ptype ~= 'function' then error("bad argument #2 to 'fork' (function expected, got "..ptype..")", 2) end local cr = cocreate(func) local waitobj = setmetatable({}, metatbl) local currentcont local contactive = false local function resume (...) local ok, ret = coresume(cr, ...) if not ok then msgh(cr, ret) elseif ret and ret == currentcont then contactive = true elseif costatus(cr) ~= 'dead' then msgh(cr, "async coroutine yielded without a continuation function") end end function waitobj.makecontinuation () if currentcont then error("a continuation function already exists", 2) end local function cont (...) if cont ~= currentcont then error("continuation function was already used", 2) end if not contactive then error("continuation function is not active yet", 2) end contactive = false currentcont = nil resume(...) end currentcont = cont return cont end waitobj.yield = coyield waitobj.msgh = msgh resume(waitobj, ...) end
With this module the example code would now look like this:
local asynwait = require "asynwait" function sleep (wait, seconds) sleepcb = wait.makecontinuation() return wait.yield(sleepcb) end function getinput (wait) inputcb = wait.makecontinuation() return wait.yield(inputcb) end asynwait.fork(function (wait) print("The task has started.") sleep(wait, 3) print("The task has continued after waiting for 3 seconds") local text = getinput(wait) print("The user supplied the task with the following text: " .. text) end, function (cr, err) print(debug.traceback(cr, err)) end) -- the callbacks are called directly as a test print("After a few seconds the sleep callback is called.") sleepcb() print("The user has decided on a text.") inputcb("Hello world!")
The fork function has the same arguments as the xpcall function. Most importantly it features a message handler that will be called in case of errors that occurred during the execution of the task.
The task function does not receive an immediate continuation function but rather a "wait" object which contains the function makecontinuation that returns a continuation function that can be used just a single time. To immediately catch accidental calls to yield, the current continuation function has to be passed as a parameter to yield, to prove that the place that called yield also had access to the continuation callback.
To get even closer to the syntax of C# my small module also allows "monkey patching" the wait object and adding custom functions to it:
asynwait.index.sleep = sleep asynwait.index.getinput = getinput asynwait.fork(function (wait) print("The task has started.") wait:sleep(3) print("The task has continued after waiting for 3 seconds.") local text = wait:getinput() print("The user supplied the task with the following text: " .. text) end, function (cr, err) print(debug.traceback(cr, err)) end)
"Monkey patching" is controversial but in well defined environments the added clarity of the waiting calls can be worth it.
The async-await syntax can help writing asynchronous tasks. As demonstrated in this article it is quite easy to implement such tasks using Lua coroutines. Depending on your exact use case, you can use either the default wrap function, my own module or some other implementation.