It’s the ability to manually yield and resume coroutines that makes them useful as iterators, producer-consumers, state machines, and other solutions of technical sounding jargon.
But sometimes, we don’t care about all of that. We just want a coroutine to run some code concurrently to the main thread. And rather often as it seems. But because the OS cannot schedule or resume coroutines, we have to implement our own scheduler. Making that rather inconvenient.
The Roblox engine has its own scheduler that manages running tasks each frame. So why not make use of that?
The task library does just that—integrates coroutines in with the task scheduler making them behave very thread-like, under an easy to use interface.
Waiting
We’ve encountered lots of times.
Although the name is ‘wait,’ this function will also yield a coroutine AND resume it the heartbeat after the designated wait time has lapsed.
local co = coroutine.create(function()
for i = 1, 10 do
task.wait(0.5)
print("Iterate", i)
end
end)
coroutine.resume(co)
print("does not block!")
-- does not block!
-- Iterate 1
-- Iterate 2
-- Iterate 3
-- Iterate 4
-- Iterate 5
-- Iterate 6
-- Iterate 7
-- Iterate 8
-- Iterate 9
-- Iterate 10
Spawn
The spawn function creates a coroutine and immediately runs it. Returns the coroutine.
Additional arguments are passed to the provided function.
This is most similar to the ‘create’ function from the coroutine library.
local function someFn(task_id)
local waitTime = math.random()
task.wait(waitTime)
print(string.format("task %i completed in %f seconds", task_id, waitTime))
end
for i = 1, 5 do
task.spawn(someFn, i)
end
-- task 4 completed in 0.020479 seconds
-- task 5 completed in 0.358170 seconds
-- task 1 completed in 0.414421 seconds
-- task 3 completed in 0.443009 seconds
-- task 2 completed in 0.987697 seconds
Just like we saw earlier, if you do not yield it, the coroutines will run sequentially.
local function spawnCountToMax(max, taskId)
print(string.format('Spawn %s will count to %i.', taskId, max))
for i = 1, max do
print(taskId, "current count:", i)
end
print("------", taskId, "completed------")
end
task.spawn(spawnCountToMax, math.random(500, 1000), "a")
task.spawn(spawnCountToMax, math.random(500, 1000), "b")
task.spawn(spawnCountToMax, math.random(500, 1000), "c")
print("all tasks completed")
-- Spawn a will count to 647.
-- a current count: 1
-- a current count: 2
-- ...
-- a current count: 646
-- a current count: 647
-- ------ a completed------
-- Spawn b will count to 532.
-- b current count: 1
-- ...
-- b current count: 532
-- ------ b completed------
-- Spawn c will count to 650.
-- c current count: 1
-- ...
-- c current count: 650
-- ------ c completed------
-- all tasks completed
If you use ‘coroutine.yield’ inside a scheduled coroutine, the task scheduler will not resume it.
You can use ‘task.wait’ instead. The downside is that this will limit a loop to one cycle each heartbeat. The upside is that this does not cause your game to hang.
task.wait(5)
local function spawnCountToMax(max, taskId)
local value = 0
local yieldAt = 100000
print(string.format('Spawn %s will count to %i.', taskId, max))
for i = 1, max do
value = value + math.random()
if i % yieldAt == 0 then
task.wait() --cycle start/end
print(taskId, "resumed.")
end
end
print(string.format("%s completed. Final value is %f", taskId, value))
end
task.spawn(spawnCountToMax, math.random(5000000, 10000000), "a")
task.spawn(spawnCountToMax, math.random(5000000, 10000000), "b")
task.spawn(spawnCountToMax, math.random(5000000, 10000000), "c")
print("all tasks completed")
-- Spawn a will count to 6620088.
-- Spawn b will count to 5619805.
-- Spawn c will count to 5823009.
-- all tasks completed
-- a resumed.
-- b resumed.
-- c resumed.
-- a resumed.
-- ...
-- b completed. Final value is 2809756.622520
-- ...
-- c resumed.
-- c completed. Final value is 2909718.114857
-- ...
-- a resumed.
-- a completed. Final value is 3309506.540970
Deferred Tasks
The ‘task.defer’ function is similar to spawn. But rather than immediately running, defers it until a later “resumption cycle.” This is determined by the scheduler but in general this occurs during the current or immediately following heartbeat.
Unless you are running code that needs to be processed immediately, the task.defer function is recommended over task.spawn.
local function someFn(taskType, t)
local et = tick() - t
print(string.format("%s waited %f seconds", taskType, et))
end
for i = 1, 5 do
local t = tick()
task.defer(someFn, "defer", t)
task.spawn(someFn, "spawn", t)
end
--spawn waited 0.000008 seconds
--spawn waited 0.000008 seconds
--spawn waited 0.000007 seconds
--spawn waited 0.000006 seconds
--spawn waited 0.000006 seconds
--defer waited 0.012851 seconds
--defer waited 0.013005 seconds
--defer waited 0.013073 seconds
--defer waited 0.013133 seconds
--defer waited 0.013209 seconds
Delaying Tasks
Use the ‘task.delay’ function when you want to schedule a function to run sometime in the future. Runs the following heartbeat after the time has lapsed.
Additional arguments are passed to the function when it runs.
local function someFn(t)
local et = tick() - t
print(string.format("Waited %f seconds", et))
end
task.delay(2, someFn, tick())
--Waited 2.007945 seconds
Yielding Functions
If the thread encounters a long operation such as the asset download, it will still block.
This puts the thread in a situation where it cannot proceed beyond that point to call a yield but calling the yield before the download won’t initiate the download.
Both things must happen at the same time.
The final piece of the puzzle then must be handled automatically in the form of a yielding or ‘asynchronous functions.’
These types of functions usually have the word ‘async’ somewhere in its name:
Path:ComputeAsync()
DataStore:SetAsync()
TeleportService:TeleportPartyAsync()
Regardless, on the API they will always have the ‘Yields‘ label.
These type of functions yield the task and pushes it onto the scheduler which will resume once the operation is completed.
If we return to the asset download problem, we can illustrate this by downloading a series of assets.
local InsertService = game:GetService("InsertService")
local modelsList = {}
local idList = {
47433,
187789986,
6432233485,
6370681139,
175696937,
47620,
53326,
290069528,
187790284,
3374795585,
6933556508,
246270069,
113855979,
286794498,
6433316269,
6934081776
}
print("load")
local t = tick()
for index, id in ipairs(idList) do
local model = InsertService:LoadAsset(id)
table.insert(modelsList, model)
print(index, id, "loaded successfully.")
end
print("Done. ET:", tick() - t)
------ Done. ET: 4.495105028152466
The “naïve” approach is simple but it blocks the script and downloads each asset sequentially. The total time is the sum of all the downloads.
Instead, a coroutine or one of the task library functions can be used to download the assets in the background so the thread can process other things.
local InsertService = game:GetService("InsertService")
local modelsList = {}
local idList = {
47433,
187789986,
6432233485,
6370681139,
175696937,
47620,
53326,
290069528,
187790284,
3374795585,
6933556508,
246270069,
113855979,
286794498,
6433316269,
6934081776
}
local function loadInCoroutine(index, id)
local model = InsertService:LoadAsset(id)
table.insert(modelsList, model)
print(index, id, "loaded successfully.")
end
local t = tick()
for index, id in ipairs(idList) do
coroutine.wrap(loadInCoroutine)(index, id)
end
repeat
task.wait()
until #modelsList >= #idList
print("Done. ET:", tick() - t)
-- Done. ET: 0.8133363723754883
(Note that in a production environment, it is recommended to wrap the ‘LoadAsset’ function in a pcall.)