Cover image for Python Concurrency Basics: Threads, Processes, and asyncio

At a glance

Reading time

~200 words/min

Published

8 hours ago

Jun 13, 2026

Views

3

All-time total

Python Concurrency Basics: Threads, Processes, and asyncio

Your programs so far have done one thing at a time, and for eleven lessons that was correct. But consider a script that downloads a hundred web pages: each request spends two seconds waiting for a server, and almost nothing computing. Done one at a time, that is more than three minutes of your program sitting on its hands. Concurrency is the family of techniques for overlapping the waiting, and the same hundred pages finish in a few seconds. This lesson is the plain-spoken, honest introduction: what concurrency is and is not, the three tools Python offers, and exactly when to reach for each.

Concurrency has a reputation for difficulty, and the reputation is earned in general but avoidable for you, because difficulty lives almost entirely in shared mutable state, threads writing to the same data simultaneously. The modern patterns this lesson teaches, executor maps and async tasks, are designed so you rarely touch that stove. The goal today is not mastery of every primitive; it is a correct mental model and two working recipes, which puts you ahead of a surprising share of working programmers.

What you will learn in Part 12

  • I/O-bound versus CPU-bound: the one diagnosis that decides everything
  • Threads with ThreadPoolExecutor: overlap waiting in four lines
  • The GIL, explained honestly and without panic
  • Processes for genuinely parallel computation
  • asyncio: async, await, and gather for thousands of concurrent waits
  • How to choose between threads, processes, and async, every time

Note

Before you start

You need functions from Part 4, exceptions from Part 7, and the generator intuition from Part 9, because async functions pause and resume exactly the way generators taught you. This is the most conceptual lesson in the course; read slowly and run everything.

1. The diagnosis: are you waiting or computing?

Every performance problem in this territory starts with one question. An I/O-bound program spends its time waiting for things outside the CPU: network responses, disk reads, database replies, a user. A CPU-bound program spends its time computing: image processing, cryptography, training models, crunching numbers. The distinction decides your tool with almost no exceptions: I/O-bound work wants threads or asyncio, which interleave the waiting; CPU-bound work wants processes, which recruit more processor cores. Misdiagnose, and your concurrency makes nothing faster while adding every cost.

The decision table for this entire topic
Your bottleneck Tool Why it works
Waiting on many network calls or files threads or asyncio While one task waits, another runs; the waiting overlaps
Heavy computation on multiple cores multiprocessing Separate processes sidestep the GIL and use all cores
Thousands of simultaneous connections asyncio Tasks are far cheaper than threads at large scale
A handful of blocking calls to speed up ThreadPoolExecutor Smallest change to existing code, four lines
Simple script, no real waiting none Concurrency adds complexity; earn it with a real bottleneck

2. Threads: overlapping the waiting

A thread is an independent flow of execution inside your program; the operating system switches between threads, and when one blocks on a network read, others proceed. Raw thread management is fiddly, so modern Python wraps it in an executor: create a pool, map your function over your inputs, collect results in order. These four lines are the single most useful concurrency recipe in the language, and they convert the three-minute downloader into a six-second one without restructuring anything.

from concurrent.futures import ThreadPoolExecutor
import time

def fetch(url):                  # stand-in for a real network call
    time.sleep(1)                # the waiting we want to overlap
    return f"{url}: ok"

urls = [f"https://site/{n}" for n in range(8)]

start = time.perf_counter()
with ThreadPoolExecutor(max_workers=8) as pool:
    results = list(pool.map(fetch, urls))
elapsed = time.perf_counter() - start

print(results[0], "...")
print(f"8 one-second fetches in {elapsed:.1f}s")   # ~1.0s, not 8

Now the famous asterisk: the Global Interpreter Lock. CPython, the standard interpreter, allows only one thread to execute Python bytecode at any instant. For I/O-bound work this barely matters, because waiting threads release the lock, which is why the downloader above genuinely speeds up. For CPU-bound work it matters completely: eight threads crunching numbers take turns on one core and finish no faster, sometimes slower. The GIL is not a scandal, it is a design trade-off, and recent Python versions are gradually offering a GIL-free build; but the guidance for you stands regardless, threads for waiting, processes for computing.

