Event-driven simulation and timers
An event-driven system keeps every future event in a min-heap keyed by (time, seq). The loop pops the earliest event, moves the clock to its time, handles it, and pushes any new events that handling creates. The seq counter makes ties deterministic. Each event costs O(log n), and the clock jumps over idle time for free.
The idea in plain words
Section titled “The idea in plain words”Think of a desk diary with sticky notes: “at 9:05, call Ana”, “at 9:05, email Bo”, “at 9:30, stand-up”. You don’t stare at the clock every second. You look at the earliest note, wait until then, do it, and maybe write new notes (“call Ana back at 10:00”).
That’s the whole model:
- An event is “at time
t, dox”. - The queue holds future events, earliest first. A heap gives you the earliest in O(1) and insert/remove in O(log n).
- The loop pops the earliest, sets
now = event.time, and runs it. Running it may schedule more events.
Two details make it trustworthy:
- The clock is data, not a system call. In a simulation,
nowonly moves when the loop moves it. Same input, same output, every run. That’s what makes it testable. - Ties need a rule. Two events at the same time must come out in an order you chose, not the order a heap happens to produce. The usual rule is creation order, enforced with a sequence number.
See it
Section titled “See it”The program schedules timers A–E at t = 0 and a repeating tick every 4. Press Run or step tick by tick. Add your own timers at any time, cancel one, then try jump to next event. Finally, untick break ties by seq and run to t = 5: A, C and D share a fire time, and the heap alone returns them in a different order.
time name#id.What to notice:
- The root of the heap is always the next timer to fire. The rest of the array is only partly ordered; that’s why push and pop are O(log n), not O(n).
- Cancel doesn’t search the heap. It marks the id as dead. The entry stays (dimmed) until it reaches the root, then gets popped and skipped. This is lazy deletion, the approach the Python
heapqdocs recommend. - A repeating timer is popped, fired, and pushed back with its next fire time.
When to reach for it
Section titled “When to reach for it”| The problem says… | Why an event queue fits |
|---|---|
“time is passed in” / “advance the clock by ms” |
You own the clock, so a jump-to-next-event loop is natural and deterministic. |
| timers, timeouts, retries, deadlines, expiries | Each is a future event. The earliest deadline is a heap peek. |
| “simulate N workers / machines / arrivals” | Arrivals and completions are events; handling one schedules the next. |
| “in order of time, ties broken by id / insertion” | Exactly the (time, seq) key. |
| huge time ranges with few events | Jumping costs per event, not per millisecond. |
How it works
Section titled “How it works”The queue: a heap with a tie-breaker
Section titled “The queue: a heap with a tie-breaker”- Keep a counter
seqthat only goes up. Every pushed event takes the next value. - Order entries by
(time, seq). Equal times now pop in insertion order, and the payload is never compared (so it needn’t be comparable). - Pop is O(log n). Peek at the root to learn the next fire time without removing it.
import heapqfrom itertools import countfrom typing import Generic, TypeVar
T = TypeVar("T")
class EventQueue(Generic[T]): """Min-heap of (time, seq, item). seq makes equal times pop in insertion order."""
def __init__(self) -> None: self._heap: list[tuple[int, int, T]] = [] self._seq = count() # 1. a monotonically increasing tie-breaker
def push(self, time: int, item: T) -> None: # 2. the tuple compares time first, then seq; item is never compared, # so items don't need to be orderable at all heapq.heappush(self._heap, (time, next(self._seq), item))
def peek_time(self) -> int | None: return self._heap[0][0] if self._heap else None
def pop(self) -> tuple[int, T]: time, _, item = heapq.heappop(self._heap) # 3. O(log n) return time, item
def __len__(self) -> int: return len(self._heap)
if __name__ == "__main__": q: EventQueue[str] = EventQueue() for t, name in [(5, "a"), (3, "b"), (5, "c"), (5, "d"), (1, "e")]: q.push(t, name) assert q.peek_time() == 1 order = [q.pop() for _ in range(len(q))] # ties at t=5 come out a, c, d: the order they were pushed assert order == [(1, "e"), (3, "b"), (5, "a"), (5, "c"), (5, "d")] print("ok")import assert from "node:assert/strict";
/** Min-heap of {time, seq, item}. seq makes equal times pop in insertion order. */export class EventQueue<T> { private heap: { time: number; seq: number; item: T }[] = []; private seq = 0; // 1. a monotonically increasing tie-breaker
// 2. compare time first, then seq; items are never compared private less(i: number, j: number): boolean { const a = this.heap[i], b = this.heap[j]; return a.time !== b.time ? a.time < b.time : a.seq < b.seq; } private swap(i: number, j: number): void { [this.heap[i], this.heap[j]] = [this.heap[j], this.heap[i]]; }
push(time: number, item: T): void { this.heap.push({ time, seq: this.seq++, item }); let i = this.heap.length - 1; // sift up while (i > 0 && this.less(i, (i - 1) >> 1)) { this.swap(i, (i - 1) >> 1); i = (i - 1) >> 1; } }
peekTime(): number | undefined { return this.heap[0]?.time; } get size(): number { return this.heap.length; }
pop(): [number, T] { if (this.heap.length === 0) throw new RangeError("empty queue"); const top = this.heap[0]; // 3. O(log n): move the last leaf to the root, sift down const last = this.heap.pop()!; if (this.heap.length > 0) { this.heap[0] = last; for (let i = 0; ; ) { const l = 2 * i + 1, r = l + 1; let m = i; if (l < this.heap.length && this.less(l, m)) m = l; if (r < this.heap.length && this.less(r, m)) m = r; if (m === i) break; this.swap(i, m); i = m; } } return [top.time, top.item]; }}
const q = new EventQueue<string>();for (const [t, name] of [[5, "a"], [3, "b"], [5, "c"], [5, "d"], [1, "e"]] as const) q.push(t, name);assert.equal(q.peekTime(), 1);const order: [number, string][] = [];while (q.size > 0) order.push(q.pop());// ties at t=5 come out a, c, d: the order they were pushedassert.deepEqual(order, [[1, "e"], [3, "b"], [5, "a"], [5, "c"], [5, "d"]]);console.log("ok");// .NET's PriorityQueue does not promise any order among equal priorities,// so the priority is a (time, seq) pair: equal times pop in insertion order.var q = new EventQueue<string>();foreach (var (t, name) in new[] { (5, "a"), (3, "b"), (5, "c"), (5, "d"), (1, "e") }) q.Push(t, name);if (q.PeekTime() != 1) throw new Exception("expected 1");
var order = new List<string>();while (q.Count > 0) { var (t, name) = q.Pop(); order.Add($"{t}{name}"); }// ties at t=5 come out a, c, d: the order they were pushedif (string.Join(",", order) != "1e,3b,5a,5c,5d") throw new Exception(string.Join(",", order));Console.WriteLine("ok");
class EventQueue<T>{ private readonly PriorityQueue<T, (long Time, long Seq)> _pq = new(); private long _seq; // 1. a monotonically increasing tie-breaker
// 2. value tuples compare Time first, then Seq public void Push(long time, T item) => _pq.Enqueue(item, (time, _seq++));
public long? PeekTime() => _pq.TryPeek(out _, out var p) ? p.Time : null;
public int Count => _pq.Count;
public (long Time, T Item) Pop() { // 3. O(log n) if (!_pq.TryDequeue(out var item, out var p)) throw new InvalidOperationException("empty queue"); return (p.Time, item); }}The loop: a classic discrete-event simulation
Section titled “The loop: a classic discrete-event simulation”Here’s the textbook example: send a message across a network with link latencies and find when each node first hears it.
- Seed the queue with the starting event.
- Pop the earliest event and jump the clock to its time. Never step through empty time.
- If the event is stale (here: the node already heard the message), skip it.
- Handle it, which may push new events with
time = now + delay.
import heapqfrom itertools import count
Graph = dict[str, list[tuple[str, int]]] # node -> [(neighbour, link latency)]
def flood(graph: Graph, source: str) -> dict[str, int]: """Discrete-event simulation: when does each node first hear a message?""" seq = count() events: list[tuple[int, int, str]] = [(0, next(seq), source)] # 1. seed the queue heard: dict[str, int] = {} while events: now, _, node = heapq.heappop(events) # 2. jump the clock to the next event if node in heard: continue # 3. a later copy of the message: stale, skip it heard[node] = now for nxt, latency in graph.get(node, []): # 4. handling an event schedules new events in the future heapq.heappush(events, (now + latency, next(seq), nxt)) return heard
if __name__ == "__main__": net: Graph = { "a": [("b", 4), ("c", 1)], "c": [("b", 2), ("d", 7)], "b": [("d", 1)], "d": [], } # b hears via c at 1+2=3, before the direct link would deliver at 4 assert flood(net, "a") == {"a": 0, "c": 1, "b": 3, "d": 4} assert flood(net, "d") == {"d": 0} print("ok")import assert from "node:assert/strict";
type Graph = Record<string, [string, number][]>; // node -> [neighbour, link latency]type Ev = { time: number; node: string };
/** Insert after every event with time <= ev.time: a sorted array that is stable on ties. * O(n) per insert; fine for a small sim, swap in a (time, seq) heap at scale. */function schedule(events: Ev[], ev: Ev): void { let lo = 0, hi = events.length; while (lo < hi) { const mid = (lo + hi) >> 1; if (events[mid].time <= ev.time) lo = mid + 1; else hi = mid; } events.splice(lo, 0, ev);}
/** Discrete-event simulation: when does each node first hear a message? */export function flood(graph: Graph, source: string): Map<string, number> { const events: Ev[] = [{ time: 0, node: source }]; // 1. seed the queue const heard = new Map<string, number>(); while (events.length > 0) { const { time: now, node } = events.shift()!; // 2. jump the clock to the next event if (heard.has(node)) continue; // 3. a later copy of the message: stale, skip it heard.set(node, now); for (const [next, latency] of graph[node] ?? []) { schedule(events, { time: now + latency, node: next }); // 4. events schedule events } } return heard;}
const net: Graph = { a: [["b", 4], ["c", 1]], c: [["b", 2], ["d", 7]], b: [["d", 1]], d: [] };// b hears via c at 1+2=3, before the direct link would deliver at 4assert.deepEqual([...flood(net, "a")], [["a", 0], ["c", 1], ["b", 3], ["d", 4]]);assert.deepEqual([...flood(net, "d")], [["d", 0]]);console.log("ok");var net = new Dictionary<string, (string To, int Latency)[]>{ ["a"] = [("b", 4), ("c", 1)], ["c"] = [("b", 2), ("d", 7)], ["b"] = [("d", 1)], ["d"] = [],};var heard = Sim.Flood(net, "a");// b hears via c at 1+2=3, before the direct link would deliver at 4var got = string.Join(",", heard.Select(kv => $"{kv.Key}={kv.Value}").Order());if (got != "a=0,b=3,c=1,d=4") throw new Exception(got);if (Sim.Flood(net, "d").Count != 1) throw new Exception("d reaches only itself");Console.WriteLine("ok");
static class Sim{ /// Discrete-event simulation: when does each node first hear a message? public static Dictionary<string, int> Flood(Dictionary<string, (string To, int Latency)[]> graph, string source) { var events = new PriorityQueue<string, (int Time, long Seq)>(); long seq = 0; events.Enqueue(source, (0, seq++)); // 1. seed the queue var heard = new Dictionary<string, int>(); while (events.TryDequeue(out var node, out var key)) // 2. jump the clock to the next event { int now = key.Time; if (!heard.TryAdd(node, now)) continue; // 3. a later copy: stale, skip it foreach (var (to, latency) in graph.GetValueOrDefault(node, [])) events.Enqueue(to, (now + latency, seq++)); // 4. events schedule new events } return heard; }}If that looks like Dijkstra’s algorithm, it is. Dijkstra is a discrete-event simulation where events are “the wavefront reaches node v”.
Inside real event loops
Section titled “Inside real event loops”| Runtime | What it does with timers |
|---|---|
| Node.js | setTimeout timers sit in per-duration lists, and the lists sit in a priority queue by expiry with an id as tie-break; libuv’s own timer heap breaks ties with a start_id counter. All queued Promise callbacks (microtasks) run before the next timer callback. |
| Python asyncio | call_at / call_later push a TimerHandle onto a heapq heap. Handles compare by time only, so equal-time timers have no documented order. cancel() just marks the handle; the loop drops dead handles lazily and rebuilds the heap once they exceed half of it. |
| .NET | Task.Delay and System.Threading.Timer are backed by runtime timer queues; for your own simulation, PriorityQueue with a (time, seq) tuple priority is the tool. |
What it costs
Section titled “What it costs”| Operation | Cost | Why |
|---|---|---|
| schedule (push) | O(log n) | sift up a heap of n pending events |
| next fire time (peek) | O(1) | it’s the root |
| fire (pop) | O(log n) | sift down |
| cancel, lazy | O(1) now, O(log n) later | mark dead; pay when it surfaces |
| cancel, eager | O(n) or O(log n) | find it (O(n)), or keep an index map in an indexed heap |
| advance over idle time | O(1) | jump now to the next event |
Lazy deletion has a hidden cost: dead entries take memory and inflate n. If programs cancel most timers (very common for timeouts that usually don’t fire), the heap can fill with corpses. Fixes: rebuild when dead entries exceed half the heap (CPython’s asyncio does this), or use a timer wheel.
Variations you’ll meet
Section titled “Variations you’ll meet”- Repeating timers. After firing, push again at
time + period. Use the scheduled time, not “now”, or the schedule drifts. Decide (and ask!) what happens if oneadvancecovers several periods, and whether the repeat keeps its original tie-break rank. - Timer wheel. A ring of buckets, one per tick (Varghese and Lauck, 1987). O(1) insert and cancel, used by the Linux kernel and Kafka for huge numbers of timeouts. Costs precision and needs a fixed tick.
- Indexed heap. Keep
id → heap positionso you can cancel or reschedule in O(log n) without leaving corpses. - Ready queue + timer heap. Real loops separate “runnable now” (a FIFO) from “runnable later” (the heap). Due timers move into the FIFO.
- Two-level ordering.
(time, priority, seq)when same-time events have priorities, such as “completions before arrivals”.
Pitfalls
Section titled “Pitfalls”- No tie-breaker. Tests pass by luck, then fail when the heap shape changes. Always
(time, seq). - Comparing payloads. In Python,
(time, task)tuples crash withTypeErroron a tie if tasks aren’t orderable. Theseqin the middle prevents it. - Firing events scheduled during the same advance. If a handler schedules something with delay 0, does it run in this pass or the next? Decide, and say so.
<vs<=at the boundary. “Fires atnow + delay” withadvanceto exactly that time: due or not? Pin it down.- Mutating the heap while iterating it. Pop in a loop instead (
while heap and heap[0].time <= target). - Drift in intervals.
next = now + perioddrifts whennowovershoots;next = scheduled + perioddoesn’t.
In the interview
Section titled “In the interview”- Ask: who owns the clock (passed in, or real time)? Can time go backwards? What order for ties? Can a handler schedule or cancel other events while firing?
- Say the invariant: “Every pending event is in the heap, keyed by
(time, seq); anything with time ≤ now has already fired.” - State costs per operation, and the lazy-delete trade-off.
- Follow-ups to expect: repeating timers, cancellation, “next fire time” queries, priorities among same-time events, millions of timers (timer wheel), and thread safety if real threads schedule timers.
Check yourself
Section titled “Check yourself”Practice on your own
Section titled “Practice on your own”These are classic exercises, not gym questions:
- Simulate a supermarket with k checkout lanes and random arrivals; report average wait. (Events: arrival, service done.)
- Elevator simulation: requests arrive over time, one car, compute when each rider arrives.
- Compute when each cell catches fire in a grid where fire spreads to neighbours with different delays.
- Implement an indexed min-heap that supports
decrease_keyandremove(id)in O(log n).
Go deeper
Section titled “Go deeper”- Python
heapqdocs, “Priority Queue Implementation Notes”: the(priority, count, task)entry and lazy removal. - .NET
PriorityQueue<TElement,TPriority>: note the remark that equal priorities have no guaranteed order. - Node.js: The event loop, timers and
process.nextTick(), and the libuv timer source for thestart_idtie-break. - Varghese and Lauck, “Hashed and Hierarchical Timing Wheels” (SOSP 1987), the origin of timer wheels.