Skip to main content

Python asyncio: Event Loops, Coroutines, Tasks

The asyncio module provides Python's foundation for asynchronous programming. The event loop manages execution flow, coroutines define work, and tasks schedule coroutines to run concurrently. Understanding how these three components interact is essential for writing efficient I/O-bound applications that handle hundreds of concurrent connections without blocking.


What Is the asyncio Module?

The asyncio module provides a framework for writing concurrent code using async/await syntax. It powers high-performance web servers, API clients, and any application that waits for I/O (network requests, file operations, database calls). Understanding its three core components—event loop, coroutines, and tasks—is key to mastering async Python.


How Does the Event Loop Work?

The event loop is the central engine that orchestrates all asynchronous operations in asyncio. Think of it as a traffic controller managing a queue of work. Its job is to:

  1. Manage a queue of tasks waiting to execute
  2. Run one task at a time
  3. When a task hits an await expression (e.g., network I/O), pause it and switch to another
  4. Resume paused tasks when their I/O completes

This constant switching between tasks creates the illusion of simultaneous execution—many tasks run concurrently without true parallelism:

import asyncio
import time

async def fetch_data(url: str, delay: float) -> str:
"""Simulates fetching data from a URL."""
print(f"Fetching {url}...")
await asyncio.sleep(delay) # Pause here; event loop switches tasks
return f"Data from {url}"

# Modern Python (3.7+) uses asyncio.run() to manage the event loop
asyncio.run(fetch_data("https://api.example.com", 2.0))

In modern Python (3.7+), you rarely manage the event loop manually. The asyncio.run() function handles creating, running, and closing it for you.


What Are Coroutines?

A coroutine, defined with async def, is the fundamental unit of work in asyncio. It's a special function that can be paused at await points and resumed later. Critically, calling a coroutine function does not execute it—it returns a coroutine object that the event loop runs.

import asyncio

# Defining a coroutine (not executing it)
async def greet(name: str) -> None:
print(f"Hello, {name}!")
await asyncio.sleep(1) # Pause here
print(f"Goodbye, {name}!")

# Calling the function returns a coroutine object, doesn't run it
coro = greet("Alice") # No output yet

# The event loop runs it
asyncio.run(coro)

# Output (after ~1 second):
# Hello, Alice!
# Goodbye, Alice!

Key distinction: async def defines; await or event loop execution runs.

Coroutines can await only in other coroutines:

async def step1() -> str:
await asyncio.sleep(1)
return "Step 1 done"

async def step2(prev_result: str) -> None:
result = await step1() # Can await coroutines
print(f"{prev_result} -> Step 2 done")

asyncio.run(step2("Starting"))

How Do Tasks Enable Concurrent Execution?

A Task wraps a coroutine and schedules it to run "in the background" on the event loop as soon as possible. Tasks enable true concurrency—multiple tasks run interleaved while waiting for I/O.

Without tasks, coroutines run sequentially:

async def say_hello() -> None:
print("Hello...")
await asyncio.sleep(2)
print("...World!")

async def say_goodbye() -> None:
print("Goodbye...")
await asyncio.sleep(1)
print("...Everyone!")

async def sequential():
"""Sequential: waits for each coroutine to finish. Total: ~3 seconds."""
await say_hello() # Wait 2 seconds
await say_goodbye() # Wait 1 second
# Total: 3 seconds

asyncio.run(sequential())

With tasks, they run concurrently:

async def concurrent():
"""Concurrent: runs both tasks at once. Total: ~2 seconds."""
# Create tasks (start scheduling them immediately)
task1 = asyncio.create_task(say_hello())
task2 = asyncio.create_task(say_goodbye())

# Await both to ensure they finish
await task1
await task2
# Total: 2 seconds (both wait concurrently)

asyncio.run(concurrent())

Output timing:

Hello...
Goodbye...
...Everyone! (after ~1 second, task2 finishes)
...World! (after ~2 seconds, task1 finishes)

Complete example with timing:

import asyncio
import time

