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.
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.
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.
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:
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.
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.
--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.
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.
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)