Concurrency in Python: Threads, Processes, and Async I/O

Concurrency in Python: Threads, Processes, and Async I/O

Concurrency is one of the most misunderstood areas of Python. The language offers three distinct models for doing more than one thing at a time, each built for a different kind of workload, and each with its own rules, primitives, and failure modes. This article walks through all three: thread-based concurrency with the threading module, process-based parallelism with multiprocessing, and single-threaded cooperative multitasking with asyncio. It also covers the shared high-level interface exposed by concurrent.futures, the role of the Global Interpreter Lock, and the recent shift toward a free-threaded CPython.

Concurrency Versus Parallelism

These two words are often used interchangeably, but they describe different things. Concurrency is about structure: a program is concurrent when it is composed of multiple tasks that can make progress independently, interleaving in time. Parallelism is about execution: a program runs in parallel when multiple tasks execute literally at the same instant on different CPU cores.

A concurrent program is not necessarily parallel. A single CPU core can run many concurrent tasks by rapidly switching between them, giving the illusion of simultaneity. Parallelism, by contrast, requires hardware with more than one core and a runtime willing to use them. The distinction matters in Python because the choice of model determines whether you get true parallelism or only concurrency.

The practical question that drives every design decision is whether your workload is I/O-bound or CPU-bound. An I/O-bound task spends most of its time waiting: for a network response, a disk read, a database query. A CPU-bound task spends most of its time computing: hashing, compressing, resizing images, crunching numbers. This single property tells you which concurrency model to reach for.

The Global Interpreter Lock

The Global Interpreter Lock, universally called the GIL, is a mutex inside the CPython interpreter that ensures only one thread executes Python bytecode at any given moment. It exists to make memory management, and in particular reference counting, safe without requiring fine-grained locking on every object.

The consequence is significant: even on an eight-core machine, a pure-Python CPU-bound program using threads will not run faster, because the GIL serializes bytecode execution. Threads take turns rather than running together. This is why threading in Python is excellent for I/O-bound work, where threads spend most of their time blocked on system calls with the GIL released, but useless for speeding up CPU-bound Python code.

Two important caveats soften this picture. First, the GIL is released during blocking I/O and inside many C extensions, so libraries like NumPy can perform heavy numerical work in parallel across threads. Second, as of Python 3.13 an experimental build of CPython can run entirely without the GIL, and Python 3.14 promotes this free-threaded mode to an officially supported configuration. We return to that development near the end.

Thread-Based Concurrency

The threading module provides OS-level threads that share the same memory space. Because threads share memory, communication between them is cheap, but that same sharing is the source of race conditions. The simplest way to start a thread is to pass a callable to the Thread constructor and call start().

import threading
import time


def download(name, seconds):
    print(f"{name} started")
    time.sleep(seconds)  # simulates a blocking network call
    print(f"{name} finished after {seconds}s")


threads = []
for i in range(3):
    t = threading.Thread(target=download, args=(f"task-{i}", 2))
    t.start()
    threads.append(t)

for t in threads:
    t.join()  # wait for each thread to complete

print("all downloads done")

The three simulated downloads run concurrently and the whole program finishes in roughly two seconds rather than six, because each thread spends its time sleeping with the GIL released. The join() call blocks the main thread until the target thread terminates, which is how you wait for results before proceeding.

You can also subclass Thread and override run() when a thread carries state, though passing a target function is usually cleaner. A useful flag is daemon=True: daemon threads do not keep the interpreter alive, so the program can exit even if they are still running.

Race Conditions and Synchronization

When multiple threads modify shared state, operations that look atomic in source code are not atomic in bytecode. The classic example is incrementing a counter: counter += 1 compiles to a read, an add, and a write, and a thread can be suspended between any of those steps.

import threading

counter = 0


def increment():
    global counter
    for _ in range(1_000_000):
        counter += 1  # not atomic: read, add, write


threads = [threading.Thread(target=increment) for _ in range(4)]
for t in threads:
    t.start()
for t in threads:
    t.join()

print(counter)  # almost never 4_000_000

The fix is a Lock, which guarantees mutual exclusion. Only one thread can hold the lock at a time; others block until it is released. Using the lock as a context manager guarantees release even if the protected block raises an exception.

import threading

counter = 0
lock = threading.Lock()


def increment():
    global counter
    for _ in range(1_000_000):
        with lock:  # acquire on enter, release on exit
            counter += 1

