Filter concepts by levelShowing all levels.

Python · Section 13

Async Python

Level
advanced
Read
140 min
Concepts
12

The event loop, coroutines, Tasks, and the async equivalents of with/for — then the production discipline (blocking calls, backpressure, graceful cancellation) that decides whether an async application actually holds up under load.

This section

What is true here

  1. async def call() builds a coroutine object that does nothing until awaited — the definition and the running are two separate steps.
  2. create_task() schedules a coroutine to run concurrently right away; gather() runs several and returns their results in call order.
  3. A blocking call inside a coroutine freezes the WHOLE event loop, not just that task — the most common production async mistake.
  4. Semaphore/Queue(maxsize=n) enforce a deliberate concurrency limit, applying backpressure instead of launching unlimited concurrent work.
  5. Threading fits sync I/O-bound work, multiprocessing fits CPU-bound work, asyncio fits high-concurrency I/O-bound work — pick by workload shape and scale.

What you will be able to do

  • Explain why calling a coroutine function does not run it, and what await actually does
  • Use asyncio.create_task() and asyncio.gather() to run several coroutines concurrently and collect their results
  • Cancel a running task and enforce a timeout, and explain why cancellation only takes effect at an await point
  • Write an async context manager and an async generator, and know when async for is required instead of for
  • Identify a blocking call inside async code and fix it with an async-native client or asyncio.to_thread()
  • Apply a Semaphore or bounded Queue to cap concurrency and create backpressure deliberately
  • Choose between threading, multiprocessing, and asyncio for a given workload, and justify the choice

Concepts

The vocabulary and mechanics of asyncio — the event loop, coroutines, awaitables, Tasks, scheduling several at once, cancellation and timeouts, and the async equivalents of with and for.

Event loop and coroutines

coreintermediate

The event loop runs one task at a time, switching to another whenever the current one awaits something. A coroutine is a function defined with async def — calling it builds a paused coroutine object, it does not run yet.

Think of it as

The event loop is a single chef working several dishes at once. It works on one dish, and the moment that dish needs to "wait" (water boiling), the chef switches to another dish instead of standing still. Nothing ever actually overlaps — only the waiting does.

python
async def fetch(url):
    ...            # a coroutine function

coro = fetch(url)  # builds a coroutine object -- nothing has run yet
result = await coro  # runs it, suspends the caller until it finishes

asyncio.run(main())  # the only place a loop is created from ordinary code

What we're doing: Show that calling a coroutine function does not run it, and that await is what actually runs it.

coroutine_basics.pypython
import asyncio

async def greet(name):
    print(f"{name}: starting")
    await asyncio.sleep(0.05)
    print(f"{name}: done")
    return f"hello, {name}"

async def main():
    coro = greet("Ada")          # nothing printed yet -- just an object
    print("type of coro:", type(coro).__name__)
    result = await coro          # THIS is what actually runs greet
    print("result:", result)

asyncio.run(main())
10
greet("Ada") builds a coroutine object. No print inside greet has run yet.
12
await coro is what actually starts running greet — the two "starting"/"done" prints happen here.
Output
type of coro: coroutine
Ada: starting
Ada: done
result: hello, Ada

Why this works: Defining a coroutine and running it are two separate steps, unlike a normal function call. This separation is what lets asyncio.gather() and create_task() schedule several coroutines before any of them actually starts.

Calling a coroutine function without await and expecting it to run

Wrong

python
import asyncio

async def save_record(data):
    await asyncio.sleep(0.01)
    print("saved:", data)

async def main():
    save_record({"id": 1})   # BUG: builds a coroutine, never runs it
    print("done")

asyncio.run(main())

Better

python
import asyncio

async def save_record(data):
    await asyncio.sleep(0.01)
    print("saved:", data)

async def main():
    await save_record({"id": 1})   # actually runs it
    print("done")

asyncio.run(main())

What you see: The program finishes without printing "saved: ..." at all, and Python prints a RuntimeWarning: coroutine 'save_record' was never awaited.

