Minisched, a <100* line Lua scheduler

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

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!

MINI-SHED, GET IT?

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.

19 Likes

I really would recommend testing it now just to be on the safe side.

1 Like

I tested minisched:Wait(5) :stuck_out_tongue:

You should try it out for yourself too though!

1 Like

Good job on this, I can think of a few viable use cases for this right off the bat!

1 Like

A new version of Minisched has been released! As always, the code is under 100 lines, but the utility functions have grown by a lot.

Change list

  • Added functions Unschedule, Resume, EventWait, Encapsulate, and Delegate
    • Unschedule removes a task from the queue
    • Resume immediately resumes a task, optionally unscheduling it for you as well
    • EventWait waits for an event to fire, and resumes it in a Minisched context rather than the Roblox task scheduler
    • Encapsulate allows you to use Roblox yielding functions and still return to your Minisched context
    • Delegate is like Queue but it instantly runs the function. It’s effectively corospawn but again in a Minisched context
  • Added auto-targeting functions TWait, TEventWait, TEventTimeout, TEncapsulate, TQueue, TDelegate, TDelay
    • In a Minisched context, you can call these functions to target the Minisched instance your thread is currently running under. Be aware that threads can be juggled between schedulers, if you call a roblox yielding function or a yielding function under a specific Minisched instance.
    • You may see the term “Minisched context” being tossed around. Whenever Minisched resumes a task, it sets the “Minisched context” so that autotargeting works. This is reset after the coroutine finishes or yields, which is why yielding to a different task scheduler always affects auto-targeting (this cannot be fixed easily). The minisched context is basically a variable that points to whatever Minisched instance is currently executing a task.

Breaking changes

  • Changed Corospawn and Quickspawn to be static methods. This means you call them with . instead of :
  • Changed Schedule to return the task that was queued rather than a function that you can call to remove it. This allows for more flexibility: resuming the task earlier than planned, changing its scheduled time (docs have yet to be written on the structure of the task object), and still removing it of course.

The model and Pastebin have both been updated. Enjoy!

4 Likes