Scheduler
Runs kernel work in priority order under a per-frame budget, with deadlines instead of polling.
- Realm
- Shared
- Require
ReplicatedStorage.ChloeKernel .Scheduler - Since
- v0.1.0
- Source
- Scheduler.luau
The Scheduler decides when kernel work runs. It keeps five priority queues, drains them once per engine step under a
time budget measured with os.clock(), and defers whatever does not fit to a later step instead of blowing the frame.
Timers live in a deadline heap, and a scheduler with nothing ready disconnects from its engine phase and arms one wakeup
for its earliest deadline, so an idle scheduler costs nothing per frame. Kernel boot
creates one bound to Heartbeat, and processes, diagnostics and every kit job run through it.
Scopes own the handles it returns.
Mental model
Section titled “Mental model”The Scheduler holds three kinds of pending work:
- Five FIFO queues, one per priority:
Kernel = 1,High = 2,Normal = 3,Low = 4,Background = 5. Lower runs first. Each queue is an array with a head index, so dequeue is O(1) with notable.removeshifting.schedule()puts a task straight into its queue. - A deadline heap for
every(interval)with a positive interval and forafter(delay). It is a binary min-heap keyed by the next deadline, and cancelling a handle removes its entry directly. - A frame list for
every(0)jobs, which want every step.
A step does this, in order:
- Reads the engine frame stats once and computes the budget.
- Pops every heap entry whose deadline has passed and enqueues it at its priority, then enqueues every
every(0)job that is not already queued or running. - Drains queue 1 through queue 5. Before each task outside
Kernelpriority it comparesos.clock()with the deadline. Once the budget is spent, every task still waiting in that queue and the queues after it counts as deferred and waits for the next step. - Records the step’s time and deferrals, then decides whether to stay attached to the phase (see Sleeping while idle).
Each queue’s depth is snapshotted when its drain begins. Work added to the queue being drained, or to a queue this step already drained, waits for the next step, so a task that reschedules itself cannot livelock the frame. Work added to a lower-priority queue the step has not reached yet can still run in this step if the budget allows.
Every task runs under xpcall with debug.traceback. A crash fires Scheduler.OnError and the step continues with the
next task.
Two properties follow from the drain order and are deliberate:
Kernelpriority is budget-exempt. Deferring the kernel’s own work (diagnostics, health reporting) would stall the thing doing the deferring, so Kernel-priority tasks run even on a saturated frame.Backgroundstarves first. Under sustained overload the budget runs out somewhere in the middle queues and queue 5 is never reached.Backgroundmeans “only on spare budget”. Work that must eventually run belongs atLow.
Sleeping while idle
Section titled “Sleeping while idle”Since 0.8.0 a bound scheduler is connected to its phase signal only while it has something to do. After every step, schedule, cancel and wake it checks for runnable work:
- a non-empty queue,
- an
every(0)job that is live and not mid-run, or - a deadline at most 34 ms away.
With runnable work it connects the phase signal (if it was not already connected) and steps every frame. Without it,
it disconnects and arms one task.delay for the earliest deadline, set to fire 34 ms early. The delayed thread runs no
tasks. It only reattaches the phase signal, so the deadline is served by a normal budgeted step. Scheduling earlier work
replaces the pending wake, and cancelling the last timer cancels it.
The 34 ms window matters in both directions. A recurring job with a short interval such as every(0.02) keeps the
scheduler attached instead of disconnecting and arming a delayed thread around every run (0.9.0), and a longer deadline
wakes one window early so the phase is attached before it comes due.
The result: 1,000 timers in the future cost one delayed thread and zero steps until the first comes due, and an
every(0) job is the only permanent per-frame cost. unbind() disconnects and cancels the wake; step() still works
manually after it.
The frame budget
Section titled “The frame budget”An explicit step(budgetSeconds) argument always wins. Otherwise a configured TargetFrameSeconds selects frame-aware
mode, and without one the fixed BudgetSeconds slice applies.
The server defaults to frame-aware mode against a 16.5 ms target, slightly under 1/60 s to leave headroom for engine
work. With Target = TargetFrameSeconds and Floor = BudgetSeconds:
first step after reattaching: Budget = max(Target - HeartbeatTime - PhysicsStepTime, Floor)no earlier step, or late: Budget = Floor (last step at least 1.25 x Target ago)otherwise: Others = max(HeartbeatTime - StepEma, 0) + PhysicsStepTime Budget = max(Target - Others, Floor)What each term is for:
HeartbeatTimeis the engine’s measured compute share of the frame, and it already includes the scheduler’s own previous work. The scheduler’s smoothed step cost (StepEma, an exponential average with alpha0.1, matched to the stat’s own smoothing) is subtracted back out. Without that, a scheduler filling idle frames would read as a busy engine and shrink its own budget in a feedback loop.PhysicsStepTimeis added separately because physics time is not part ofHeartbeatTime.- A late frame (the previous step was at least 1.25 times the target ago) does not trust the engine stats and drops to the floor. This catches overload and costs the stats do not measure, such as replication serialization.
- The first step after the scheduler reattaches from idle skips both the
StepEmacorrection and the late-frame rule. The scheduler did no work in the frame it slept through, so the whole engine counter belongs to other work. Before 0.9.0 that step always hit the late-frame rule and ran at the floor. - The budget never drops below
BudgetSeconds, default0.0015(1.5 ms). In frame-aware mode it is the floor.
An idle server drains backlog at up to about 15 ms per frame, a physics-heavy server backs off by itself, and a starved server still makes 1.5 ms of progress every frame.
The stats come from Stats.HeartbeatTime and Stats.PhysicsStepTime in seconds (engine 0.649 and later). The module
checks once at load whether HeartbeatTime exists, and on older engines and test doubles it reads the deprecated
HeartbeatTimeMs and PhysicsStepTimeMs and divides by 1000. Both are read once per step, so the budget and the
diagnostics describe the same frame.
The client defaults to a fixed 1.5 ms slice. HeartbeatTime excludes the render thread, which is the most expensive
thing a client does, so a frame-aware client budget would see a mostly empty frame, fill it with task work and starve
rendering.
Passing BudgetSeconds alone selects fixed mode on either machine, and passing TargetFrameSeconds selects frame-aware
mode with that target. Scheduler.spec.luau checks both.
Try it
Section titled “Try it”The model below runs the same step every frame: queues drained Kernel first, the deadline checked before each task
below Kernel priority, deferred work left in place for the next frame. Raise the load until lower priorities start
deferring, or drop in a single 12 ms task to watch one long task overrun the whole frame. Three one-second windows in a
row with deferrals publish Kernel.Overload on the server. The client view starts from a smaller load, because the
client only gets its fixed slice (BudgetSeconds, 1.5 ms by default) and leaves the rest of the frame to rendering; the
default client boot does not enable diagnostics, so it publishes no Kernel.Overload.
kernel.Scheduler is the Heartbeat scheduler, and kernel.Priority re-exports Scheduler.Priority.
-- One-shot on the next step, Normal priority by defaultkernel.Scheduler:schedule(function() print("next step")end, kernel.Priority.High)
-- Extra arguments pass through to the taskkernel.Scheduler:schedule(function(a: number, b: number) print(a + b)end, nil, 2, 3)
-- One-shot on a deadline: runs about 5 seconds from nowlocal Reminder = kernel.Scheduler:after(5, function() kernel.Bus:publish("Lobby.Reminder")end)Reminder:cancel() -- removed from the heap at once; the callback never runs
-- Recurring every 30 seconds, only on spare budgetlocal Sweep = kernel.Scheduler:every(30, function() kernel.Bus:publish("World.Sweep")end, kernel.Priority.Background)Sweep:cancel()
-- Task crashes are isolated and land herekernel.Scheduler.OnError:connect(function(err: string) print("task crashed:", err)end)A burst of changes coalesces into one piece of work with one pending after handle:
local Pending: any = nil
local function markDirty() if Pending then return -- a rebuild is already armed end Pending = kernel.Scheduler:after(0.5, function() Pending = nil kernel.Bus:publish("Leaderboard.Rebuild") end, kernel.Priority.Low)endevery(0, fn) is the per-frame case. A positive interval is drift-free: the entry is pushed back onto the heap only
after the callback returns, with its next deadline at the previous deadline plus the interval. When the scheduler has
fallen behind by a whole interval the next deadline snaps to now + interval, so missed ticks never fire in a burst.
every() and after() reject a non-number, NaN, a negative value and math.huge at the call site, with
Scheduler.every interval must be a finite number >= 0 (got <value>) or the matching Scheduler.after delay message. A
NaN interval would otherwise run the task every frame forever, which is invisible until a live server melts.
every() and after() pass no arguments to the callback.
Staggering
Section titled “Staggering”Jobs created in the same frame with the same interval fire in the same frame forever. StaggerRecurring = true (a
boot option, or a Scheduler.new option) delays the first deadline of
each positive-interval every() by ((n * 0.618...) mod 1) * interval, where n counts every() calls on that
scheduler. First runs spread over one extra interval, and later runs keep their cadence. after() deadlines are never
staggered. Leave it off when game rules depend on periodic jobs starting in the same frame.
Phase schedulers
Section titled “Phase schedulers”The default scheduler steps on Heartbeat. Work that has to line up with physics or the camera gets its own scheduler
on another engine phase through the kernel:
-- Physics-coupled work: runs before each simulation steplocal PreSim = kernel:phaseScheduler("PreSimulation")
-- Camera work, client only: runs before each renderlocal PreRender = kernel:phaseScheduler("PreRender")Phases are "Heartbeat", "PreSimulation", "PostSimulation" and "PreRender". Binding "PreRender" on the server
errors PreRender schedulers are client-only. Phase schedulers are cached per phase and run a fixed budget equal to the
main scheduler’s BudgetSeconds, because a frame-aware phase scheduler would claim slack the Heartbeat scheduler
already spent.
Tasks that yield
Section titled “Tasks that yield”Tasks run inside the step, on the phase signal’s thread. A task that yields suspends the rest of its step, and the next phase signal starts a new step that takes over the queues. When the yielded task resumes, its step sees that newer steps ran and returns without recording its stale elapsed time, deferrals or window maximum. Before 0.9.0 the yield time leaked into the frame budget and the overload statistics.
A recurring callback that yields cannot overlap itself: its next deadline is pushed only after it returns, and a
mid-run every(0) job is not queued again. It can still interleave with other work, and cancelling its handle stops
future runs without undoing what a running callback already did.
Do not yield in a task. Wrap an *Async call in task.spawn with a busy flag:
local HttpService = game:GetService("HttpService")local Players = game:GetService("Players")local Logger = require(game:GetService("ReplicatedStorage").ChloeKernel.Logger)
local Log = Logger.scope("StatsUpload")local StatsUrl = "https://example.com/stats"local Busy = false
kernel.Scheduler:every(60, function() if Busy then return -- the previous upload is still running end Busy = true task.spawn(function() local Body = HttpService:JSONEncode({ Players = #Players:GetPlayers() }) local Ok, Err = pcall(HttpService.PostAsync, HttpService, StatsUrl, Body) -- yields Busy = false if not Ok then Log.warn("upload failed", Err) end end)end, kernel.Priority.Background)Driving a scheduler manually
Section titled “Driving a scheduler manually”step(budget?) is public so tests can drive time without binding or waiting:
local Scheduler = require(game:GetService("ReplicatedStorage").ChloeKernel.Scheduler)
local Sched = Scheduler.new()local Order = {}Sched:schedule(function() table.insert(Order, "normal")end, Scheduler.Priority.Normal)Sched:schedule(function() table.insert(Order, "kernel")end, Scheduler.Priority.Kernel)Sched:step(1) -- a one-second budget drains everything-- Order is { "kernel", "normal" } whatever the schedule orderSched:destroy()step(0) is the starvation test: due timers and every(0) jobs still move into their queues, but only Kernel-priority
work runs. An unbound scheduler never wakes by itself; only step() runs it.
API reference
Section titled “API reference”Constructor and statics
Section titled “Constructor and statics”| Member | Description |
|---|---|
Scheduler.new(config?): Scheduler |
config = { BudgetSeconds: number?, TargetFrameSeconds: number?, Phase: Phase?, ProfileTasks: boolean?, StaggerRecurring: boolean? }. Server default: frame-aware, target 0.0165, floor 0.0015. Client default: fixed 0.0015. Phase defaults to "Heartbeat", ProfileTasks to true, StaggerRecurring to false. |
Scheduler.Priority |
{ Kernel = 1, High = 2, Normal = 3, Low = 4, Background = 5 }. Re-exported as Kernel.Priority. |
Scheduler.setTaskOrigin(fn, label) |
Names fn in the profiler instead of its script:line. Process uses it to label each process Process <Name>. |
Scheduler.setMicroProfilerZones(enabled) |
Turns named MicroProfiler zones around every task on or off, for every scheduler in the VM. Default: on in Studio only. |
Scheduler.getMicroProfilerZones(): boolean |
Current zone state. |
Instance
Section titled “Instance”| Member | Description |
|---|---|
scheduler:schedule(fn, priority?, ...): TaskHandle |
One-shot on the next step. Extra arguments pass through to fn. |
scheduler:after(delaySeconds, fn, priority?): TaskHandle |
One-shot when the deadline passes. Errors on a NaN, negative or infinite delay. Added in 0.8.0. |
scheduler:every(intervalSeconds, fn, priority?): TaskHandle |
Recurring. 0 runs every step; a positive interval uses the deadline heap. Errors on a NaN, negative or infinite interval. |
scheduler:step(budgetSeconds?) |
Runs one drain. An explicit budget overrides both modes. |
scheduler:bind() / scheduler:unbind() |
Enables or disables automatic stepping on the configured phase. A bound scheduler attaches only while it has runnable work. |
scheduler:setTaskProfiling(enabled) |
Turns per-task profiling on or off at runtime. Turning it off clears the profile and the window’s task and deferral attribution. |
scheduler:takeStepWindow(): (number, number, number, number, number, string, string) |
Drains the diagnostics window: total step time, step count, longest step, deferred count, longest task, that task’s origin, and the top five deferred origins as one string. |
scheduler:queueDepths(): ({ number }, number) |
Depth per priority queue, plus the number of pending every() and after() entries. Debug only. |
scheduler:topTasks(count): { HotTask } |
Profiler readout, hottest first. HotTask = { Key, Time, Calls, Max }. |
scheduler:resetProfile() |
Clears the profiler. Pair with topTasks over a fixed window. |
scheduler:destroy() |
Unbinds, destroys OnError, cancels every handle it owns and clears every queue. Later schedule, after, every and bind calls assert scheduler is destroyed; step() does nothing. |
scheduler:scheduleEntry(entry) |
Internal allocation-free enqueue used by Process. The caller must never queue one entry twice. Game code should not use it. |
scheduler.OnError |
Signal fired with the traceback string when a task crashes. The kernel connects a [ChloeKernel] warning at boot. |
scheduler.BudgetSeconds |
The fixed slice, or the floor in frame-aware mode. |
scheduler.TargetFrameSeconds |
The frame-aware target, or nil in fixed mode. |
scheduler.Phase |
The engine phase bind() attaches to. |
scheduler.ProfileTasks, scheduler.StaggerRecurring |
The current settings. |
scheduler.Bound, scheduler.Destroyed |
Lifecycle flags. |
TaskHandle is { Cancelled: boolean, cancel: (self) -> () }. Cancelling an after() or every() handle removes the
entry from the heap or frame list at once and updates the wakeup. Cancelling a queued schedule() handle skips the task
when its turn comes. A cancelled task never runs. Cancelling a handle whose task already ran does nothing.
Priorities are floored and clamped to 1-5, so 2.5 runs at High. Before 0.9.0 a fractional priority indexed a
missing queue and errored.
scheduler.Stats is updated every step:
| Field | Meaning |
|---|---|
LastStepSeconds |
Wall time of the last completed step. |
LastBudgetSeconds |
The budget the last step ran under. Changes per frame in frame-aware mode. |
LastFrameBusySeconds |
Stats.HeartbeatTime read at step start. |
LastPhysicsSeconds |
Stats.PhysicsStepTime read at step start. |
TasksRun |
Tasks run since creation. |
TasksDeferred |
Tasks pushed past the deadline in the last completed step. Mid-step reads see the previous step’s value, so overload never reads as zero. |
StepEma |
Smoothed step cost (alpha 0.1), the frame-aware correction term. |
WindowTime / WindowSteps / WindowMax / WindowDeferred |
Accumulators drained by takeStepWindow(). |
WindowTaskMax / WindowTaskKey |
Longest single task in the window and its origin. Profiling only. |
WindowDeferredKeys |
Deferred count per origin in the window. Profiling only. |
Deferral comes in bursts, so a point sample between bursts reads zero during real overload. Diagnostics report the
window sum, and kernel:enableDiagnostics() publishes Kernel.Overload on the Bus only after deferred
work shows up in three consecutive windows.
The hot-task profiler
Section titled “The hot-task profiler”Task profiling is on by default. Each task’s wall time is attributed to the script:line where its function was
defined:
debug.info(fn, "sl")runs once per function identity, and origins are cached in a weak-keyed table, so steady-state attribution is a table lookup.topTasks(n)returns the hottest origins by total time with call counts and the longest call. Paired withresetProfile()over a fixed window,Timereads as milliseconds per second. That is whatenableDiagnosticsdoes for itsHotTasksattributes and what the debug panels show.- The window also tracks the single longest task (
TaskMaxMsin diagnostics) and which origins were deferred (DeferredKeys), so an overload report names the work that did not fit. Scheduler.setTaskOrigin(fn, label)names a function explicitly. Without it every resumed process would share onescript:lineinside the process module.
Profiled durations are elapsed wall time, including any yield inside the task. They are not CPU time.
The README benchmark puts the profiler at about 33 ns per task (see Benchmarks). A lean
production boot can drop even that with ProfileTasks = false: no clock reads per task, no origin lookups and no scan
of the deferred backlog, while the aggregate budget statistics stay. enableDiagnostics turns profiling back on,
because its attributes need it.
MicroProfiler zones are a separate, heavier layer: debug.profilebegin and debug.profileend around every task, named
by the origin’s Module:line label, so per-task cost shows in the engine timeline (Ctrl+F6). The benchmarks measure
388 ns per schedule and step with zones against 354 ns without, so zones are on in Studio only by default. A live server
opts in with Scheduler.setMicroProfilerZones(true).
Design notes and gotchas
Section titled “Design notes and gotchas”- There is no separate
deferAPI. A task that callsschedule()at its own priority or a higher one hands that work to the next step, which is how a task yields the rest of the frame to the budget. OnErrorreceives the traceback string, since tasks run underxpcall(fn, debug.traceback). Its second argument is an entry name thatschedule,everyandafternever set, so handlers should expectnil.- Process crashes never reach
OnError. A process catches its own errors and ends asCrashed.