cld-toys › Toys › mini-asyncio

Commentary: mini-asyncio

A ready queue, a timer heap, and generators driven by send(). One neighbour burning 200ms between yields turns a sleep(1000) into a 1400ms tick — while the loop's own timer heap reports every delivery perfectly on time. A study guide for mini_asyncio.py.

mini-asyncio/ on GitHub·the source with line numbers, for reading rather than downloading
How to read this This is the only documentation the toy has — read it with mini_asyncio.py open beside you. mini_asyncio.py is the toy itself (223 lines: a loop, a timer, a future, a task); demo.py runs the headline schedule and derives the number; test_mini_asyncio.py locks in the trace and the closed form (18 tests). Every transcript below was captured from a real run on macOS (Darwin 25.5.0, arm64), Python 3.15.0a8 (main, Apr 14 2026, 14:20:41) [Clang 22.1.3], stdlib only.
cd mini-asyncio
python3 demo.py                # the aha (§6)
python3 test_mini_asyncio.py   # pins every number this page quotes
Contents
  1. Orientation
  2. The problem this mechanism exists to solve
  3. Background you need
  4. The mental model
  5. Reading the source
  6. The demo, and what it proves
  7. Design decisions and roads not taken
  8. What's simplified vs. the real thing
  9. Check yourself
  10. Further reading

1. Orientation

An event loop runs many tasks on one thread by never running two at once. It keeps a ready queue of callbacks that can run now and a timer heap of callbacks that should run later, and it goes round and round: expire the timers that are due, run what is ready, repeat. Tasks are generators, so "suspending" is just returning from send() and "resuming" is calling send() again.

That much is the diagram everyone has seen, and a toy that stopped there would be teaching you that two sleeping tasks can interleave without threads — which is what you already expect an event loop to do.

The thing worth building it for is what the diagram leaves out. There is no preemption. A task runs until it chooses to yield. So a timer is not a promise that your code runs at time T; it is a promise that your code will not run before T. The gap between those two statements is filled by whatever else happened to be in the queue, and this toy measures it.

The measurement has a sting in it. Run demo.py and a task asking for a 1000ms tick, next to one neighbour that burns 200ms of CPU between yields, gets ticked every 1400ms — 40% late, forever, in a steady state that never recovers. Nothing raises. Nothing warns. And the loop's own view of its timer heap says every single timer expired at the exact millisecond it was due, because it did.

By the end you should be able to:


2. The problem this mechanism exists to solve

You have ten thousand network connections and one machine. The obvious answer, a thread per connection, spends most of its memory on stacks and most of its CPU on context switches to discover that nothing has arrived. The event loop's bet is that if the work is dominated by waiting, you can multiplex all of it onto one thread and keep a few hundred bytes of state per connection instead of a megabyte of stack.

That bet pays enormously, and it is paid for with a specific currency: the scheduler loses the ability to interrupt you.

A kernel thread scheduler is preemptive. It owns a timer interrupt, and when your thread's slice expires the hardware takes the CPU away whether your code consents or not. That is what makes a thread's sleep(1.0) accurate to within the scheduler's granularity no matter what its neighbours are doing: the neighbour cannot refuse to be interrupted.

An event loop has no timer interrupt. It regains control only when a task returns. So the same sleep(1.0), expressed cooperatively, means "put me on the heap, and hand me back the CPU on some pass of the loop at or after T" — and which pass that is depends entirely on the other tasks.

The competing goals that make more than one design defensible:

And the loop must resolve all of this without the one instrument that would settle it — it cannot ask a task how long it intends to run, and it cannot stop it once it starts.


3. Background you need

ConceptWhere it's used in the toyOne link
Generators as coroutines Task.step drives a generator with send(); yield is the suspension point PEP 342
Cooperative scheduling The whole result. Loop never interrupts a callback; run_once regains control only when one returns PEP 3156
Ready queue (call_soon) Loop.ready, a deque appended at the back. The FIFO discipline is where the lateness accumulates asyncio-eventloop
Timer heap (call_later) Loop.timers, a heapq of (when, seq, Timer); seq keeps equal deadlines FIFO heapq
Futures, and resolving vs. resuming Future.set_result schedules its waiter rather than calling it. This is the second hop, worth a whole chunk of lateness asyncio-future
Monotonic virtual time Loop.clock is an integer millisecond counter advanced only by declared work; there is no time.monotonic() in the toy time.monotonic