The threading module offers a family of synchronization primitives beyond the basic lock, each suited to a particular coordination pattern:

  • Lock: the primitive mutex, non-reentrant; a thread that tries to acquire it twice deadlocks itself.
  • RLock: a reentrant lock that the owning thread can acquire multiple times, useful when a locked method calls another locked method.
  • Semaphore: allows up to n threads to enter a section at once, ideal for limiting concurrency such as capping simultaneous connections.
  • Event: a simple flag one thread sets and others wait on, good for signalling that something is ready.
  • Condition: combines a lock with the ability to wait for and notify about state changes, the building block for producer-consumer queues.
  • Barrier: makes a fixed number of threads wait for each other before all proceed together.

A semaphore that limits concurrency looks like this. Only three workers can be inside the protected region simultaneously, regardless of how many threads exist.

import threading
import time

limit = threading.Semaphore(3)  # at most 3 concurrent workers


def worker(n):
    with limit:
        print(f"worker {n} acquired a slot")
        time.sleep(1)
        print(f"worker {n} releasing")


threads = [threading.Thread(target=worker, args=(i,)) for i in range(10)]
for t in threads:
    t.start()
for t in threads:
    t.join()

For producer-consumer patterns, do not build your own locking around a list. The queue.Queue class is thread-safe out of the box and handles all the synchronization internally, blocking on get() when empty and supporting graceful shutdown through sentinel values.

import queue
import threading

work = queue.Queue()
DONE = None  # sentinel to stop consumers


def consumer():
    while True:
        item = work.get()  # blocks until an item is available
        if item is DONE:
            work.task_done()
            break
        print(f"processing {item}")
        work.task_done()


consumers = [threading.Thread(target=consumer) for _ in range(2)]
for c in consumers:
    c.start()

for i in range(10):
    work.put(i)
for _ in consumers:
    work.put(DONE)  # one sentinel per consumer

work.join()  # wait until every item is processed

The ThreadPoolExecutor

Manually creating, starting, and joining threads is verbose and error-prone. The concurrent.futures.ThreadPoolExecutor manages a pool of reusable worker threads and hands you back Future objects that represent pending results. This is the recommended way to run I/O-bound work concurrently in modern Python.

from concurrent.futures import ThreadPoolExecutor, as_completed
import urllib.request


def fetch(url):
    with urllib.request.urlopen(url) as response:
        return url, len(response.read())


urls = [
    "https://example.com",
    "https://python.org",
    "https://docs.python.org",
]

with ThreadPoolExecutor(max_workers=5) as executor:
    futures = [executor.submit(fetch, url) for url in urls]
    for future in as_completed(futures):
        url, size = future.result()  # re-raises exceptions here
        print(f"{url} -> {size} bytes")

Two patterns are worth knowing. The as_completed generator yields futures in the order they finish, which lets you process fast results without waiting for slow ones. When you want results in input order and do not care about early completion, the executor's map method is more concise, behaving like the built-in map but running calls concurrently.

from concurrent.futures import ThreadPoolExecutor

with ThreadPoolExecutor(max_workers=5) as executor:
    # results come back in the same order as the inputs
    for url, size in executor.map(fetch, urls):
        print(f"{url} -> {size} bytes")

Exiting the with block calls shutdown(), which waits for all submitted work to finish. A crucial detail about Future.result() is that it re-raises any exception raised inside the worker, so exceptions surface at the point where you read the result rather than being silently swallowed.

Process-Based Parallelism

Because the GIL prevents threads from parallelizing CPU-bound Python code, the answer for compute-heavy work is to use separate processes. Each process has its own interpreter and its own GIL, so processes run on different cores in true parallel. The multiprocessing module mirrors the threading API, which makes the transition familiar.

import multiprocessing as mp


def cpu_heavy(n):
    total = 0
    for i in range(n):
        total += i * i
    return total


if __name__ == "__main__":  # required on Windows and with spawn
    with mp.Pool(processes=4) as pool:
        results = pool.map(cpu_heavy, [10_000_000] * 4)
    print(results)

The if __name__ == "__main__" guard is not optional decoration. On Windows, and on any platform using the spawn start method, child processes re-import the main module, and without the guard that re-import would recursively spawn more processes. Python 3.14 makes spawn the default on Linux as well, so this guard is now mandatory everywhere for portable code.

Because processes do not share memory, arguments and return values are transferred by pickling: they are serialized in the parent, sent through a pipe, and deserialized in the child. This has two implications. First, anything you pass must be picklable, which rules out lambdas, local functions, and open file handles. Second, transferring large objects is expensive, so process-based parallelism pays off only when the computation per task clearly outweighs the serialization cost.

Sharing State Between Processes

Since memory is not shared, you need explicit channels to communicate. The module provides several options. A multiprocessing.Queue works like the threading queue but across process boundaries. Shared-memory objects such as Value and Array let processes read and write a common region under a lock.