async def operation(name: str, duration: float) -> None:
print(f"[{name}] Starting (duration: {duration}s)")
await asyncio.sleep(duration)
print(f"[{name}] Done")

async def main() -> None:
start = time.time()

# Create three concurrent tasks
task1 = asyncio.create_task(operation("Task1", 3.0))
task2 = asyncio.create_task(operation("Task2", 2.0))
task3 = asyncio.create_task(operation("Task3", 1.0))

# Wait for all to complete
await asyncio.gather(task1, task2, task3)

elapsed = time.time() - start
print(f"\nAll tasks completed in {elapsed:.2f} seconds")

asyncio.run(main())

# Output:
# [Task1] Starting (duration: 3.0s)
# [Task2] Starting (duration: 2.0s)
# [Task3] Starting (duration: 1.0s)
# [Task3] Done
# [Task2] Done
# [Task1] Done
# All tasks completed in 3.01 seconds

Notice: total time is 3 seconds (the longest task), not 6 seconds (sum of all durations).


How Do These Components Work Together?

  1. Coroutines define the workasync def creates pausable functions
  2. Tasks schedule coroutinesasyncio.create_task() wraps coroutines and registers them with the event loop
  3. The event loop executes — manages task scheduling, pauses at await, resumes when I/O completes
  4. asyncio.run() orchestrates — creates event loop, runs your main coroutine, cleans up
import asyncio

async def fetch_url(url: str, delay: float) -> str:
"""Simulates fetching from a URL."""
await asyncio.sleep(delay)
return f"Data from {url}"

async def main() -> None:
"""Orchestrates multiple concurrent fetches."""
# Create tasks (coroutines scheduled on event loop)
results = await asyncio.gather(
fetch_url("api.example.com/users", 1.0),
fetch_url("api.example.com/posts", 0.8),
fetch_url("api.example.com/comments", 1.2),
)

for result in results:
print(result)

# asyncio.run() creates and manages the event loop
asyncio.run(main())

Key Takeaways

  • Coroutines (async def) define pausable work; calling them returns a coroutine object, not execution
  • Tasks (asyncio.create_task()) schedule coroutines to run concurrently on the event loop
  • Event loop manages execution, pauses tasks at await, and resumes when I/O completes
  • Concurrency without parallelism: Tasks run interleaved during I/O waits, not on separate CPU cores
  • asyncio.run() handles event loop creation and cleanup in modern Python (3.7+)
  • Time savings: Concurrent tasks complete in the duration of the longest task, not the sum of all tasks

Frequently Asked Questions

What is the difference between await and asyncio.create_task()?

await pauses the current coroutine and waits for the target to complete before proceeding. asyncio.create_task() schedules a coroutine to run "in the background" immediately, returning control. Use create_task() for concurrency; use await to wait for results. asyncio.gather() combines them: schedules multiple tasks and waits for all.

Can I use asyncio with regular (non-async) functions?

No, regular functions cannot await. If you need to call a regular blocking function from async code, use loop.run_in_executor() to run it in a thread pool:

loop = asyncio.get_event_loop()
result = await loop.run_in_executor(None, blocking_function, arg)

What is asyncio.gather() and when should I use it?

asyncio.gather() schedules multiple coroutines and waits for all to complete, returning a list of results. Use it when you need results from multiple concurrent tasks:

results = await asyncio.gather(
fetch_url("url1"),
fetch_url("url2"),
fetch_url("url3"),
) # Returns list of results

What does "blocking the event loop" mean?

A blocking operation (like time.sleep() or a synchronous network call) pauses the event loop entirely—other tasks cannot run. Always use async alternatives: await asyncio.sleep() instead of time.sleep(), async HTTP libraries instead of requests. Blocking the loop kills concurrency.

Can I cancel a running task?

Yes, use task.cancel() to request cancellation. The task raises asyncio.CancelledError at the next await point:

task = asyncio.create_task(long_operation())
await asyncio.sleep(5)
task.cancel() # Request cancellation
try:
await task
except asyncio.CancelledError:
print("Task was cancelled")

Further Reading