Skip to main content

Python asyncio Event Loop

Deep dive into Python's asyncio library, understanding event loops, coroutines, tasks, and async/await patterns with interactive visualizations.

What is asyncio?

asyncio is Python's built-in library for writing single-threaded concurrent code using the async/await syntax. It provides an event loop that manages and executes asynchronous tasks, making it perfect for I/O-bound and high-level structured network code.

The Event Loop: Heart of asyncio

The event loop manages and schedules tasks on a single thread. Follow Task Queue → Event Loop → Pending I/O → Completed to see how it continues other work while network, database and file operations wait. It switches tasks at await points instead of blocking for I/O.

In Coroutine Flow, choose a single coroutine or three concurrent coroutines with asyncio.gather(), then step through defined, queued, running, awaiting and returned states. The highlighted zone and code line show the current action. Coroutines cooperate by parking at await points and waking when their I/O is ready; this avoids the memory and context-switch cost of an OS thread per coroutine.

Sync vs Async runs the same calls sequentially or concurrently. Each synchronous request finishes before the next starts; concurrent requests wait only for the slowest call. Try the call-count scenarios and scrub the timeline: synchronous wall time grows with the number of calls, while asynchronous wall time follows the slowest single call. At 100 calls × 2s, sync takes 200s and async takes 2s. For I/O-bound web scraping, API fan-out and microservice calls, the original example contrasts service rates of 10 and 1000 requests/sec.

Event loop
Demo
Task Queue

No tasks waiting

↓ pick next

Event Loop · Single Thread

↓ await I/O

Pending I/O

No I/O operations

↓ done!

Completed · Done: 0
Elapsed
0.0s
Queue
0
Running
0
I/O Wait
0
Done
0

Running: none. Task Queue: empty. I/O Wait: none. Completed: 0.

asyncio_eventloop.py
import asyncio

async def fetch_data(url):
    print(f"Starting fetch: {url}")
    await asyncio.sleep(2)  # Simulate network I/O
    print(f"Completed: {url}")
    return f"Data from {url}"

async def main():
    # Create tasks for concurrent execution
    tasks = [
        asyncio.create_task(fetch_data("api.example.com/users")),
        asyncio.create_task(fetch_data("api.example.com/posts")),
        asyncio.create_task(fetch_data("api.example.com/comments"))
    ]

    # Wait for all tasks to complete
    results = await asyncio.gather(*tasks)
    print(f"All done! Got {len(results)} results")

# Run the event loop
asyncio.run(main())

Core Concepts

1. Coroutines

Coroutines are special functions defined with async def that can be paused and resumed:

python
import asyncio

async def hello_world():
    print("Hello")
    await asyncio.sleep(1)  # Pause here, let other tasks run
    print("World")

# Coroutines must be awaited or run by the event loop
asyncio.run(hello_world())

Key Points:

  • Defined with async def
  • Can use await to pause execution
  • Return coroutine objects when called (not executed immediately)
  • Must be run by an event loop

2. Event Loop

The event loop is the core of every asyncio application. It runs asynchronous tasks and callbacks, performs network I/O operations, and runs subprocesses.

python
import asyncio

async def task(name, delay):
    print(f"Task {name} starting")
    await asyncio.sleep(delay)
    print(f"Task {name} completed after {delay}s")
    return f"Result-{name}"

async def main():
    # The event loop runs multiple coroutines concurrently
    results = await asyncio.gather(
        task("A", 2),
        task("B", 1),
        task("C", 3)
    )
    print(f"All results: {results}")

# asyncio.run() creates an event loop, runs the coroutine, and closes the loop
asyncio.run(main())

3. Tasks

Tasks are used to schedule coroutines concurrently:

python
import asyncio
import time

async def fetch_data(id, delay):
    print(f"Fetching data {id}...")
    await asyncio.sleep(delay)
    return f"Data-{id}"

async def main():
    start = time.time()

    # Create tasks to run concurrently
    task1 = asyncio.create_task(fetch_data(1, 2))
    task2 = asyncio.create_task(fetch_data(2, 3))
    task3 = asyncio.create_task(fetch_data(3, 1))

    # Wait for all tasks
    results = await asyncio.gather(task1, task2, task3)

    print(f"Results: {results}")
    print(f"Total time: {time.time() - start:.2f}s")  # ~3s, not 6s!

asyncio.run(main())

async/await Syntax

The async Keyword

  • async def: Defines a coroutine function
  • Returns a coroutine object when called
  • Can contain await expressions

The await Keyword

  • await: Pauses the coroutine until the awaited task completes
  • Can only be used inside async functions
  • Yields control back to the event loop
