Python Concurrency: Threading vs Multiprocessing vs Asyncio
Compare Python's concurrency models -- threading, multiprocessing, and asyncio -- with practical examples and performance guidance.
What you'll learn
- ✓How the GIL affects threading in CPython
- ✓When to use threading vs multiprocessing vs asyncio
- ✓Building concurrent programs with each approach
- ✓Common pitfalls and how to avoid them
Prerequisites
- •Basic Python knowledge
- •Understanding of functions and classes
- •Familiarity with I/O operations
The Concurrency Landscape in Python
Python offers three main approaches to concurrency:
- Threading: Multiple threads within a single process. Best for I/O-bound work.
- Multiprocessing: Multiple processes, each with its own Python interpreter. Best for CPU-bound work.
- Asyncio: Cooperative multitasking with a single thread. Best for high-concurrency I/O.
Choosing the wrong model leads to programs that are slower than their sequential counterparts. This guide will help you pick the right tool.
The GIL: The Elephant in the Room
CPython’s Global Interpreter Lock (GIL) prevents multiple threads from executing Python bytecode simultaneously. This means threading does not provide true parallelism for CPU-bound work.
import threading
import time
def cpu_heavy(n):
"""Simulate CPU-bound work."""
total = 0
for i in range(n):
total += i * i
return total
# Sequential
start = time.perf_counter()
cpu_heavy(10_000_000)
cpu_heavy(10_000_000)
print(f"Sequential: {time.perf_counter() - start:.2f}s")
# Threaded -- NOT faster due to GIL
start = time.perf_counter()
t1 = threading.Thread(target=cpu_heavy, args=(10_000_000,))
t2 = threading.Thread(target=cpu_heavy, args=(10_000_000,))
t1.start(); t2.start()
t1.join(); t2.join()
print(f"Threaded: {time.perf_counter() - start:.2f}s")
The threaded version will likely be the same speed or even slightly slower than sequential, because the GIL forces threads to take turns.
Note: Python 3.13 introduced an experimental free-threaded mode (--disable-gil) that removes this limitation. It is still experimental as of 2026, but worth following.
Threading: Best for I/O-Bound Work
When your program waits on network requests, file reads, or database queries, threads release the GIL during the wait. This makes threading effective for I/O-bound workloads.
import threading
import time
from concurrent.futures import ThreadPoolExecutor
def download(url):
"""Simulate downloading from a URL."""
time.sleep(1) # Simulates network I/O
return f"Data from {url}"
urls = [f"https://api.example.com/data/{i}" for i in range(10)]
# Sequential: ~10 seconds
start = time.perf_counter()
results = [download(url) for url in urls]
print(f"Sequential: {time.perf_counter() - start:.2f}s")
# ThreadPoolExecutor: ~1 second
start = time.perf_counter()
with ThreadPoolExecutor(max_workers=10) as executor:
results = list(executor.map(download, urls))
print(f"Threaded: {time.perf_counter() - start:.2f}s")
ThreadPoolExecutor from concurrent.futures is the modern, preferred way to use threads. It manages the thread pool, handles exceptions, and provides a clean API.
Multiprocessing: True Parallelism for CPU Work
Each process has its own Python interpreter and its own GIL. This enables genuine parallel execution on multiple CPU cores.
import time
from concurrent.futures import ProcessPoolExecutor
def cpu_heavy(n):
total = 0
for i in range(n):
total += i * i
return total
numbers = [10_000_000] * 4
# Sequential
start = time.perf_counter()
results = [cpu_heavy(n) for n in numbers]
print(f"Sequential: {time.perf_counter() - start:.2f}s")
# Multiprocessing
start = time.perf_counter()
with ProcessPoolExecutor(max_workers=4) as executor:
results = list(executor.map(cpu_heavy, numbers))
print(f"Multiprocessing: {time.perf_counter() - start:.2f}s")
On a 4-core machine, the multiprocessing version should be roughly 4x faster.
Sharing Data Between Processes
Processes do not share memory by default. Use multiprocessing.Queue, multiprocessing.Value, or multiprocessing.Manager for inter-process communication:
from multiprocessing import Process, Queue
def worker(queue, data):
result = sum(x * x for x in data)
queue.put(result)
if __name__ == "__main__":
queue = Queue()
data_chunks = [range(i * 1000, (i + 1) * 1000) for i in range(4)]
processes = [Process(target=worker, args=(queue, chunk)) for chunk in data_chunks]
for p in processes:
p.start()
for p in processes:
p.join()
total = sum(queue.get() for _ in processes)
print(f"Total: {total}")
Always guard multiprocessing code with if __name__ == "__main__": to prevent recursive process spawning.
Asyncio: High-Concurrency I/O
Asyncio uses cooperative multitasking. A single thread runs an event loop, and tasks voluntarily yield control at await points. This avoids threading overhead and is ideal for handling thousands of concurrent connections.
import asyncio
import time
async def fetch(url):
"""Simulate an async HTTP request."""
await asyncio.sleep(1) # Non-blocking sleep
return f"Data from {url}"
async def main():
urls = [f"https://api.example.com/data/{i}" for i in range(100)]
# Launch all requests concurrently
tasks = [asyncio.create_task(fetch(url)) for url in urls]
results = await asyncio.gather(*tasks)
print(f"Fetched {len(results)} results")
start = time.perf_counter()
asyncio.run(main())
print(f"Asyncio: {time.perf_counter() - start:.2f}s")
# ~1 second for 100 concurrent requests
The key advantage of asyncio is scalability. Threading with 100 threads creates 100 OS threads with their associated memory overhead. Asyncio handles 100 tasks within a single thread.
Comparison Table
| Feature | Threading | Multiprocessing | Asyncio |
|---|---|---|---|
| Best for | I/O-bound | CPU-bound | High-concurrency I/O |
| Parallelism | No (GIL) | Yes | No |
| Memory | Shared | Separate | Shared |
| Overhead | Low | High (process spawn) | Very low |
| Scalability | ~100s threads | ~10s processes | ~10,000s tasks |
| Debugging | Hard (race conditions) | Moderate | Moderate |
Thread Safety and Locks
When threads share mutable state, you need synchronization:
import threading
class SafeCounter:
def __init__(self):
self.value = 0
self._lock = threading.Lock()
def increment(self):
with self._lock:
self.value += 1
counter = SafeCounter()
threads = [
threading.Thread(target=counter.increment)
for _ in range(10_000)
]
for t in threads:
t.start()
for t in threads:
t.join()
print(counter.value) # Always 10000
Without the lock, you would get race conditions and an incorrect count.
Practical Guidelines
- Default to sequential code unless you have a measured performance problem.
- I/O-bound with few connections: Use
ThreadPoolExecutor. - I/O-bound with many connections: Use
asyncio. - CPU-bound: Use
ProcessPoolExecutor. - Mixed workloads: Combine asyncio with
run_in_executorfor blocking calls.
import asyncio
from concurrent.futures import ProcessPoolExecutor
def cpu_work(n):
return sum(i * i for i in range(n))
async def main():
loop = asyncio.get_running_loop()
with ProcessPoolExecutor() as pool:
result = await loop.run_in_executor(pool, cpu_work, 10_000_000)
print(result)
asyncio.run(main())
Wrapping Up
Python’s concurrency story has three distinct chapters. Threading handles I/O waits efficiently despite the GIL. Multiprocessing provides true parallelism for CPU-heavy computation. Asyncio scales to thousands of concurrent I/O operations in a single thread. Understanding the strengths and limitations of each model lets you write programs that are genuinely faster, not just more complicated.
Related articles
- Python Python Concurrency: asyncio vs threading vs multiprocessing
Compare Python's concurrency models side by side. Learn when to use asyncio, threading, or multiprocessing with practical benchmarks and real-world examples.
- Python Python Multiprocessing vs Threading
When to use threads, when to use processes, and why the GIL shapes both choices. A practical comparison with code, benchmarks, and patterns for real workloads.
- Python Advanced asyncio Patterns for Production Python
Go beyond basic async/await with structured concurrency, task groups, semaphores, cancellation, and error handling patterns in Python asyncio.
- Python Python asyncio Event Loop Guide
Understand how Python's asyncio event loop schedules coroutines, what await actually does, and how to avoid the classic mistakes that turn async code into a tangle of bugs.