Python · Lesson 20 of 21
async/await and Concurrency
Learn Python async/await with asyncio: coroutines, the event loop, gather, TaskGroup, timeouts, and when to use threads or processes instead.
- Advanced
- 20 min read
- 4 objectives
Before this lessonLesson 19: JSON, Dates and the Standard Library
What you will learn
- Explain concurrency versus parallelism and when async helps
- Write and run coroutines with async def and await
- Run tasks concurrently with gather and TaskGroup
- Choose between asyncio, threads and processes
Your Progress
0 of 21 lessons 0%
- Lessons0 / 21
- Completed0
- Est. time left~ 5 hours
Create a free account to keep your progress on every device.
Tip: pressing Next marks this lesson complete automatically.
Most real programs spend a lot of time waiting: for a web API to answer, a database query to return, a file to download. While one request waits, the CPU sits idle. Concurrency means making progress on several tasks during those waits instead of doing them strictly one after another.
Python's asyncio module, together with the async and await keywords, is the standard way to do this for I/O-heavy work. It powers web frameworks like FastAPI and HTTP clients like httpx. In this lesson we simulate network calls with asyncio.sleep so every example runs anywhere.
Concurrency is not parallelism
Picture a chef making three dishes. Parallelism is hiring three chefs. Concurrency is one chef who puts the pasta on to boil, chops vegetables while it cooks, then checks the oven. asyncio is the single chef: one thread that switches between tasks whenever the current one is waiting. It will not make CPU-heavy maths faster, but it makes waiting-heavy code dramatically faster.
- I/O-bound (network, disk, databases): asyncio or threads.
- CPU-bound (image processing, heavy number crunching): multiple processes, or libraries that release the GIL.
Coroutines: async def and await
A function defined with async def is a coroutine function. Calling it does not run it; it returns a coroutine object. You run coroutines with await (from inside another coroutine) or start the whole program with asyncio.run(), which creates the event loop. await means "pause me here until this finishes, and let other tasks run meanwhile".
import asyncio
async def fetch_user(user_id):
print(f"fetching user {user_id}")
await asyncio.sleep(0.1) # stands in for a network call
return {"id": user_id, "name": f"user{user_id}"}
async def main():
coro = fetch_user(1)
print(type(coro).__name__)
user = await coro
print(user)
asyncio.run(main())coroutine
fetching user 1
{'id': 1, 'name': 'user1'}Sequential awaits are still sequential
A common first surprise: awaiting three calls in a row takes three times as long, because each await waits before the next call even starts. Here each call takes 0.2 seconds, so the total is about 0.6.
import asyncio
import time
async def fetch(name, delay):
await asyncio.sleep(delay)
return f"{name} done"
async def main():
start = time.perf_counter()
a = await fetch("orders", 0.2)
b = await fetch("users", 0.2)
c = await fetch("stats", 0.2)
print(a, b, c)
print(f"took about {round(time.perf_counter() - start, 1)}s")
asyncio.run(main())orders done users done stats done took about 0.6s
Running tasks concurrently with gather
asyncio.gather() starts several coroutines at once and waits for all of them, returning results in the same order you passed them in. The same calls now overlap, so the total is roughly the slowest one (0.3 seconds) instead of the sum (0.6). Notice the completion order in the log differs from the result order.
import asyncio
import time
async def fetch(name, delay):
await asyncio.sleep(delay)
print(f"finished {name}")
return f"{name} done"
async def main():
start = time.perf_counter()
results = await asyncio.gather(
fetch("orders", 0.3),
fetch("users", 0.1),
fetch("stats", 0.2),
)
print(results)
print(f"took about {round(time.perf_counter() - start, 1)}s")
asyncio.run(main())finished users finished stats finished orders ['orders done', 'users done', 'stats done'] took about 0.3s
Structured concurrency with TaskGroup
Since Python 3.11, asyncio.TaskGroup is the recommended way to run a group of tasks. It waits for every task when the async with block ends, and if any task fails it cancels the others and raises the errors together as an ExceptionGroup, which you catch with except*. Nothing is left running in the background by accident.
import asyncio
async def charge(customer, amount):
await asyncio.sleep(0.1)
if amount <= 0:
raise ValueError(f"bad amount for {customer}")
return f"charged {customer} ${amount}"
async def main():
async with asyncio.TaskGroup() as tg:
t1 = tg.create_task(charge("Ada", 30))
t2 = tg.create_task(charge("Linus", 12))
print(t1.result(), "|", t2.result())
try:
async with asyncio.TaskGroup() as tg:
tg.create_task(charge("Grace", 20))
tg.create_task(charge("Bob", 0))
except* ValueError as group:
for err in group.exceptions:
print("failed:", err)
asyncio.run(main())charged Ada $30 | charged Linus $12 failed: bad amount for Bob
Timeouts and limiting concurrency
Network calls can hang, so put a deadline on them with asyncio.timeout() (3.11+). When you have hundreds of URLs, starting them all at once can overwhelm a server; an asyncio.Semaphore caps how many run at the same time.
import asyncio
async def slow_api():
await asyncio.sleep(5)
return "never"
async def download(sem, page, active):
async with sem: # at most 2 inside at once
active.append(page)
print(f"page {page} start, running: {len(active)}")
await asyncio.sleep(0.1)
active.remove(page)
return page * 10
async def main():
try:
async with asyncio.timeout(0.2):
await slow_api()
except TimeoutError:
print("slow_api timed out")
sem = asyncio.Semaphore(2)
active = []
sizes = await asyncio.gather(*(download(sem, p, active) for p in range(1, 5)))
print(sizes)
asyncio.run(main())slow_api timed out page 1 start, running: 1 page 2 start, running: 2 page 3 start, running: 1 page 4 start, running: 2 [10, 20, 30, 40]
Threads and processes with concurrent.futures
Not every library is async. concurrent.futures gives the same "run many things and collect results" idea for ordinary functions. ThreadPoolExecutor suits blocking I/O. ProcessPoolExecutor runs CPU-heavy work on multiple cores, sidestepping the Global Interpreter Lock (GIL). Python 3.13+ also offers an optional free-threaded build without the GIL, but most installs still have it, so processes remain the safe choice for CPU work.
import time
from concurrent.futures import ThreadPoolExecutor
def blocking_fetch(url):
time.sleep(0.2) # a blocking call, like requests.get
return f"{url}: 200"
urls = ["/learn", "/blog", "/about", "/contact"]
start = time.perf_counter()
with ThreadPoolExecutor(max_workers=4) as pool:
results = list(pool.map(blocking_fetch, urls))
print(results)
print(f"took about {round(time.perf_counter() - start, 1)}s")['/learn: 200', '/blog: 200', '/about: 200', '/contact: 200'] took about 0.2s
pool.map keeps the input order, just like gather. Four 0.2-second calls finished in about 0.2 seconds total.
Recap
- Concurrency overlaps waiting; asyncio does it on one thread for I/O-bound work.
async defcreates coroutines; run them withawaitorasyncio.run().- Awaiting calls one by one is sequential; use
gatherorTaskGroupto overlap them. - Add deadlines with
asyncio.timeout, cap load withSemaphore, and never block the loop. - Use
ThreadPoolExecutorfor blocking libraries andProcessPoolExecutorfor CPU-heavy work.
# Write your solution here
Finished reading? Mark this lesson complete to track your progress.