Why: A coroutine call only builds an object describing the work — it is not scheduled or run until something awaits it or wraps it in create_task(). Forgetting await silently skips the work instead of raising an error.

Calling a coroutine builds an object; await is what runs it
main()
greet()
  1. 1. coro = greet("Ada")builds a coroutine object — nothing runs yet
  2. 2. await coro
  3. 3. await asyncio.sleep(0.05)loop switches to other work here
  4. 4. return "hello, Ada"
  1. main() → greet(): coro = greet("Ada") (builds a coroutine object — nothing runs yet)
  2. main() → greet(): await coro
  3. greet() → greet(): await asyncio.sleep(0.05) (loop switches to other work here)
  4. greet() → main(): return "hello, Ada"

asyncio — the entry points worth knowing

asyncio — the entry points worth knowing
CallEffect
asyncio.run(coro())creates a loop, runs coro() to completion, closes the loop
await coro()runs a coroutine and waits for its result — only valid inside another coroutine
asyncio.create_task(coro())schedules a coroutine to run concurrently, returns a Task immediately
asyncio.get_running_loop()returns the event loop currently running this coroutine

Together

python
import asyncio

async def greet(name):
    await asyncio.sleep(0.05)
    return f"hello, {name}"

async def main():
    result = await greet("Ada")
    print(result)

asyncio.run(main())

Remember: async def call() builds a coroutine object that does nothing; await is what actually runs it and gives back the result.

See also: awaitables tasks and futures · scheduling with gather · asynchronous programming

Awaitables, Tasks, and Futures

coreintermediate

Anything usable after await is an awaitable — a coroutine, a Task, or a Future. asyncio.create_task(coro()) wraps a coroutine in a Task, which starts running immediately in the background, not only when you await it.

Think of it as

await coro() is handing a job straight to a worker and waiting right there for it to finish. asyncio.create_task(coro()) is handing the job to a worker and walking away — the worker starts immediately, and you can come back and collect the result with await later.

python
task = asyncio.create_task(fetch(url))  # starts running NOW, in the background
# ... do other work here while it runs ...
result = await task                     # wait for it, get the result

What we're doing: Show that two tasks created with create_task() run concurrently, so the total time is close to the slowest one, not the sum of both.

tasks_run_concurrently.pypython
import asyncio
import time

async def worker(n):
    await asyncio.sleep(0.05)
    return n * n

async def main():
    start = time.perf_counter()
    t1 = asyncio.create_task(worker(2))
    t2 = asyncio.create_task(worker(3))
    r1 = await t1
    r2 = await t2
    elapsed = time.perf_counter() - start
    print("results:", r1, r2)
    print("elapsed under 0.09s:", elapsed < 0.09)

asyncio.run(main())
10
t1 starts running worker(2) immediately, before t2 is even created.
11
t2 starts running worker(3) — both tasks are now progressing at the same time.
12
await t1 only waits for t1; by then t2 has likely already finished its own 0.05s sleep too.
Output
results: 4 9
elapsed under 0.09s: True

Why this works: Each worker sleeps 0.05s. Run sequentially with plain await, that would be ~0.1s total. Because create_task() starts both immediately, their sleeps overlap and the total stays close to 0.05s.

Using await instead of create_task() and losing concurrency

Wrong

python
async def main():
    start = time.perf_counter()
    r1 = await worker(2)   # blocks here until worker(2) finishes...
    r2 = await worker(3)   # ...only THEN does worker(3) even start
    print(time.perf_counter() - start)  # ~0.1s, not ~0.05s

Better

python
async def main():
    start = time.perf_counter()
    t1 = asyncio.create_task(worker(2))
    t2 = asyncio.create_task(worker(3))
    r1, r2 = await t1, await t2   # both already running concurrently
    print(time.perf_counter() - start)  # ~0.05s

What you see: The program takes roughly the sum of every await's wait time instead of the maximum — no error, just no concurrency.

Why: await coro() runs and waits for that one coroutine before moving to the next line. Nothing else runs concurrently unless it was scheduled with create_task() (or gathered) first.

await coro() vs create_task(coro())

