tl;dr: free model here, it depends on Class. pastebin here (I don’t guarantee it will stay up to date with the model).
Introduction
Today I felt especially coroutine-y, so I casually wrote a mini task scheduler in Roblox called Minisched.
Here’s some literature for your perusal.
What’s a task scheduler?
(“coroutine” and “thread” will be used interchangeably here, because they are basically the same thing. “Thread” is not a hardware thread, but rather a Lua thread, which is a coroutine.)
A task scheduler is what allows coroutines and yielding to work correctly. For example, when you wait(5)
in Roblox, the wait
function tells the Roblox task scheduler “resume my coroutine in 5 seconds”, and then calls coroutine.yield()
to pause the thread until the Roblox task scheduler resumes it.
While the thread is paused, other threads in the game are resumed by the scheduler and run as normal, which is why it is important to yield all the time so that other code in the game gets to execute. In fact, other code (even non-Lua code*) can only run while every Lua thread is yielding, which is why while true do end
can crash Roblox!
*except the script watchdog which forcefully terminates code that doesn’t yield, allowing Roblox to start running again
How does the Roblox scheduler work?
The Roblox task scheduler runs in an infinite loop, managing the entire game’s running threads. Every time a function yields using Roblox’s functions (as opposed to calling coroutine.yield()
directly), the Roblox task scheduler is notified. Once it’s time for your coroutine to run again, the task scheduler resumes your thread, executing the next portion of your code (until the function ends or it yields again).
A task scheduler can be a surprisingly simple system: all you need is a task queue, to store which coroutines you should resume and when, and a function that executes all overdue tasks based on the current time. You can call this function, which I named Dispatch
, whenever you want. By default, Minisched dispatches tasks every RunService.Stepped
, but you can change this.
The Roblox scheduler is almost certainly more complicated than a simple task queue, as you can bind tasks to any event (like BindableEvents, ChildAdded etc.) and tell the scheduler to resume your code only once an event is fired, but if you don’t need that functionality you don’t need to build it into your scheduler. Minisched only includes a simple task queue due to its “mini-” nature, but it can be easily extended.
When you call a function like spawn
, it places a new coroutine in the task scheduler’s queue rather than resuming it immediately, meaning your function will only start when the scheduler next executes. Internally, this is probably done by putting the newly created coroutine into the queue at time 0, and since the current time is always greater than or equal to 0, it always gets executed the next cycle.
delay
is like wait
in that it tells the scheduler to execute a function after some time, except instead of yielding the current coroutine to resume it later, it creates a new coroutine and tells the scheduler to start executing that one after some time.
Various other functions work simply by telling the task scheduler to resume some coroutine at some time. That is the basis of a working scheduler.
Why use a custom scheduler?
One of the issues with Roblox’s task scheduler is that you can’t really control tasks directly.
What if you want to cancel that RBXScriptSignal:Wait()
function you just called? Well, sorry, you can’t stop that, and if you resume that coroutine manually then Roblox is going to throw you a big fat error in the console if the scheduler attempts to resume it again.
What if you wait(60)
in a loop but then you find out that you need to resume that loop early? Well, you could do some trickery and math and stuff, or use an event instead of wait and control when the loop executes somewhere else and yadda yadda, ooor… you could just use a custom scheduler.
What are the advantages of a custom scheduler?
Custom schedulers are usually built to be a little more controllable than the Roblox scheduler. Minisched specifically gives you access to its queue (if you really want it), and additionally lets you cancel schedules you no longer need.
That’s right, Minisched lets you call wait()
* and then cancel it! It comes with a built-in utility function to wait for an event to fire but also return early if X seconds pass, and without leaving behind any lingering threads for Roblox’s own scheduler to worry about! The catch is that it’s only as accurate as however often you call Dispatch, and perhaps not as efficient as Roblox’s scheduler.
*well, minisched has its own wait function. You call that.
Additionally, you can manually schedule any coroutine to be resumed at any time, and as long as you call Dispatch frequently, Minisched will do just that.
Speaking of advantages, however… You may notice the asterisk in the title of this post, well that is because the scheduler itself is only around 100 lines long, but I included some utility functions like various spawns and waits that extend the length to about 160 230 lines.
Those utility functions act as a reference implementation of a couple common use cases for a custom scheduler, and also some examples of various spawns that have been used in the past to get around Roblox’s scheduling woes.
Minisched also includes your standard spawn and delay, but with one difference: Spawn
is intentionally called Queue
, to more accurately describe what it actually does rather than misleading the user. If you want to start executing a coroutine immediately, may I recommend Corospawn
? Or maybe Quickspawn
, if you want stack traces.
So with all that out of the way, what’s Minisched?
Minisched is a custom scheduler that I wrote in about a day. It started with a simple idea: wait for an event, with a timeout. But then I really wanted to not leave any lingering threads around if the event fired before the timeout, which meant I had to implement some sort of cancellable wait… which lead to a simple custom scheduler.
Some of the advantages of Minisched are mentioned in the previous section. There are more. The code is designed to be readable, and Minisched is designed to be flexible. You can make subclasses of Minisched, or even create multiple instances of Minisched that operate completely independently. You can call Dispatch manually whenever your code has a spare moment.
You can also read the code (and this little guide :D) to learn a little about how coroutines and yielding work in Roblox.
Docs
When you require
the ModuleScript you will get a default Minisched instance that is already bound to RunService.Stepped
. In most cases, this will be enough for most scripters, but you can always create new instances or even subclass Minisched.
Here’s detailed documentation on every single function Minisched offers.
Docs
Task Scheduler Functions
Minisched:New([bind: boolean]): Minisched
Creates a new Minisched instance. If bind
is not false
, then it will automatically bind RunService.Stepped
to its own Dispatch
method.
You don't need to call this if you don't need additional Minisched instances. For most use cases, the one scheduler will be enough. But this is always there if you need it.
Minisched:SortSchedule(): nil
Sorts the Minisched schedule so that the tasks set to execute the soonest are ordered first. This is needed for :Dispatch
to work correctly, and it done automatically by the :Schedule
function.
Minisched:Schedule(coro: coroutine, at: number[, ...]): Task
Schedules the coroutine coro
to be resumed by this Minisched instance once tick()
becomes greater than or equal to at
. If provided, the arguments ...
will be passed to the coroutine.
Like all coroutines in Lua, when passing arguments to a coroutine, if it hasn't been started yet then these will be the arguments passed to the coroutine's function. If it has started and is only yielding, these arguments will be what the coroutine.yield()
call returns.
This function returns the task that has been scheduled. If you want to cancel the task, you can use :Unschedule
. If you would like to run the task immediately, you can use :Resume
.
Minisched:Unschedule(task: Task): boolean
Removes the specified task from the queue, preventing it from being resumed if it hasn't been already.
Returns true if the task could be removed from the queue.
Minisched:Resume(task: Task[, resume: boolean]): boolean?[, ???]
Immediately resumes the specified task in the Minisched way, i.e. logging errors, setting auto-target and the like.
Returns nil if the coroutine can't be resumed (non-suspended
status). Returns the results of coroutine.resume
if it could be resumed.
This function is what's called by :Dispatch
to execute tasks.
Minisched:GetOverdueTasks([t: number[, remove: boolean]]): Task[]
Calling this function with remove
set to true
will remove all overdue tasks from the queue. If you do not resume these tasks manually, you will have dropped them, and that is very bad for a task scheduler!
Returns all overdue tasks from the task queue. If remove
is true
, all tasks returned will have been removed from their respective queues, and can be safely executed once. Otherwise no task queues will be mutated.
Normally, this function is only called by :Dispatch
. This is what you would override if you wanted to add, for example, multiple queues to the scheduler, for event waiting and such.
Minisched:Dispatch(): nil
Remember to call this function often, or else tasks will not be executed at the right times! If you don't call this frequently, tasks could be resumed very late, or even never. By default, Minisched automatically binds RunService.Stepped
to dispatch its tasks, and you don't have to call it manually, but keep this in mind if you tell it not to do that.
Resumes all overdue tasks in the task queue. If any task is not in a fit state to be resumed, prints a warning to console and moves on to the next task. If any task errors while being resumed by Minisched, prints a warning to console with the error message and traceback.
If you want to change which tasks are executed by Dispatch, you should override :GetOverdueTasks
.
Minisched:BindTo(event: RBXScriptSignal): RBXScriptConnection
Connects to event
so that every time it fires, this Minisched instance :Dispatch
es.
Returns the RBXScriptConnection
so that you can disconnect it later if needed.
Utility Functions
Minisched:Wait([t: number[, ...]]): ???
This function will throw an error if the current context is not yieldable. Usually, you don't have to worry about this. You will know what this means if you're working with non-yieldable code.
Schedules the current coroutine to be resumed by Minisched after t
(or 0) seconds, with the arguments specified by ...
. Yields the result of the :Schedule
, and returns the result of the yield.
In some cases if you're doing complicated coroutine magic the return results of this function may be different than what you passed to it. If they are, yay! You're doing your god's work here. Totally. Unless it caused you a headache. In which case I'm sorry.
Minisched:EventWait(event: RBXScriptSignal): ???
This function will throw an error if the current context is not yieldable. Usually, you don't have to worry about this. You will know what this means if you're working with non-yieldable code.
Waits for the passed event to fire. This works just like event:Wait()
, but resumes the current coroutine from this Minisched so that auto-targeting functions still work afterwards.
This function does not queue a task, it resumes it immediately as soon as the event fires.
Minisched:EventTimeout(event: RBXScriptSignal, t: number): boolean[, ???]
This function will throw an error if the current context is not yieldable. Usually, you don't have to worry about this. You will know what this means if you're working with non-yieldable code.
Waits for one of two things:
- One for when the event fires,
- And one for when the timeout is up.
When any one of these things happen, the thread is resumed. Then the other thing is immediately torn down and results are returned.
If the timeout is triggered, this function will return false
.
If the event fires before the timeout, this function will return true
plus whatever arguments were passed to the event.
Minisched:Encapsulate(func[, ...]): ???
This function will throw an error if the current context is not yieldable. Usually, you don't have to worry about this. You will know what this means if you're working with non-yieldable code.
Runs the function func
with the provided arguments. func
is allowed to yield using Roblox functions, and after it has completed, Minisched will resume the current thread. Returns whatever func
does.
This function is not a replacement for pcall
. Errors thrown in the provided function will propagate up.
Minisched.Corospawn(func: function[, ...]): coroutine[, ???]
Call this function with a dot, not a colon.
Returns a new coroutine coro
created from func
and the results of calling coroutine.resume(coro, ...)
.
Minisched.Quickspawn(func: function[, ...]): nil
Call this function with a dot, not a colon.
Uses a BindableEvent
to instantly call func
.
Minisched:Queue(func: function[, ...]): coroutine, function
Returns a new coroutine of func
and the results of :Schedule
-ing that coroutine to run immediately.
Minisched:Delegate(func: function[, ...]): coroutine, boolean[, ...]
Returns a new coroutine of func
and the results of immediately resuming that coroutine within this Minisched's context.
This function can be used to quickly shove another function onto another instance of Minisched. It's like corospawn, but the new coroutine runs in this instance of Minisched.
Minisched:Delay(func: function, t: number, [, ...]): coroutine, function
Returns a new coroutine of func
and the results of :Schedule
-ing that coroutine to run t
seconds from now.
Auto-targeting
While a coroutine is being resumed by Minisched’s dispatcher, it sets a context variable to let the auto-targeting functions know which Minisched instance is currently executing. This allows auto-targeting functions to work. What is auto-targeting, you may be wondering?
Currently, Wait
, EventWait
, EventTimeout
, Encapsulate
, Queue
, Delegate
, and Delay
have auto-targeting variants. You can invoke these by Minisched.T<func>(…)
. That is with a dot, not a colon. To invoke Wait on the current instance, you call TWait, or you can call TEventTimeout to invoke EventTimeout and so on.
These auto-targeting functions were created so that you can do this:
local wait,
eventWait,
eventTimeout,
corospawn,
quickspawn,
queue --[[ /spawn ]],
delegate,
delay =
Minisched.TWait,
Minisched.TEventWait,
Minisched.TEventTimeout,
Minisched.TEncapsulate,
Minisched.Corospawn,
Minisched.Quickspawn,
Minisched.TQueue,
Minisched.TDelegate,
Minisched.TDelay
Ta-da, now you can call wait()
as you would normally, as long as your code is running within Minisched. However, be warned that if your code yields at all outside of these auto-targeting functions, they won’t work anymore since the Roblox task scheduler will take over.
You can use :Encapsulate
to help with this problem, if you want your coroutine to remain within Minisched.
Minisched. Minisched. Minisched. Minisched. Minished- DAMNIT!
How to get it working
You can find Minisched in my models here. It depends on Class, which you can find here (just make Class a sibling of Minisched and don’t rename it). I would recommend putting both Minisched and Class in ReplicatedStorage (or ServerStorage if that’s the only place you’re using it).
Congratulations, you are using completely untested code! That’s right, I haven’t tested a single line of code in this module but I’m confident enough to release it to YOU, the general public. :DDD
Because I’m a totally great scripter.
Make sure to let me know which bugs you find. Will you find this one? That one? The other one? Who knows! It’s like an easter egg hunt. Except I’m late for easter.
I’m joking. I still haven’t tested this, but I would love to know what you all think and I definitely would love to know about any bugs you find so I can fix them.