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_threador 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
TaskGroupfor 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.