await worker(2)

  • +Blocks the caller until worker(2) finishes
  • +worker(3) does not even start until then
  • +Total time: sum of every wait

create_task(worker(2))

  • Starts running immediately, in the background
  • A second create_task() overlaps with the first
  • Total time: close to the slowest task alone
  • await worker(2)
    • Blocks the caller until worker(2) finishes
    • worker(3) does not even start until then
    • Total time: sum of every wait
  • create_task(worker(2))
    • Starts running immediately, in the background
    • A second create_task() overlaps with the first
    • Total time: close to the slowest task alone

Task — the methods worth knowing

Task — the methods worth knowing
CallEffect
asyncio.create_task(coro())schedules coro to run now, returns a Task immediately
await taskwaits for the task to finish and returns its result (or re-raises its exception)
task.done()True once the task has finished, been cancelled, or raised
task.result()the return value — raises if the task is not done, or re-raises its exception
task.cancel()requests cancellation; the task receives a CancelledError at its next await point

Together

python
import asyncio

async def worker(n):
    await asyncio.sleep(0.05)
    return n * n

async def main():
    task = asyncio.create_task(worker(4))
    print("done right after creating?", task.done())
    result = await task
    print("result:", result, "done now?", task.done())

asyncio.run(main())

Remember: await coro() runs one thing and waits; asyncio.create_task(coro()) starts it running in the background immediately, so you can await it later.

See also: event loop and coroutines · scheduling with gather · cancellation and timeouts

Scheduling with asyncio.gather

standardintermediate

asyncio.gather(*coros) runs several coroutines concurrently and returns their results in the same order they were passed, once all of them finish.

Think of it as

gather() is sending several requests out at once and waiting for every reply, then handing you back the answers in the order you asked the questions — not the order the answers arrived.

python
results = await asyncio.gather(fetch("a"), fetch("b"), fetch("c"))
# results == [result_of_a, result_of_b, result_of_c] -- order preserved

What we're doing: Run three fetches concurrently with gather, and show the results preserve call order even though the calls overlap.

gather_concurrent.pypython
import asyncio
import time

async def fetch(name, delay):
    await asyncio.sleep(delay)
    return f"{name} done"

async def main():
    start = time.perf_counter()
    results = await asyncio.gather(fetch("a", 0.05), fetch("b", 0.05), fetch("c", 0.05))
    elapsed = time.perf_counter() - start
    print(results)
    print("elapsed under 0.1s:", elapsed < 0.1)

asyncio.run(main())
10
All three fetch() calls start concurrently — three 0.05s sleeps overlap instead of stacking to 0.15s.
Output
['a done', 'b done', 'c done']
elapsed under 0.1s: True

Why this works: gather() schedules all three coroutines before waiting on any of them, so their sleeps overlap. Sequential awaits would take roughly 0.15s; gather takes roughly 0.05s.

Remember: asyncio.gather(*coros) runs everything concurrently and returns results in call order — one raised exception cancels the rest unless return_exceptions=True.

See also: awaitables tasks and futures · cancellation and timeouts

Cancellation and timeouts

standardintermediate

task.cancel() raises a CancelledError inside the task at its next await point. asyncio.timeout(seconds) does the same thing automatically if a block runs too long, raising TimeoutError instead.

Think of it as

Cancellation is not a kill switch — it is a polite interrupt raised at the task's next checkpoint (its next await). A task that never awaits inside a long loop cannot be cancelled until it reaches one.

python
task = asyncio.create_task(long_job())
task.cancel()
try:
    await task
except asyncio.CancelledError:
    ...   # the task was cancelled

async with asyncio.timeout(2):
    await slow_operation()   # raises TimeoutError if it takes over 2s

What we're doing: Cancel a running task and confirm it receives CancelledError, then show asyncio.timeout() doing the same thing automatically.

cancellation.pypython
import asyncio

async def long_task():
    try:
        await asyncio.sleep(10)
        return "finished"
    except asyncio.CancelledError:
        print("long_task: cancelled, cleaning up")
        raise