python
async def fetch_user(user_id):
    # Simulate API call
    await asyncio.sleep(1)
    return {"id": user_id, "name": f"User-{user_id}"}

async def fetch_posts(user_id):
    # Simulate database query
    await asyncio.sleep(0.5)
    return [f"Post-{i}" for i in range(3)]

async def get_user_data(user_id):
    # Concurrent execution using gather
    user, posts = await asyncio.gather(
        fetch_user(user_id),
        fetch_posts(user_id)
    )
    return {"user": user, "posts": posts}

# Run the async function
result = asyncio.run(get_user_data(123))

Common asyncio Patterns

1. Fire and Forget

python
async def background_task(name):
    await asyncio.sleep(2)
    print(f"Background task {name} completed")

async def main():
    # Create task but don't await it immediately
    asyncio.create_task(background_task("cleanup"))

    # Do other work
    print("Main work...")
    await asyncio.sleep(1)
    print("Main work done")

    # Give background tasks time to complete
    await asyncio.sleep(2)

asyncio.run(main())

Schedule a background task and scrub the timeline to compare its completion with the main coroutine.

Fire & Forget — Live
0.00s / 3.4s
Speed
main()sleep(1)
0s1.7s3.4s
background_task("cleanup")sleep(2)
0s1.7s3.4s
stdout
[0.0s] [main] Main work...
fire_and_forget.py
async def background_task(name):    await asyncio.sleep(2)    print(f"Background task {name} completed")async def main():    asyncio.create_task(background_task("cleanup"))    print("Main work...")    await asyncio.sleep(1)    print("Main work done")    await asyncio.sleep(2)

Highlighted line is active

Main work... Main is awaiting sleep(1); background cleanup is awaiting sleep(2).

Schedule a background task, keep doing your work, and let it finish on its own clock.

2. Timeout Handling

python
async def slow_operation():
    await asyncio.sleep(10)
    return "Complete"

async def main():
    try:
        # Wait maximum 3 seconds
        result = await asyncio.wait_for(slow_operation(), timeout=3.0)
        print(result)
    except asyncio.TimeoutError:
        print("Operation timed out!")

asyncio.run(main())

Scrub the timeline to follow the wait budget and the cancellation of the slow operation.

Timeout Handling — Live
0.00s / 4.2s
Speed
slow_operation() — sleep(10)running
0s3s timeout boundary10s (intended)
wait_for budget (3.0s)3.00s left
Waiting for timeout boundary at t = 3.0s ...
timeout.py
async def slow_operation():    await asyncio.sleep(10)    return "Complete"async def main():    try:        result = await asyncio.wait_for(            slow_operation(), timeout=3.0        )        print(result)    except asyncio.TimeoutError:        print("Operation timed out!")

Highlighted line is active

Waiting for timeout boundary at 3.0s. slow_operation() is running.

wait_for caps the wait at 3s. The slow operation wants 10s — it gets cancelled before it finishes.

3. Producer-Consumer Pattern

python
import asyncio
import random

async def producer(queue, producer_id):
    for i in range(5):
        item = f"Item-{producer_id}-{i}"
        await queue.put(item)
        print(f"Producer {producer_id} added {item}")
        await asyncio.sleep(random.uniform(0.5, 1.5))

async def consumer(queue, consumer_id):
    while True:
        item = await queue.get()
        if item is None:  # Poison pill
            break
        print(f"Consumer {consumer_id} processed {item}")
        await asyncio.sleep(random.uniform(0.2, 0.8))
        queue.task_done()

async def main():
    queue = asyncio.Queue(maxsize=10)

    # Create producers and consumers
    producers = [asyncio.create_task(producer(queue, i)) for i in range(2)]
    consumers = [asyncio.create_task(consumer(queue, i)) for i in range(3)]

    # Wait for producers to finish
    await asyncio.gather(*producers)

    # Wait for queue to be processed
    await queue.join()

    # Stop consumers
    for _ in consumers:
        await queue.put(None)

    await asyncio.gather(*consumers)

asyncio.run(main())

Follow producers adding items to the queue and consumers processing them as the timeline advances.

Producer / Consumer — Live
0.00s / 15.6s
Speed
Producers
Producer 0
idle
Producer 1
idle
Queue (FIFO)
depth 0/10
Consumers
Consumer 0waiting
Consumer 1waiting
Consumer 2waiting
Processed
0 / 18
producer_consumer.py
queue = asyncio.Queue(maxsize=10)async def producer(queue, pid):    for i in range(N):        item = f"Item-{pid}-{i}"        await queue.put(item)        await asyncio.sleep(jitter())async def consumer(queue, cid):    while True:        item = await queue.get()        if item is None:            break        await process(item)        queue.task_done()

