🐍 Python Course · Stage 2 · Lesson 27 of 89 · Concurrency
Course Overview →

🔵 Read Beginner then Intermediate tabs. Visit Expert when ready.

← Type Hints Testing →
thecodex.expert · The Codex Family of Knowledge
Python

Concurrency in Python

Python's three concurrency models — threading, multiprocessing, and asyncio — when to use each, the role of the GIL, and the unified concurrent.futures interface.

Python 3.13 docs.python.org Last verified:
Canonical Definition

Python offers three concurrency approaches: threading for I/O-bound work using OS threads constrained by the Global Interpreter Lock, multiprocessing for CPU-bound work using separate processes that bypass the GIL, and asyncio for high-volume I/O-bound work using a single-threaded cooperative event loop.

🟩 Beginner

Doing more than one thing at a time

What you'll learn: The critical distinction between I/O-bound and CPU-bound work, and which Python concurrency tool fits each. Threading for I/O tasks, asyncio for high-volume I/O, multiprocessing for computation.

How to read this tab: Read the decision table first, before anything else. The whole page rests on one question: is your code waiting (I/O-bound) or computing (CPU-bound)? Answer that question, and the right tool is obvious.

⏱ 50 min 📄 4 sections 🔶 Prerequisite: Modules & Packages
The idea

Concurrency means doing more than one thing at a time. Python gives you three tools, and choosing the right one depends on what is slowing you down: waiting (network, disk, database) or computing (heavy number-crunching). The wrong choice can make things slower, so the distinction matters.

This decision table is the whole lesson. Print it. Stick it on your wall. Before writing any concurrent code, ask: "Is my code waiting or computing?" Waiting (network, file, database, sleep) → threads or asyncio. Computing (maths, image processing, parsing large files) → multiprocessing. Using threads for CPU-bound work is worse than a single-threaded loop due to the GIL.

Which model to use

ModelBest forHow it runs
threadingI/O-bound (waiting on network, disk)Multiple OS threads, one at a time due to the GIL
multiprocessingCPU-bound (heavy computation)Multiple processes, truly parallel
asyncioI/O-bound at high volume (thousands of connections)One thread, cooperative event loop
I/O-bound vs CPU-bound — the key question

I/O-bound: your program spends most of its time waiting — for a web response, a file, a database. Threads and asyncio help because while one task waits, another runs. CPU-bound: your program spends its time calculating — image processing, maths. Only multiprocessing gives a real speed-up here, because it sidesteps the GIL.

💡

Threads for I/O work. Python's threading.Thread creates OS threads, but they can't run Python bytecode simultaneously (the GIL prevents it). However, the GIL IS released while waiting for I/O — so threads genuinely speed up code that spends time waiting for networks, files, or databases. The overhead of creating threads is low.

Threading — for I/O-bound work

A thread runs a function alongside others in the same process. Because of the GIL (explained in the Expert tab), threads do not speed up pure computation — but they are excellent when tasks spend time waiting, because Python releases the GIL during I/O.

Pythonthreading_basics.py
import threading
import time

def download(name: str) -> None:
    print(f"Starting {name}")
    time.sleep(2)            # simulates waiting for a network response
    print(f"Finished {name}")

# Run three "downloads" concurrently
threads = []
for site in ["site-a", "site-b", "site-c"]:
    t = threading.Thread(target=download, args=(site,))
    t.start()               # begins running the function
    threads.append(t)

# Wait for all threads to complete
for t in threads:
    t.join()

# Without threads this takes ~6s (2s x 3).
# With threads it takes ~2s — they wait at the same time.
print("All downloads done")

✅ Beginner tab complete — check your understanding

  • I can explain the difference between I/O-bound and CPU-bound work
  • I know: I/O-bound → threading or asyncio; CPU-bound → multiprocessing
  • I can write a basic threading.Thread example
  • I can write a basic asyncio coroutine with async/await
  • I understand why threads do NOT speed up CPU-heavy Python code

Continue to Testing →

🔵 Intermediate

multiprocessing, concurrent.futures, and the GIL

What you'll learn: multiprocessing.Pool for true parallelism. concurrent.futures as the clean unified API over threads and processes. The Global Interpreter Lock — what it is and what it means for your code.

