async-await in Lua

Abstract

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.

Introduction to async-await

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.

async-await with the default Lua library

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.

A small specialized library

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.

Conclusion

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.