6.3 Coroutines

Say your game needs to download several assets from the web. And each download will take a bit of time.

The thing about asset download operations is that they are “blocking” calls.

Under the sequential approach that we have been using, this means that the thread must halt at each download and wait for it to finish.

Meaning if there are a lot of downloads, a significant amount of time will be spent just waiting.

Lua
local function longDownload(arg)
	--simulate a long download
	local tid = arg --task id
	local timeLeft = math.random()
	local t1 = tick()

	while timeLeft > 0 do
		local t2 = tick()
		local dt = t2 - t1 --delta time 
		t1 = t2
		timeLeft = timeLeft - dt
	end
	print(tid, "complete")
	return math.random(1000)
end

local startTime = tick()

local asset1 = longDownload("a") --"blocking" function call
local asset2 = longDownload("b") --"blocking" function call

print("R1:", asset1, "R2:", asset2)
print(string.format("Completed in %f seconds.", tick() - startTime))

-- a complete
-- b complete
-- R1: 275 R2: 337
-- Completed in 1.650384 seconds.

The ‘longDownload’ function is just to simulate a long blocking call. The implementation does not matter, we only care that it simulates a block and prevents the script from proceeding until the “download” is complete.

This is known as an ‘I/O bound operation’ because the CPU is unable to process anything else while it waits on input or output from another source.

More likely than not, there are other things that do need to be handled. So instead of waiting around, what if we can restructure the script in such a way that downloads can run simultaneously with other tasks?

This is something you already do instinctively. If you’ve ever, for instance, kept busy with some other task while that tasty burrito was heating in the microwave.


So lets rewrite the function to initialize the download but then push them to the background so the thread does not block.

A function is returned which that can be invoked to get the download status.

Lua
local function initDownload(arg)
	--simulate non-blocking download
	local tid = arg
	local timeLeft = math.random()
	local t1 = tick()

	return function()
		local t2 = tick()
		local dt = t2 - t1
		t1 = t2
		timeLeft = timeLeft - dt

		if timeLeft < 0 then
			print(tid, "complete.")
			return true, math.random(1000)
		else
			return false 
		end
	end
end

local download1 = initDownload("a")
local download2 = initDownload("b")

local asset1 = nil
local asset2 = nil

local startTime = tick()

repeat
	if not asset1 then
		local complete, result = download1()
		asset1 = result
	end

	if not asset2 then
		local complete, result  = download2()
		asset2 = result
	end
until asset1 and asset2

print("Asset 1:", asset1, "Asset 2:", asset2)
print(string.format("Downloaded assets in %f seconds.", tick() - startTime))

-- b complete.
-- a complete.
-- Asset 1: 365 Asset 2: 396
-- Downloaded assets in 0.489248 seconds.

Again, ‘initDownload’ just is to simulate a long download so the actual implementation does not matter.

The function initializes the download and returns a function. Each time that function is invoked, it returns the status of the download until the download is completed, in which case it returns the download.

At the top level, the loop still blocks the thread until both downloads are completed but from this, we can see how ‘concurrency’ can be used to run multiple downloads and thus reduce the overall time.

By strategically switching between processing tasks and doing so quickly enough, this interweaving gives the illusion that multiple operations are being run at the same time.

What we’ve just created is a primitive form of ‘multitasking’


Obviously, the example above is too niche and not really usable in a general setting.

