6.4 Task Library

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.

Lua
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.

Lua
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.

Lua
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.

Lua
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.

Lua
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.

Lua
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.

Lua
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.

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