Skip to content

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.

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 no table.remove shifting. schedule() puts a task straight into its queue.
  • A deadline heap for every(interval) with a positive interval and for after(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:

  1. Reads the engine frame stats once and computes the budget.
  2. 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.
  3. Drains queue 1 through queue 5. Before each task outside Kernel priority it compares os.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.
  4. 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:

  • Kernel priority 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.
  • Background starves first. Under sustained overload the budget runs out somewhere in the middle queues and queue 5 is never reached. Background means “only on spare budget”. Work that must eventually run belongs at Low.

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.

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:

  • HeartbeatTime is 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 alpha 0.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.
  • PhysicsStepTime is added separately because physics time is not part of HeartbeatTime.
  • 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 StepEma correction 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, default 0.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.

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 default
kernel.Scheduler:schedule(function()
print("next step")
end, kernel.Priority.High)
-- Extra arguments pass through to the task
kernel.Scheduler:schedule(function(a: number, b: number)
print(a + b)
end, nil, 2, 3)
-- One-shot on a deadline: runs about 5 seconds from now
local 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 budget
local Sweep = kernel.Scheduler:every(30, function()
kernel.Bus:publish("World.Sweep")
end, kernel.Priority.Background)
Sweep:cancel()
-- Task crashes are isolated and land here
kernel.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)
end

every(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.

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.

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 step
local PreSim = kernel:phaseScheduler("PreSimulation")
-- Camera work, client only: runs before each render
local 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 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)

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 order
Sched: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.

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

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 with resetProfile() over a fixed window, Time reads as milliseconds per second. That is what enableDiagnostics does for its HotTasks attributes and what the debug panels show.
  • The window also tracks the single longest task (TaskMaxMs in 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 one script:line inside 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).

  • There is no separate defer API. A task that calls schedule() 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.
  • OnError receives the traceback string, since tasks run under xpcall(fn, debug.traceback). Its second argument is an entry name that schedule, every and after never set, so handlers should expect nil.
  • Process crashes never reach OnError. A process catches its own errors and ends as Crashed.

ChloeKernel v0.9.1. Access to the framework is private and granted individually by Chloe. Games built with it credit CryptedChloes in their description.