But it gives us a model from which to abstract a set of rules and implement a system that is useful in a general setting.

    First, this system must be able to suspend execution and resume at that same point at a later time. So this would also mean it needs data to persist between calls.

    Additionally, because we want this to be a user level component (i.e. to be implemented by you, the developer), it must also be at the user level where the suspension is initiated.


    Functions are often called ‘subroutines.’ And depending on who you ask, might or might not be the same thing. Though they differ drastically, from the point of view of the developer, coroutines also look a lot like functions.

    Pedantry aside, both represent some form of routine for the CPU to run.

    But it is the requirements set above that define what a coroutine is and also underscore how coroutines are used to address a different set of problems.

    Creating and Resuming

    coroutine.create‘ takes a functional argument and returns a new coroutine.

    The returned coroutine has yet to run and needs to be started for the first time using the ‘coroutine.resume’ function.

    Arguments given at this time will be passed to the function and will persist throughout the coroutine’s lifetime unless overridden.

    The ‘coroutine.yield’ function must be called from within the coroutine to yield it. Then the resume function can be used again to resume where the coroutine left off.

    Lua
    local co = coroutine.create(function(arg)
    	print("Recieved input:", arg)
    	coroutine.yield()
    	
    	print(string.format("%i times 2 is %i", arg, arg * 2))
    end)
    
    coroutine.resume(co, math.random(10))
    coroutine.resume(co)
    
    -- Recieved input: 3
    -- 3 times 2 is 6

    To give a slightly more detailed example:

    Lua
    local function incrementRandom(arg)
    	print("Got initial value", arg)
    	local loops = 3
    	coroutine.yield()
    
    	repeat
    		local rand = math.random(10)
    		arg = arg + rand
    		print("Added", rand)
    		local stop = coroutine.yield()
    	until stop
    	
    	print("The final value is", arg)
    end
    
    local co = coroutine.create(incrementRandom)
    
    coroutine.resume(co, math.random(10))
    coroutine.resume(co)
    coroutine.resume(co)
    coroutine.resume(co)
    coroutine.resume(co)
    print("Status:", coroutine.status(co))
    coroutine.resume(co, true)
    print("Status:", coroutine.status(co))
    
    -- Got initial value 7
    -- Added 7
    -- Added 2
    -- Added 5
    -- Added 8
    -- Status: suspended
    -- The final value is 29
    -- Status: dead

    Here, we use yield to step through each iteration of a loop and increment a local value. When enough loops have run, we can pass in an argument which exits the loop and prints out the final value.

    The ‘coroutine.status’ function can be used to get the status of a coroutine at any time


    Instead of running in a loop, this example illustrates different “entry points” of the coroutine.

    Lua
    local co = coroutine.create(function(arg)
    	print(string.format("Received %i.", arg))
    	local n = coroutine.yield("Value to add?\n")
    	
    	arg = arg + n
    	print(string.format("Added %i. New value is %i.", n, arg))
    	n = coroutine.yield("Value to multiply?\n")
    	
    	arg = arg * n
    	print(string.format("Multiplied by %i. New value is %i.", n, arg))
    	n = coroutine.yield("Raise to what power?\n")
    	
    	arg = arg^n
    	print(string.format("Raised to the power of %i. Final value is %i.", n, arg))
    	return arg
    end)
    
    local success, output = coroutine.resume(co, math.random(10))
    print(result)
    
    success, output = coroutine.resume(co, math.random(10))
    print(result)
    
    success, output = coroutine.resume(co, math.random(10))
    print(result)
    
    success, result = coroutine.resume(co, math.random(10))
    
    -- Received 9.
    -- Value to add?
    
    -- Added 3. New value is 12.
    -- Value to multiply?
    
    -- Multiplied by 2. New value is 24.
    -- Raise to what power?
    
    -- Raised to the power of 6. Final value is 191102976.

    Each step is passed in a new argument and returns a different message which is printed out to the console.


    Coroutines are NOT Threads

    So now you might be confused.

    Coroutines are often called ‘threads.’ And if you pass a coroutine to the ‘type’ function, it will return type ‘thread.’ This has led to the belief that coroutines are threads or generate new threads.

    And for the sake of convention, this is fine as long as you understand the distinction between coroutines and threads.

    A thread is an “execution context” that is managed and scheduled [usually] by the operating system.

    That might not mean anything to you. Which is also fine.

    The only part we’re concerned with is the thing about ‘managed/scheduled by the OS.’ (There are such things as “user-level threads” but just pretend I didn’t tell you that.)

    While a program is running, it (the ‘process’) maintains one or more threads that need to be executed by the CPU. The scheduler then assigns CPU time for each thread to run.

    Because the thread is managed by the scheduler, it may be suspended at any time to execute a different thread. This switching between threads is called ‘preemptive scheduling’ and happens automagically in the background, fully transparent to the user.

    Coroutines are a type of “non-preemptive task.” This means that they are not managed by the scheduler. They cannot be called by the scheduler and cannot be yielded externally. This puts the “scheduling” in your hands.

    And with great power comes great responsibility.

    Coroutines have to be manually started and also manually resumed. And while the coroutine is running, it will consume the thread until its execution is complete or it encounters a yield condition.

    If a coroutine has a long operation and you do not define it a yield condition, the coroutine will hold that thread until it has completed. Or indefinitely if there is not a yield condition.

    Lua
    --run this in a script
    task.wait(5) --wait for play session to load
    
    local function count_to(max, taskId)
    	local value = 0
    	print(string.format('Starting coroutine %s.', taskId))
    
    	for i = 1, max do
    		value = value + math.random()
    	end
    	print(string.format("%s completed. Final value is %f", taskId, value))
    end
    
    local co1 = coroutine.create(count_to)
    local co2 = coroutine.create(count_to)
    local co3 = coroutine.create(count_to)
    
    coroutine.resume(co1, 20000000, "a")
    coroutine.resume(co2, 20000000, "b")
    coroutine.resume(co3, 20000000, "c")

    Although three coroutines are created, there is still only one thread. Because none of them yield, they still run in a sequential manner (and likely also causes your session to hang for short amount of time).

    To allow the other tasks to run, we’ll have to give it a point to yield and then resume it at a later time.

    Here is a modified version that yields every n iterations, allowing the thread to be release. We can then use a loop to interweave between tasks.

    Lua
    task.wait(5)
    
    local function count_to(max, taskId)
    	local yieldEvery = 100
    	local value = 0
    
    	print(string.format('Coroutine %s will run %i iterations.', taskId, max))
    	coroutine.yield()
    
    	for i = 1, max do
    		value = value + math.random()
    
    		if i % yieldEvery == 0 then
    			print(taskId, "yielded.")
    			coroutine.yield()
    			print(taskId, "resumed.")
    		end	
    	end
    	
    	print(taskId, "final count:", value)
    end
    
    local co1 = coroutine.create(count_to)
    local co2 = coroutine.create(count_to)
    local co3 = coroutine.create(count_to)
    
    coroutine.resume(co1, math.random(500, 1000), "a")
    coroutine.resume(co2, math.random(500, 1000), "b")
    coroutine.resume(co3, math.random(500, 1000), "c")
    
    local statusRunning = false
    
    repeat
    	statusRunning = false
    
    	if coroutine.status(co1) == 'suspended' then
    		coroutine.resume(co1)
    		statusRunning = true
    	end
    
    	if coroutine.status(co2) == 'suspended' then
    		coroutine.resume(co2)
    		statusRunning = true
    	end
    
    	if coroutine.status(co3) == 'suspended' then
    		coroutine.resume(co3)
    		statusRunning = true
    	end
    
    until not statusRunning
    
    print("all tasks completed")
    
    -- Coroutine a will run 921 iterations.
    -- Coroutine b will run 897 iterations.
    -- Coroutine c will run 943 iterations.
    -- a yielded.
    -- b yielded.
    -- c yielded.
    -- a resumed.
    -- a yielded.
    -- b resumed.
    -- ...
    -- ...
    -- a yielded.
    -- b resumed.
    -- b final count: 434.5365143915687
    -- c resumed.
    -- c yielded.
    -- a resumed.
    -- a final count: 453.16351512303373
    -- c resumed.
    -- c final count: 481.672688307171
    -- all tasks completed

    In the next section, we’ll see how the task library can make coroutines behave very thread-like but just keep in mind that at its core, coroutines are built atop the basic requirements we defined earlier.


    Wrap

    The ‘wrap’ function is an abstraction to simplify the creation and usage of coroutines. Wrap returns a function (not coroutine) that is directly invoked to resume the coroutine.

    Any arguments passed to that function are then forwarded to the underlying coroutine.

    Lua
    local addIngredient = coroutine.wrap(function(arg)
    	print("What would like to add to your wrap?")
    	local ingredients = {arg}
    	local nextIngredient = nil
    	
    	repeat
    		nextIngredient = coroutine.yield(#ingredients)
    		
    		if nextIngredient ~= nil then
    			table.insert(ingredients, nextIngredient)
    			print(string.format("Added %s. Anything else?", nextIngredient))
    		end
    		
    	until nextIngredient == nil
    	
    	print("Here is your delicious wrap.\n")
    	return ingredients
    end)
    
    local _ = addIngredient("Tortilla")
    _ = addIngredient("Chicken")
    _ = addIngredient("Tomato")
    _ = addIngredient("Chipotle Sauce")
    local ingredients = addIngredient()
    print(ingredients)