Python's asyncio.sleep versus time.sleep — the schedule that is not a crontab
The loop kept other work. The sleep owned the thread.
<!-- hal:authoritative:yaml -->
The loop kept other work. The sleep owned the thread.
§I — Frame
Wednesday's Dev lesson named else. try/else runs when no exception fired. for/else runs when no break fired. Coin of that day: the else that is not a handler, under the trio coin the failover that is not a replica.
Today the Ops tool lists EventBridge buses, rules, and targets. A ScheduleExpression is a different owner. It is not a line in a host crontab. The language surface is one step closer to the interpreter. Python already gave you two waits that people keep reading as the same pause.
Ramalho puts the experiment in Ch.19. Import time. In the slow coroutine, replace await asyncio.sleep(3) with time.sleep(3). The spinner Task is created. The spinner never appears. The program hangs for three seconds. Then 42 prints and the program ends (Fluent Python 2ed, Ch.19, printed pp.709-710). The warning that follows is the law: never use time.sleep in an asyncio coroutine unless you want to pause the whole program. If a coroutine needs to spend some time doing nothing, it should await asyncio.sleep(DELAY). That yields control back to the event loop, which can drive other pending coroutines.
A reader who sees sleep thinks "wait here." Both calls wait. Only one of them leaves the loop able to run other work. Coin the correction: the sleep that is not a block. Same trio coin as Ops: the schedule that is not a crontab.
This lesson does not reopen async context managers (08-11). It does not reopen TaskGroups (06-05). It does not wrap boto3.client("events"). The cut under the knife is the wait, and the two functions that look like one.
§II — Language idiom: two waits, one thread
**time.sleep owns the thread.**
The call blocks the Python thread until the clock advances. In a one-thread asyncio program that thread is the only thread. Ramalho's experiment is the proof: after await slow(), control enters slow, time.sleep(3) holds the thread, and the spin coroutine never starts its body. The Task exists. The body does not run. The operating system continues with other processes. This process does not.
Lutz's timer pair is the wall-clock cousin, not the primary cut. time.clock (Windows) versus time.time (some Unix) is how the older book times a loop (Learning Python, Timing Iteration Alternatives, pp.564-565). Those calls measure. They do not schedule other work. time.sleep is the pause that uses the same family of clocks and then holds the thread. A crontab is that pause lifted onto a host: the line runs, the host is busy with that line, nothing else in that process is scheduled.
**asyncio.sleep yields to the loop.**
await asyncio.sleep(3) suspends the current coroutine and returns control to the event loop. The loop is free to drive other pending coroutines. After three seconds of loop time, the sleeper is woken and continues. Ramalho: "This yields control back to the asyncio event loop, which can drive other pending coroutines" (Ch.19, p.710). The spinner experiment with the real asyncio.sleep is the opposite film: the spinner appears, it keeps spinning, slow finishes, the spinner is cancelled.
Ch.21 draws the channel. A user function starts the event loop, scheduling an initial coroutine with asyncio.run. Each user coroutine drives the next with await, forming a channel between a library and the loop. Under the hood the loop makes the .send calls. The await chain eventually reaches a low-level awaitable the loop can drive in response to events such as timers or network I/O (Fluent Python 2ed, Ch.21, Figure 21-1). asyncio.sleep is that timer. It is a schedule the loop owns. It is not a crontab the coroutine owns.
The loop clock is not the wall.
asyncio.sleep is relative. It asks the loop to wake this coroutine after a delay. It does not ask datetime.now() whether it is noon. EventBridge rate(5 minutes) is the same shape: a delay the service owns. EventBridge cron(0 12 * * ? *) is the other shape: a calendar the service owns. Both still live on the bus. Neither is a line in /var/spool/cron. The language cousin of the rate is asyncio.sleep. The language cousin of a host crontab is time.sleep in the only thread.
time.monotonic is the clock you use when you write a deadline yourself. 08-26 already used it in a while/else timeout. This lesson does not reopen else. It names the clock so you do not build a deadline from time.time() and then sleep with time.sleep inside a coroutine. time.time() can step when NTP steps. time.monotonic() does not. The loop's own clock is monotonic. asyncio.sleep consults that clock. A crontab consults the wall.
Cancellation is a different owner too.
Ramalho's spinner catches asyncio.CancelledError when Task.cancel is called (Ch.19, p.709). asyncio.sleep is cancellable. The loop can interrupt the wait. time.sleep is not cancellable from another coroutine in the same thread. You cannot cancel a crontab from a second process without killing the first. You can disable an EventBridge rule and the next tick does not fire. The language rhyme is CancelledError on asyncio.sleep, not a second time.sleep that you hope the first one notices.
§III — Code worked example
The first block is Ramalho's experiment written as a census of the wait. It does not talk to AWS. It proves the owner.
import asyncio
import time
async def spin(msg: str) -> None:
ticks = 0
try:
while True:
ticks += 1
await asyncio.sleep(0.1)
except asyncio.CancelledError:
print(f"spin cancelled after {ticks} ticks")
raise
async def slow_block() -> int:
time.sleep(0.3)
return 42
async def slow_yield() -> int:
await asyncio.sleep(0.3)
return 42
async def run(kind: str) -> None:
spinner = asyncio.create_task(spin("thinking"))
print(f"kind={kind} task={spinner}")
if kind == "block":
result = await slow_block()
else:
result = await slow_yield()
spinner.cancel()
try:
await spinner
except asyncio.CancelledError:
pass
print(f"kind={kind} result={result}")
async def main() -> None:
await run("yield")
await run("block")
if __name__ == "__main__":
asyncio.run(main())
Run it. kind=yield prints ticks. kind=block prints spin cancelled after 0 ticks or a single tick if the task started and then froze. The Task object existed in both cases. The body ran in one case. That is the EventBridge finding in miniature: a rule can exist (create_task) and still have no listener that ran (time.sleep held the thread).
The second block is a deadline that refuses the wall. It uses time.monotonic and asyncio.sleep. It does not use datetime.now.
import asyncio
import time
async def ping(ok_after: float, started: float) -> bool:
await asyncio.sleep(0.05)
return (time.monotonic() - started) >= ok_after
async def wait_for_primary(timeout: float, ok_after: float) -> str:
started = time.monotonic()
deadline = started + timeout
while time.monotonic() < deadline:
if await ping(ok_after, started):
return "primary"
await asyncio.sleep(0.02)
return "timeout"
async def main() -> None:
print(await wait_for_primary(0.4, 0.15))
print(await wait_for_primary(0.1, 0.5))
if __name__ == "__main__":
asyncio.run(main())
wait_for_primary is a schedule the loop still owns. Each await ping and each await asyncio.sleep(0.02) lets other tasks run. Replace those awaits with time.sleep and you have written a crontab inside the process: the function will hit the deadline, and nothing else on the loop will move. The Ops census prints a scheduled rule with zero targets. This function, if rewritten with time.sleep, is a scheduled rule that stole the only worker.
Do not put # comments in the blocks. The prose above is the explanation. Lutz times loops with time.clock / time.time because he is measuring a tight iteration, not scheduling a fleet. Keep that pair in the timing chapter. Do not import it into a coroutine as the wait.
§III.B — Four more rules the wait will break
**Rule one. asyncio.sleep(0) is a yield, not a delay you can bill.**
Ramalho uses await asyncio.sleep(0) every 100,000 iterations as a stopgap so a CPU loop gives the spinner a chance to run (Ch.19, around the is_prime experiment). The zero is not a schedule you would write on a bus. It is a courtesy to the loop: I still have work, but you may drive someone else first. A crontab of every minute is not that courtesy. A rate(1 minute) is not that courtesy. If you need a real delay, pass a real number. If you need to unblock the loop during a tight calculation, you are already in the warning: move the calculation off the loop. The zero is a bandage. Gift's timed Lambda is a real timer (Ch.15, p.640). Do not confuse the bandage with the timer.
**Rule two. loop.time() is the clock asyncio.sleep consults.**
asyncio.get_running_loop().time() returns the loop clock. It is monotonic. It is not datetime.now(timezone.utc).timestamp(). A function that computes deadline = time.time() + 5 and then await asyncio.sleep(5) has mixed owners. The sleep will wait five loop-seconds. The deadline was five wall-seconds. If the wall steps, the two disagree. EventBridge rate(5 minutes) is five service-minutes. A host crontab every five minutes is five wall-minutes on that host, including the hour the laptop slept. The ops census prints ScheduleExpression. It does not print the last time the instance woke. The language census prints loop.time(). It does not print datetime.now().
**Rule three. create_task is not await, and run is not a crontab.**
asyncio.create_task schedules a coroutine on the running loop. It returns a Task. The body starts when the loop next drives it, not when create_task returns. Ramalho's experiment prints the Task as pending, then time.sleep prevents the drive. Ops prints a rule as ENABLED, then finds zero targets. Both objects exist. Both can do no work.
asyncio.run is the one-shot owner: start a loop, schedule the first coroutine, close the loop when that coroutine returns (Ch.21, Figure 21-1). It is not a while True around time.sleep(60). It is not cron. A script that calls asyncio.run(main()) from a host crontab is using the host as the outer clock and the loop as the inner clock. That can be correct. It is two owners. The Dev lesson names the inner one. The Ops lesson names the bus that replaces the outer one.
**Rule four. Blocking I/O is time.sleep in another costume.**
Ch.21 is blunt: for peak performance with asyncio, replace every function that does I/O with an asynchronous version activated with await or asyncio.create_task, so control returns to the loop while the function waits. If you cannot rewrite a blocking function, run it in an executor so the loop stays free (Fluent Python 2ed, Ch.21, The All-or-Nothing Problem). A requests.get inside a coroutine is time.sleep with a network on the other end. The thread is owned until the socket returns. The spinner stops. The other EventBridge targets on the same rule would have run in parallel (MonitoringLogging.md). Your other tasks will not.
The executor is a different bus. loop.run_in_executor moves the block onto a thread pool. The await on that future is asyncio.sleep shaped: the loop can drive other work. Do not pretend the blocking call became non-blocking because you put async def above it. The keyword is not the owner. The await that actually yields is the owner.
These four rules are why the trio coin is a language coin and not a boto3 coin. The wait has an owner. Name it. If the owner is the thread, you wrote a crontab. If the owner is the loop, you wrote a schedule.
Keep one more distinction when you read a stack trace. asyncio.sleep cancelled mid-wait raises CancelledError at the await. time.sleep cancelled from another thread is a different API (pthread interruption, process kill) and is not available to a sibling coroutine. If the on-call needs to stop a wait without stopping the process, the wait had to be the loop's wait. EventBridge disable_rule is that stop on the bus. kill -9 on the instance is that stop on the crontab. The language cut is the first family, not the second.
When you write a test for the wait, patch asyncio.sleep, not time.sleep, unless you intentionally wrote the block. A test that patches the wrong name will pass while production hangs. That is the same class of finding as a census that lists default-bus rules and never asks a custom bus.
§IV — Connection to today's Ops lesson
The Ops tool lists buses, then rules, then targets. A ScheduleExpression without a target is a clock nobody hears. asyncio.create_task(spin(...)) without await asyncio.sleep in the other coroutine is a Task nobody drives. Gift's sentence is "cron-like" CloudWatch Events triggering Lambda (Python for DevOps, Ch.13, p.513). Cron-like. The expression looks like crontab. The owner is the bus. asyncio.sleep looks like time.sleep. The owner is the loop.
The ops finding schedule-without-listener is the language finding kind=block. One coin.
The regional client is the other rhyme. EventBridge is per region. The event loop is per thread. A coroutine that calls time.sleep has left its region: other tasks in that loop cannot see the clock. A census that constructs boto3.client("events") without region_name has left its region the other way. Pass the owner. Print the owner.
§V — Prior-lesson reach
08-26 taught else on try and for. The success path is not a replica of the handler. Today's success path is the yield. asyncio.sleep is not a replica of time.sleep. People treat both as "the pause." Both wait. Only one of them is a schedule.
08-23 taught repr and field(repr=False). The print is not the value. Today's cousin is the Task printed as pending while its body has not run. Ramalho's experiment prints the Task object, then nothing spins. repr(spinner) is not a tick.
08-17 taught weakref.finalize and atexit. The signal that must still fire. Today's cousin is CancelledError on a sleep you can cancel. time.sleep has no such signal in the same thread. If you needed the signal, you needed asyncio.sleep.
08-11 taught async context managers. This lesson does not reopen __aenter__. The wait is enough.
§VI — Closing
time.sleep owns the thread. asyncio.sleep yields to the loop. asyncio.run schedules the first coroutine. The await chain ends at a timer or at I/O. A crontab owns a host. A scheduled rule owns a bus. One coin: the schedule that is not a crontab.
Examine the next coroutine that says sleep. If the next line only makes sense when nothing else can run, you wrote the block. Change the call, or change the owner.
Related
- Prior arc: try/else and for/else (2026-08-26)
- Language hub: Cross-References/dev-languages/Python
- Grounding: Fluent Python 2ed — Ch.19 asyncio.sleep versus time.sleep