How to read this tab: Start with concurrent.futures — it's the highest-level API and the right starting point for most real work. Drop down to threading or multiprocessing directly only when you need the extra control.

⏱ 40 min 📄 2 sections 🔶 Prerequisite: Beginner tab

The if __name__ == "__main__" guard is mandatory. On Windows (and macOS with Python 3.8+ default), multiprocessing uses "spawn" to create child processes — which re-imports your script. Without the guard, child processes would spawn more child processes recursively. Always wrap multiprocessing entry code in this guard.

Multiprocessing — for CPU-bound work

The multiprocessing module creates separate Python processes, each with its own interpreter and memory — and crucially, its own GIL. This achieves true parallelism on multiple CPU cores for computation-heavy work. The trade-off: starting processes is more expensive than threads, and data must be sent between them (pickled), so it suits coarse-grained, CPU-bound tasks.

Pythonmultiprocessing_basics.py
from multiprocessing import Pool

def heavy_compute(n: int) -> int:
    # A CPU-bound task — pure calculation
    return sum(i * i for i in range(n))

if __name__ == "__main__":     # required guard for multiprocessing
    numbers = [1_000_000, 2_000_000, 3_000_000, 4_000_000]

    # Pool distributes the work across CPU cores, truly in parallel
    with Pool(processes=4) as pool:
        results = pool.map(heavy_compute, numbers)

    print(results)
    # On a 4-core machine this runs ~4x faster than a plain loop,
    # because each process runs on its own core with its own GIL.
The if __name__ == "__main__" guard is required

On platforms that start processes via "spawn" (Windows, and macOS by default), the child process re-imports your script. Without the main-guard, that would recursively start more processes. Always wrap multiprocessing entry code in if __name__ == "__main__":.

💡

asyncio runs one thing at a time — but switches between them. It's not parallel. It's cooperative: when a coroutine hits an await, it says "I'm waiting, go run something else." This means you can handle thousands of simultaneous network connections on a single thread, as long as they spend most of their time waiting.

asyncio — single-threaded concurrency

asyncio runs many tasks on one thread using an event loop and the async/await keywords. When a coroutine hits an await on something that would wait (a network call), it yields control so another coroutine can run. This scales to thousands of simultaneous connections with very little overhead — ideal for network servers and clients. The Codex has a dedicated deep-dive: asyncio in the Standard Library.

Pythonasyncio_basics.py
import asyncio

async def fetch(name: str) -> str:
    print(f"Starting {name}")
    await asyncio.sleep(2)       # yields control while "waiting"
    print(f"Finished {name}")
    return name

async def main() -> None:
    # Run three coroutines concurrently and wait for all
    results = await asyncio.gather(
        fetch("api-1"),
        fetch("api-2"),
        fetch("api-3"),
    )
    print(results)               # ['api-1', 'api-2', 'api-3']

# asyncio.run starts the event loop and runs main() to completion
asyncio.run(main())
# Total time ~2s, not 6s — all three wait concurrently on one thread.
Commonly confused
Threads do not speed up CPU-bound Python. Because of the GIL, only one thread executes Python bytecode at a time. For heavy computation, threading gives little or no speed-up — use multiprocessing instead. Threads shine only when tasks wait.
asyncio is concurrency, not parallelism. It runs on a single thread. It interleaves many waiting tasks extremely efficiently, but it does not use multiple CPU cores. For parallel computation you still need multiprocessing.
You cannot mix blocking calls into asyncio freely. A regular blocking call (like time.sleep or a synchronous database driver) inside a coroutine stalls the entire event loop. Use the async equivalents (asyncio.sleep) or run blocking work in an executor.

✅ Intermediate tab complete — check your understanding

  • I can use ThreadPoolExecutor for I/O-bound work and ProcessPoolExecutor for CPU-bound work
  • I can use pool.map() to apply a function to a list in parallel
  • I can explain what the GIL is and why Python threads don't run Python bytecode in parallel
  • I always include if __name__ == "__main__": when using multiprocessing

Continue to Testing →

🔴 Expert

The GIL's implementation and PEP 703

What you'll learn: Why the GIL exists (CPython's reference counting is not thread-safe). When the GIL is released (blocking I/O, C extensions). PEP 703 — the experimental free-threaded build in Python 3.13+ that makes the GIL optional.