import multiprocessing as mp


def add_to(shared, lock, amount):
    for _ in range(100_000):
        with lock:
            shared.value += amount


if __name__ == "__main__":
    counter = mp.Value("i", 0)   # shared signed int in shared memory
    lock = mp.Lock()

    procs = [
        mp.Process(target=add_to, args=(counter, lock, 1))
        for _ in range(4)
    ]
    for p in procs:
        p.start()
    for p in procs:
        p.join()

    print(counter.value)  # 400_000

For richer shared data structures, a multiprocessing.Manager exposes proxy objects such as shared dictionaries and lists, at the cost of higher overhead because every access goes through a server process. As a rule, prefer message passing over shared state: sending immutable messages through queues avoids most of the subtle bugs that plague shared-memory concurrency.

For CPU-bound work the cleanest high-level tool is ProcessPoolExecutor, which shares the same interface as its thread counterpart. Switching a program from thread-based to process-based parallelism can be as simple as changing the class name, provided the callables and data are picklable.

from concurrent.futures import ProcessPoolExecutor


def is_prime(n):
    if n < 2:
        return False
    i = 2
    while i * i <= n:
        if n % i == 0:
            return False
        i += 1
    return True


if __name__ == "__main__":
    candidates = [112272535095293, 112582705942171, 115280095190773]
    with ProcessPoolExecutor() as executor:
        for n, prime in zip(candidates, executor.map(is_prime, candidates)):
            print(f"{n} prime? {prime}")

Asynchronous I/O

The third model is fundamentally different from the first two. Instead of relying on the operating system to switch between threads or processes preemptively, asyncio runs a single thread and switches between tasks cooperatively at explicit suspension points marked by await. This is possible because I/O-bound tasks spend most of their time waiting, and while one task waits, the event loop runs another.

The core concepts are the coroutine, a function defined with async def; the await expression, which suspends the current coroutine until an awaitable completes; and the event loop, which schedules and runs coroutines. A coroutine does nothing when called; it returns a coroutine object that must be driven by the loop.

import asyncio


async def download(name, seconds):
    print(f"{name} started")
    await asyncio.sleep(seconds)  # yields control to the event loop
    print(f"{name} finished")
    return name


async def main():
    # gather runs the coroutines concurrently and collects results
    results = await asyncio.gather(
        download("a", 2),
        download("b", 1),
        download("c", 3),
    )
    print(results)


asyncio.run(main())  # creates the loop, runs main, closes the loop

The whole program finishes in three seconds, the duration of the longest task, because all three run concurrently in one thread. The key insight is that await asyncio.sleep(seconds) does not block the thread; it registers a timer and hands control back to the event loop, which is free to advance the other coroutines. Note that a blocking call like time.sleep would freeze the entire loop, so async code must use async-aware libraries throughout.

The critical distinction between await and concurrency is worth emphasizing. Simply awaiting coroutines one after another runs them sequentially. To get concurrency you must schedule them together, either with asyncio.gather or by wrapping each in a Task with asyncio.create_task, which schedules the coroutine to run on the loop immediately.

import asyncio


async def work(n):
    await asyncio.sleep(n)
    return n * 10


async def sequential():
    a = await work(1)  # waits 1s
    b = await work(1)  # then another 1s: total 2s
    return a + b


async def concurrent():
    task_a = asyncio.create_task(work(1))  # scheduled now
    task_b = asyncio.create_task(work(1))  # scheduled now
    return await task_a + await task_b  # total 1s

Structured Concurrency With TaskGroup

Since Python 3.11, the recommended way to manage a set of related tasks is the asyncio.TaskGroup, which brings structured concurrency to the standard library. All tasks created inside the group are awaited when the block exits, and if any task fails the others are cancelled and the errors are collected into an ExceptionGroup. This removes an entire class of bugs where a failing task leaves siblings orphaned.

import asyncio


async def fetch(name, delay):
    await asyncio.sleep(delay)
    return f"result-{name}"


async def main():
    async with asyncio.TaskGroup() as tg:
        t1 = tg.create_task(fetch("a", 1))
        t2 = tg.create_task(fetch("b", 2))
    # on exit, both tasks are guaranteed complete
    print(t1.result(), t2.result())


asyncio.run(main())

The async ecosystem also supports asynchronous context managers with async with and asynchronous iterators with async for, which is how libraries expose non-blocking resources such as HTTP sessions and streaming responses. A common real-world shape, using a hypothetical async HTTP client, looks like this.

import asyncio


