Skip to main content

Inside the Asyncio Event Loop

Asyncio Core

Event Loop Fundamentals

The event loop drives asyncio. Understand how it schedules tasks, handles callbacks, and wakes up on I/O readiness.

What the loop actually does

Strip away the machinery and the event loop is a single-threaded while loop doing two jobs, over and over:

  1. Run every task that is ready. The loop keeps a queue of callbacks and tasks that can make progress right now. It runs each one until that task hits an await on something unfinished — at which point the task suspends itself and hands control back.
  2. Wait for something to become ready. With the queue empty, the loop asks the OS selector (epoll on Linux, kqueue on macOS, IOCP on Windows) to sleep until a socket has data, a timer expires, or a future resolves. Whatever woke up goes back on the ready queue, and the cycle repeats.

That's the whole trick: one thread multiplexing thousands of waits, because waiting is delegated to the OS while running happens one task at a time.

The scheduling is cooperative. The loop never interrupts a task; a task only yields control at an await point. That is asyncio's contract, and both of its consequences matter: tasks interleave predictably at awaits, and a task that never awaits starves everyone else.

Cooperative scheduling in action

Watch two coroutines take turns. Each prints, then awaits — and every await asyncio.sleep is a scheduling point where the loop switches to whoever else is ready:

import asyncio

async def worker(name):
for step in range(3):
print(f"{name} step {step}")
await asyncio.sleep(0.1)

async def main():
await asyncio.gather(worker("A"), worker("B"))

asyncio.run(main())
A step 0
B step 0
A step 1
B step 1
A step 2
B step 2

The alternation is not luck. gather schedules A first, so A prints and suspends on its sleep; the loop then runs B, which prints and suspends too. When the timers fire 0.1s later, both tasks are ready again and run in the order they were scheduled. Replace the sleeps with real socket reads and the picture is the same — the loop just wakes tasks on I/O readiness instead of timers.

Blocking the loop

"Blocking the loop" means running something inside a coroutine that doesn't await — the loop can't switch away, so every other task freezes. The classic mistake is calling time.sleep (or requests.get, or a sync database query) from async code:

import asyncio
import time

async def ticker():
for i in range(3):
print(f"tick {i}")
await asyncio.sleep(0.5)

async def blocker():
time.sleep(2) # blocking call — freezes the whole loop
print("blocker done")

async def main():
await asyncio.gather(ticker(), blocker())

asyncio.run(main())
tick 0
blocker done
tick 1
tick 2

ticker should print every half second — instead it prints once, then goes silent for two full seconds while time.sleep holds the thread. Only after blocker finishes does the loop get control back and let the remaining ticks through.

The fix depends on what's blocking:

  • You wrote the wait yourself: use await asyncio.sleep(...) — it suspends the task, not the thread.
  • A library blocks and you can't change it: push the call into a worker thread with asyncio.to_thread (or loop.run_in_executor), and await the result.

Same program with the one-line fix — the ticks keep flowing while the blocking sleep runs on a worker thread:

import asyncio
import time

async def ticker():
for i in range(3):
print(f"tick {i}")
await asyncio.sleep(0.5)

async def blocker():
await asyncio.to_thread(time.sleep, 2) # blocks a worker thread instead
print("blocker done")

async def main():
await asyncio.gather(ticker(), blocker())

asyncio.run(main())
tick 0
tick 1
tick 2
blocker done

Lifecycle and executors

You rarely manage the loop by hand. asyncio.run(main()) does the full lifecycle for you: it creates a fresh event loop, runs main to completion, cancels any leftover tasks, and closes the loop. Call it once at the top of your program; inside coroutines, asyncio.get_running_loop() hands you the current loop if you need it.

run_in_executor is the loop's escape hatch, and it takes any executor — which matters for CPU-bound work. A thread doesn't help there (the computation holds the GIL and still starves the loop), but a process pool moves the crunching off the interpreter entirely:

import asyncio
from concurrent.futures import ProcessPoolExecutor

def crunch(n):
return sum(i * i for i in range(n))

async def main():
loop = asyncio.get_running_loop()
with ProcessPoolExecutor() as pool:
result = await loop.run_in_executor(pool, crunch, 10_000_000)
print(result) # 333333283333335000000

if __name__ == "__main__":
asyncio.run(main())

Rule of thumb: asyncio.to_thread for legacy blocking-I/O calls, run_in_executor with a ProcessPoolExecutor for CPU work, and plain await for everything async-native.

Frequently Asked Questions

How do I find out what is blocking my event loop?

Run asyncio in debug mode: asyncio.run(main(), debug=True). The loop then logs a warning naming any callback or task step that held the thread longer than 0.1 seconds (tunable via loop.slow_callback_duration), which points you straight at the blocking call.

import asyncio, time

async def main():
time.sleep(0.3) # culprit

asyncio.run(main(), debug=True)
# Warning logged: "Executing <Task ... main()...> took 0.300 seconds"

Do I ever need to create an event loop manually?

Almost never in application code. asyncio.run covers the create/run/close cycle, and asyncio.get_running_loop gives coroutines access to the current loop. Manual loop construction with asyncio.new_event_loop is for special embeddings — GUI frameworks, custom loop policies, or running a loop in a background thread.

Is the event loop itself multi-threaded?

No — one loop runs in exactly one thread, and everything it schedules runs in that thread. That is why a single blocking call freezes all tasks, and it is also why asyncio code mostly avoids locks: two coroutines in the same loop can never touch data at the same instant, only between await points.

Next up in your learning path