Python asyncio: Running Jobs at the Same Time Without Threads
Picture a small shop dashboard on a Monday morning. Before the first order ships, someone has to check stock with four suppliers, one for tote bags, one for mugs, one for notebooks, one for lamps. Each check takes a couple of seconds while the supplier's system answers. Done one after another, that is eight seconds of staring at a spinner, and the computer spends almost all of it doing nothing, just waiting for a reply. The work is not hard, it is slow, and the slowness comes from waiting, not from thinking. That is the exact gap asyncio is built for: one program that starts several waits at once and picks each one up when its answer arrives.
What I would check before using it
My first check is what the program is actually waiting on. Asyncio helps when the time goes to waiting: network calls, database queries, slow file reads. It does nothing for work that keeps the processor busy, like crunching numbers or resizing images, because one thread still does that work one piece at a time. If the job is mostly computing, threads or separate processes are the honest answer, not asyncio.
My second check is whether the libraries I need can play along. Asyncio only works if the waiting code is written for it. A normal requests call blocks the whole loop while it waits, which quietly turns a concurrent program back into a sequential one. So I check for an async client, like aiohttp for HTTP or asyncpg for Postgres, before I commit. If the only library available is synchronous, asyncio buys me nothing.
My third check is how many things run at once, and who reads the code later. Four supplier checks are easy to reason about. Four hundred are still possible, but then I need to limit how many run together, or the program hammers the supplier's server. Async code is also harder to debug: an error can surface in a different task from the one that caused it. For a script I run once a month, sequential code is easier to trust.
My verdict, as judgement: use asyncio when a program makes several independent waits and the libraries support it. That is the shape of most small dashboards, scrapers, and notification senders. The recommendation flips the moment there is only one wait, the work is mostly computing, or the libraries are synchronous. In those cases the plain version is shorter, easier to debug, and just as fast.
Four orders, checked at the same time
The example is a small shop checking stock for four orders before the day starts. Each check waits on a supplier's system, and the waiting time is different for each one: a second and a half for the tote bags, two seconds for the mugs, one second for the notebooks, two and a half for the lamp. In the script, asyncio.sleep stands in for that waiting, since there is no real supplier to call. The real question is only how long four waits take when they run together, and the script prints the answer.
Here is the whole program. The check_stock coroutine waits, then reports the order as in stock. The main coroutine hands all four checks to asyncio.gather, which starts them together and waits until every one of them has finished. The timer around it measures the whole run.
import asyncio
import time
# Four shop orders. The sleep stands in for the wait while a
# supplier's stock service answers; each one is slow on its own.
ORDERS = [
{"order": "ORD-501", "customer": "Ayesha", "item": "Canvas tote bag", "delay": 1.5},
{"order": "ORD-502", "customer": "Bilal", "item": "Ceramic mug", "delay": 2.0},
{"order": "ORD-503", "customer": "Sara", "item": "Notebook set", "delay": 1.0},
{"order": "ORD-504", "customer": "Danish", "item": "Desk lamp", "delay": 2.5},
]
async def check_stock(order):
await asyncio.sleep(order["delay"])
return f'{order["order"]} ({order["customer"]}, {order["item"]}): in stock'
async def main():
start = time.perf_counter()
results = await asyncio.gather(*(check_stock(o) for o in ORDERS))
for line in results:
print(line)
elapsed = time.perf_counter() - start
print(f"Checked {len(ORDERS)} orders concurrently in {elapsed:.2f} seconds")
asyncio.run(main())
Here is the real output from running it:
ORD-501 (Ayesha, Canvas tote bag): in stock
ORD-502 (Bilal, Ceramic mug): in stock
ORD-503 (Sara, Notebook set): in stock
ORD-504 (Danish, Desk lamp): in stock
Checked 4 orders concurrently in 2.50 seconds
All four results came back, and the whole run took 2.50 seconds. That is not the sum of the four waits, it is the longest single wait, the two and a half seconds for the lamp. While the lamp check was waiting, the other three finished and reported in. That is the entire trick: overlapping the waits instead of stacking them.
The thing that went wrong was my first version of this script. I wrote the natural loop, awaiting each check inside it, and the program checked one order, waited for the answer, then moved on to the next. It worked, and it took the full seven seconds, one wait stacked on the next. Here is that version, with its output:
async def main():
start = time.perf_counter()
for order in ORDERS:
# Awaiting inside the loop: each check waits for the last one.
result = await check_stock(order)
print(result)
elapsed = time.perf_counter() - start
print(f"Checked {len(ORDERS)} orders one by one in {elapsed:.2f} seconds")
ORD-501 (Ayesha, Canvas tote bag): in stock
ORD-502 (Bilal, Ceramic mug): in stock
ORD-503 (Sara, Notebook set): in stock
ORD-504 (Danish, Desk lamp): in stock
Checked 4 orders one by one in 7.01 seconds
The fix was one line: gather the four coroutines instead of awaiting them in a loop. Same checks, same waits, same results, but the elapsed time dropped from 7.01 seconds to 2.50. The lesson is that await inside a loop means finish this one, then start the next, while gather means start them all, and tell me when the last one finishes.
Common mistakes and how to fix them
First, forgetting await. Calling a coroutine without awaiting it does not run it, it hands back a coroutine object and prints a warning at shutdown. This is the real output from a script that made that mistake:
Got: <coroutine object check_stock at 0x70cc65761840>
RuntimeWarning: coroutine 'check_stock' was never awaited
The fix is to await the call, or pass it to asyncio.gather, and to treat that warning as a bug, not noise, because the check never ran at all.
Second, awaiting inside a loop, as in the example above. The symptom is a program that works but takes the full summed time, 7.01 seconds here instead of 2.50. The fix is asyncio.gather, or asyncio.create_task for checks that should start right away.
Third, a timeout that cancels the task. Wrapping a slow check in asyncio.wait_for with a short timeout raises TimeoutError and cancels the check, so the order never gets an answer. This is the real output from a check given half a second for a two second wait:
Timed out waiting for ORD-502 after 0.5 seconds
The fix is to catch TimeoutError and decide: retry once, mark the order as unchecked, or allow a longer wait, instead of letting the exception end the whole run.
The verdict, after timing it
Asyncio is worth learning the moment a program waits on more than one thing. The example above is the whole case in miniature: same four checks, same waits, 7.01 seconds one way and 2.50 the other, with no extra libraries and no threads to manage. The price is a new habit: every wait has to be awaited, and the code has to be written for the loop. If your next script spends its time waiting on a network or a database, try it on that one script first. Run it, time it, and let the elapsed seconds make the decision for you.
Further reading
- Python documentation, asyncio, https://docs.python.org/3/library/asyncio.html
- Python documentation, Coroutines and Tasks, https://docs.python.org/3/library/asyncio-task.html
Comments
Post a Comment