Processes are the heavy tool: separate Python interpreters with separate memory, coordinated by the multiprocessing module or, more pleasantly, the same executor API with ProcessPoolExecutor swapped in. Each process has its own GIL, so eight processes genuinely use eight cores. The costs are real, slower startup and data copied between processes rather than shared, which is why you reserve them for actual computation. When Part 15 trains neural networks, the libraries underneath are doing exactly this kind of parallelism in optimized C, which is also the deeper lesson: in data work, you mostly ride parallelism that libraries built for you.

A practical question every executor user faces immediately: how many workers? For I/O-bound thread pools, the answer is governed by the waiting, not your core count; eight, sixteen, or more threads are reasonable when each spends its life blocked on a server, though the polite ceiling is what the remote service can bear. For process pools, more workers than CPU cores buys nothing and costs memory, so os.cpu_count() is the natural default. In both cases the honest method is the one this course keeps teaching: pick a sensible number, measure with perf_counter, adjust once, and stop tuning.

Checkpoint

Your script resizes 10,000 photos and maxes out one CPU core. Which tool actually helps?

3. asyncio: concurrency as a language feature

The third tool moves concurrency into the language itself. An async def function is a coroutine: calling it creates a waiting task rather than running the body, exactly the deferred behavior you learned from generators in Part 9, and that is no coincidence, coroutines grew directly out of generator machinery. Inside a coroutine, await marks the points where the function may pause; while it is paused, the event loop, a scheduler built into asyncio, runs other coroutines. One thread, thousands of tasks, switching only at awaits: that is the whole model.

import asyncio

async def fetch(url: str) -> str:
    await asyncio.sleep(1)          # a polite, non-blocking wait
    return f"{url}: ok"

async def main() -> None:
    urls = [f"https://site/{n}" for n in range(100)]
    results = await asyncio.gather(*(fetch(u) for u in urls))
    print(f"{len(results)} fetches done")

asyncio.run(main())                 # ~1 second for all 100

Read main carefully, because it contains the two idioms that carry async Python. gather takes many coroutines, runs them concurrently, and returns all results in order; the generator expression feeding it is your Part 9 skill again. And asyncio.run is the bridge from the ordinary world: it starts the event loop, runs one coroutine to completion, and cleans up. The rules that follow from the model: await only inside async def, never call blocking functions like time.sleep inside a coroutine, use the async equivalents, and remember that async helps only when there is waiting to overlap; it does nothing for computation.

When should a beginner actually choose asyncio over threads? Scale and ecosystem. At a dozen concurrent waits, the thread pool is simpler and your libraries do not need to cooperate. At hundreds or thousands, or when you build with frameworks that are async-native, asyncio wins decisively: tasks cost almost nothing, and the await points make the switching visible in the code. Modern web backends live there, which is why our advanced track devotes a full lesson to it; the async Python deep dive with httpx picks up exactly where today ends, with real network calls and production patterns.

Checkpoint

What does await actually do inside an async function?

4. Practice: a concurrent download simulator

The playground below runs real asyncio in your browser, simulating a batch downloader with variable delays, a concurrency limit, and per-task error handling, the honest shape of production async code. One browser-specific note that is itself a good lesson: this page already runs inside an event loop, so instead of asyncio.run(main()) we await main() directly, exactly what you would do in a Jupyter notebook, while on your own machine the asyncio.run form from section 3 is correct.

Python playground

Two production-grade details hide in that toy. The semaphore caps simultaneous work, which real servers require of you; unlimited concurrency is a denial-of-service attack with extra steps. And return_exceptions=True turns gather into a collector of outcomes rather than a single point of failure, the Part 7 philosophy, failures as values to handle, scaled to a hundred tasks. Keep both habits and your first real async program will already look senior.