async def main():
    task = asyncio.create_task(long_task())
    await asyncio.sleep(0.05)
    task.cancel()
    try:
        await task
    except asyncio.CancelledError:
        print("main: task was cancelled")
    print("task.cancelled():", task.cancelled())

asyncio.run(main())
5
The task is sleeping — waiting at an await point, which is exactly where cancellation can take effect.
6
task.cancel() does not stop the task instantly — it schedules a CancelledError for the next time it resumes.
14
Re-raising CancelledError after cleanup is the expected pattern — swallowing it silently is a mistake.
Output
long_task: cancelled, cleaning up
main: task was cancelled
task.cancelled(): True

Why this works: task.cancel() injects CancelledError at long_task's current await (inside asyncio.sleep(10)). The except block runs, then re-raises, and awaiting the cancelled task raises CancelledError to the caller too.

Remember: task.cancel() raises CancelledError at the task's next await; asyncio.timeout(seconds) does the same automatically and raises TimeoutError.

See also: awaitables tasks and futures · graceful cancellation

Async context managers

standardintermediate

async with works like with, but its setup and teardown are coroutines. A class opts in with async def __aenter__ and async def __aexit__ instead of the plain __enter__/__exit__.

Think of it as

A regular context manager opens and closes a resource synchronously — like unlocking a door by hand. An async context manager does the same job, but the unlocking itself can involve waiting, like requesting a badge from a remote server before the door opens.

python
class AsyncResource:
    async def __aenter__(self):
        await self.open()
        return self

    async def __aexit__(self, exc_type, exc, tb):
        await self.close()
        return False

async with AsyncResource() as r:
    await r.use()

What we're doing: Write an async context manager for a mock DB connection and confirm open/close both run around the block.

async_context_manager.pypython
import asyncio

class AsyncDBConnection:
    async def __aenter__(self):
        await asyncio.sleep(0.01)
        print("connection: opened")
        return self

    async def __aexit__(self, exc_type, exc, tb):
        await asyncio.sleep(0.01)
        print("connection: closed")
        return False

    async def query(self, sql):
        await asyncio.sleep(0.01)
        return f"result of {sql!r}"

async def main():
    async with AsyncDBConnection() as conn:
        result = await conn.query("SELECT 1")
        print(result)

asyncio.run(main())
4
__aenter__ is a coroutine — it can await real I/O (a connection handshake) before the block starts.
9
__aexit__ always runs when the block exits, including on an exception, same guarantee as a sync __exit__.
Output
connection: opened
result of 'SELECT 1'
connection: closed

Why this works: async with awaits __aenter__ before entering the block and awaits __aexit__ on the way out — the same guaranteed-cleanup contract as with, extended to setup/teardown that needs to await.

Remember: async with calls await __aenter__() then guarantees await __aexit__() on exit — the same contract as with, for setup/teardown that needs to await.

See also: event loop and coroutines · async context managers · with statement

Async iterators and async generators

standardintermediate

async for consumes an async iterator — an object with async def __anext__. An async generator is the easy way to write one: an async def function that uses yield instead of implementing __anext__ by hand.

Think of it as

A regular generator hands you the next value as soon as you ask. An async generator can hand you the next value only after waiting for something — like a paginated API where each "next" is itself a network call.

python
async def countdown(start):
    while start > 0:
        await asyncio.sleep(0.01)
        yield start
        start -= 1

async for n in countdown(3):
    print(n)

values = [n async for n in countdown(3)]  # async comprehension

What we're doing: Write an async generator and consume it with both async for and an async comprehension.

async_generator.pypython
import asyncio

async def countdown_gen(start):
    while start > 0:
        await asyncio.sleep(0.01)
        yield start
        start -= 1

async def main():
    values = [v async for v in countdown_gen(3)]
    print(values)

asyncio.run(main())
5
await inside the generator body is what makes this an ASYNC generator — a regular generator cannot contain await.
10
async for is required here, not for — the values only arrive one at a time, each after an await.
Output
[3, 2, 1]

Why this works: yield inside an async def function turns it into an async generator automatically — no manual __aiter__/__anext__ needed, the same convenience regular generators give over hand-written iterators.