The two flagged as ☋ carry the result. Cooperative scheduling is why the effect exists; the ready queue and the future hop are where the milliseconds actually go, and they are separable — §6.3 shows one of them is worth 200ms on its own.


4. The mental model

One pass of the loop. Time only ever moves inside the shaded box.

┌──────────────────────────────────────────┐ timer heap │ ONE PASS (run_once) │ ┌──────────┐ │ │ │ when=1000│──┼─▶ (1) expire: every timer with │ │ when=2500│ │ when <= clock is popped and │ │ when=4000│ │ APPENDED TO THE BACK of ready │ └──────────┘ │ │ │ │ ▼ │ ready queue │ ┌────────────────────────────────────┐ │ ┌──────────┐──┼─▶│ (2) run a SNAPSHOT: exactly the n │ │ │ hog.step │ │ │ callbacks present right now. │ │ │ fire │ │ │ ░░░ clock advances here ░░░ │ │ │ ... │ │ │ Anything they schedule waits │ │ └──────────┘ │ │ for the NEXT pass. │ │ ▲ │ └────────────────┬───────────────────┘ │ └────────┼───── call_soon ◀──┘ │ └──────────────────────────────────────────┘ A delivery of `sleep(1000)` has FOUR timestamps, not two: asked for ──────▶ expired ──────▶ callback ran ──────▶ task resumed 1000 1000 1200 1400 ▲ ▲ ▲ │ │ │ the heap is punctual queued behind the future's (this gap is always 0) the hog set_result() only (one pass: 200) call_soon()s you (one more: 200) Nothing above is an error condition. Every arrow is the loop working exactly as designed.

The mental shortcut to carry away: R is one full pass of the ready queue — the sum of the CPU every runnable task burns before the loop gets back to the top. A timer cannot be delivered at a finer granularity than R, and it costs one extra R for each queue hop between the timer and your code. Everything in §6 is that sentence with numbers in it.


5. Reading the source

5.1 The clock, and the two things that move it

mini_asyncio.py · lines 202–212
    def run(self, until):
        """Run until the virtual clock reaches `until`, or nothing is left."""
        while self.clock < until:
            if not self.ready:
                if not self.timers:
                    return
                # Nothing runnable: idle forward to the next deadline. This is
                # the only place the clock moves without a task having burned
                # CPU, and it is why a lone sleeper is never late.
                self.clock = max(self.clock, self.timers[0][0])
            self.run_once()

This is the determinism decision, and it is worth more than it looks. A real loop calls select(timeout) here and lets the OS burn the wall-clock interval. This one jumps the clock to the next deadline instead — but only when there is genuinely nothing runnable.

That asymmetry is the entire model. Idle time is free and exact, so a task with no neighbours lands on its deadline to the millisecond. Busy time is paid for by whoever burned it, so a task with neighbours lands wherever their arithmetic puts it. Nothing else in the file reads a clock at all.

The clock is an integer count of milliseconds, which is not fussiness; §7.3 shows the float version producing periods like 1.4000000000000004, 1.4000000000000008, 1.4000000000000012 — drifting, unquotable, and impossible to derive by hand.

5.2 run_once — the whole scheduler, in nine lines

mini_asyncio.py · lines 186–200
    def run_once(self):
        """One pass, in CPython's order: expire timers, then run a snapshot
        of the ready queue.
        """
        while self.timers and self.timers[0][0] <= self.clock:
            _, _, timer = heapq.heappop(self.timers)
            timer.expired_at = self.clock       # noticed exactly on time
            self.ready.append(timer.callback)   # ...and queued behind the rest

        # A SNAPSHOT, not a drain. Anything these callbacks schedule waits for
        # the next pass. Change `for _ in range(len(self.ready))` to
        # `while self.ready:` and a task that reschedules itself every step
        # never lets the loop reach a timer again -- silently, forever.
        for _ in range(len(self.ready)):
            self.ready.popleft()()

Two decisions, both load-bearing, and both easy to read past.

self.ready.append(timer.callback). A timer that comes due does not get priority. It goes to the back of a queue that already contains whatever was scheduled on the previous pass. This is why expired_at is always exactly the deadline while fired_at is not: the heap did its job the instant the clock reached the deadline, and then the callback stood in line. §7.2 runs the variant that jumps the queue, and it recovers half the lateness — not all of it.

for _ in range(len(self.ready)). The pass is bounded to the callbacks that were already there. A callback that calls call_soon — which every burn does, and which every await in real asyncio does — is queued for the next pass and cannot extend this one. That single expression is what guarantees the loop reaches the top of run_once again, which is the only place timers are ever examined. §7.1 removes it, and the heartbeat stops forever without an error.

