Module 01: Introduction to Async Python¶
← Topic Home | Next → Module 02
Table of Contents¶
Overview¶
This module answers the foundational question: why does Python have asyncio, and when should you use it?
Most Python programs you have written so far are synchronous — one statement runs, it completes, then the next statement runs. That model is simple and correct for most programs. But consider a program that needs to fetch data from 1,000 different URLs. A synchronous approach would fetch URL 1, wait for the response, fetch URL 2, wait, and so on — spending most of its time idle, waiting for the network. Asyncio allows that same program to have all 1,000 requests in-flight simultaneously on a single thread, processing responses as they arrive.
Understanding why asyncio works this way — and the constraints it operates under — is essential before writing a single line of async code. This module gives you that foundation: the GIL, the difference between concurrency and parallelism, what a coroutine is, and how the event loop ties it all together.
Difficulty: Beginner | Estimated time: 4 hours
Prerequisites¶
Required Concepts¶
- Python functions — you must be comfortable writing and calling regular Python functions
- Context managers — you should understand
withstatements and how__enter__/__exit__work, becauseasync withis a direct extension of this pattern - Generators — familiarity with
yieldhelps, as coroutines share deep similarities with generators at the implementation level; not strictly required but very useful
[!TIP] If
yieldis unfamiliar, don't block on it — you can follow this module without deep generator knowledge. Just know that coroutines and generators are related concepts in Python. Return to generators after completing Module 01.
Objectives¶
By the end of this module, you will be able to:
- Explain the difference between concurrency and parallelism in plain language
- Describe what the GIL is and why it affects Python's threading performance
- Compare threading, multiprocessing, and asyncio and choose the right model for a given problem
- Explain what a coroutine is and how it differs from a regular function
- Write a basic
async deffunction and run it withasyncio.run() - Explain what the event loop does and how it schedules coroutines
Theory¶
The Problem: Blocking I/O¶
Most real-world programs spend the majority of their time waiting. A typical web API spends
perhaps 5% of its time running Python code and 95% waiting: for a database query to return,
for an upstream HTTP request to complete, for a cache lookup. During all that waiting, the
CPU is completely idle — sitting at a recv() system call, doing nothing.
In a traditional synchronous Python program, this idle time is wasted. Consider fetching data from two separate services:
import time
import urllib.request
def fetch_sync(url: str) -> str:
"""Synchronous HTTP fetch — blocks the entire thread until done."""
with urllib.request.urlopen(url) as response:
return response.read().decode()
def main_sync():
start = time.perf_counter()
# These run sequentially: fetch A, THEN fetch B
# Total time = time(A) + time(B)
result_a = fetch_sync("http://httpbin.org/delay/1") # waits ~1 second
result_b = fetch_sync("http://httpbin.org/delay/1") # waits ~1 second
elapsed = time.perf_counter() - start
print(f"Sequential: {elapsed:.2f}s") # ~2 seconds
main_sync()
The problem is obvious once you see it: while we are waiting for the response from service A, we could have been waiting for service B at the same time. We wasted a full second of wall clock time because the code was blocked on I/O.
Python's Concurrency Models¶
Python offers three concurrency models, each suited to a different problem class:
| Model | When to Use | Bottleneck | GIL? | Parallelism? |
|---|---|---|---|---|
| threading | I/O-bound, moderate concurrency | Network/disk I/O, legacy blocking libs | Released during I/O | No (GIL prevents CPU parallelism) |
| multiprocessing | CPU-bound | Computation, number crunching | Each process has own GIL | Yes (multiple processes) |
| asyncio | I/O-bound, high concurrency | Network/disk I/O, async-aware libs | Sidesteps it entirely | No (single thread) |
Threads help with I/O because the GIL is released during I/O system calls — when a thread is
blocked on recv(), the GIL is free for other threads. But thread creation and context
switching carry real overhead, and OS threads consume memory (typically 1–8 MB per thread
for the stack). Scaling to thousands of concurrent I/O operations with threads is expensive.
Asyncio solves this by never blocking at all. A single thread services all operations by
switching between coroutines at await points — no OS context switches, no stack memory per
concurrent operation (coroutine stacks are heap-allocated and tiny).
The key constraint: asyncio only helps for I/O-bound work. If your bottleneck is CPU computation, asyncio cannot help and you need multiprocessing.
The GIL: What It Is and Why It Exists¶
The Global Interpreter Lock (GIL) is a mutex in CPython (the standard Python interpreter) that allows only one thread to execute Python bytecode at any given moment. Even on a multi-core CPU, Python threads take turns — they do not run in true parallel.
The GIL exists because CPython manages memory using reference counting. Every Python
object has an integer ob_refcnt field that tracks how many references point to it. When
a reference is added or removed, that counter is updated. If two threads incremented or
decremented ob_refcnt simultaneously, the counter could become corrupted — objects would
be freed too early (crash) or never freed (memory leak).
The GIL is a blunt but effective solution: only one thread touches reference counts at a time. It also makes C extension modules easier to write safely.
The practical consequence:
import threading
counter = 0
def increment():
global counter
for _ in range(1_000_000):
counter += 1 # NOT thread-safe! Read-modify-write is not atomic.
# Two threads attempting to share counter will produce incorrect results
# despite the GIL — counter += 1 is multiple bytecode instructions
t1 = threading.Thread(target=increment)
t2 = threading.Thread(target=increment)
t1.start(); t2.start()
t1.join(); t2.join()
# counter will likely not be 2,000,000 — a classic race condition
print(counter)
Asyncio sidesteps the GIL entirely because there is only one thread. Two coroutines can never truly run simultaneously — the event loop runs one at a time. This eliminates entire classes of race conditions that affect multithreaded code, though async code has its own subtler race conditions (covered in Module 05).
Coroutines: The Building Block¶
A coroutine is a special kind of function that can suspend its execution at defined
points and later resume from exactly where it left off. In Python, coroutines are defined
with async def and suspended with await.
The critical difference from a regular function: calling a regular function runs it to
completion immediately. Calling an async def function does NOT run it — it returns a
coroutine object.
# A regular function: calling it runs it immediately
def regular_greet(name: str) -> str:
return f"Hello, {name}!"
result = regular_greet("Alice") # runs immediately, result is "Hello, Alice!"
print(type(result)) # <class 'str'>
# A coroutine: calling it returns a coroutine OBJECT, not the result
async def async_greet(name: str) -> str:
return f"Hello, {name}!"
coro = async_greet("Alice") # does NOT run — returns a coroutine object
print(type(coro)) # <class 'coroutine'>
print(coro) # <coroutine object async_greet at 0x...>
# You'll see a warning: RuntimeWarning: coroutine 'async_greet' was never awaited
# The coroutine was created but never scheduled to run
coro.close() # suppress the warning by closing it explicitly
This is the single most important thing to understand in all of asyncio: a coroutine object is not running until something schedules it. The event loop is the scheduler.
The await keyword can be used inside an async def function to pause the current
coroutine and give control back to the event loop until the awaited thing completes:
import asyncio
async def delayed_greeting(name: str, delay: float) -> str:
"""A coroutine that suspends for `delay` seconds, then returns a greeting."""
# await asyncio.sleep() suspends THIS coroutine without blocking the thread
# The event loop can run other coroutines during this pause
await asyncio.sleep(delay)
return f"Hello, {name}!"
async def main():
# await runs the coroutine to completion from this context
result = await delayed_greeting("Alice", 1.0)
print(result) # Hello, Alice!
# asyncio.run() is the standard entry point from synchronous code
# It creates an event loop, runs main() to completion, then cleans up
asyncio.run(main())
The Event Loop: The Heart of asyncio¶
The event loop is a continuously running scheduler that:
- Maintains a queue of coroutines ready to run
- Runs each coroutine until it hits an
awaitand suspends - While a coroutine is suspended (waiting for I/O, a timer, etc.), runs other ready coroutines
- When an I/O operation completes or a timer fires, marks the waiting coroutine as ready again
Think of the event loop as a very efficient juggler: it keeps many balls in the air by catching one, giving it a toss, and immediately turning to catch another — never waiting idle for any single ball to fall back down.
import asyncio
async def task(name: str, delay: float):
"""A coroutine that simulates I/O with a sleep."""
print(f"{name}: starting, will pause for {delay}s")
await asyncio.sleep(delay) # event loop runs other tasks during this pause
print(f"{name}: done after {delay}s")
return name
async def main():
import time
start = time.perf_counter()
# Without concurrent scheduling, this would take 1+2+3 = 6 seconds
# With asyncio.gather(), all three run concurrently — total ~3 seconds
results = await asyncio.gather(
task("A", 1.0),
task("B", 2.0),
task("C", 3.0),
)
elapsed = time.perf_counter() - start
print(f"All done in {elapsed:.2f}s: {results}")
# Output: All done in ~3.00s: ['A', 'B', 'C']
asyncio.run(main())
The event loop runs task("A", 1.0), task("B", 2.0), and task("C", 3.0) by starting
all three, and as each hits await asyncio.sleep(...) it suspends. The event loop then
tracks which suspensions will expire soonest and wakes them up in order. All three complete
in approximately the time of the longest single task (3 seconds), not the sum (6 seconds).
This is the core value proposition of asyncio: concurrent I/O without threads.
Key Concepts¶
Coroutine¶
A function defined with async def that can pause at await expressions and resume later.
Calling a coroutine function returns a coroutine object; the object does not run until it
is scheduled by the event loop. Coroutines are the fundamental unit of async Python work.
See [[shared/glossary#coroutine]].
Event Loop¶
The central asyncio scheduler. It runs coroutines in a cooperative fashion: each coroutine
runs until it awaits something, then the next ready coroutine runs. The event loop also
monitors I/O readiness and timers to know when to resume suspended coroutines.
asyncio.run() creates, runs, and closes an event loop automatically.
See [[shared/glossary#event-loop]].
await¶
A keyword used inside async def functions to suspend the current coroutine until the
awaited expression completes. Only awaitables can be awaited: coroutines, Tasks, and Futures.
await expr is approximately "pause here, run the event loop until expr is done,
then resume with expr's result."
Concurrency vs. Parallelism¶
Concurrency is handling multiple tasks by interleaving their progress over time — tasks
take turns on one processor. Parallelism is executing multiple tasks at the same moment
on multiple processors. Asyncio provides concurrency; it does not provide parallelism. For
CPU-heavy work that needs parallelism, use multiprocessing.
See [[shared/glossary#concurrency]] and [[shared/glossary#parallelism]].
I/O-Bound vs. CPU-Bound¶
I/O-bound work is limited by waiting for external operations (network, disk, database).
CPU-bound work is limited by computation speed. Asyncio is highly effective for I/O-bound
work and completely ineffective for CPU-bound work (which needs multiprocessing).
See [[shared/glossary#io-bound]] and [[shared/glossary#cpu-bound]].
asyncio.run()¶
The standard entry point for async programs. Takes a coroutine, creates a new event loop,
runs the coroutine to completion, closes the loop, and returns the result. Always use
asyncio.run() to start an async program from synchronous code. Do not call it inside an
already-running event loop.
Examples¶
Example 1: Sequential vs. Async HTTP Fetching¶
Scenario: Fetch data from 3 slow endpoints. Measure the difference in time between sequential and concurrent approaches.
Goal: Demonstrate concretely why asyncio matters for I/O-bound work.
[!NOTE] This example uses
asyncio.sleep()to simulate network latency rather than making real HTTP requests, so it runs offline. Real HTTP requests withaiohttpare covered in Module 04.
import asyncio
import time
# Simulate a network request that takes `delay` seconds
async def fake_fetch(url: str, delay: float) -> dict:
"""Simulates a slow API call by sleeping for `delay` seconds."""
await asyncio.sleep(delay) # surrenders control to the event loop
return {"url": url, "data": f"response from {url}"}
# --- Sequential approach (pretend we're calling them one by one) ---
async def sequential():
"""Fetch three URLs one after another — total time = sum of delays."""
start = time.perf_counter()
r1 = await fake_fetch("https://api.example.com/users", 1.0)
r2 = await fake_fetch("https://api.example.com/posts", 1.0)
r3 = await fake_fetch("https://api.example.com/comments", 1.0)
elapsed = time.perf_counter() - start
print(f"Sequential: {elapsed:.2f}s for 3 requests")
# Output: Sequential: ~3.00s for 3 requests
# --- Concurrent approach using asyncio.gather() ---
async def concurrent():
"""Fetch three URLs at the same time — total time = max of delays."""
start = time.perf_counter()
# gather() starts all three coroutines immediately and waits for all to finish
results = await asyncio.gather(
fake_fetch("https://api.example.com/users", 1.0),
fake_fetch("https://api.example.com/posts", 1.0),
fake_fetch("https://api.example.com/comments", 1.0),
)
elapsed = time.perf_counter() - start
print(f"Concurrent: {elapsed:.2f}s for 3 requests")
print(f"Got {len(results)} results")
# Output: Concurrent: ~1.00s for 3 requests
async def main():
await sequential()
await concurrent()
asyncio.run(main())
What to notice: The concurrent version takes approximately 1 second, not 3. The three "requests" all run during the same second. This is the core benefit of asyncio for I/O-bound work.
Example 2: Your First Real Coroutine¶
Scenario: Write a simple coroutine that does something meaningful — greets multiple users with a simulated database lookup delay for each.
Goal: Practice the async def / await / asyncio.run() pattern from scratch.
import asyncio
# A simple "database lookup" — in real code this would use an async DB client
async def lookup_user(user_id: int) -> dict:
"""Simulate fetching a user record from a database."""
# In production, this would be: user = await db.fetch_one("SELECT ...", user_id)
await asyncio.sleep(0.05) # simulate 50ms DB query
return {"id": user_id, "name": f"User #{user_id}", "active": True}
async def greet_user(user_id: int) -> str:
"""Fetch a user and compose a greeting."""
# await suspends this coroutine until lookup_user completes
user = await lookup_user(user_id)
if user["active"]:
return f"Welcome back, {user['name']}!"
else:
return f"Account {user_id} is inactive."
async def main():
# Greet 5 users concurrently
user_ids = [101, 102, 103, 104, 105]
greetings = await asyncio.gather(
*[greet_user(uid) for uid in user_ids]
)
for greeting in greetings:
print(greeting)
# Standard entry point
asyncio.run(main())
# Output (all 5 in ~50ms total, not 250ms):
# Welcome back, User #101!
# Welcome back, User #102!
# Welcome back, User #103!
# Welcome back, User #104!
# Welcome back, User #105!
What to notice: The list comprehension [greet_user(uid) for uid in user_ids] creates
5 coroutine objects without running them. asyncio.gather(*...) schedules all 5 and runs
them concurrently. The * unpacks the list into positional arguments.
Example 3: Choosing the Right Tool — Concurrency Model Decision¶
Scenario: You need to build three different programs. For each, determine whether to use asyncio, threading, or multiprocessing.
Goal: Develop practical intuition for which concurrency model to choose.
import asyncio
import concurrent.futures
import multiprocessing
# ---- Program A: Download 500 files from the internet ----
# Decision: asyncio — pure I/O-bound, high concurrency needed
async def download_file(url: str, semaphore: asyncio.Semaphore) -> bytes:
"""Async download with concurrency limit."""
async with semaphore: # limit to 50 concurrent downloads
# In real code: async with aiohttp.ClientSession() as session: ...
await asyncio.sleep(0.1) # simulate download time
return b"file content"
async def download_all(urls: list) -> list:
sem = asyncio.Semaphore(50) # max 50 at a time
tasks = [download_file(url, sem) for url in urls]
return await asyncio.gather(*tasks)
# ---- Program B: Process a million images with PIL ----
# Decision: multiprocessing — CPU-bound, need multiple cores
def process_image(path: str) -> str:
"""CPU-intensive image processing — runs in a subprocess."""
import hashlib
# Simulate heavy computation
result = hashlib.sha256(path.encode()).hexdigest()
return result
def process_images_parallel(paths: list) -> list:
with multiprocessing.Pool() as pool:
# Each worker process gets its own GIL — true parallelism
return pool.map(process_image, paths)
# ---- Program C: Call a legacy blocking database library ----
# Decision: threading — I/O-bound but can't use async; GIL released during I/O
def legacy_db_query(query: str) -> list:
"""Blocking database call using a legacy non-async library."""
import time
time.sleep(0.1) # simulate blocking DB query
return [{"result": query}]
async def query_with_threading(queries: list) -> list:
"""Run blocking queries in a thread pool to avoid blocking the event loop."""
loop = asyncio.get_event_loop()
with concurrent.futures.ThreadPoolExecutor(max_workers=10) as executor:
futures = [
loop.run_in_executor(executor, legacy_db_query, q)
for q in queries
]
return await asyncio.gather(*futures)
# asyncio.to_thread() (Python 3.9+) is a simpler alternative:
async def query_with_to_thread(queries: list) -> list:
tasks = [asyncio.to_thread(legacy_db_query, q) for q in queries]
return await asyncio.gather(*tasks)
What to notice: asyncio.to_thread() is the modern, clean way to call a blocking
function from async code. It runs the function in a thread pool so the event loop stays free.
This is the bridge between the sync and async worlds.
Common Pitfalls¶
Pitfall 1: Forgetting to await a coroutine¶
The most common mistake. A coroutine that is not awaited simply never runs.
Wrong:
import asyncio
async def save_to_database(data: dict) -> None:
await asyncio.sleep(0.01) # simulate DB write
print(f"Saved: {data}")
async def main():
# BUG: save_to_database() returns a coroutine object; it is never actually called!
save_to_database({"user": "alice"}) # no await!
print("Done")
# Output: Done (save never happened)
# Python may print: RuntimeWarning: coroutine 'save_to_database' was never awaited
asyncio.run(main())
Right:
async def main():
# Correct: await the coroutine so it actually executes
await save_to_database({"user": "alice"})
print("Done")
# Output: Saved: {'user': 'alice'}
# Done
Why this happens: async def functions return coroutine objects when called. Calling
them without await is like getting a to-do note instead of doing the task. Always await
coroutines unless you are intentionally scheduling them as a background task with
asyncio.create_task().
Pitfall 2: Blocking the event loop with synchronous I/O¶
Calling a blocking function directly in an async context freezes the entire program — every other coroutine is stuck waiting.
Wrong:
import asyncio
import time
import urllib.request
async def fetch_blocking(url: str) -> str:
# BUG: urllib.request.urlopen() is SYNCHRONOUS — it blocks the entire event loop
# for the duration of the request. No other coroutine can run while this is blocked.
response = urllib.request.urlopen(url)
return response.read().decode()
async def heartbeat():
"""Should print every 0.5s to show the event loop is alive."""
while True:
print("heartbeat")
await asyncio.sleep(0.5)
async def main():
# These two tasks should interleave, but they won't — fetch_blocking freezes everything
await asyncio.gather(
fetch_blocking("http://httpbin.org/delay/2"), # blocks for 2 seconds
heartbeat(), # will not print anything during those 2 seconds
)
Right:
import aiohttp # pip install aiohttp
async def fetch_async(url: str) -> str:
# Correct: aiohttp is async-native — it yields control at every await point
async with aiohttp.ClientSession() as session:
async with session.get(url) as response:
return await response.text()
Why this happens: The event loop only gets to run other coroutines at await points.
A blocking call contains no await points — the entire thread is stuck. Always use
async-native libraries for I/O in async code.
Pitfall 3: Running asyncio.run() inside an already-running event loop¶
This is a common mistake when experimenting in Jupyter notebooks or calling async functions from existing async code.
Wrong:
import asyncio
async def main():
asyncio.run(some_other_coro()) # ERROR: cannot run nested event loop!
Right:
async def main():
# Already inside an event loop — just await directly
await some_other_coro()
# Or create a task if you want it to run concurrently
task = asyncio.create_task(some_other_coro())
await task
Why this happens: asyncio.run() creates and runs a new event loop. Calling it when an
event loop is already running raises RuntimeError: This event loop is already running.
Inside an async def function, you are already in an event loop — use await or
asyncio.create_task() instead. (In Jupyter, use await directly in cells.)
Cross-Links¶
- [[django-fastapi-flask]] — FastAPI is built on asyncio; understanding this module is a
prerequisite for writing performant FastAPI route handlers with
async def - [[postgresql]] — async database access (covered in Module 08) depends on understanding the event loop model introduced here
- [[systems-architecture]] — the I/O-bound vs. CPU-bound distinction introduced here has direct implications for how distributed systems are architected; async services are a standard building block in microservice architectures
Summary¶
- Concurrency is interleaving tasks over time on one processor; parallelism is executing tasks simultaneously on multiple processors. Asyncio provides concurrency, not parallelism.
- I/O-bound work (network, disk, database) benefits from asyncio because coroutines can suspend during I/O and let other coroutines run. CPU-bound work requires multiprocessing.
- The GIL prevents multiple threads from running Python bytecode simultaneously, making threading ineffective for CPU-bound work. Asyncio sidesteps the GIL by using a single thread.
- A coroutine is defined with
async def. Calling it returns a coroutine object; it does not run until scheduled. Useawaitinside an async function to run a coroutine and get its result. - The event loop is the scheduler: it runs one coroutine at a time, suspending at
awaitpoints and resuming coroutines when their I/O completes. asyncio.run(coro)is the standard entry point: it creates an event loop, runs the coroutine to completion, and closes the loop.asyncio.gather(*coros)runs multiple coroutines concurrently and returns all results.- Never block the event loop with synchronous I/O or CPU-heavy work inside an async
function — use async-native libraries for I/O, and
asyncio.to_thread()for blocking functions.