Remember: async def with yield is an async generator; consume it with async for, which awaits between items instead of getting them all at once.

See also: iterator protocol · event loop and coroutines

Semaphores and async locks

standardintermediate

asyncio.Semaphore(n) lets at most n coroutines through a block at once — used to cap concurrency, like limiting simultaneous requests. asyncio.Lock is a semaphore of 1: only one coroutine at a time.

Think of it as

A Semaphore(n) is a parking lot with n spaces — the (n+1)th car waits until a space frees up. A Lock is that same lot with exactly one space, guaranteeing true mutual exclusion.

python
sem = asyncio.Semaphore(2)  # at most 2 concurrent

async def fetch_page(n):
    async with sem:
        return await do_request(n)

lock = asyncio.Lock()

async def increment(counter):
    async with lock:
        counter["value"] += 1

What we're doing: Cap concurrent "requests" to 2 at a time with a Semaphore and confirm the timing proves the cap is real.

semaphore_limit.pypython
import asyncio
import time

async def fetch_page(sem, n):
    async with sem:
        await asyncio.sleep(0.05)
        return f"page-{n}"

async def main():
    sem = asyncio.Semaphore(2)   # at most 2 concurrent
    start = time.perf_counter()
    results = await asyncio.gather(*(fetch_page(sem, i) for i in range(4)))
    elapsed = time.perf_counter() - start
    print(results)
    # 4 tasks, 2 at a time, 0.05s each -> ~0.1s, not ~0.05s or ~0.2s
    print("elapsed between 0.09 and 0.15:", 0.09 <= elapsed <= 0.15)

asyncio.run(main())
5
async with sem holds one of the 2 available slots for the duration of the sleep — the 3rd and 4th calls wait here.
12
4 tasks at 2-at-a-time, 0.05s each, takes roughly 2 rounds of 0.05s -- close to 0.1s, not 0.05s (unlimited) or 0.2s (serial).
Output
['page-0', 'page-1', 'page-2', 'page-3']
elapsed between 0.09 and 0.15: True

Why this works: Only 2 coroutines can be inside async with sem: at once. The other 2 wait until a slot frees, so total time lands between "all 4 at once" (~0.05s) and "one at a time" (~0.2s).

Remember: asyncio.Semaphore(n) caps concurrent access to n coroutines at a time; asyncio.Lock() is the n=1 special case — both used with async with.

See also: awaitables tasks and futures · backpressure and concurrency limits · lock and rlock

Advertisement

Production concerns

What actually breaks async applications in production — a blocking call freezing the whole loop, unbounded concurrency, sloppy cancellation — and the closing decision: threading vs. multiprocessing vs. asyncio.

Blocking code inside async applications

coreintermediate

A synchronous blocking call (time.sleep, a non-async DB driver, plain requests.get) inside a coroutine freezes the entire event loop, not just that one task — every other coroutine has to wait too.

Think of it as

The event loop is one chef. If that chef stops to personally wait by the oven instead of switching to another dish, EVERY dish stalls — not just the one in the oven. Async only helps if the "waiting" is done in a way the chef can walk away from.

python
# BAD -- blocks the whole event loop
def handler():
    time.sleep(1)

# GOOD -- yields control, other coroutines keep running
async def handler():
    await asyncio.sleep(1)

# GOOD -- for a genuinely blocking call you cannot avoid
async def handler():
    result = await asyncio.to_thread(blocking_function, arg)

What we're doing: Show a blocking time.sleep() inside a coroutine freezing an unrelated concurrent task, then fix it with asyncio.sleep().

blocking_vs_async.pypython
import asyncio
import time

async def bad_sleep():
    print("bad: about to block the event loop")
    time.sleep(0.05)          # BLOCKS the whole loop, not just this coroutine
    print("bad: done blocking")

async def other_task():
    print("other: started")
    await asyncio.sleep(0.01)   # this SHOULD interleave with bad_sleep, but can't
    print("other: finished")

async def main():
    await asyncio.gather(bad_sleep(), other_task())