5.3 Task.step — resumption is one trip through the queue

mini_asyncio.py · lines 99–123
    def step(self):
        """Resume the generator once and dispose of whatever it yields.

        Entering `step` *is* the delivery: `self.loop.clock` right now is when
        this task's own code got the CPU, which is the only reading its author
        would ever be able to observe.
        """
        if self.deadline is not None:
            self.loop.trace.append(Wake(self.name, self.deadline,
                                        self.expired_at, self.fired_at,
                                        self.loop.clock))
            self.deadline = self.expired_at = self.fired_at = None

        try:
            kind, arg = self.coro.send(None)
        except StopIteration:
            return

        if kind == BURN:
            self.loop.clock += arg
            self.loop.call_soon(self.step)
        elif kind == SLEEP:
            self._suspend(arg)
        else:
            raise ValueError(f"unknown command {kind!r}")

self.loop.clock at the top of step is the only timestamp a task's author could ever observe — it is what time.monotonic() would return on the first line after their await. Recording it here, rather than when the timer expired, is what lets §6 put the loop's opinion and the task's experience in adjacent columns and show they disagree.

The BURN branch is the model of ordinary Python between two awaits: it costs time, and then it goes to the back of the queue. Note what it is not — it is not a special "CPU-bound task" mode. burn(200) is exactly do_200ms_of_work(); await asyncio.sleep(0), which is the shape of a task that its author believes is well-behaved, because it does yield, on every single iteration.

5.4 _suspend — where the second hop comes from

mini_asyncio.py · lines 125–154
    def _suspend(self, delay):
        """Model of `asyncio.sleep(delay)` for delay > 0.

        CPython does not arm a timer that resumes the task. It arms a timer
        whose callback resolves a *future*, and awaits that future:

            h = loop.call_later(delay, futures._set_result_unless_cancelled,
                                future, result)
            return await future

        (verbatim shape of `inspect.getsource(asyncio.sleep)`). So the wakeup
        costs two trips through the ready queue, not one.

        `loop.hops == 1` is the counterfactual: the armed callback resumes the
        task itself, which is what `loop.call_later(delay, cb)` gives you.
        """
        loop = self.loop
        self.deadline = loop.clock + delay
        fut = Future(loop)
        fut.waiter = self.step

        def fire():                   # hop 1: the callback the timer armed
            self.expired_at = timer.expired_at
            self.fired_at = loop.clock
            if loop.hops == 2:
                fut.set_result()      # -> call_soon(self.step), a second trip
            else:
                self.step()           # resumed in place, no second trip

        timer = loop.call_later(delay, fire)

This is the toy's one deliberate act of archaeology. The docstring makes a claim about CPython; here is the claim being checked rather than trusted. inspect.getsource(asyncio.sleep) on the declared interpreter contains, in order:

