Sitemap
Abhishek Agrawal

Abhishek Agrawal

Software engineer building scalable systems and exploring the intersection of code, design, and impact. Sharing what I learn along the way.

Async Python, Explained Like You’re 10

The GIL, threads, processes, and asyncio — from the very basics to the stuff senior engineers get asked in interviews. No prior magic required.

7 min readJul 16, 2026

--

Imagine you’re a chef in a tiny kitchen. You have soup to boil, bread to bake, and a salad to chop. How you juggle these three jobs is exactly the puzzle Python solves with threads, processes, and asyncio. Let’s learn it the way a curious kid would — one small idea at a time.

1. Two words people always mix up

Before any code, we need two words: concurrency and parallelism. They sound like twins but they are not.

Concurrency is one cook handling three dishes by switching between them — stir the soup, then check the bread, then chop a carrot, then back to the soup. Only one thing happens at any exact moment, but all three dishes make progress.

Parallelism is three cooks, each on their own dish, all working at the very same instant. To do this for real, you need more than one pair of hands — more than one CPU core.

Press enter or click to view image in full size
Concurrency is one cook switching between dishes; parallelism is many cooks working at once.

Remember: asyncio gives you concurrency (one clever cook). Only multiprocessing gives you true parallelism (many cooks).

2. The GIL: Python’s single golden key

Here’s the surprising part. Even if your computer has 8 cores, plain Python code runs on only one at a time. Why? Because of a rule inside CPython called the GIL — the Global Interpreter Lock.

Think of the GIL as a single golden key. To run Python code, a thread must hold the key. There’s only one key, so only one thread runs Python at any moment. The others wait their turn.

Press enter or click to view image in full size
The GIL is a single key — only the thread holding it can run Python code.

This sounds bad, but there’s a clever twist. When a thread is waiting — for a web page to download, a file to read, a database to answer — it doesn’t need to run Python code. So it hands the key to someone else while it waits.

That gives us the golden rule that explains everything:

Waiting work (I/O) shares nicely. Thinking work (CPU) does not.

  • I/O-bound work (network, disk, database) = mostly waiting → threads and asyncio help a lot.
  • CPU-bound work (big math, hashing, image resizing) = mostly thinking → the GIL blocks you; you need separate processes.

3. Three tools, three jobs

Python gives you three ways to juggle work. Here’s the whole map on one page:

multiprocessing beats the GIL with a simple trick: it starts brand-new Python processes. Each process gets its own interpreter and its own golden key. Eight processes on eight cores = eight things truly at once. The cost: processes are heavier, and any data you pass between them must be packed up (pickled) and shipped over.

Press enter or click to view image in full size
A simple decision guide: waiting work goes to asyncio or threads, computing work goes to processes.

4. The event loop: asyncio’s beating heart

Now the star of the show. asyncio is our single super-efficient cook. The engine that makes them so efficient is the event loop.

Get Abhishek Agrawal’s stories in your inbox

Join Medium for free to get updates from this writer.

The event loop is one thread running in a circle, forever, doing this:

  1. Pick a task that’s ready to run.
  2. Run it until it hits an await on something not ready yet (like a download).
  3. The task politely pauses and hands control back.
  4. The loop runs another ready task. When the download finishes, it comes back to the paused one.
Press enter or click to view image in full size
The event loop spins forever, switching tasks at every await point.

The one rule you must never break: never block the event loop. Because it’s a single thread, if one task refuses to pause, every other task freezes with it.

Here’s the difference in code. The word await means "I'm about to wait — go do something else meanwhile."

import asyncio, time

# BAD: time.sleep freezes the WHOLE loop for 5 seconds
async def bad():
time.sleep(5) # no await = no sharing

# GOOD: asyncio.sleep lets other tasks run while we wait
async def good():
await asyncio.sleep(5) # yields control back to the loop

5. Coroutine vs Task vs await

Three words trip up almost everyone. Let’s nail them with a tiny example.

async def foo():
return 1

coro = foo() # a COROUTINE: not running yet, just a recipe
result = await foo() # runs it NOW and waits for the answer
task = asyncio.create_task(foo()) # a TASK: starts running in the background
  • A coroutine is what you get when you call an async def function. Nothing runs yet — it's a recipe on paper.
  • await foo() runs the recipe now and waits for it to finish.
  • A Task (via create_task) starts the recipe cooking in the background, so you can start other recipes too, then collect them all later.

