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.
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
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:
(⌈P/R⌉ + hops)·R, and check it against a run;await asyncio.sleep(1.0) is delivered strictly later than loop.call_later(1.0, cb) asking for the same instant, and by exactly how much;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.
| Concept | Where it's used in the toy | One 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.
One pass of the loop. Time only ever moves inside the shaded box.
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.
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.
run_once — the whole scheduler, in nine lines 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.
Task.step — resumption is one trip through the queue 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.
_suspend — where the second hop comes from 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:
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.
Future.set_result — the line the hop is made of 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.
The schedule is two tasks. One asks for a 1000ms period and does nothing else. The other burns 200ms of CPU and yields, forever.
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.
python3 demo.py
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.
t=0 for t=1000. 1000 is on the grid, so the sweep at 1000 expires the timer: expired = 1000, late by 0.step which was queued on the previous pass. That pass runs [hog.step, fire]: the hog burns 200 → clock 1200, then fire runs: callback = 1200, late by 200. That is one R.fire resolves the future, which call_soons the heartbeat — for the next pass, because this one was snapshotted. That pass runs [hog.step, beat.step]: the hog burns 200 → clock 1400, then the heartbeat resumes: ran = 1400, late by 400. That is a second R.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.
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:
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.
Demo section 4 checks the general rule against runs:
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:
⌈P/R⌉ · R — rounding up to the grid. Your deadline gets served at a granularity of R, so if R does not divide P you lose the remainder before anything else happens. This is why chunk=300 costs more than chunk=400 at three hogs would suggest: ⌈1000/300⌉ = 4, so the deadline at 1000 is not even looked at until 1200.+ hops · R — the queue hops. One R for standing behind the already-queued work, one more for the future's call_soon.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.
RThe 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:
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.
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.
python3 test_mini_asyncio.py
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:
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:
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.
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:
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.
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.
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:
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.
async/awaitThe 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.
sleep(0) command. There isn't one, because it would be a synonym for burn(0) and would suggest a distinction the loop does not make. §6.5's point is precisely that sleep(0) is not a lever.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:
epoll/kqueue between passes, which has its own cost and its own resolution; the toy jumps the clock for free.R is a distribution, not the constant this toy uses.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.
_run_once computes a select() timeout from the nearest timer, and that computation is a whole second source of lateness the toy does not model: if the loop is blocked in select with a 5-second timeout and a call_soon arrives from another thread, it needs the self-pipe trick to wake up at all.asyncio.sleep keeps a handle h and calls h.cancel() in a finally — visible in the source quoted in §5.4, and absent here. Cancellation is what makes futures genuinely necessary rather than an indirection, and skipping it lets the toy present the future as pure cost, which is unfair to it.StopIteration ends a task silently and anything else escapes into run_once. Real asyncio routes exceptions into the task's future, and a future nobody awaits produces the "Task exception was never retrieved" warning that exists because of exactly this.Future.waiter is a single slot; asyncio keeps a callback list, because many tasks can await one future.gather, no nurseries, no composition. Tasks are independent. Structured concurrency changes who waits for whom, not how a pass is scheduled.run_in_executor, which is the actual production answer to the hog: move the CPU-bound work to a thread pool, where a preemptive scheduler exists and this entire page stops applying.Executing %s took %.3f seconds (base_events.py, inside _run_once) when a callback exceeds loop.slow_callback_duration, which defaults to 0.1 — 100ms, checked on the declared interpreter. That is the closest thing production has to an alarm for this, and it has three problems. It is off unless debug mode is on. It reports the hog, not the victim — it would never mention the heartbeat that went 40% late. And its threshold is not even monotonic in the harm: a 90ms chunk never trips the 100ms warning and inflates the heartbeat to 1260ms (260 late), while a 100ms chunk does trip it and inflates it only to 1200ms (200 late). The quieter hog is the more damaging one, because 90 does not divide 1000 and the grid term collects the difference.Answer before expanding. Each answer is derivable from the source.
The demo's heartbeat asks for 1000ms and is delivered every 1400ms. Does the drift accumulate — will it be 1800 later, then 2200?
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.
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?
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.
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?
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.
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?
(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.
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?
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.
call_soon definition and its guarantee that "callbacks are called in the order in which they were scheduled" are the specification of call_soon/call_later this toy models, and it is candid about cooperative scheduling's terms._run_once; ntodo = len(self._ready) is §7.1's load-bearing line in production, with the comment explaining why.sleep is eleven lines. The call_later + await future pair is the second hop measured in §6.3.call_soon, call_later, and run_in_executor, which is the standard answer to the hog and the one that leaves cooperative scheduling entirely.send() comes from, and therefore why a generator can be a task at all.(when, seq, item) tuple trick, which keeps equal deadlines FIFO and avoids comparing the payloads, is standard and worth stealing.