future = loop.create_future() h = loop.call_later(delay, futures._set_result_unless_cancelled, return await future

CPython does not arm a timer that resumes your task. It arms a timer whose callback resolves a future, and awaits that future. So the wakeup path is timer expires → callback runs → future resolved → task scheduled → task runs, and there are two queue boundaries in it, not one.

hops == 1 is the counterfactual, and it is a fair one: it is precisely what loop.call_later(delay, cb) gives you, where the callback you registered is the work. Everything else about the run is held constant. §6.3 measures the difference in the toy and then against real CPython.

5.5 Future.set_result — the line the hop is made of

mini_asyncio.py · lines 80–84
    def set_result(self):
        self.done = True
        if self.waiter is not None:
            waiter, self.waiter = self.waiter, None
            self.loop.call_soon(waiter)          # hop 2

call_soon(waiter), not waiter(). Resolving a future never runs the waiter; it schedules it.

That is not an oversight in asyncio, and it should not be "fixed". Calling the waiter directly would mean set_result can execute arbitrary user code re-entrantly, in the middle of whatever was calling it — a callback could resolve a future, and find that by the time set_result returned, the world had changed underneath it. Deferring through the queue makes every resumption happen at a known place with an empty stack above it. The price is one pass of the loop, and this toy exists to show you what that pass costs when the loop is busy.


6. The demo, and what it proves

The schedule is two tasks. One asks for a 1000ms period and does nothing else. The other burns 200ms of CPU and yields, forever.

demo.py · lines 16–29
def heartbeat(period):
    """A well-behaved task: asks for a fixed period and does no work."""
    while True:
        yield sleep(period)


def hog(chunk):
    """A neighbour that burns `chunk` ms of CPU between yield points.

    It is not badly written: it *does* yield, every single iteration. It
    simply does a chunk of ordinary Python before it does.
    """
    while True:
        yield burn(chunk)

Neither is badly written. The hog yields on every iteration; a reviewer looking for a missing await would find nothing.

6.1 The transcript

python3 demo.py
mini-asyncio: a timer is a floor, not a promise heartbeat asks for sleep(1000ms); neighbour burns 200ms between yields 1. The heartbeat alone ---------------------- periods: [1000, 1000, 1000, 1000, 1000, 1000] nothing else wants the CPU, so the loop idles forward to each timer and every delivery is exact. 2. The heartbeat next to one hog (hops=2, faithful to asyncio.sleep) -------------------------------------------------------------------- asked for expired late callback late task ran late 1000 1000 0 1200 200 1400 400 2400 2400 0 2600 200 2800 400 3800 3800 0 4000 200 4200 400 5200 5200 0 5400 200 5600 400 6600 6600 0 6800 200 7000 400 periods: [1400, 1400, 1400, 1400] Read the 'expired' column: the loop noticed every one of these timers at the exact millisecond it came due. Not once late, not by 1ms. A loop instrumenting its own timer heap would report a perfectly healthy scheduler. The task still resumed 400ms after it asked to, and nothing anywhere raised, warned, or returned an error. 3. The same run with hops=1 (faithful to loop.call_later) --------------------------------------------------------- periods: [1200, 1200, 1200, 1200, 1200] Same loop, same hog, same 1000ms request. The only difference is that the timer's callback resumes the task itself instead of resolving a future that then schedules the task. That indirection is worth 200ms -- one whole trip through the ready queue -- on every single beat.

6.2 The timer is punctual; the task is late

Look only at the expired column: 1000, 2400, 3800, 5200, 6600, and the lateness beside each is 0. Every timer in this run was noticed on the exact millisecond it came due. The heap is not the problem, the clock is not the problem, and nothing is being dropped — all five deliveries arrive, none is skipped or coalesced.

This is the part worth carrying around. If you instrumented this loop the obvious way — record when, compare against the clock when you pop the heap — your dashboard would show a flat zero and you would go looking elsewhere. The lateness lives in the two queue hops after the heap has already done its job correctly, and the only place it is observable is inside the sleeping task, on the first line after its await.

The failure is silent in a stronger sense than "no exception is raised": the component best placed to notice it is measuring the one interval that is genuinely fine.

Now the arithmetic behind 1400.

The hog burns 200ms and yields, so a full pass of the ready queue costs R = 200ms. That makes the loop's timer checks land on a 200ms grid: it looks at the heap at 200, 400, 600, … and nowhere in between, because between those points it is inside the hog and has no way to take the CPU back.

0 + R + R = 400ms late, and because the heartbeat re-arms from 1400 (not from 1000), the drift does not accumulate — it locks into a steady state where every subsequent request is also 400 late: 2400 → 2800, 3800 → 4200. Hence a period of 1000 + 400 = 1400, forever.

6.3 The hop nobody predicts: 1200 vs 1400

Section 3 of the demo holds everything constant and changes only how the timer reaches the sleeper. loop.call_later style: 1200. asyncio.sleep style: 1400. One extra R, on every beat, bought by an indirection that appears nowhere in the calling code.

This is a claim about real CPython, so it was measured against real CPython. A scratch script reads inspect.getsource(asyncio.sleep), then runs the same two shapes with a real time.monotonic() burn loop. These are the only wall-clock numbers on this page, and they are valid only for this banner:

machine banner ==================================================================== macOS-26.5.2-arm64-arm-64bit-Mach-O arm64 Python 3.15.0a8 (main, Apr 14 2026, 14:20:41) [Clang 22.1.3 ] measured 2026-07-31 how we know asyncio.sleep takes two hops: read it ==================================================================== future = loop.create_future() h = loop.call_later(delay, futures._set_result_unless_cancelled, return await future the same hog, two ways of asking for a 1.0s tick ==================================================================== chunk=200ms call_later gaps=[1.201, 1.201, 1.2, 1.2] hog burns at each tick=[6, 12, 18, 24] chunk=200ms sleep gaps=[1.403, 1.401, 1.401] hog burns at each tick=[7, 14, 21] chunk=300ms call_later gaps=[1.5, 1.501, 1.5] hog burns at each tick=[5, 10, 15] chunk=300ms sleep gaps=[1.8, 1.801, 1.801] hog burns at each tick=[6, 12, 18] model predicts, for chunk=200ms and a 1000ms request: hops=1 -> 1200ms hops=2 -> 1400ms for chunk=300ms: hops=1 -> 1500ms hops=2 -> 1800ms

Four configurations, four matches: 1.201 ≈ 1200, 1.403 ≈ 1400, 1.500 ≈ 1500, 1.800 ≈ 1800.

The hog burns at each tick column is the cleanest evidence on this page, because it is an integer and no timing noise can smear it. At chunk=200, call_later is delivered after the hog's 6th burn and asyncio.sleep after its 7th — one whole burn apart, every tick, exactly as the model says. 6 × 200 = 1200 and 7 × 200 = 1400. At chunk=300 it is the 5th against the 6th: 1500 and 1800.

6.4 The closed form

Demo section 4 checks the general rule against runs:

4. Where the number comes from ------------------------------ R = one full pass of the ready queue = n_hogs * chunk observed = (ceil(PERIOD / R) + hops) * R hogs chunk hops R ceil(P/R) predicted observed 1 100 1 100 10 1100 [1100] 1 100 2 100 10 1200 [1200] 1 200 1 200 5 1200 [1200] 1 200 2 200 5 1400 [1400] 1 300 1 300 4 1500 [1500] 1 300 2 300 4 1800 [1800] 1 400 1 400 3 1600 [1600] 1 400 2 400 3 2000 [2000] 2 100 1 200 5 1200 [1200] 2 100 2 200 5 1400 [1400] 2 200 1 400 3 1600 [1600] 2 200 2 400 3 2000 [2000] 2 300 1 600 2 1800 [1800] 2 300 2 600 2 2400 [2400] 2 400 1 800 2 2400 [2400] 2 400 2 800 2 3200 [3200] 3 100 1 300 4 1500 [1500] 3 100 2 300 4 1800 [1800] 3 200 1 600 2 1800 [1800] 3 200 2 600 2 2400 [2400] 3 300 1 900 2 2700 [2700] 3 300 2 900 2 3600 [3600] 3 400 1 1200 1 2400 [2400] 3 400 2 1200 1 3600 [3600]
The closed form observed period = (⌈P / R⌉ + hops) · R, where R = one full pass of the ready queue.

The two terms are the two separate things that go wrong, and it is worth keeping them apart:

Note the third block: with three hogs at 400ms, R = 1200 already exceeds the 1000ms period, ⌈P/R⌉ = 1, and the beat is delivered every 3600ms — a 3.6× inflation of a timer nobody touched. test_closed_form_predicts_every_combination checks this over 180 combinations (5 periods × 6 chunks × 2 hop counts × 3 hog counts) and every one matches.

6.5 The only lever is R

The instinct on reading the above is to reach for await asyncio.sleep(0). That instinct is confused, and the confusion is worth naming: sleep(0) is what makes a chunk of work a yield point at all. The hog already has one — burn(200) is work(); await sleep(0). Adding another one does not help, because lateness is bounded below by hops · R, and R is set by how much work you do between yields, not by how many yield statements you write.

Demo section 5, and a scratch counterfactual in more detail:

CF 3: how often the hog yields, and what it buys ============================================================ Same 200ms of work per outer iteration, split into finer yields. yield every yields/200ms period late 200 1 1400 400 100 2 1200 200 50 4 1100 100 25 8 1050 50 10 20 1020 20 5 40 1010 10 1 200 1002 2 Lateness tracks the chunk, not the total work: the hog does exactly 200ms of work per outer iteration in every row.

The hog does the same 200ms of work in every row. What changes is how finely it is chopped, and the lateness is 2 × chunk down the whole column — the hops · R term, with the grid term vanishing once chunk divides 1000.

The uncomfortable consequence: the chunk you are allowed to hold is set by the tightest deadline anywhere else in the process, which is a number your module cannot see. A library that does 50ms of parsing between awaits is perfectly well-behaved next to a 10-second health check and is a 100ms latency bug next to a 20ms one, and nothing about the library changed.

6.6 The boundary condition — where this vanishes

The effect disappears when R ≪ P. As R → 0, ⌈P/R⌉·R → P and hops·R → 0, so the observed period converges on exactly what was asked for. That is not a hypothetical: it is the chunk=1 row above, 1002 against a request of 1000, and it is the regime real async servers are designed for.

Concretely, this never bites the thing async is famous for. A proxy holding 10,000 connections, doing tens of microseconds of header parsing between awaits, has R in the low milliseconds even with hundreds of tasks runnable at once — its timers are accurate to well under 1% and the analysis in this page is a rounding error. Cooperative scheduling is exactly the right answer there, and the absence of preemption costs nothing, because no task ever wanted the CPU for long enough for preemption to matter.

It bites when one task's between-await work is a meaningful fraction of the tightest timer in the process. In practice that means: a JSON or protobuf decode of a multi-megabyte payload, template rendering, a compression or hashing pass, re against a big string, a synchronous DB driver call someone forgot to move to a thread, an ORM materialising ten thousand rows. None of those look like "blocking the event loop" in review, because they all return promptly on the data the author tested with.

And the reliable early symptom is not a slow endpoint — it is a heartbeat. Health checks, gossip timers, lease renewals and metrics flushes are the tasks with the tightest periods and the least work, so they are the first things to go late, and going late is the one thing they are not designed to report.

6.7 The trace is pinned by tests

python3 test_mini_asyncio.py
ok test_clock_starts_at_zero_and_is_an_integer ok test_a_lone_sleeper_is_never_late ok test_idle_jump_is_the_only_free_clock_movement ok test_burn_is_the_only_other_clock_movement ok test_headline_period_is_1400_not_1000 ok test_the_timer_itself_is_never_late ok test_nothing_raises_and_no_delivery_is_dropped ok test_one_hop_is_1200_and_two_hops_is_1400 ok test_the_hop_difference_is_exactly_one_chunk ok test_hop_two_resolves_a_future_and_hop_one_does_not ok test_future_set_result_schedules_rather_than_calls ok test_closed_form_predicts_every_combination ok test_lateness_floor_is_hops_times_R ok test_snapshot_bounds_a_pass_to_the_queue_it_started_with ok test_timers_are_queued_behind_existing_ready_work ok test_equal_deadlines_stay_fifo ok test_runs_are_bit_identical ok test_spawn_order_does_not_change_the_steady_state 18 tests passed

7. Design decisions and roads not taken

7.1 The load-bearing line: for _ in range(len(self.ready))

run_once runs a bounded snapshot of the ready queue. Change that one expression to while self.ready: — a change most reviewers would call a simplification, and which passes any test that does not involve a timer — and here is the whole result:

CF 1: the snapshot line in run_once ============================================================ for _ in range(len(self.ready)) beats in 60 virtual seconds = 42 clock stopped at 60000 while self.ready: beats in 60 virtual seconds = 0 clock stopped at 10000200 The drained loop burned 10,000 virtual seconds of CPU and delivered the heartbeat zero times. No exception, no warning, no dropped-timer counter. The timer is still sitting in the heap, still due.

42 deliveries become 0. Not late — never. The drained loop ran for 10,000 virtual seconds (that is the scratch script's escape hatch, not a natural stopping point) and the heartbeat's timer sat in the heap the whole time, expired and unexamined. Removing the escape hatch and asking the loop to stop at until=3000 confirms the obvious: a single run_once call never returns at all, so until is never re-checked.

The mechanism is worth stating precisely, because it is the sharpest possible statement of what "cooperative" means. Timers are examined at exactly one place: the top of run_once. The hog's burn ends with call_soon(self.step), which re-arms it inside the current pass. With the snapshot, that new callback is out of scope for this pass, the pass ends, and control returns to the top where timers are checked. Without it, the queue is never empty, the while never exits, and the top of run_once is never reached again — for the life of the process.

So the difference between a 40%-late heartbeat and a heartbeat that never fires again is not the hog, not the timer, and not the workload. It is whether the loop bounds one pass.

This is not a distinction CPython gets right by luck. Reading _run_once out of the installed stdlib on the declared interpreter:

# Note: We run all currently scheduled callbacks, but not any # callbacks scheduled by callbacks run this time around -- # they will be run the next time (after another I/O poll). # Use an idiom that is thread-safe without using locks. ntodo = len(self._ready) for i in range(ntodo): handle = self._ready.popleft()

ntodo = len(self._ready) is this toy's snapshot line in production, and the comment above it is a deliberate statement of the invariant — including the parenthetical that matters most, "after another I/O poll", which is the real loop's way of saying that control returns to the top, where timers are checked.

7.2 Giving due timers priority: buys back half

If the lateness comes from timers queueing behind existing work, the obvious fix is to stop making them queue. A counterfactual that puts expired timers at the front of the ready queue instead of the back:

CF 4: what if a timer's callback jumped the ready queue? ============================================================ timers queued at the back period=1400 first: asked 1000, callback 1200, ran 1400 timers queued at the front period=1200 first: asked 1000, callback 1000, ran 1200 Priority buys back one chunk of the two, not both: the future hop still costs a full pass, because set_result() call_soon()s the task and the pass it would have joined has already been snapshotted.

It works, and it works exactly halfway: 1400 → 1200. The callback now runs at 1000, dead on. But it then resolves a future, whose call_soon puts the task on the next pass, and that pass still starts with the hog. The second R is untouched.

That is the useful lesson from the road not taken. You cannot fix this with a priority policy, because the cost is not one queue decision — it is one per hop, and the hops are structural. This is also why the road stays not taken: it doubles the policy surface of the scheduler to halve a number that a 100ms-smaller chunk would remove entirely.

7.3 Integer milliseconds, not float seconds

The clock was floats first. The repo's rule is that every transcript must be byte-identical on every machine, and floats technically satisfy it — IEEE 754 is deterministic. They fail a different requirement: the reader has to be able to derive the numbers.

CF 2: why the clock is an integer ============================================================ 0.2 accumulated 11 times = 2.1999999999999997 ... == 2.2 -> False a float clock at this point is -4.440892098500626e-16 away from the deadline

A hog burning 0.2 per pass arrives at what should be the 2.2 deadline holding 2.1999999999999997, and timers[0][0] <= self.clock is then decided by the last bit of a mantissa. The whole schedule shifts by one pass on the strength of it. Running the identical schedule both ways:

int ms periods: [1400, 1400, 1400, 1400] float sec periods: [1.4000000000000004, 1.4000000000000008, 1.4000000000000012, 1.4000000000000012]

The float row is not wrong, exactly — it is unquotable. §6.2's derivation ("0 + R + R = 400") cannot be written against numbers that drift in the fifteenth place, and a reader checking the page by hand would conclude the arithmetic was broken. Integers cost one unit of awkwardness in the API (everything is milliseconds) and buy a page whose every number can be reproduced with a pencil.

7.4 Generators, not async/await

The toy drives generators with send() rather than real coroutine objects. Real async def coroutines would also work — they expose send() too, which is exactly how asyncio drives them — but they cannot yield a value to their driver without an awaitable protocol, so the toy would need an __await__ shim on every command purely to look modern. Generators make the suspension points visible in the source, which is what a reader is here for.

7.5 Roads not taken elsewhere


8. What's simplified vs. the real thing

8.1 The model is a lower bound on real lateness, never an over-estimate

This is the disclosure that matters most, because the toy's whole credibility rests on it. Tasks declare their own CPU cost. burn(200) means "assume this took 200ms", and the loop simply adds 200 to an integer. That is a fiction in three specific directions, and every one of them makes the real world worse than the model, never better:

So the model gives you the floor. The measurement in §6.3 is the evidence: the model says exactly 1400 and real CPython delivered 1.403, 1.401, 1.401 — always at or above, never below. Read every number on this page as "at least this late".

The virtual clock is also why the toy is honest about something a wall-clock version could not be. A real-time version of §6.4's 180-combination table would take hours to run and would produce a different table every time, which is another way of saying the closed form could not have been found.

8.2 The other simplifications


9. Check yourself

Answer before expanding. Each answer is derivable from the source.

Question 1

The demo's heartbeat asks for 1000ms and is delivered every 1400ms. Does the drift accumulate — will it be 1800 later, then 2200?

Answer

No. It locks into a steady state at 1400 and stays there, which the transcript shows: [1400, 1400, 1400, 1400].

The reason is in Task._suspend: self.deadline = loop.clock + delay. The next deadline is computed from when the task actually resumed, not from the deadline it missed. So the first request (0 → 1000) is served at 1400, and the second is then asked for at 1400 + 1000 = 2400 — which the transcript confirms — and is served 400 late at 2800.

Each beat is 400 late relative to its own request, and the requests themselves have already absorbed the drift. Note the flip side: a loop that re-armed from the deadline instead (self.deadline += delay) would keep asking for instants that are already in the past, and would fire in a burst trying to catch up — which is the other classic timer bug, and why real schedulers make you choose between fixed-rate and fixed-delay.

Question 2

In §6.4, three hogs at 400ms gives a period of 3600ms — but three hogs at 300ms gives 3600ms too. Why do the two agree, when one does 25% more work per pass?

Answer

Because the grid term rounds them onto the same point.

At chunk=300, n_hogs=3: R = 900, ⌈1000/900⌉ = 2, so (2 + 2) × 900 = 3600.

At chunk=400, n_hogs=3: R = 1200, ⌈1000/1200⌉ = 1, so (1 + 2) × 1200 = 3600.

The first pays four passes of 900; the second pays three passes of 1200. The lesson is that the observed period is not proportional to the CPU burned — it is a step function of it, because ⌈P/R⌉ is a step function. Making the hogs 33% heavier here costs exactly nothing, and making them slightly lighter (300 → 250, R = 750, (2+2) × 750 = 3000) is what helps. That last figure was run, not just computed.

Question 3

Future.set_result calls self.loop.call_soon(waiter). Change it to waiter() and the hop disappears — the period drops to 1200. Is that a bug fix?

Answer

It is a real 200ms improvement and a bad idea.

It is real: the counterfactual was written and run, and a re-entrant set_result does measure 1200 — the same figure hops=1 gives, in the toy and in real CPython (gaps=[1.201, 1.201, 1.2, 1.2]).

It is a bad idea because it makes set_result re-entrant. Whoever calls it would find arbitrary user code — the resumed task, and everything it resumes — running to completion inside their call, on top of their stack, before set_result returns. Any invariant they were mid-way through establishing is now visible, and a task that resolves a future in a loop can recurse arbitrarily deep. call_soon costs one pass and buys the guarantee that every resumption happens at a known place with nothing above it.

The honest framing: asyncio is paying 200ms here, under a pathological neighbour, to avoid a class of re-entrancy bug everywhere. §6.5 is the better answer — the same 200ms is free if the hog chops its work to 100ms.

Question 4

You own the heartbeat task and the hog is in a third-party library. Without touching the library, which of these fixes the lateness: (a) a second event loop, (b) asking for sleep(600) instead of sleep(1000), (c) run_in_executor, (d) loop.call_later instead of await sleep?

Answer

(c), and partly (d).

(a) A second loop in the same thread does nothing — one thread, and the hog still owns it. In a second thread it works, but that is (c) with extra steps.

(b) Asking for less makes it worse in the sense that matters. R = 200 and P = 600 gives (⌈600/200⌉ + 2) × 200 = 1000, and the run confirms it. You asked for 600 and got 1000 — a 67% error where before you had 40%. Shortening the period does not buy accuracy; the grid is unchanged and the hops · R term is unchanged, so the absolute lateness stays 400ms while the thing you are comparing it to gets smaller.

(c) run_in_executor is the real answer. It moves the hog to a thread, where a preemptive OS scheduler exists, and the loop's R collapses. This is why it is the standard advice, and it is worth noticing that it works by leaving cooperative scheduling rather than by tuning it.

(d) loop.call_later genuinely saves you one hop — 1400 → 1200, measured both ways in §6.3. It is a real 200ms and it is the cheapest change on the list. But it only removes 1 × R; the grid term and the first queue hop are untouched, so it is a mitigation, not a fix.

Question 5

Reaching past the toy: your service runs 12 replicas, each with a 5-second lease renewal on an event loop, and a 15-second lease TTL. A new code path adds a 700ms JSON decode between awaits. Are you safe? What breaks first, and what will the incident look like?

Answer

Do the arithmetic before deciding. With R = 700 and P = 5000: ⌈5000/700⌉ = 8, so (8 + 2) × 700 = 7000ms — and the toy, run with those parameters, gives exactly 7000.

The renewal that was supposed to happen every 5s now happens every 7s. The TTL is 15s, so a single late renewal survives — you have not lost the lease yet. But the margin went from 3 renewals per TTL to 2.1, and the model in §8.1 is a lower bound: add one GC pause or one payload twice the test size and R grows, and R = 1500 gives (⌈5000/1500⌉ + 2) × 1500 = 9000ms (also run), at which point two consecutive delays exceed the TTL.

What breaks first is not the JSON path — that returns correct results the whole time, a little slower. It is the lease. And the incident looks like a replica losing a lease it never stopped trying to renew, followed by a failover, on a deploy whose diff touches neither leases nor timers.

The general form, and the thing to carry: adding CPU-bound work to an event loop changes the timing guarantees of every unrelated task in the process. That coupling has no import statement, does not appear in the call graph, and is invisible to code review. It is the price of the thread you saved.


10. Further reading