Highlighted line is active

Queue: empty. Processing: none. Processed: 0 of 18.

2 producers feed a 10-slot queue drained by 3 slower consumers — watch the queue back up.

4. Semaphore for Rate Limiting

python
async def rate_limited_request(semaphore, url):
    async with semaphore:  # Acquire semaphore
        print(f"Requesting {url}")
        await asyncio.sleep(1)  # Simulate API call
        return f"Response from {url}"

async def main():
    # Limit to 3 concurrent requests
    semaphore = asyncio.Semaphore(3)

    urls = [f"http://api.example.com/endpoint/{i}" for i in range(10)]

    tasks = [rate_limited_request(semaphore, url) for url in urls]
    results = await asyncio.gather(*tasks)

    print(f"Completed {len(results)} requests")

asyncio.run(main())

Scrub the timeline to see the semaphore limit concurrent requests and release slots as requests finish.

Semaphore Rate Limit — Live
0.00s / 4.4s
Speed
Waiting on semaphore7
/ep/3/ep/4/ep/5/ep/6/ep/7/ep/8/ep/9
Semaphore — capacity 3
Slot 0/ep/0
Slot 1/ep/1
Slot 2/ep/2
Completed0 / 10
none yet
semaphore.py
async def rate_limited_request(semaphore, url):    async with semaphore:        print(f"Requesting {url}")        await asyncio.sleep(1)        return f"Response from {url}"async def main():    semaphore = asyncio.Semaphore(3)    urls = [f".../endpoint/{i}" for i in range(10)]    tasks = [rate_limited_request(s, u) for u in urls]    results = await asyncio.gather(*tasks)

Highlighted line is active

Slots acquired: slot 0, /ep/0; slot 1, /ep/1; slot 2, /ep/2. Waiting: 7. Completed: 0 of 10.

10 tasks fight for 3 slots. Only 3 run at once; the rest wait their turn.

Real-World Example: Web Scraper

python
import asyncio
import aiohttp
from typing import List, Dict

async def fetch_page(session: aiohttp.ClientSession, url: str) -> Dict:
    """Fetch a single page"""
    try:
        async with session.get(url, timeout=5) as response:
            return {
                "url": url,
                "status": response.status,
                "content": await response.text(),
                "headers": dict(response.headers)
            }
    except asyncio.TimeoutError:
        return {"url": url, "error": "Timeout"}
    except Exception as e:
        return {"url": url, "error": str(e)}

async def fetch_all_pages(urls: List[str]) -> List[Dict]:
    """Fetch multiple pages concurrently"""
    connector = aiohttp.TCPConnector(limit=10)  # Limit connections
    timeout = aiohttp.ClientTimeout(total=30)

    async with aiohttp.ClientSession(
        connector=connector,
        timeout=timeout
    ) as session:
        tasks = [fetch_page(session, url) for url in urls]
        return await asyncio.gather(*tasks)

async def process_pages(urls: List[str]):
    """Process pages with progress reporting"""
    print(f"Fetching {len(urls)} pages...")

    results = await fetch_all_pages(urls)

    successful = [r for r in results if "error" not in r]
    failed = [r for r in results if "error" in r]

    print(f"✅ Success: {len(successful)}")
    print(f"❌ Failed: {len(failed)}")

    for failure in failed:
        print(f"  - {failure['url']}: {failure['error']}")

    return results

# Usage
urls = [
    "https://httpbin.org/delay/1",
    "https://httpbin.org/delay/2",
    "https://httpbin.org/status/200",
    "https://httpbin.org/status/404",
]

results = asyncio.run(process_pages(urls))

asyncio vs Threading

asynciocooperative
Threadingpreemptive
Concurrency Model
Cooperative, single-threaded — tasks yield at await points
Preemptive, multi-threaded — OS schedules threads anywhere
Best For
I/O-bound tasks (network, disk, sockets)
CPU-bound work or blocking-I/O libraries with no async port
Context Switch
Very fast — pure user space, no syscall
Slower — kernel-mediated thread switch
Memory Usage
Low — one stack, lightweight coroutine frames
Higher — each thread reserves its own stack (~8 MB default)
GIL Impact
None — only one thread runs all coroutines
Limited — GIL serializes Python bytecode across threads
Debugging
Easier — sequential reasoning, deterministic switch points
Harder — race conditions, deadlocks, locking overhead
Scalability
10,000+ concurrent tasks on one thread
Hundreds to a few thousand threads before OS thrashes

Advanced Features

1. Async Context Managers

