Concurrency and parallelism are often used interchangeably, but they represent distinct concepts in computer science.
Concurrency is about dealing with many tasks at once (structure), while parallelism
is about doing many tasks at once (execution). In Python, the Global Interpreter Lock (GIL) adds a unique twist,
making threads unsuitable for CPU‑bound parallelism. This guide breaks down the theory, demonstrates practical
code examples using threading, asyncio, and multiprocessing, and provides
decision frameworks to choose the right approach for your application.
| Aspect | Concurrency | Parallelism |
|---|---|---|
| Definition | Multiple tasks making progress in overlapping time periods (interleaved). | Multiple tasks executing simultaneously at the exact same time. |
| Hardware | Can run on a single core (task switching). | Requires multiple cores or multiple CPUs. |
| Focus | Structure of program (handling many tasks). | Speedup (reducing execution time). |
| Python mechanisms | Threading, asyncio, coroutines. | Multiprocessing, concurrent.futures.ProcessPoolExecutor. |
Python's GIL is a mutex that protects access to Python objects, preventing multiple threads from executing Python bytecode
simultaneously in the same process. This means that CPython threads cannot achieve true parallelism for CPU‑bound code.
However, for I/O‑bound tasks (network, disk, user input), threads are still effective because the GIL is released during I/O operations.
For CPU‑bound parallelism, you must use multiple processes (multiprocessing).
import threading
import time
# CPU-bound task (heavy computation)
def countdown(n):
while n > 0:
n -= 1
# I/O-bound task (simulated network sleep)
def io_task():
time.sleep(1)
# Run with threads (CPU-bound will not speed up due to GIL)
# Run with processes for CPU-bound speedup
Python's threading module allows you to run multiple threads within a single process.
Threads share memory, making data sharing easy, but require locks to avoid race conditions.
Because the GIL is released on I/O, threads can achieve concurrency for network requests, file reads, etc.
import threading
import requests
import time
urls = ["https://example.com" for _ in range(10)]
def fetch_url(url):
resp = requests.get(url)
print(f"Fetched {url} length {len(resp.text)}")
start = time.time()
threads = []
for url in urls:
t = threading.Thread(target=fetch_url, args=(url,))
t.start()
threads.append(t)
for t in threads:
t.join()
print(f"Threading time: {time.time() - start:.2f}s")
Asyncio uses an event loop to manage coroutines. It is single‑threaded and extremely efficient for thousands of I/O‑bound tasks
because it switches at await points without the overhead of thread context switching. Ideal for high‑throughput
network servers, API gateways, and real‑time data pipelines.
import asyncio
import aiohttp
async def fetch(session, url):
async with session.get(url) as resp:
return await resp.text()
async def main():
urls = ["https://example.com" for _ in range(10)]
async with aiohttp.ClientSession() as session:
tasks = [fetch(session, url) for url in urls]
results = await asyncio.gather(*tasks)
print(f"Fetched {len(results)} pages")
asyncio.run(main())
The multiprocessing module spawns separate processes, each with its own Python interpreter and memory space.
This allows true parallelism on multiple CPU cores. However, inter‑process communication (IPC) is slower than shared memory,
and data must be serialized (pickled). Use for CPU‑intensive tasks like numerical simulations, image processing, or data analysis.
import multiprocessing
import time
def cpu_intensive(n):
"""Compute sum of squares (CPU heavy)"""
total = 0
for i in range(n):
total += i * i
return total
if __name__ == "__main__":
N = 10_000_000
start = time.time()
# Single process
result = cpu_intensive(N)
print(f"Single process: {time.time() - start:.2f}s")
# Parallel with 4 processes
start = time.time()
with multiprocessing.Pool(processes=4) as pool:
# Split work: each process handles 1/4 of the range
chunk = N // 4
results = pool.map(cpu_intensive, [chunk] * 4)
total = sum(results)
print(f"Multiprocessing (4 processes): {time.time() - start:.2f}s")
| Feature | Threading | Asyncio | Multiprocessing |
|---|---|---|---|
| Type | Concurrency | Concurrency | Parallelism |
| GIL impact | Limited (released on I/O) | Single thread, no GIL issue | No GIL (separate processes) |
| Best for | I/O‑bound, moderate concurrency | High‑concurrency I/O (thousands) | CPU‑bound, heavy computation |
| Memory overhead | Low (shared) | Low (single thread) | High (each process separate) |
| Data sharing | Shared memory (needs locks) | Shared memory (no locks needed) | IPC (pickle, queues, pipes) |
| Startup cost | Low | Low | High |
Let's compare single thread, threading, and multiprocessing for a CPU‑heavy function (sum of primes up to N). As expected, threading shows no improvement (or degradation) while multiprocessing scales with cores.
import threading
import multiprocessing
import time
def is_prime(n):
if n < 2: return False
for i in range(2, int(n**0.5) + 1):
if n % i == 0: return False
return True
def count_primes(start, end):
count = 0
for num in range(start, end):
if is_prime(num):
count += 1
return count
def run_threads():
threads = []
results = [0]*4
def worker(idx, start, end):
results[idx] = count_primes(start, end)
chunk = 250000 # 0 to 1,000,000
for i in range(4):
t = threading.Thread(target=worker, args=(i, i*chunk, (i+1)*chunk))
t.start()
threads.append(t)
for t in threads:
t.join()
return sum(results)
def run_processes():
with multiprocessing.Pool(4) as pool:
chunk = 250000
results = pool.starmap(count_primes, [(i*chunk, (i+1)*chunk) for i in range(4)])
return sum(results)
if __name__ == "__main__":
# Single thread
start = time.time()
total = count_primes(0, 1_000_000)
print(f"Single thread: {time.time()-start:.2f}s, total primes {total}")
# Threads
start = time.time()
total = run_threads()
print(f"Threading (4 threads): {time.time()-start:.2f}s, total primes {total}")
# Processes
start = time.time()
total = run_processes()
print(f"Multiprocessing (4 processes): {time.time()-start:.2f}s, total primes {total}")
threading.RLock or with lock: context managers.time.sleep, requests.get) inside a coroutine. Use await asyncio.sleep and aiohttp.multiprocessing.Queue or Manager for complex objects; avoid lambda functions in pools.Pool) for many small tasks to amortize startup cost.
For applications that need both high‑concurrency I/O and CPU‑bound processing, you can combine asyncio for the network layer
and offload CPU work to a process pool. Use asyncio.to_thread (for I/O) or loop.run_in_executor
with a ProcessPoolExecutor for CPU work.
import asyncio
import concurrent.futures
def cpu_heavy(data):
# simulate heavy processing
return sum(i*i for i in range(data))
async def main():
loop = asyncio.get_running_loop()
with concurrent.futures.ProcessPoolExecutor() as pool:
# Run CPU work in process pool without blocking event loop
result = await loop.run_in_executor(pool, cpu_heavy, 10_000_000)
print(f"CPU result: {result}")
# Continue with async I/O tasks...
asyncio.run(main())
PEP 554 (subinterpreters) aims to provide true parallelism without multiprocessing overhead. Additionally, the "nogil" project (PEP 703) proposes making the GIL optional in CPython. While experimental in 2026, these developments may change the landscape, but for now, understanding the current tools is essential.
Concurrency and parallelism serve different purposes. In Python, threading and asyncio provide concurrency for I/O‑bound tasks, while multiprocessing delivers true parallelism for CPU‑bound workloads. The GIL is not a bug but a design choice that simplifies memory management for single‑threaded code. By matching the right tool to your problem—threads for moderate I/O, asyncio for massive I/O, and processes for CPU crunching—you can build scalable, high‑performance Python applications. Measure, profile, and always be mindful of the trade‑offs.
Start with the simplest solution (single thread), then introduce concurrency or parallelism only when needed. With the knowledge from this guide, you're equipped to make the right decisions and avoid common pitfalls.