asyncio.run(main())
6
time.sleep() blocks the OS thread the event loop runs on — it cannot switch to other_task during this call.
11
other_task never gets a chance to run its await until bad_sleep fully finishes, even though it only needed 0.01s.
Output
bad: about to block the event loop
bad: done blocking
other: started
other: finished

Why this works: "other: started" prints only AFTER "bad: done blocking" — proof the two did not interleave. A real await (asyncio.sleep) would have let other_task run its print in between.

Calling a synchronous blocking function directly from async code

Wrong

python
async def handler(request):
    data = requests.get(url).json()   # sync, blocking network call
    return process(data)

Better

python
async def handler(request):
    data = await asyncio.to_thread(lambda: requests.get(url).json())
    return process(data)
    # better still: use an async HTTP client (httpx.AsyncClient) directly

What you see: Throughput collapses under load — every concurrent request queues behind the one currently blocking the loop, even though the app "looks" async.

Why: requests.get() blocks the thread the whole event loop runs on. Every other coroutine, including unrelated requests, stalls until it returns — async gives no benefit if even one call in the hot path is synchronous.

time.sleep() freezes the WHOLE loop, not just its own task
bad_sleep()
event loop
other_task()
  1. 1. time.sleep(0.05)blocks the OS thread the loop runs on
  2. 2. (cannot switch — thread is blocked)
  3. 3. resumes after 0.05s
  4. 4. await asyncio.sleep(0.01)only now does other_task get to run
  1. bad_sleep() → event loop: time.sleep(0.05) (blocks the OS thread the loop runs on)
  2. event loop → other_task(): (cannot switch — thread is blocked)
  3. event loop → bad_sleep(): resumes after 0.05s
  4. event loop → other_task(): await asyncio.sleep(0.01) (only now does other_task get to run)

Remember: A blocking call inside a coroutine freezes the ENTIRE event loop, not just that task — use an async client, or asyncio.to_thread() for calls you cannot avoid.

See also: async io clients and connection pooling · cpu bound vs io bound workloads

Async HTTP/database clients and connection pooling

standardintermediate

A synchronous client (requests, psycopg2) blocks the event loop on every network call. An async client (httpx.AsyncClient, asyncpg) uses await instead, so other coroutines keep running while it waits.

Think of it as

A sync client is a phone call — you stand there until the other side answers. An async client is a text message — you send it, do other things, and come back when the reply arrives.

python
import httpx

async def fetch_all(urls):
    async with httpx.AsyncClient() as client:   # one pooled client, reused
        responses = await asyncio.gather(*(client.get(u) for u in urls))
    return [r.json() for r in responses]

# asyncpg -- an async Postgres client with a built-in pool
pool = await asyncpg.create_pool(dsn, min_size=5, max_size=20)
async with pool.acquire() as conn:
    rows = await conn.fetch("SELECT * FROM orders WHERE id = $1", order_id)

Remember: An async client (httpx.AsyncClient, asyncpg) awaits instead of blocking — reuse one client/pool across requests rather than opening a new connection each call.

See also: blocking code in async apps · backpressure and concurrency limits

Backpressure and concurrency limits

standardintermediate

Backpressure is deliberately slowing a producer down so it cannot outrun what a consumer or downstream service can handle. A Semaphore or a bounded Queue are the usual tools to enforce a concurrency limit that creates it.

Think of it as

Without backpressure, launching 10,000 concurrent requests is like opening 10,000 taps into one drain — the drain (a downstream API, a database) overflows. A concurrency limit caps how many taps run at once, so the drain never gets more than it can handle.

python
queue = asyncio.Queue(maxsize=100)   # producer blocks once 100 items are pending

async def producer():
    for item in source():
        await queue.put(item)   # blocks here if the queue is full -- backpressure

async def consumer():
    while True:
        item = await queue.get()
        await process(item)
        queue.task_done()

What we're doing: Show a bounded Queue applying backpressure — a fast producer is forced to wait once the queue fills, instead of growing unboundedly.

backpressure_queue.pypython
import asyncio

