Topic 12.9
Choosing and Debugging Concurrency
In one line
Pick the model from the bottleneck: many API calls mean asyncio, a blocking library means threads, CPU-bound Python means processes, and array maths means NumPy. This topic gives the decision guide, a full async pipeline with a limit, timeout and retries, and the bugs that most often go wrong.
Think of it like this
Choosing how to move house. For a few boxes, you carry them yourself (no concurrency needed). For many small errands with long waits, like waiting for the electricity office, you start several errands and check back (asyncio). If a helper only works through a window one at a time, you hire a few helpers (threads). For a mountain of heavy lifting, you need more people with their own hands (processes), or a truck (a C library like NumPy). The right choice depends on what is slow.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- Bottleneck
- The slowest part of a program, the one that limits total speed.
- SDK
- A library that a service provider (for example an AI company) gives you so you can call its API from Python without writing the web requests yourself.
- Backoff
- Waiting longer after each failed retry, so a struggling server gets time to recover.
- Jitter
- A small random extra delay so many clients do not retry at the same instant.
- Rate limit (HTTP 429)
- The response a server sends when you ask too often.
- Debug mode
- A mode in asyncio that logs slow callbacks and coroutines that were never awaited.
- Fire-and-forget
- Starting a task and never looking at its result. Errors in it get lost.
- Back-pressure
- Slowing producers when consumers cannot keep up.
- Structured concurrency
- Tasks must finish inside the block that started them.
Step by step
01Measure the bottleneck
Before choosing, time the real steps. A program that spends 95% of its time inside requests.get is I/O-bound; one that spends 95% in a loop of arithmetic is CPU-bound. Guessing wrongly is expensive: adding threads to CPU-bound Python makes no difference, and adding processes to a program that waits on one slow API changes nothing.
02The decision guide
Follow the questions from the top. The chart gives a starting point, not a law: real systems often combine two answers.
03A full LLM-style pipeline
The pipeline below combines Topic 12.7's parts: a semaphore shares three slots, each call has a 0.15 s timeout, failures are retried with a growing pause, and gather(..., return_exceptions=True) collects every outcome so one failure does not hide the others. The fake model fast always works, hard fails twice with a connection error then works, and hang never answers in time, so it gives up after three tries.
Read the printed order: each failure is printed by the task that saw it, and the final lines are in the order of the prompts, because zip(prompts, outcomes) (Topic 2.6) pairs each prompt with the result at the same position, and gather returns results in the order it was given.
04Never lose a task
asyncio.create_task(coro) without keeping the result is a trap. The event loop keeps only a weak reference, so the task can be garbage-collected mid-flight, and an exception inside it is reported only when it is garbage-collected, with the vague message Task exception was never retrieved. The safe pattern is to put the task in a set, remove it with add_done_callback(tasks.discard), and read its result (or use a TaskGroup). The runnable example shows the safe version.
background = set()
task = asyncio.create_task(boom())
background.add(task)
task.add_done_callback(background.discard) # forget it when it ends05Turn on debug mode
Run your program with python -X dev app.py or set PYTHONASYNCIODEBUG=1. Asyncio then logs any callback or task step that blocks the loop for more than 0.1 seconds, with the line where the slow task was created, and warns about coroutines never awaited. It slows the program slightly, so use it while developing. The block below was run with Python 3.12 on a desktop.
06Test concurrent code by order, not by clock
Timing-based tests fail on a busy machine. Make the fake dependency deterministic (short sleeps with clear differences, or events that you set yourself) and assert on the order of events and the results, as this whole phase does. For libraries, pytest-asyncio or anyio run async tests (Phase 11 covers testing in general).
Try it yourself
- 1
Break the pipeline on purpose
In the pipeline change
asyncio.timeout(0.15)toasyncio.timeout(0.005)and predict which prompts now fail. Evenfasttakes 0.01 s inside the model, so it times out as well. Timeouts must be set from real response times, with room to spare. - 2
Shrink the semaphore
Change
Semaphore(3)toSemaphore(1). The three prompts now run one at a time, so the printed lines come in a different order. Notice that results are still returned in prompt order:gatherkeeps that, whatever the execution order. - 3
Lose a task
In the second example replace the safe pattern with a bare
asyncio.create_task(boom())and aawait asyncio.sleep(0.05). On your computer you will seeTask exception was never retrieved; in the browser the message is worded differently. Either way, the error was never handled by your code.
Code & diagrams
A fake model with three behaviours. Change the timeout to 0.5 to see 'hang' still fail, or the number of tries to 2 to see 'hard' fail.
Expected output
hard: attempt 1 failed (ConnectionError)
hard: attempt 2 failed (ConnectionError)
hang: attempt 1 failed (TimeoutError)
hang: attempt 2 failed (TimeoutError)
hang: attempt 3 failed (TimeoutError)
fast -> answer(fast)
hard -> answer(hard)
hang -> FAILED: hang: gave up after 3 triesThe task set keeps the task alive and the done-callback tidies it up. gather collects the error, so nothing is lost.
Expected output
collected: RuntimeError('lost in the background')
tasks still tracked: 0Not runnable in the browser (the Run button cannot pass debug=True to asyncio.run). Verified with Python 3.12 on a desktop; the blocked time and the paths differ on every machine. The warning appears because time.sleep blocked the loop for longer than 0.1 seconds.
import asyncio
import time
async def main():
time.sleep(0.2) # blocks the loop: debug mode will notice
asyncio.run(main(), debug=True)Output
Executing <Task finished name='Task-1' coro=<main() done, defined at main.py:5> result=None created at .../asyncio/runners.py:100> took 0.203 secondsBreak it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
Fire and forget a task that fails
Start a task that raises, keep no reference, and never await it.
import asyncio
async def boom():
await asyncio.sleep(0.01)
raise RuntimeError("lost in the background")
async def main():
asyncio.create_task(boom()) # no reference kept, never awaited
await asyncio.sleep(0.05)
print("main finished")
asyncio.run(main())Myth vs fact
Myth
asyncio is always the best choice.
Fact
It is best for many concurrent waits. For CPU-bound work it does nothing, and for a few blocking calls a thread pool is simpler.
Myth
More concurrency always means faster.
Fact
Past the limit of the server, the rate limit or your memory, more concurrency adds errors and retries. Tune it to the slowest shared resource.
Myth
If it works on my machine it is correct.
Fact
Concurrency bugs depend on timing and load. Test for order and results, with deliberate delays, and under load.
Myth
Retrying immediately is the best way to recover.
Fact
Immediate retries add load to a server that is already struggling. Use backoff with a little jitter, and respect Retry-After.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
Production retry logic: retry only transient errors (timeouts, connection errors, HTTP 429 and 5xx), never validation errors (HTTP 400, 401). Use exponential backoff with jitter (for example wait
random.uniform(0, base * 2**attempt)), a retry budget, and make the operation idempotent (safe to repeat), or the retry may do the work twice. Libraries such astenacityand the built-in retries of SDKs implement this. - ▸
Control the blast radius: one semaphore per downstream service, a total timeout on the whole batch with
asyncio.timeout, and a limit on queue length. When the downstream is down, a circuit breaker (stop calling for a while) is better than retrying forever. - ▸
Observe it: log task names, record per-call latency and failures, and expose the number of in-flight calls. A rising in-flight count with flat throughput is the signature of a stalled dependency or a leaked task.
- ▸
To mix models safely: an async web server (event loop thread) can send CPU-heavy work to a
ProcessPoolExecutorwithloop.run_in_executor, and blocking SDK calls toto_thread. Keep shared state in the event loop thread, or behind a queue, so no locks are needed. Check the free-threaded build (Topic 12.2) before relying on single-threaded assumptions in libraries that use threads internally. - ▸
Finding a blocked loop in production: measure loop lag by having a task sleep a short interval and record how late it wakes (a healthy loop wakes within a few milliseconds), export it as a metric, and alert when it grows. When it does,
py-spy dump --pid <pid>shows what the main thread is executing, andasyncio.all_tasks()lists the tasks that exist. Debug mode names the slow callback but is too costly to leave on. - ▸
Retries and timeouts must fit together: a total deadline for the whole operation (
asyncio.timeoutaround the retry loop) plus a per-attempt timeout, otherwise three retries of a 60 s call take three minutes. Count a timed-out call as possibly executed: the server may have finished the work after you stopped waiting, which is why retried writes need idempotency keys.
Remember this
- 1
Start with the bottleneck, not the tool: is the program waiting or calculating? Measure first (
time.perf_counter, a profiler, Topic 13.7). The question "would a faster network help, or a faster CPU?" settles most cases (Topic 12.1). - 2
Decision guide. Many network or API calls (an LLM batch, a crawler, a service that calls tools): asyncio with a Semaphore (a limit on how many run at once, Topic 12.7). A blocking library you cannot change, a handful of calls: a thread pool or
to_thread. CPU-bound pure Python on many items: a process pool. Numeric work on arrays: NumPy (it uses optimised C code, and often many cores). GPU work: PyTorch. One slow step that nobody else waits on: usually none of these. - 3
AI workloads. Calling a hosted model 1,000 times: asyncio, a semaphore sized to the rate limit, a timeout on each call, retries with backoff for transient errors (429 and 5xx). Embedding documents: batch the texts into fewer calls first (fewer round trips beats more concurrency). Streaming to a user: an async generator. Running a model on your own machine: the framework handles the cores and the GPU; your Python only feeds it.
- 4
Common bugs. (1) Blocking the event loop (
time.sleep,requests, a heavy loop) inside a coroutine. (2) A missingawait. (3) Tasks whose exception nobody reads, or whose reference was dropped. (4) SwallowingCancelledError. (5) No timeout, so one stalled call hangs everything. (6) Unbounded concurrency that triggers rate limits or exhausts memory. (7) Shared mutable state between threads without a lock. (8) Starting processes without the main guard or with unpicklable arguments. - 5
Debugging tools. Asyncio debug mode (
PYTHONASYNCIODEBUG=1, orpython -X dev, orasyncio.run(main(), debug=True)) logs coroutines that were never awaited and callbacks that block the loop for more than 0.1 seconds. Naming your tasks (create_task(coro, name="fetch-1")) makes tracebacks andasyncio.all_tasks()readable. Log with timestamps, task names and thread names. Keep test timings out of assertions: assert on order and results, as the examples in this phase do. - 6
Structured concurrency is the habit that prevents most of these bugs: start tasks inside a
TaskGroupso their lifetime is the lifetime of a block, give every call a timeout, and let errors travel up instead of being swallowed. You will use these skills again in Phase 15 (web frameworks that run on the event loop) and Phase 16 (LLM clients, agents that call several tools at once).
Explain it without notes
A script calls an LLM API 2,000 times. Describe how you would make it fast and safe.
When would you choose threads, processes or NumPy over asyncio?
What are three bugs that commonly appear in async Python, and how do you detect each?
What is structured concurrency, and which asyncio features support it?
Your async service becomes slow and unresponsive under load, and CPU is at 100% on one core. How do you find the cause and what are the usual fixes?
Practice
Write a helper retry(make_coro, tries) that awaits make_coro() up to tries times, retrying only on ConnectionError, and re-raises the last error if all tries fail. Test it with a function that fails once.
Write run_limited(jobs, limit) that takes a list of zero-argument coroutine functions and returns their results in order, running at most limit at once.
Write first_result(coros) that runs several coroutines together and returns the result of whichever finishes first, cancelling the rest (use asyncio.wait with FIRST_COMPLETED).
Trade-offs
- ↔
Retries: raise the success rate for transient failures but multiply load and cost on a struggling server; always cap tries and back off.
- ↔
One shared limit vs per-service limits: a single semaphore is simple; separate ones stop one slow service from starving the others.
- ↔
Debug mode: catches blocking and unawaited coroutines early, but slows the program, so keep it for development.
Done when you can
Done when you can choose asyncio, threads, processes or NumPy for a described workload and give the reason.
Done when you can build an async pipeline with a semaphore, a timeout, retries and gathered results.
Done when you can list the common async bugs and how to spot each.
Done when you can keep references to background tasks and explain why.
Done when you can turn on asyncio debug mode and read its warnings.