Preemption
Time-sliced execution and cooperative yielding — build fiber schedulers, fair multitasking, and sandboxed execution entirely from script.
Overview
Preemption allows the VM to automatically interrupt long-running code after a configurable number of instructions. When combined with continuations, this enables powerful patterns like cooperative fibers, round-robin schedulers, and resource-bounded execution — all written in pure Zym script.
All preemption functions are accessed through the global Preempt module.
Preemption is disabled by default and has zero overhead when not in use.
How It Works
Every instruction the VM executes decrements a yield budget. When the budget reaches zero (or a yield is manually requested), the VM triggers a preemption event. What happens next depends on whether you have registered a callback:
- With a callback — The VM calls your script function directly. Your callback can capture a continuation, yield to a scheduler, or perform any other action.
- Without a callback — The VM suspends execution and returns control to the host (CLI). The host can then resume with
zym_resume().
Key Concepts
| Concept | Description |
|---|---|
| Timeslice | Number of instructions before a preemption event fires |
| Yield Budget | Remaining instructions in the current timeslice |
| Callback | A script function invoked automatically on preemption |
| Disable Depth | Layered counter that suppresses preemption when > 0 |
Getting Started
Basic Preemption
The simplest use of preemption is to detect when code has run for too long:
var preempted = false // Register a callback that fires on preemption Preempt.setCallback(func() { preempted = true print("Preempted!") }) // Enable with a 1000-instruction timeslice Preempt.enable() Preempt.setTimeslice(1000) // This loop will be interrupted after ~1000 instructions var i = 0 while (i < 100000) { i = i + 1 } print("Was preempted: " + str(preempted)) // true
Cooperative Yielding
You can also yield explicitly without waiting for the timeslice to expire:
Preempt.setCallback(func() { print("Yielded!") }) Preempt.enable() // Do some work... print("Working...") // Yield control at a convenient point Preempt.request() // Execution continues after the callback returns print("Resumed!")
API Reference
Enable / Disable
Enables preemption. The VM will start checking the yield budget on every instruction. Has no effect if the disable depth is greater than zero (preemption remains suppressed until all layers are popped).
Globally disables preemption. The VM stops checking the yield budget entirely. This is a hard disable — it ignores the disable depth.
Returns true if preemption is enabled and the disable depth is zero. This reflects whether preemption will actually fire.
Preempt.enable() print(Preempt.isEnabled()) // true Preempt.pushDisable() print(Preempt.isEnabled()) // false (suppressed by depth) Preempt.popDisable() print(Preempt.isEnabled()) // true
Timeslice Control
Sets the number of instructions per timeslice. When the yield budget reaches zero, a preemption event fires and the budget is reset to this value. Minimum is 1.
Preempt.setTimeslice(10000) // Default-like: ~10k instructions per slice Preempt.setTimeslice(500) // Aggressive: frequent preemption Preempt.setTimeslice(100000) // Relaxed: less overhead
Returns the current timeslice setting.
Returns how many instructions remain in the current timeslice before the next preemption event.
Resets the yield budget to a full timeslice and clears any pending preemption request. Useful when resuming a fiber to give it a fresh time allocation.
Yield Control
Requests a preemption event at the next opportunity. The event fires at the next instruction boundary, regardless of remaining budget.
Requests immediate preemption by setting the remaining budget to zero and flagging a request. Equivalent to calling Preempt.request() with zero budget.
Callback
Registers a zero-argument function as the preemption callback. When preemption fires, the VM calls this function directly. The callback runs with preemption automatically disabled to prevent recursive preemption.
Preempt.setCallback(func() { // This runs when preemption fires // Preemption is disabled inside the callback print("Tick!") })
Scoped Disable
Increments the disable depth by one. While the depth is greater than zero, preemption events will not fire even if preemption is enabled.
Decrements the disable depth by one. When the depth returns to zero, preemption can fire again.
Returns the current disable depth as a number.
Executes fn (zero-argument function) with the disable depth incremented. The depth is automatically decremented when fn returns, even if a continuation is captured.
// Protect a critical section from preemption Preempt.withDisabled(func() { // Preemption cannot fire in here updateSharedState() commitTransaction() }) // Preemption can fire again here
Layered Disable
The disable depth is a counter, not a boolean. This means nested disables stack correctly:
Preempt.enable() print(Preempt.isEnabled()) // true Preempt.pushDisable() // depth = 1 print(Preempt.isEnabled()) // false Preempt.pushDisable() // depth = 2 print(Preempt.isEnabled()) // false Preempt.popDisable() // depth = 1 print(Preempt.isEnabled()) // false (still suppressed) Preempt.popDisable() // depth = 0 print(Preempt.isEnabled()) // true
Preemption & Continuations
Preemption becomes truly powerful when combined with continuations. The preemption callback can capture a continuation to save the interrupted computation, then a scheduler can decide what to run next.
The Pattern
The general approach for building a fiber scheduler is:
- Create a prompt tag for your fiber boundary
- Register a callback that captures a continuation on preemption
- Run each fiber inside a prompt boundary
- On preemption, the callback captures the fiber’s state as a continuation
- The scheduler picks the next fiber and resumes it
Cont.capture() result. The scheduler doesn’t need to know whether the fiber yielded voluntarily or was preempted — the continuation works the same way.
Fiber Scheduler Example
This example demonstrates a round-robin fiber scheduler using preemption.
It uses Cont.pushPrompt / Cont.popPrompt for the prompt boundary, which is important for long-running schedulers.
pushPrompt instead of withPrompt?
Using Cont.withPrompt(tag, fn) inside a scheduler loop creates a new frame each iteration.
When a fiber is preempted and captured, that wrapper frame is included in the continuation.
On the next resume, yet another wrapper is added. After N preemptions, the continuation contains N wrapper frames —
unbounded growth that will eventually overflow.
Using raw pushPrompt/popPrompt installs the prompt at the current frame level without adding a frame,
keeping continuation size constant across preemption cycles.
var FIBER_TAG = Cont.newPrompt("fiber") var fiberQueue = [] var currentFiberId = 0 // Preemption callback: captures the interrupted fiber func onPreempt() { Cont.capture(FIBER_TAG) } // Create a dormant fiber from a function func createFiber(id, fn) { return Cont.withPrompt(FIBER_TAG, func() { func start() { var received = Cont.capture(FIBER_TAG) fn() return "done" } return start() }) } func spawn(id, fn) { var k = createFiber(id, fn) push(fiberQueue, { id: id, continuation: k }) } // Spawn some fibers spawn(1, func() { var i = 0 while (i < 5) { print("Fiber 1: " + str(i)); i = i + 1 } }) spawn(2, func() { var i = 0 while (i < 5) { print("Fiber 2: " + str(i)); i = i + 1 } }) // Configure preemption Preempt.setCallback(onPreempt) Preempt.enable() Preempt.setTimeslice(500) // Scheduler loop while (length(fiberQueue) > 0) { var fiber = shift(fiberQueue) currentFiberId = fiber.id // Install prompt and resume the fiber Preempt.pushDisable() Cont.pushPrompt(FIBER_TAG) Preempt.popDisable() Preempt.reset() var result = Cont.resume(fiber.continuation, null) // Preemption is now disabled (callback ran with it disabled) if (Cont.isContinuation(result)) { // Fiber was preempted — re-queue it fiber.continuation = result push(fiberQueue, fiber) } else { print("Fiber " + str(fiber.id) + " completed") } } print("All fibers done.")
Common Patterns
Protecting Critical Sections
Use Preempt.withDisabled or pushDisable/popDisable around code that must not be interrupted:
func atomicTransfer(from, to, amount) { Preempt.pushDisable() from.balance = from.balance - amount to.balance = to.balance + amount Preempt.popDisable() } // Or with the scoped helper: func atomicTransfer(from, to, amount) { Preempt.withDisabled(func() { from.balance = from.balance - amount to.balance = to.balance + amount }) }
Sandboxed Execution
Use preemption to limit how many instructions untrusted code can run:
var tooLong = false var SANDBOX_TAG = Cont.newPrompt("sandbox") Preempt.setCallback(func() { tooLong = true Cont.abort(SANDBOX_TAG, "timeout") }) Preempt.enable() Preempt.setTimeslice(50000) // 50k instruction limit var result = Cont.withPrompt(SANDBOX_TAG, func() { // Run untrusted code here return untrustedFunction() }) if (tooLong) { print("Code exceeded instruction limit!") } else { print("Result: " + str(result)) } Preempt.disable()
Voluntary Yield Points
In cooperative multitasking, fibers can yield explicitly at convenient points:
func processItems(items) { var i = 0 while (i < length(items)) { doExpensiveWork(items[i]) i = i + 1 // Yield after each item to let other fibers run Preempt.yield() } }
Preemption State & Continuations
When a continuation is captured, the current preemption disable depth is saved as part of the continuation’s state. When the continuation is resumed, the saved disable depth is restored.
This means:
- A fiber that was running with preemption enabled will resume with preemption enabled, even if the scheduler runs with it disabled.
- A fiber captured inside a
pushDisableblock will resume with that disable depth intact. - The scheduler can safely use
pushDisable/popDisablearound its bookkeeping without affecting fiber state.
// Fiber was preempted with disable depth = 0 // Scheduler runs with disable depth = 1 Preempt.pushDisable() // ... scheduler bookkeeping ... // Resume fiber — disable depth is restored to 0 Cont.resume(fiber.continuation, null) // After capture, disable depth returns to scheduler's state
Error Reference
| Error | Cause |
|---|---|
argument must be a function | setCallback or withDisabled received a non-function argument |
argument must be a number | setTimeslice received a non-number argument |
function must take 0 arguments | withDisabled callback must be a zero-argument function |
stack overflow | Maximum call depth reached during preemption handling |
Best Practices
- Disable preemption when not needed — Preemption adds a small overhead to every instruction. Keep it disabled during initialization and teardown.
- Use
pushDisable/popDisablearound scheduler bookkeeping — The scheduler itself should not be preempted while deciding which fiber to run next. - Reset the budget before resuming a fiber — Call
Preempt.reset()to give each fiber a full, fresh timeslice. - Use
pushPrompt/popPromptin scheduler loops — AvoidwithPromptinside a loop that resumes continuations. Each iteration would add a wrapper frame to the continuation, causing unbounded growth. Use the raw prompt API instead. - Keep callbacks short — The preemption callback should capture or abort and return quickly. Avoid complex logic inside it.
- Protect shared state — If multiple fibers share mutable state, wrap access in
Preempt.withDisabled()orpushDisable/popDisableto prevent mid-update preemption. - Use named prompt tags —
Cont.newPrompt("scheduler")makes error messages much clearer when debugging complex fiber systems.
See also: Continuations — CLI Overview — Garbage Collection