NANDHOO.

Concurrency & Async Python

Concurrency & Async Python


Modern applications need to handle many tasks at once: downloading files, calling APIs, querying databases, processing requests. Python offers multiple tools for concurrency — asyncio, threads, and processes. Knowing when and how to use each one is a key skill for writing fast, efficient Python programs.


Why This Chapter Matters


Without concurrency, your programs wait idle for I/O (network, files, databases) instead of doing useful work. Understanding async programming and concurrency makes your applications significantly faster and more scalable.


The Three Concurrency Models


ModelBest ForPython Tool
Async (cooperative)Many I/O-bound tasks (API calls, DB queries)asyncio, async/await
ThreadingI/O-bound tasks, simpler codethreading, concurrent.futures
MultiprocessingCPU-bound tasks (computation, image processing)multiprocessing, concurrent.futures

Synchronous vs Asynchronous


Synchronous (blocking)


import time

def download(url): time.sleep(2) # simulates network delay return f"Content from {url}"


Downloads one at a time — 6 seconds total

for url in ["a.com", "b.com", "c.com"]: print(download(url))


Asynchronous (non-blocking)


import asyncio

async def download(url): await asyncio.sleep(2) # simulates async network delay return f"Content from {url}"


async def main(): # All three run concurrently — ~2 seconds total results = await asyncio.gather( download("a.com"), download("b.com"), download("c.com"), ) print(results)


asyncio.run(main())


asyncio Fundamentals


Coroutines


A coroutine is a function defined with async def. It can be "paused" at await points to let other coroutines run.


import asyncio

async def say_hello(): print("Hello...") await asyncio.sleep(1) print("...World!")


asyncio.run(say_hello())


await


await pauses the current coroutine and gives control back to the event loop while waiting for the result.


async def fetch_data():
    print("Fetching...")
    await asyncio.sleep(2)   # non-blocking pause
    return {"data": 42}

async def main(): result = await fetch_data() print(result)


asyncio.run(main())


Running Multiple Coroutines: asyncio.gather()


import asyncio

async def task(name, delay): await asyncio.sleep(delay) print(f"{name} done after {delay}s") return name


async def main(): results = await asyncio.gather( task("Download", 2), task("Upload", 1), task("Process", 3), ) print("All done:", results)


asyncio.run(main())

Total time ~3s (longest task), not 6s (2+1+3)


Creating Tasks


asyncio.create_task() starts a coroutine immediately and lets other coroutines run while it works:


async def main():
    task1 = asyncio.create_task(task("A", 2))
    task2 = asyncio.create_task(task("B", 1))

result1 = await task1
result2 = await task2
print(result1, result2)

Timeouts


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.0) except asyncio.TimeoutError: print("Operation timed out!")


asyncio.run(main())


Async HTTP Requests with httpx


For real async HTTP requests, use httpx:


pip install httpx

import asyncio
import httpx

async def fetch(url): async with httpx.AsyncClient() as client: response = await client.get(url) return response.json()


async def main(): urls = [ "https://jsonplaceholder.typicode.com/posts/1", "https://jsonplaceholder.typicode.com/posts/2", "https://jsonplaceholder.typicode.com/posts/3", ] results = await asyncio.gather(*[fetch(url) for url in urls]) for r in results: print(r["title"])


asyncio.run(main())


Threading


Threads share memory and are good for I/O-bound tasks. The Python GIL (Global Interpreter Lock) limits true parallelism for CPU work.


import threading
import time

def worker(name, delay): print(f"{name} starting") time.sleep(delay) print(f"{name} done")


threads = [ threading.Thread(target=worker, args=("Thread-1", 2)), threading.Thread(target=worker, args=("Thread-2", 1)), ]


for t in threads: t.start()


for t in threads: t.join() # wait for all threads to finish


print("All threads done")


concurrent.futures.ThreadPoolExecutor


A higher-level interface for threading:


from concurrent.futures import ThreadPoolExecutor, as_completed
import time

def slow_job(n): time.sleep(1) return n * n


with ThreadPoolExecutor(max_workers=4) as executor: futures = [executor.submit(slow_job, i) for i in range(8)] for future in as_completed(futures): print(future.result())


Multiprocessing


Multiprocessing creates separate OS processes — each has its own memory and Python interpreter. This bypasses the GIL and is true parallelism for CPU-bound work.


from concurrent.futures import ProcessPoolExecutor
import time

def cpu_intensive(n): # Simulate heavy computation return sum(i ** 2 for i in range(n))


with ProcessPoolExecutor() as executor: results = list(executor.map(cpu_intensive, [100_000, 200_000, 300_000])) print(results)


Choosing the Right Concurrency Tool


Is your task...

Waiting for network/file/database? → Use asyncio (best) or threading


Doing heavy computation (math, image, ML)? → Use multiprocessing


Calling a library that isn't async-compatible? → Use threading


Running FastAPI/web server? → async def routes + asyncio automatically


Async Context Managers


async def main():
    async with httpx.AsyncClient() as client:
        resp = await client.get("https://example.com")
        print(resp.status_code)

Async Generators and Iterators


async def count_up(n):
    for i in range(n):
        await asyncio.sleep(0.1)
        yield i

async def main(): async for value in count_up(5): print(value)


Common Mistakes


  • using await outside an async def function (SyntaxError)
  • calling asyncio.run() inside an already-running event loop (use await instead)
  • using blocking code (like time.sleep()) inside an async function — use await asyncio.sleep() instead
  • using multiprocessing for I/O-bound tasks (threading or asyncio is better)
  • using threading for CPU-bound tasks (multiprocessing is needed to bypass the GIL)

Mini Exercises


  1. Write three async coroutines that each sleep for 1 second and run them concurrently with asyncio.gather().
  2. Use asyncio.wait_for() to add a 2-second timeout to a slow coroutine.
  3. Fetch 5 URLs concurrently using httpx.AsyncClient and print the status codes.
  4. Use ThreadPoolExecutor to read 5 files concurrently.
  5. Use ProcessPoolExecutor to compute the sum of squares for 4 large ranges in parallel.

Review Questions


  1. What is the difference between concurrency and parallelism?
  2. When should you use asyncio vs threading vs multiprocessing?
  3. What does await do in an async function?
  4. What is the Python GIL and why does it matter for multiprocessing?
  5. Why should you not use time.sleep() inside an async function?

Reference Checklist


  • I understand the difference between async, threading, and multiprocessing
  • I can write async functions with async def and await
  • I can run multiple coroutines concurrently with asyncio.gather()
  • I can use ThreadPoolExecutor and ProcessPoolExecutor
  • I know how to fetch URLs asynchronously with httpx
  • I can choose the right concurrency model for a given task