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