async def producer(queue, n):
    for i in range(n):
        print(f"producer: about to put {i}")
        await queue.put(i)          # blocks once the queue is full
        print(f"producer: put {i}")

async def consumer(queue, n):
    for _ in range(n):
        item = await queue.get()
        await asyncio.sleep(0.02)   # simulate slow processing
        print(f"consumer: took {item}")

async def main():
    queue = asyncio.Queue(maxsize=2)   # tiny, to force backpressure quickly
    await asyncio.gather(producer(queue, 4), consumer(queue, 4))

asyncio.run(main())
6
put() only blocks once 2 items are already queued — the 4th put() has to wait for the consumer to make room.
16
maxsize=2 is the concurrency limit here — a larger queue would let the producer race further ahead before pausing.
Output
producer: about to put 0
producer: put 0
producer: about to put 1
producer: put 1
producer: about to put 2
producer: put 2
producer: about to put 3
consumer: took 0
producer: put 3
consumer: took 1
consumer: took 2
consumer: took 3

Why this works: The queue holds up to 2 unconsumed items, but put() only actually blocks trying to add a 3RD pending item (put 2 succeeds because get() immediately reserved a slot) — "producer: put 3" cannot print until the consumer's first sleep finishes and removes an item. The queue forces the fast producer down to the slow consumer's pace.

Remember: Backpressure means slowing a producer to match a consumer — Semaphore or Queue(maxsize=n) enforce it.

See also: semaphores and async locks · async io clients and connection pooling

Graceful cancellation

standardintermediate

Graceful cancellation means catching CancelledError just long enough to release resources (close a connection, roll back a transaction), then re-raising it — never swallowing it outright.

Think of it as

CancelledError is like a fire alarm — you are allowed a moment to save your work and get out, but you must still leave. Catching it and not re-raising it is refusing to leave the building; the caller thinks the task finished when it didn't.

python
async def worker():
    resource = await acquire()
    try:
        await do_work(resource)
    finally:
        await resource.release()   # runs on success, error, AND cancellation

# or, if cleanup needs to distinguish cancellation specifically:
async def worker():
    try:
        await do_work()
    except asyncio.CancelledError:
        await cleanup()
        raise   # ALWAYS re-raise -- do not swallow it

What we're doing: Show try/finally releasing a resource correctly even when the task is cancelled mid-work.

graceful_cancel.pypython
import asyncio

async def worker():
    print("worker: acquiring resource")
    try:
        await asyncio.sleep(10)   # simulated long work
        print("worker: finished (never reached)")
    finally:
        print("worker: releasing resource")   # runs even on cancellation

async def main():
    task = asyncio.create_task(worker())
    await asyncio.sleep(0.02)
    task.cancel()
    try:
        await task
    except asyncio.CancelledError:
        print("main: confirmed task was cancelled")

asyncio.run(main())
7
try/finally guarantees this cleanup runs on the normal path, an exception, OR a cancellation — no special-casing needed.
8
This line never runs — cancellation interrupts the sleep before it returns.
9
finally still runs the release, in the same order it would on any other kind of interruption.
Output
worker: acquiring resource
worker: releasing resource
main: confirmed task was cancelled

Why this works: CancelledError is a real exception raised at the current await point — try/finally treats it exactly like any other exception, running cleanup before it propagates out of worker().

Remember: Use try/finally (or catch CancelledError and re-raise it) so cancellation still runs cleanup — never catch CancelledError without re-raising it.

See also: cancellation and timeouts · finally clause

Choosing threading, multiprocessing, or asyncio

coreintermediate

Threading suits synchronous I/O-bound work. Multiprocessing suits CPU-bound work needing real parallelism. AsyncIO suits high-concurrency I/O-bound work — thousands of simultaneous waits on one thread.

Think of it as

Ask what the work is doing while it "waits." Computing (CPU-bound) needs multiprocessing — the GIL blocks CPU parallelism on threads. Waiting on I/O needs either threading or asyncio; asyncio wins once the number of concurrent waits reaches into the thousands, where one thread per wait becomes too expensive.

