Advanced
15 min read
#Python#Async#asyncio#Concurrency#Coroutines

Async Python — The Complete Notebook

Comprehensive guide on Async Python — The Complete Notebook.

Async Python

1. Overview#

Async Python lets a single thread juggle many tasks that spend time waiting — for network responses, file I/O, or database queries — without blocking the whole program. It does not give you true parallelism (that's multiprocessing); it gives you highly efficient concurrency for I/O-bound work.

Rule of thumb: use asyncio for I/O-bound work (network calls, APIs, DB queries). Use multiprocessing for CPU-bound work (heavy computation). Async code doesn't make math run faster — it makes waiting cheaper.


2. Concurrency vs Parallelism vs Blocking#

ConceptWhat It MeansPython Tool
Synchronous / BlockingOne task runs fully before the next startsRegular functions
ConcurrencyMultiple tasks make progress by interleaving, on one threadasyncio
ParallelismMultiple tasks run at literally the same time, on multiple coresmultiprocessing
🐍 Python
import time def blocking_fetch(name, delay): print(f"Fetching {name}...") time.sleep(delay) # blocks the entire program print(f"Done: {name}") blocking_fetch("A", 2) blocking_fetch("B", 2) # Total time: ~4 seconds — one after another

3. Coroutines: async def and await#

🐍 Python
import asyncio async def fetch_data(name, delay): print(f"Fetching {name}...") await asyncio.sleep(delay) # non-blocking "wait" — hands control back to the event loop print(f"Done: {name}") return f"{name} result" async def main(): result = await fetch_data("A", 2) print(result) asyncio.run(main()) # entry point that starts the event loop

Calling fetch_data("A", 2) on its own does not run the function — it creates a coroutine object. The function body only executes once it's await-ed or scheduled on the event loop.

🐍 Python
async def greet(): return "hello" coro = greet() # nothing has run yet print(coro) # <coroutine object greet at 0x...> result = asyncio.run(coro) # now it actually runs

4. Running Things Concurrently#

4.1 asyncio.gather — Run Many Coroutines at Once#

🐍 Python
import asyncio async def fetch_data(name, delay): print(f"Fetching {name}...") await asyncio.sleep(delay) print(f"Done: {name}") return f"{name} result" async def main(): results = await asyncio.gather( fetch_data("A", 2), fetch_data("B", 2), fetch_data("C", 2), ) print(results) asyncio.run(main()) # Total time: ~2 seconds, not 6 — all three run concurrently

4.2 asyncio.create_task — Schedule Work in the Background#

create_task starts a coroutine running immediately (scheduled on the event loop) without waiting for it — useful when you want to kick off work and do something else before collecting the result.

🐍 Python
import asyncio async def fetch_data(name, delay): await asyncio.sleep(delay) return f"{name} result" async def main(): task_a = asyncio.create_task(fetch_data("A", 2)) task_b = asyncio.create_task(fetch_data("B", 2)) print("Tasks started, doing other work...") result_a = await task_a result_b = await task_b print(result_a, result_b) asyncio.run(main())

4.3 asyncio.wait_for — Timeouts#

🐍 Python
import asyncio async def slow_operation(): await asyncio.sleep(5) return "done" async def main(): try: result = await asyncio.wait_for(slow_operation(), timeout=2) except asyncio.TimeoutError: print("Operation timed out") asyncio.run(main())

5. Async Context Managers & Iterators#

5.1 async with#

Used for resources that need asynchronous setup/teardown — like a network connection pool.

🐍 Python
class AsyncConnection: async def __aenter__(self): print("Opening connection...") await asyncio.sleep(1) # simulate async connect return self async def __aexit__(self, exc_type, exc_val, exc_tb): print("Closing connection...") await asyncio.sleep(0.5) async def main(): async with AsyncConnection() as conn: print("Using connection") asyncio.run(main())

5.2 async for#

Iterates over an async generator — one item produced at a time, each possibly involving an await.

🐍 Python
import asyncio async def fetch_pages(): for page in range(1, 4): await asyncio.sleep(1) # simulate an API call per page yield f"Page {page} data" async def main(): async for page_data in fetch_pages(): print(page_data) asyncio.run(main())

6. Concurrency Control: Semaphores and Locks#

6.1 asyncio.Semaphore — Limit Concurrent Operations#

Useful when calling an API that rate-limits how many requests can run at once.

🐍 Python
import asyncio async def fetch_with_limit(semaphore, name): async with semaphore: print(f"Fetching {name}...") await asyncio.sleep(2) print(f"Done: {name}") async def main(): semaphore = asyncio.Semaphore(2) # max 2 concurrent fetches await asyncio.gather(*(fetch_with_limit(semaphore, f"item-{i}") for i in range(5))) asyncio.run(main())

6.2 asyncio.Lock — Protect Shared State#

🐍 Python
import asyncio counter = 0 lock = asyncio.Lock() async def increment(): global counter async with lock: current = counter await asyncio.sleep(0.01) # simulate work between read and write counter = current + 1 async def main(): await asyncio.gather(*(increment() for _ in range(10))) print(counter) # 10 — safe, thanks to the lock asyncio.run(main())

7. Common Pitfalls#

INCORRECT: Mixing Blocking Calls Into Async Code#

🐍 Python
import asyncio import time async def bad_fetch(): time.sleep(3) # BLOCKS the entire event loop — defeats the purpose of async return "data"

CORRECT: Fix — Use the Async Equivalent, or Offload to a Thread#

🐍 Python
import asyncio async def good_fetch(): await asyncio.sleep(3) # yields control back to the event loop return "data" # For unavoidable blocking/CPU-bound calls, offload to a thread pool: async def wraps_blocking_call(): result = await asyncio.to_thread(time.sleep, 3) return result

INCORRECT: Forgetting to await a Coroutine#

🐍 Python
async def fetch(): return "data" async def main(): result = fetch() # BUG: this is a coroutine object, not the result! print(result) # <coroutine object fetch at 0x...>

CORRECT: Fix#

🐍 Python
async def main(): result = await fetch() print(result) # data

INCORRECT: Creating a Task and Never Awaiting or Storing It#

🐍 Python
async def main(): asyncio.create_task(fetch_data("A", 2)) # fire-and-forget — may be garbage collected mid-run

CORRECT: Fix — Keep a Reference and Await It#

🐍 Python
async def main(): task = asyncio.create_task(fetch_data("A", 2)) await task

8. A Realistic Example: Concurrent API Calls#

🐍 Python
import asyncio import random async def call_api(endpoint): print(f"Calling {endpoint}...") await asyncio.sleep(random.uniform(1, 3)) # simulate variable network latency return {"endpoint": endpoint, "status": 200} async def fetch_all(endpoints): semaphore = asyncio.Semaphore(3) # limit to 3 concurrent calls async def bounded_call(endpoint): async with semaphore: return await call_api(endpoint) tasks = [bounded_call(ep) for ep in endpoints] return await asyncio.gather(*tasks) async def main(): endpoints = [f"/api/resource/{i}" for i in range(8)] results = await fetch_all(endpoints) for r in results: print(r) asyncio.run(main())

9. Summary & Best Practices Checklist#

  • Use asyncio for I/O-bound work; use multiprocessing for CPU-bound work.
  • Always await a coroutine — calling it alone only creates the coroutine object.
  • Use asyncio.gather to run independent coroutines concurrently.
  • Use asyncio.create_task when you want work to start now but collect the result later.
  • Never call blocking functions (time.sleep, blocking I/O) directly inside async def — use asyncio.sleep or asyncio.to_thread.
  • Use a Semaphore to cap concurrency against rate-limited APIs.
  • Use a Lock when multiple coroutines mutate shared state.
  • Always keep a reference to tasks created with create_task until they're awaited.
Knowledge Checkpoint

Asynchronous Programming with Asyncio Checkpoint

Q1.What happens when you call a blocking synchronous I/O function (e.g. `time.sleep(5)`) inside an `async def` coroutine?
AAsyncio automatically converts it to non-blocking I/O.
BIt freezes the entire single-threaded event loop, stopping all other concurrent tasks from progressing for 5 seconds.
CIt automatically spawns a separate OS process.
DIt raises a CoroutineBlockingError immediately.
Q2.In Python 3.11+, what structured concurrency construct is recommended over `asyncio.gather()` for managing groups of concurrent tasks?
A`asyncio.TaskGroup`
B`asyncio.ThreadPool`
C`asyncio.MultiProcess`
D`asyncio.BatchRunner`
Q3.What is the difference between a Coroutine function and a Task in asyncio?
ACoroutines run on background threads; Tasks run on the main thread.
BA Coroutine is a callable returning a coroutine object that only runs when awaited; a Task wraps a coroutine and schedules it immediately onto the event loop.
CTasks are synchronous; Coroutines are asynchronous.
DTasks cannot return values.
Track Your Learning

Finished studying this notebook?

Mark this guide as completed to update your course progress roadmap.