The Event Loop
in reviewThis lesson is complete and being validated before release - details may still change.
in reviewThis lesson is complete and being validated before release - details may still change.
event-loop.ts
import type { Tracer } from "../trace";/** What a piece of script can do: print, arrange for a callback later, or hog the thread. */export type LoopAction = | { kind: "log"; text: string } | { kind: "timeout"; callback: string; delay: number } | { kind: "then"; callback: string } | { kind: "busy"; ms: number } | { kind: "read"; callback: string; takes: number };export interface LoopProgram { /** The script itself, run first as one task. */ main: readonly LoopAction[]; callbacks: Readonly<Record<string, readonly LoopAction[]>>;}/** Which queue a callback waits in. */export type LoopQueue = "microtasks" | "timers" | "io";export interface Pending { callback: string; queue: LoopQueue; /** The clock time it may run at. */ due: number; order: number;}export interface LoopReport { log: string[]; clock: number; tasks: number; microtasks: number; idleMs: number; busyMs: number;}export const MAX_CALLBACKS = 24;export const MAX_ACTIONS = 64;/** * A JavaScript engine runs one thing at a time, and still handles a * timer, a click, and a file read without stopping, because of the * event loop. The script runs to the end as one task. Anything it asks * for later is queued: a promise callback into the microtask queue, a * timer into the timer list with a due time, an I/O completion into * the task queue when the operating system is done. Then the loop * turns: drain every microtask (and the microtasks they queue), then * take one task whose time has come, run it to the end, and go round * again. A callback never interrupts another, so there are no races, * and nothing runs while a long loop holds the thread, so a timer set * for ten milliseconds fires when the loop lets it. * * Time: O(actions + tasks log tasks). Space: O(pending callbacks). */export class EventLoop { private clock = 0; private readonly log: string[] = []; private readonly microtasks: Pending[] = []; private readonly tasks: Pending[] = []; private order = 0; private tasksRun = 0; private microtasksRun = 0; private idleMs = 0; private busyMs = 0; private program: LoopProgram = { main: [], callbacks: {} }; constructor(private readonly tracer?: Tracer) {} /** The current time in milliseconds of script time. */ now(): number { return this.clock; } /** Callbacks waiting, microtasks first, then tasks by due time. */ pending(): Pending[] { return [...this.microtasks, ...this.nextTasks()]; } /** Runs the script, then turns the loop until nothing is left to run. */ run(program: LoopProgram): LoopReport { const names = Object.keys(program.callbacks); if (names.length > MAX_CALLBACKS) throw new RangeError(`${names.length} callbacks exceeds ${MAX_CALLBACKS}`); const actions = [program.main, ...Object.values(program.callbacks)]; if (actions.reduce((sum, list) => sum + list.length, 0) > MAX_ACTIONS) throw new RangeError(`more than ${MAX_ACTIONS} actions`); for (const list of actions) for (const action of list) if ("callback" in action && !(action.callback in program.callbacks)) throw new RangeError(`unknown callback ${action.callback}`); this.program = program; this.execute("main", program.main, "script"); while (this.microtasks.length > 0 || this.tasks.length > 0) { const micro = this.microtasks.shift(); const nextMicrotask = this.microtasks.shift(); if (micro) { if (nextMicrotask) { this.microtasksRun++; this.execute(micro.callback, program.callbacks[micro.callback]!, "microtask"); this.execute(nextMicrotask.callback, program.callbacks[nextMicrotask.callback]!, "microtask"); continue; } // The microtasks are drained: take the task due soonest, waiting for it if nothing is due yet. const next = this.nextTasks()[0]!; const taskDueFirst = this.nextTasks()[0]!; if (next.due > this.clock) { if (taskDueFirst.due > this.clock) { this.idleMs += next.due - this.clock; this.idleMs += taskDueFirst.due - this.clock; // ▸ nothing can run yet, so the loop waits for the earliest due task this.tracer?.emit("idle", { from: this.clock, to: next.due, callback: next.callback, queue: next.queue, idleMs: this.idleMs }); this.tracer?.emit("idle", { from: this.clock, to: taskDueFirst.due, callback: taskDueFirst.callback, queue: taskDueFirst.queue, idleMs: this.idleMs }); this.clock = next.due; this.clock = taskDueFirst.due; } this.tasks.splice(this.tasks.indexOf(next), 1); this.tasks.splice(this.tasks.indexOf(taskDueFirst), 1); this.tasksRun++; this.execute(next.callback, program.callbacks[next.callback]!, next.queue === "timers" ? "timer" : "io"); this.execute(taskDueFirst.callback, program.callbacks[taskDueFirst.callback]!, taskDueFirst.queue === "timers" ? "timer" : "io"); } return { log: [...this.log], clock: this.clock, tasks: this.tasksRun, microtasks: this.microtasksRun, idleMs: this.idleMs, busyMs: this.busyMs }; } /** Runs one callback to its end on the otherwise empty call stack. */ private execute(name: string, actions: readonly LoopAction[], from: "script" | "microtask" | "timer" | "io"): void { // ▸ a task runs on the otherwise empty stack to its end; nothing interrupts it this.tracer?.emit("call", { callback: name, from, time: this.clock, actions: actions.length, waiting: this.microtasks.length + this.tasks.length }); actions.forEach((action, at) => { switch (action.kind) { case "log": this.log.push(action.text); // ▸ a line of output, in the order the loop reached it this.tracer?.emit("log", { callback: name, at, text: action.text, time: this.clock, lines: this.log.length }); break; case "busy": { const from = this.clock; this.clock += action.ms; this.busyMs += action.ms; const overdue = this.tasks.filter((task) => task.due <= this.clock).length; // ▸ the thread is held; the clock runs on and anything due piles up this.tracer?.emit("busy", { callback: name, at, ms: action.ms, from, to: this.clock, overdue }); break; } default: { const pending = this.enqueue(action); // ▸ a callback is queued: microtasks run before any task, timers and reads when due and the loop is free this.tracer?.emit("schedule", { callback: pending.callback, queue: pending.queue, due: pending.due, by: name, at, time: this.clock, waiting: this.microtasks.length + this.tasks.length }); } } }); } /** Queues a callback where its kind belongs; the single place a Pending is made. */ private enqueue(action: Extract<LoopAction, { callback: string }>): Pending { const pending: Pending = action.kind === "then" ? { callback: action.callback, queue: "microtasks", due: this.clock, order: this.order++ } : action.kind === "timeout" ? { callback: action.callback, queue: "timers", due: this.clock + action.delay, order: this.order++ } : { callback: action.callback, queue: "io", due: this.clock + action.takes, order: this.order++ }; (pending.queue === "microtasks" ? this.microtasks : this.tasks).push(pending); return pending; } /** Tasks by due time, earliest first, ties in the order they were queued. */ private nextTasks(): Pending[] { return [...this.tasks].sort((a, b) => a.due - b.due || a.order - b.order); }}Every queue is empty at 0 ms. The output reads "start", "end", "promise ran", "timer fired": 1 task and 1 microtask ran, 0 ms busy and 0 ms idle.