Explanation
What just happened
Both tasks run on one thread, the one that called asyncio.run. slow_call used time.sleep, which doesn’t await: it put the whole thread to sleep. The ticker’s 20 ms were up, but the loop was stuck, so tick 2 came only after slow call ends.
Try the what-if switch
- await asyncio.sleep: the
awaitpauses onlyslow_calland hands the thread back. All five ticks print beforeslow call ends. - asyncio.to_thread: the blocking
time.sleepruns on a separate worker thread.slow_callawaits it, and the ticker keeps ticking.
Why
asyncio is cooperative. A task runs until it reaches an await that has to wait, and only then can the loop run another task. A blocking call never reaches one, so it holds the thread for everyone.
Watch out
Blocking calls don’t look different from any other function call: time.sleep, requests.get and reading a big file all block. Inside async def, use the async version or asyncio.to_thread.
Common questions
What's the difference between time.sleep and asyncio.sleep?
time.sleep blocks the thread, and in asyncio that's the event loop's only thread, so no task runs. await asyncio.sleep pauses just this task and lets the loop run others.
How do I call blocking code from asyncio?
Wrap it in await asyncio.to_thread(func, args). It runs on a worker thread from the loop's default thread pool, and the loop stays free.
Do asyncio tasks run on different threads?
No. Every task runs on the thread that called asyncio.run, one at a time. Tasks switch only at an await.
Verified: this timeline matches a real program, checked in CI on Python 3.14, 3.15 and free-threaded 3.15t. How we verify. Unclear or wrong? Tell us