How to read this tab: Read after you've used all three concurrency models in practice. The GIL section is one of the most frequently misunderstood aspects of Python — understanding it properly takes you to a different level.

⏱ 30 min 📄 1 section 🔶 Prerequisite: After Stage 2

The most misunderstood thing about Python. The GIL is a mutex that allows only one thread to execute Python bytecode at a time. This is why threads don't speed up CPU-bound Python. But it's released during I/O — which is why threads DO help I/O-bound code. The GIL also makes Python's memory management simpler and single-threaded code faster.

The Global Interpreter Lock (GIL)

In CPython, the Global Interpreter Lock is a mutex that allows only one thread to execute Python bytecode at a time. It exists because CPython's memory management (reference counting) is not thread-safe; the GIL makes the interpreter simpler and single-threaded code faster. The consequence: threads cannot run Python bytecode in parallel, so CPU-bound multithreaded code sees no speed-up. The GIL is released during blocking I/O and inside many C extensions (such as NumPy operations), which is why threading still helps I/O-bound and C-heavy workloads.

PEP 703 — the free-threaded build

PEP 703 introduced an experimental free-threaded build of CPython (available from Python 3.13 as a separate build) that can run without the GIL, enabling true multithreaded parallelism. As of 3.13 it is optional and experimental; the standard build still uses the GIL. This is the most significant change to Python's concurrency story in its history, but production code today should still assume the GIL is present.

💡

Start here for most concurrent work. concurrent.futures provides ThreadPoolExecutor and ProcessPoolExecutor with identical APIs. Switching from threads to processes is one word change. pool.map() distributes work; as_completed() lets you process results as they finish. Use this over raw threading or multiprocessing unless you need fine-grained control.

concurrent.futures — the unified high-level interface

The concurrent.futures module provides a single high-level API over both threads and processes via ThreadPoolExecutor and ProcessPoolExecutor. You submit callables and get back Future objects representing pending results. Switching between threads and processes is often a one-line change — making it the recommended starting point for most parallel work.

Pythonconcurrent_futures.py
from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor

def fetch_url(url: str) -> int:
    import time
    time.sleep(1)            # simulate an I/O wait
    return len(url)

# I/O-bound -> ThreadPoolExecutor
urls = ["a.com", "b.com", "c.com", "d.com"]
with ThreadPoolExecutor(max_workers=4) as pool:
    results = list(pool.map(fetch_url, urls))
print(results)

# CPU-bound -> ProcessPoolExecutor (same API, different executor)
def square(n: int) -> int:
    return n * n

if __name__ == "__main__":
    with ProcessPoolExecutor() as pool:
        results = list(pool.map(square, range(10)))
    print(results)

# submit() returns a Future; as_completed yields them as they finish
from concurrent.futures import as_completed
with ThreadPoolExecutor() as pool:
    futures = [pool.submit(fetch_url, u) for u in urls]
    for future in as_completed(futures):
        print("Got result:", future.result())
A decision summary

Start with concurrent.futures. Use ThreadPoolExecutor for I/O-bound tasks, ProcessPoolExecutor for CPU-bound tasks. Reach for asyncio when you have very high volumes of concurrent I/O (thousands of network operations) and libraries that support it. Use raw threading or multiprocessing only when you need fine-grained control the executors do not provide.

How this connects
Related concept

✅ Expert tab complete

  • I can explain why CPython needs the GIL (reference counting is not thread-safe)
  • I know the GIL IS released during blocking I/O and inside many C extensions
  • I know that PEP 703 introduces an optional free-threaded CPython build in Python 3.13

Continue to Testing →

Sources

1
Python Standard Library — Concurrent Execution. docs.python.org/3/library/concurrency.html.
2
Python Standard Library — threading, multiprocessing, asyncio. docs.python.org/3/library/.
3
Python Standard Library — concurrent.futures. docs.python.org/3/library/concurrent.futures.html.
4
Python Language Reference & CPython docs — Global Interpreter Lock. docs.python.org/3/glossary.html#term-global-interpreter-lock.
5
PEP 703 — Making the Global Interpreter Lock Optional in CPython. peps.python.org/pep-0703/.
Source confidence: High Last verified: Primary source: Python Standard Library · concurrency