python
# The decision, in order:
# 1. Is the work mostly computing (CPU-bound)?
#      -> multiprocessing (ProcessPoolExecutor)
# 2. Is it I/O-bound with a MODERATE number of concurrent operations,
#    using existing synchronous libraries?
#      -> threading (ThreadPoolExecutor)
# 3. Is it I/O-bound with a LARGE number of concurrent operations
#    (thousands), and async-native libraries are available?
#      -> asyncio

What we're doing: Show that the same "fetch many URLs" task reaches for a different tool depending on whether it is a handful of calls or thousands.

choosing_the_tool.pypython
def classify(workload):
    if workload["kind"] == "cpu-bound":
        return "multiprocessing"
    if workload["kind"] == "io-bound" and workload["concurrency"] <= 50:
        return "threading"
    if workload["kind"] == "io-bound" and workload["concurrency"] > 50:
        return "asyncio"
    return "unclear -- profile it"

print(classify({"kind": "cpu-bound", "concurrency": 4}))
print(classify({"kind": "io-bound", "concurrency": 10}))
print(classify({"kind": "io-bound", "concurrency": 5000}))
4
Small-scale I/O-bound work (a handful of requests) is where threading and asyncio genuinely tie — pick threading if the libraries involved are synchronous.
10
Large-scale I/O-bound work (thousands of concurrent waits) is where asyncio's single-thread model actually pays off over one-thread-per-connection.
Output
multiprocessing
threading
asyncio

Why this works: This is a simplified version of the real decision — "50" is not a hard line, but the shape of the reasoning holds: what the work is doing (computing vs. waiting) picks the family, and the scale of concurrent waits picks threading vs. asyncio within the I/O-bound family.

Reaching for asyncio to speed up CPU-bound work

Wrong

python
async def process_all(items):
    # WRONG: asyncio does not add parallelism -- one thread, one core
    return [heavy_computation(item) for item in items]

Better

python
from concurrent.futures import ProcessPoolExecutor

def process_all(items):
    with ProcessPoolExecutor() as ex:
        return list(ex.map(heavy_computation, items))

What you see: The "async" version runs no faster than a plain synchronous loop — sometimes slower, from added scheduling overhead.

Why: AsyncIO concurrency comes from overlapping I/O waits on one thread — it adds no CPU parallelism at all. CPU-bound work needs separate processes (multiprocessing) to actually use more than one core.

Workload shape and scale decide the tool
Threading
sync libraries, a handful of concurrent waits
AsyncIO
thousands of concurrent waits, one thread
Multiprocessing
CPU-bound at any scale — escapes the GIL
  • Threading: I/O-bound, Few concurrent ops — sync libraries, a handful of concurrent waits
  • AsyncIO: I/O-bound, Many concurrent ops — thousands of concurrent waits, one thread
  • Multiprocessing: CPU-bound, between Few concurrent ops and Many concurrent ops — CPU-bound at any scale — escapes the GIL

Which tool fits which workload

Which tool fits which workload
ToolFitsCost
ThreadingMostly I/O-bound, synchronous code/librariesRace conditions, deadlocks if shared state is not protected
MultiprocessingCPU-bound parallel workPickling every argument/result; higher memory (separate processes)
AsyncIOHigh-concurrency I/O-bound work (many waits at once)Needs async-native libraries end-to-end; one blocking call stalls everything

Together

python
# CPU-bound: crunching numbers -> multiprocessing
with ProcessPoolExecutor() as ex:
    results = ex.map(cpu_heavy_function, data_chunks)

# I/O-bound, a handful of calls, sync libraries -> threading
with ThreadPoolExecutor() as ex:
    results = ex.map(requests.get, urls)

# I/O-bound, THOUSANDS of concurrent connections -> asyncio
async with httpx.AsyncClient() as client:
    results = await asyncio.gather(*(client.get(u) for u in urls))

Remember: Threading fits sync I/O-bound work; multiprocessing fits CPU-bound work; asyncio fits high-concurrency I/O-bound work — pick by workload shape and scale.

See also: cpu bound vs io bound workloads · gil effects and when to use what · blocking code in async apps

Advertisement