python
class AsyncDatabase:
    async def __aenter__(self):
        print("Connecting to database...")
        await asyncio.sleep(0.5)
        self.connection = "Connected"
        return self

    async def __aexit__(self, exc_type, exc_val, exc_tb):
        print("Closing database connection...")
        await asyncio.sleep(0.2)
        self.connection = None

    async def query(self, sql):
        await asyncio.sleep(0.1)
        return f"Results for: {sql}"

async def main():
    async with AsyncDatabase() as db:
        result = await db.query("SELECT * FROM users")
        print(result)

asyncio.run(main())

2. Async Iterators

python
class AsyncCounter:
    def __init__(self, stop):
        self.current = 0
        self.stop = stop

    def __aiter__(self):
        return self

    async def __anext__(self):
        if self.current < self.stop:
            await asyncio.sleep(0.1)  # Simulate async work
            self.current += 1
            return self.current
        raise StopAsyncIteration

async def main():
    async for num in AsyncCounter(5):
        print(f"Count: {num}")

asyncio.run(main())

3. Async Generators

python
async def fetch_paginated_data(pages):
    """Async generator for paginated API calls"""
    for page in range(1, pages + 1):
        await asyncio.sleep(0.5)  # Simulate API call
        yield {
            "page": page,
            "data": [f"item-{i}" for i in range(5)]
        }

async def process_data():
    async for page_data in fetch_paginated_data(3):
        print(f"Processing page {page_data['page']}")
        # Process data as it arrives
        for item in page_data['data']:
            print(f"  - {item}")

asyncio.run(process_data())

Common Pitfalls and Solutions

1. Blocking the Event Loop

A blocking call stalls the whole loop

Avoid blocking operations inside async functions. A synchronous call such as time.sleep() stalls every other coroutine on the loop until it returns.

Bad: Blocking sleep
async def bad_example():
    # Blocks the entire event loop!
    time.sleep(5)
    return "Done"

time.sleep is synchronous — every other coroutine stalls for 5s.

Good: Async sleep
async def good_example():
    # Yields to the loop while waiting
    await asyncio.sleep(5)
    return "Done"

asyncio.sleep is awaitable — the loop runs other tasks meanwhile.

2. Forgetting to await

Bad: Missing await
async def fetch_data():
    await asyncio.sleep(1)
    return "data"

async def bad_main():
result = fetch_data() # coroutine object
print(result) # <coroutine object ...>

Calling a coroutine returns the object — it never runs.

Good: Properly awaited
async def good_main():
result = await fetch_data() # actually runs it
print(result) # "data"

await schedules the coroutine and yields the return value.

3. CPU-Bound Tasks

Bad: Tight loop in coroutine
async def cpu_intensive():
    # Hot loop never yields → loop is frozen
    for i in range(100_000_000):
        _ = i * i
    return "Done"

No await points means no other task gets a turn.

Good: Offload to a process pool
from concurrent.futures import ProcessPoolExecutor

def cpu*intensive_sync():
for i in range(100_000_000):

- = i * i
  return "Done"

async def cpu_intensive_async():
loop = asyncio.get_event_loop()
with ProcessPoolExecutor() as ex:
return await loop.run_in_executor(
ex, cpu_intensive_sync
)

run_in_executor lets the event loop keep serving I/O tasks.

Performance Tips

  1. Use asyncio.gather() for concurrent execution
  2. Limit concurrent connections with Semaphore
  3. Use connection pooling for network requests
  4. Use asyncio.create_task() for fire-and-forget tasks
  5. Profile with asyncio.get_event_loop().set_debug(True)

When to Use asyncio

Good Use Cases
  • Web scraping and API calls
  • Network servers and clients
  • Database operations with async drivers
  • File I/O with aiofiles
  • WebSocket connections
  • Real-time data processing
Not Ideal For
  • CPU-intensive computations
  • Simple scripts with little I/O
  • Legacy code with blocking libraries
  • When threading or multiprocessing is simpler

Ecosystem and Libraries

Popular asyncio-compatible libraries:

  • aiohttp: HTTP client/server
  • aiofiles: Async file I/O
  • asyncpg: PostgreSQL driver
  • motor: MongoDB driver
  • aioredis: Redis client
  • fastapi: Modern web framework
  • httpx: HTTP client with async support

Conclusion

asyncio revolutionizes how Python handles concurrent I/O operations. By using cooperative multitasking and an event loop, it enables thousands of concurrent operations on a single thread, making it perfect for modern web applications, microservices, and any I/O-bound workload.

Key takeaways:

  • Single-threaded concurrency through event loop
  • async/await syntax for clean asynchronous code
  • Perfect for I/O-bound operations
  • Massive scalability with minimal resources
  • Rich ecosystem of async libraries

If you found this explanation helpful, consider sharing it with others.

Mastodon