async def fetch_all(client, urls):
    async with asyncio.TaskGroup() as tg:
        tasks = [tg.create_task(client.get(u)) for u in urls]
    return [t.result() for t in tasks]

Bridging Blocking Code Into Async

Real programs often need to call a blocking function, a legacy library or a CPU-bound routine, from inside async code. Calling it directly would stall the event loop. The solution is asyncio.to_thread, which runs the blocking call in a thread from a pool and awaits the result without freezing the loop.

import asyncio
import time


def blocking_io():
    time.sleep(2)  # a synchronous, blocking call
    return "done"


async def main():
    # offloads the blocking call to a worker thread
    result = await asyncio.to_thread(blocking_io)
    print(result)


asyncio.run(main())

For CPU-bound work inside async code, thread offloading does not help because of the GIL; instead, use loop.run_in_executor with a ProcessPoolExecutor to push the computation into a separate process.

Choosing the Right Model

The decision follows almost mechanically from the nature of the workload. The following table summarizes the guidance.

Workload Recommended model Why
Many concurrent network or disk operations asyncio Scales to thousands of tasks with minimal overhead; the loop switches at await points
I/O-bound work with blocking libraries ThreadPoolExecutor Threads release the GIL while blocked; no need to rewrite libraries for async
CPU-bound computation ProcessPoolExecutor Separate processes bypass the GIL and use multiple cores in parallel
A mix of heavy computation and I/O asyncio plus a process pool The loop handles I/O while CPU work is offloaded to processes

Two rules of thumb keep most projects out of trouble. For I/O-bound work, reach for asyncio when you control the whole stack and can use async libraries, and for a thread pool when you must integrate blocking code. For CPU-bound work, reach for processes, and consider whether a specialized library that releases the GIL, such as NumPy, already gives you parallelism inside threads.

Free-Threaded Python and Sub-Interpreters

The concurrency landscape is shifting. Python 3.13 introduced an experimental free-threaded build of CPython, known by its origin proposal as the no-GIL build, in which the Global Interpreter Lock is removed and threads can execute Python bytecode truly in parallel. Python 3.14 promotes this configuration from experimental to officially supported, though it remains a separate build that you opt into rather than the default.

When free-threading matures, the trade-offs described in this article change. CPU-bound Python code will finally scale across cores using ordinary threads, which are far cheaper than processes and avoid pickling overhead. The catch is that removing the GIL exposes every race condition the lock previously masked, so correct synchronization becomes even more important, and single-threaded performance carries a modest cost from the finer-grained locking the interpreter now needs.

A second development is sub-interpreters. Python 3.14 exposes multiple independent interpreters within one process through the concurrent.interpreters module, following the design of its proposal. Each sub-interpreter has its own GIL, which means they can run Python code in parallel while sharing a process, offering a middle ground between threads and processes with lower overhead than spawning whole new interpreters. Both features are young, but together they signal that Python's long-standing concurrency constraints are finally loosening.

Common Pitfalls

A few mistakes recur often enough to be worth naming explicitly. Understanding them prevents most of the pain that concurrency introduces.

  • Deadlock: two threads each hold a lock the other needs. Always acquire multiple locks in a consistent global order, or use a single coarser lock.
  • Forgetting the main guard: omitting if __name__ == "__main__" with process pools causes infinite process spawning on spawn-based platforms.
  • Blocking the event loop: calling a synchronous blocking function inside a coroutine freezes every task; route such calls through asyncio.to_thread or a process pool.
  • Assuming operations are atomic: compound statements on shared data are not atomic; protect them with a lock or use thread-safe containers.
  • Swallowing exceptions: an unhandled exception in a thread or an un-awaited task can vanish silently. Read every future's result and prefer TaskGroup for async.
  • Over-parallelizing tiny tasks: when the work per task is trivial, the overhead of threads, pickling, or process startup dominates and makes the program slower.

Conclusion

Python gives you three concurrency models because there is no single right answer. Threads and asyncio deliver concurrency for I/O-bound workloads, with async scaling further and threads integrating more easily with existing blocking code. Processes deliver genuine parallelism for CPU-bound work by sidestepping the GIL entirely. The concurrent.futures interface unifies threads and processes behind a single clean API, and the async ecosystem now offers structured concurrency through TaskGroup.

The most reliable way to choose is to classify the workload first, I/O-bound or CPU-bound, and let that classification pick the model. As free-threaded CPython and sub-interpreters mature, the boundaries between these models will blur, but the underlying mental framework, distinguishing concurrency from parallelism and matching the tool to the workload, will remain the foundation of writing correct and fast concurrent Python.