Threads vs Processes¶
Understanding the fundamental differences between threads and processes is essential for choosing the right concurrency model.
Fundamental Difference¶
Process¶
A process is an independent program execution with its own: - Memory space - Python interpreter - Global variables - File descriptors
Process 1 Process 2
┌─────────────────┐ ┌─────────────────┐
│ Memory Space │ │ Memory Space │
│ ┌─────────────┐ │ │ ┌─────────────┐ │
│ │ Python │ │ │ │ Python │ │
│ │ Interpreter │ │ │ │ Interpreter │ │
│ └─────────────┘ │ │ └─────────────┘ │
│ Variables: x=1 │ │ Variables: x=2 │
│ GIL: Own GIL │ │ GIL: Own GIL │
└─────────────────┘ └─────────────────┘
Isolated Isolated
Thread¶
A thread is a lightweight unit of execution that shares: - Memory space (with other threads in same process) - Python interpreter - Global variables - File descriptors
Process (with threads)
┌───────────────────────────────────────┐
│ Shared Memory Space │
│ ┌─────────────────────────────────┐ │
│ │ Python Interpreter │ │
│ │ (One GIL) │ │
│ └─────────────────────────────────┘ │
│ │
│ Thread 1 Thread 2 Thread 3 │
│ ┌───────┐ ┌───────┐ ┌───────┐ │
│ │Stack 1│ │Stack 2│ │Stack 3│ │
│ └───────┘ └───────┘ └───────┘ │
│ │
│ Shared Variables: x, y, z │
└───────────────────────────────────────┘
Comparison Table¶
| Aspect | Thread | Process |
|---|---|---|
| Memory | Shared | Isolated |
| Creation overhead | Low (~1ms) | High (~100ms) |
| Memory overhead | Low (~1MB) | High (~10-50MB) |
| Communication | Easy (shared variables) | Complex (IPC required) |
| GIL impact | Affected (one GIL) | Not affected (own GIL) |
| Crash isolation | Crash affects all threads | Crash isolated to process |
| Best for | I/O-bound tasks | CPU-bound tasks |
Memory Sharing Demonstration¶
Threads: Shared Memory¶
import threading
import time
# Shared variable
shared_list = []
def worker(name, count):
for i in range(count):
shared_list.append(f"{name}-{i}")
time.sleep(0.01)
# Create threads
t1 = threading.Thread(target=worker, args=("A", 5))
t2 = threading.Thread(target=worker, args=("B", 5))
t1.start()
t2.start()
t1.join()
t2.join()
print(shared_list)
# ['A-0', 'B-0', 'A-1', 'B-1', 'A-2', ...] — Interleaved, shared!
Processes: Isolated Memory¶
from multiprocessing import Process
import time
# This list is NOT shared between processes
shared_list = []
def worker(name, count):
for i in range(count):
shared_list.append(f"{name}-{i}")
print(f"{name}: {shared_list}") # Each process sees its own copy
# Create processes
p1 = Process(target=worker, args=("A", 5))
p2 = Process(target=worker, args=("B", 5))
p1.start()
p2.start()
p1.join()
p2.join()
print(f"Main: {shared_list}") # Empty! Main process has its own copy
# Output:
# A: ['A-0', 'A-1', 'A-2', 'A-3', 'A-4']
# B: ['B-0', 'B-1', 'B-2', 'B-3', 'B-4']
# Main: []
Creation Overhead¶
Benchmark: Thread vs Process Creation¶
import time
import threading
from multiprocessing import Process
def dummy():
pass
# Thread creation time
start = time.perf_counter()
threads = [threading.Thread(target=dummy) for _ in range(100)]
for t in threads:
t.start()
for t in threads:
t.join()
thread_time = time.perf_counter() - start
print(f"100 threads: {thread_time*1000:.1f}ms")
# Process creation time
start = time.perf_counter()
processes = [Process(target=dummy) for _ in range(100)]
for p in processes:
p.start()
for p in processes:
p.join()
process_time = time.perf_counter() - start
print(f"100 processes: {process_time*1000:.1f}ms")
# Typical results:
# 100 threads: 15ms
# 100 processes: 1500ms (100x slower to create)
Communication Methods¶
Threads: Direct Variable Access¶
import threading
import queue
# Method 1: Shared variables (needs synchronization)
result = None
lock = threading.Lock()
def compute():
global result
value = expensive_computation()
with lock:
result = value
# Method 2: Thread-safe queue (recommended)
result_queue = queue.Queue()
def compute_with_queue():
value = expensive_computation()
result_queue.put(value)
thread = threading.Thread(target=compute_with_queue)
thread.start()
thread.join()
result = result_queue.get()
Processes: Inter-Process Communication (IPC)¶
from multiprocessing import Process, Queue, Pipe, Value, Array
# Method 1: Queue (recommended)
def worker_queue(q):
result = expensive_computation()
q.put(result)
q = Queue()
p = Process(target=worker_queue, args=(q,))
p.start()
p.join()
result = q.get()
# Method 2: Pipe
def worker_pipe(conn):
result = expensive_computation()
conn.send(result)
conn.close()
parent_conn, child_conn = Pipe()
p = Process(target=worker_pipe, args=(child_conn,))
p.start()
result = parent_conn.recv()
p.join()
# Method 3: Shared Value
def worker_value(shared_val):
shared_val.value = 42
shared = Value('i', 0) # 'i' = integer
p = Process(target=worker_value, args=(shared,))
p.start()
p.join()
print(shared.value) # 42
# Method 4: Shared Array
def worker_array(shared_arr):
for i in range(len(shared_arr)):
shared_arr[i] = i * 2
shared = Array('d', [0.0, 0.0, 0.0]) # 'd' = double
p = Process(target=worker_array, args=(shared,))
p.start()
p.join()
print(list(shared)) # [0.0, 2.0, 4.0]
Error Isolation¶
Threads: Crash Affects Entire Process¶
import threading
import time
def risky_worker():
time.sleep(0.5)
raise RuntimeError("Worker crashed!")
def stable_worker():
for i in range(5):
print(f"Stable worker: {i}")
time.sleep(0.3)
t1 = threading.Thread(target=risky_worker)
t2 = threading.Thread(target=stable_worker)
t1.start()
t2.start()
t1.join() # Exception propagates but thread terminates
t2.join()
# Both threads run in same process
# Unhandled exception in t1 prints traceback but t2 continues
# However, if t1 corrupts shared state, t2 is affected
Processes: Crash is Isolated¶
from multiprocessing import Process
import time
def risky_worker():
time.sleep(0.5)
raise RuntimeError("Worker crashed!")
def stable_worker():
for i in range(5):
print(f"Stable worker: {i}")
time.sleep(0.3)
p1 = Process(target=risky_worker)
p2 = Process(target=stable_worker)
p1.start()
p2.start()
p1.join()
p2.join()
print(f"p1 exit code: {p1.exitcode}") # Non-zero (crashed)
print(f"p2 exit code: {p2.exitcode}") # 0 (success)
# p1 crash does NOT affect p2
Resource Limits¶
Operating System Limits¶
import threading
from multiprocessing import Process
import resource
# Check limits (Unix)
soft, hard = resource.getrlimit(resource.RLIMIT_NPROC)
print(f"Max processes: soft={soft}, hard={hard}")
# Threads are limited by memory, not explicit limit
# Typical: thousands of threads possible
# But: each thread uses ~1MB stack by default
# Processes are limited by OS
# Typical: hundreds to thousands depending on system
Practical Limits¶
| Resource | Threads | Processes |
|---|---|---|
| Reasonable count | 10-100 | 2-16 (CPU cores) |
| Memory per unit | ~1MB (stack) | ~50MB+ (full Python) |
| Creation time | ~0.1ms | ~10-100ms |
| Context switch | Fast | Slower |
When to Use Each¶
Use Threads When:¶
from concurrent.futures import ThreadPoolExecutor
# ✓ I/O-bound tasks
# ✓ Need shared state
# ✓ Many concurrent tasks
# ✓ Low memory requirements
def fetch_url(url):
import requests
return requests.get(url).text
urls = ["http://example.com"] * 100
with ThreadPoolExecutor(max_workers=20) as executor:
results = list(executor.map(fetch_url, urls))
Use Processes When:¶
from concurrent.futures import ProcessPoolExecutor
# ✓ CPU-bound tasks
# ✓ Need true parallelism
# ✓ Crash isolation required
# ✓ Can tolerate memory overhead
def compute_heavy(n):
return sum(i ** 2 for i in range(n))
numbers = [10_000_000] * 8
with ProcessPoolExecutor() as executor:
results = list(executor.map(compute_heavy, numbers))
Summary Comparison¶
Threads Processes
─────── ─────────
Memory: Shared Isolated
GIL: Blocked by GIL Each has own GIL
Creation: Fast Slow
Communication: Easy Requires IPC
Best for: I/O-bound CPU-bound
Crash impact: Affects process Isolated
Memory usage: Low High
Parallelism: Concurrent True parallel
Key Takeaways¶
- Threads share memory, are lightweight, but limited by GIL for CPU work
- Processes have isolated memory, bypass GIL, enable true parallelism
- Communication: Threads use shared variables; processes need queues/pipes
- Error isolation: Process crashes are isolated; thread crashes can corrupt shared state
- Rule of thumb: Threads for I/O, processes for CPU
- concurrent.futures abstracts the choice with unified API