! Common mistakes to avoid

  • Adding threads to a CPU-bound loop and seeing zero speedup.

    The GIL serializes Python computation across threads. Diagnose first: waiting wants threads or async, computing wants ProcessPoolExecutor.

  • Calling time.sleep or other blocking functions inside async code.

    A blocking call freezes the entire event loop and every task on it. Inside async def, use await asyncio.sleep and async libraries; one blocking call undoes everything.

  • Calling an async function like a normal one and wondering why nothing happened.

    async def returns a coroutine object; nothing runs until it is awaited or passed to asyncio.run/gather. The warning "coroutine was never awaited" means exactly this.

  • Sharing and mutating data structures across threads casually.

    That is where concurrency horror stories live. Prefer the patterns shown: map inputs to outputs, collect results, mutate nothing shared. If you must share, you need locks, and that is a sign to redesign.

? Frequently asked questions

Is the GIL going away? +

Python now ships an optional free-threaded build where the GIL can be disabled, maturing steadily since 3.13. The ecosystem is migrating gradually. The diagnosis discipline you learned today remains correct either way, and that is why this lesson teaches it first.

Do I need concurrency for my scripts? +

Mostly no, and that is a fine answer. Reach for it when a real bottleneck appears, many slow network calls being the classic. Concurrency is a tool for measured problems, not a default posture.

asyncio or threads for my first concurrent program? +

ThreadPoolExecutor.map for a quick speedup of existing blocking code; asyncio when you start a new program around network calls, or when concurrency counts grow past dozens. Many working programs use both, async at the core, a thread pool for stubborn blocking libraries.

How does this relate to the web frameworks I keep hearing about? +

Directly: modern Python web servers are asyncio event loops, and every request handler is a coroutine. When you continue into our FastAPI series after this track, today's mental model is the foundation everything sits on.

5. Recap and what comes next

You can now diagnose I/O-bound versus CPU-bound and let the diagnosis choose the tool: ThreadPoolExecutor to overlap a handful of waits, ProcessPoolExecutor for real multi-core computation, asyncio with gather, semaphores, and exception collection for concurrency at scale. You understand the GIL as a trade-off rather than a mystery, and you know the cardinal sins, blocking the loop and sharing mutable state, well enough to avoid them by design.

Next is the lesson that quietly changes your standing as a programmer: Part 13, testing with pytest, where you learn to prove your code works and keep it working while you change it. The Concurrency lesson in the Learn Python app below reviews today's decision table in quiz form, and the full syllabus is on the series hub.

💡

Pro tip

Before adding any concurrency, measure: time.perf_counter() around the slow part, and find out whether you are waiting or computing. Ten lines of measurement routinely save a hundred lines of misapplied threads, and profiling before optimizing is the most adult habit in programming.

Learn Python Android app icon

Practice on the go

Learn Python, the free Android app

Every topic in this series lives in the app too: bite-size lessons, runnable examples, quizzes, mini projects, and an offline Python playground that runs on your phone.

Newsletter

Want more posts like this?

Get practical software notes and tutorials delivered when something new is published.

No spam. Unsubscribe anytime.

How did this land?

Comments

0
Log in or sign up to join the discussion and react to this post.

No comments yet. Be the first to share your thoughts.

Related posts

Important functionalities of Pandas in Python : Tricks and Features

Pandas is one of my favorite libraries in python. It’s very useful to visualize the data in a clean structural manner. Nowadays Pandas is widely used in Data Science, Machine Learning and other areas.

5 years ago

How to get data from twitter using Tweepy in Python?

To start working on Python you need to have Python installed on your PC. If you haven’t installed python. Go to the Python website and get it installed.

6 years ago

Predicting per capita income of the US using linear regression

Python enables us to predict and analyze any given data using Linear regression. Linear Regression is one of the basic machine learning or statistical techniques created to solve complex problems.

6 years ago

Essential Sorting Algorithms for Computer Science Students

Algorithms are commonly taught in Computer Science, Software Engineering subjects at your Bachelors or Masters. Some find it difficult to understand due to memorizing.

6 years ago

GraphQL in Laravel Using Lighthouse

In modern web development, GraphQL has emerged as a powerful alternative to REST APIs due to its flexibility and efficiency.

1 year ago