6. The whole point: doing many things while waiting

Say we must fetch 3 web pages, each taking 1 second. The slow way awaits them one by one:

# SLOW: 1s + 1s + 1s = 3 seconds total
async def slow():
a = await fetch(url1)
b = await fetch(url2)
c = await fetch(url3)

The fast way starts all three, then waits for the batch together with asyncio.gather:

# FAST: all three wait at the same time = ~1 second total
async def fast():
a, b, c = await asyncio.gather(
fetch(url1), fetch(url2), fetch(url3)
)
Press enter or click to view image in full size
Awaiting one by one takes 3 seconds; gather lets all three wait together in ~1 second.

The magic isn’t speed — it’s overlap. asyncio doesn’t make one download faster. It lets the waiting for all three happen at the same time.

7. A real example: fetch 100 APIs

Let’s put it together with aiohttp. We fetch many URLs concurrently and collect the results:

import asyncio, aiohttp

async def fetch(session, url):
async with session.get(url) as resp:
return await resp.json()

async def fetch_all(urls):
async with aiohttp.ClientSession() as session:
tasks = [fetch(session, u) for u in urls]
return await asyncio.gather(*tasks) # all at once!

asyncio.run(fetch_all(my_100_urls))

8. Advanced: don’t be rude — limit concurrency

Firing 100 requests at once can get you blocked. Many APIs say “max 10 at a time.” We use a Semaphore — think of it as a bouncer who only lets 10 people into the club, and lets the next one in only when someone leaves.

Press enter or click to view image in full size
A Semaphore(10) acts like a bouncer, keeping at most 10 requests running at once.
async def fetch_all(urls):
sem = asyncio.Semaphore(10) # at most 10 at once

async def bounded(session, url):
async with sem: # wait for a free slot
return await fetch(session, url)

async with aiohttp.ClientSession() as session:
tasks = [bounded(session, u) for u in urls]
return await asyncio.gather(*tasks)

9. Advanced: one bad URL shouldn’t sink the ship

What if one of the 100 URLs always times out? By default, gather raises the first error and you may lose the other 99 good results. Two clean fixes:

Option A — tell gather to hand back errors as values instead of exploding:

results = await asyncio.gather(*tasks, return_exceptions=True)

for url, r in zip(urls, results):
if isinstance(r, Exception):
print(f"{url} failed: {r}") # handle it, keep the rest
else:
use(r)

Option B — give each fetch its own timeout and safe fallback so one slow server can’t stall the batch:

async def safe_fetch(session, url):
try:
#with runs guaranteed setup when you enter a block
#and guaranteed cleanup when you leave it — even if
#the code inside crashes.
async with asyncio.timeout(5): # give up after 5s
return await fetch(session, url)
except Exception as e:
return {"error": str(e), "url": url}

Senior-level instinct: in a real service you’d combine both — a Semaphore to be polite, per-task timeouts so nothing hangs forever, and return_exceptions=True so one failure never sinks the other 99.

10. The two questions to test yourself

If you can answer these, you truly get it:

Q1. You must fetch 1,000 URLs. Why is asyncio better than 1,000 threads?
Because 1,000 threads means 1,000 chunks of memory and lots of OS switching overhead — heavy. asyncio does all 1,000 on one thread with tiny, cheap pauses at each await. Same waiting, far less weight.

Q2. You must hash 10,000 big files (CPU-heavy). Why do asyncio and threads both disappoint, and what do you use?
Because hashing is thinking, not waiting — the GIL lets only one thread compute at a time, so neither asyncio nor threads give real speedup. Reach for multiprocessing (or a ProcessPoolExecutor) to use every core at once.

The one-paragraph summary

Python runs one line of code at a time because of the GIL — a single key. For waiting work (network, disk), that’s fine: tasks hand off the key while they wait, and asyncio makes one thread juggle thousands of waits gracefully through its event loop. For thinking work (heavy math), one key isn’t enough, so you spin up multiple processes, each with its own key, for true parallelism. Waiting → asyncio. Computing → processes. That’s the whole story.

Thanks for reading. If this made the GIL finally click, share it with someone who’s still scared of async. 🐍

--

--