Multiprocessing vs Multithreading in Python: Complete Guide
Concurrency and parallelism are important concepts in modern programming for improving application performance. Python provides two main approaches: multithreading and multiprocessing. In this tutorial, we'll learn the differences between them, when to use each, and how to implement them effectively.
Core Concepts
Threading
A thread is the smallest unit of execution within a process. Multiple threads in one process share the same memory space.
Threading Characteristics:- Lightweight
- Shared memory space
- Faster context switching
- Suitable for I/O-bound tasks
Multiprocessing
A process is an independent instance of a running program. Each process has its own separate memory space.
Multiprocessing Characteristics:- Heavyweight
- Separate memory space
- True parallelism
- Suitable for CPU-bound tasks
Global Interpreter Lock (GIL)
What is GIL?
GIL is a mutex (mutual exclusion lock) that protects access to Python objects, preventing multiple threads from executing Python bytecode simultaneously.
GIL Implications:import threading
import time
CPU-bound task
def cpuintensive():
count = 0
for i in range(50000000):
count += 1
return count
Single thread
start = time.time()
cpuintensive()
cpuintensive()
end = time.time()
print(f"Single thread: {end - start:.2f}s")
Multi-threading (NO speedup due to GIL!)
start = time.time()
t1 = threading.Thread(target=cpuintensive)
t2 = threading.Thread(target=cpuintensive)
t1.start()
t2.start()
t1.join()
t2.join()
end = time.time()
print(f"Multi-threading: {end - start:.2f}s")
Output:
Single thread: 3.45s
Multi-threading: 3.52s # No speedup!
Conclusion: Threading in Python does NOT provide speedup for CPU-bound tasks due to GIL.
When to Use Threading
Threading is effective for I/O-bound tasks because GIL is released when waiting for I/O.
Example 1: Download Multiple Files
import threading
import requests
import time
urls = [
'https://jsonplaceholder.typicode.com/posts/1',
'https://jsonplaceholder.typicode.com/posts/2',
'https://jsonplaceholder.typicode.com/posts/3',
'https://jsonplaceholder.typicode.com/posts/4',
'https://jsonplaceholder.typicode.com/posts/5',
]
def download(url):
"""Download content from URL"""
response = requests.get(url)
print(f"Downloaded {url}: {len(response.content)} bytes")
Sequential (without threading)
start = time.time()
for url in urls:
download(url)
sequentialtime = time.time() - start
print(f"\nSequential: {sequentialtime:.2f}s")
Multi-threading
start = time.time()
threads = []
for url in urls:
thread = threading.Thread(target=download, args=(url,))
threads.append(thread)
thread.start()
for thread in threads:
thread.join()
threadedtime = time.time() - start
print(f"Multi-threading: {threadedtime:.2f}s")
print(f"Speedup: {sequentialtime/threadedtime:.2f}x")
When to Use Multiprocessing
Multiprocessing is effective for CPU-bound tasks because it bypasses GIL by using separate processes.
Example: Heavy Computation
import multiprocessing
import time
import math
def cpuintensivetask(n):
"""CPU-intensive calculation"""
count = 0
for i in range(n):
count += math.sqrt(i) math.sin(i)
return count
Parameters
numbers = [10000000, 10000000, 10000000, 10000000]
Sequential
start = time.time()
results = [cpuintensivetask(n) for n in numbers]
sequentialtime = time.time() - start
print(f"Sequential: {sequentialtime:.2f}s")
Multiprocessing
start = time.time()
with multiprocessing.Pool(processes=4) as pool:
results = pool.map(cpuintensivetask, numbers)
multiprocessingtime = time.time() - start
print(f"Multiprocessing: {multiprocessingtime:.2f}s")
print(f"Speedup: {sequentialtime/multiprocessingtime:.2f}x")
Output (on 4-core CPU):
Sequential: 12.45s
Multiprocessing: 3.28s
Speedup: 3.80x # Nearly linear speedup!
Threading Advanced: ThreadPoolExecutor
Python 3 provides concurrent.futures for a cleaner interface.
Basic Usage
from concurrent.futures import ThreadPoolExecutor
import time
def task(name, delay):
print(f"Task {name} starting")
time.sleep(delay)
print(f"Task {name} completed")
return f"Result from {name}"
Using ThreadPoolExecutor
with ThreadPoolExecutor(maxworkers=3) as executor:
# Submit tasks
future1 = executor.submit(task, "A", 2)
future2 = executor.submit(task, "B", 1)
future3 = executor.submit(task, "C", 3)
# Get results
print(future1.result()) # Blocks until complete
print(future2.result())
print(future3.result())
As Completed
from concurrent.futures import ThreadPoolExecutor, ascompleted
import time
import random
def task(n):
sleep
time = random.uniform(0.5, 2.0)
time.sleep(sleeptime)
return f"Task {n} completed in {sleeptime:.2f}s"
tasks = list(range(10))
with ThreadPoolExecutor(maxworkers=3) as executor:
# Submit all tasks
futuretotask = {executor.submit(task, t): t for t in tasks}
# Process as they complete
for future in ascompleted(futuretotask):
tasknum = futuretotask[future]
try:
result = future.result()
print(result)
except Exception as e:
print(f"Task {tasknum} generated exception: {e}")
Multiprocessing Advanced: ProcessPoolExecutor
Basic Usage
from concurrent.futures import ProcessPoolExecutor
import os
def cputask(n):
"""CPU-intensive task"""
print(f"Process {os.getpid()} working on {n}")
result = sum(ii for i in range(n))
return result
numbers = [1000000, 2000000, 3000000, 4000000]
with ProcessPoolExecutor(maxworkers=4) as executor:
results = executor.map(cputask, numbers)
for num, result in zip(numbers, results):
print(f"Sum of squares up to {num}: {result}")
Synchronization
Thread Locks
import threading
Without lock (race condition)
counter = 0
def increment():
global counter
for in range(100000):
counter += 1
threads = [threading.Thread(target=increment) for in range(10)]
for t in threads:
t.start()
for t in threads:
t.join()
print(f"Counter without lock: {counter}") # Incorrect! < 1000000
With lock
counter = 0
lock = threading.Lock()
def incrementwithlock():
global counter
for in range(100000):
with lock:
counter += 1
threads = [threading.Thread(target=incrementwithlock) for in range(10)]
for t in threads:
t.start()
for t in threads:
t.join()
print(f"Counter with lock: {counter}") # Correct! = 1000000
Semaphores
import threading
import time
Limit concurrent access to resource
semaphore = threading.Semaphore(3) # Max 3 threads
def accessresource(threadid):
print(f"Thread {threadid} waiting...")
semaphore.acquire()
try:
print(f"Thread {threadid} accessing resource")
time.sleep(2) # Simulate work
finally:
print(f"Thread {threadid} releasing resource")
semaphore.release()
threads = [threading.Thread(target=accessresource, args=(i,)) for i in range(10)]
for t in threads:
t.start()
for t in threads:
t.join()
Performance Comparison
Benchmark: I/O-bound vs CPU-bound
import time
from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor
I/O-bound task
def iotask(n):
time.sleep(0.1) # Simulate I/O
return n 2
CPU-bound task
def cputask(n):
return sum(ii for i in range(n))
Test data
iodata = list(range(20))
cpudata = [1000000] 8
print("=== I/O-bound Tasks ===")
Sequential I/O
start = time.time()
results = [iotask(x) for x in iodata]
print(f"Sequential: {time.time() - start:.2f}s")
Threading I/O
start = time.time()
with ThreadPoolExecutor(maxworkers=10) as executor:
results = list(executor.map(iotask, iodata))
print(f"Threading: {time.time() - start:.2f}s")
Multiprocessing I/O (overhead!)
start = time.time()
with ProcessPoolExecutor(maxworkers=4) as executor:
results = list(executor.map(iotask, iodata))
print(f"Multiprocessing: {time.time() - start:.2f}s")
print("\n=== CPU-bound Tasks ===")
Sequential CPU
start = time.time()
results = [cputask(x) for x in cpudata]
print(f"Sequential: {time.time() - start:.2f}s")
Threading CPU (no speedup!)
start = time.time()
with ThreadPoolExecutor(maxworkers=4) as executor:
results = list(executor.map(cputask, cpudata))
print(f"Threading: {time.time() - start:.2f}s")
Multiprocessing CPU
start = time.time()
with ProcessPoolExecutor(maxworkers=4) as executor:
results = list(executor.map(cputask, cpudata))
print(f"Multiprocessing: {time.time() - start:.2f}s")
Output (example):
=== I/O-bound Tasks ===
Sequential: 2.01s
Threading: 0.21s # 9.5x faster!
Multiprocessing: 0.45s # Overhead
=== CPU-bound Tasks ===
Sequential: 3.42s
Threading: 3.51s # No speedup (GIL)
Multiprocessing: 0.94s # 3.6x faster!
Best Practices
1. Choose the Right Tool
# Decision tree
def chooseconcurrency(tasktype):
if tasktype == "I/O-bound":
return "Use Threading or AsyncIO"
elif tasktype == "CPU-bound":
return "Use Multiprocessing"
else:
return "Profile first!"
2. Optimal Worker Count
import os
For I/O-bound (threading)
ioworkers = 10 # Or more, depends on I/O latency
For CPU-bound (multiprocessing)
cpuworkers = os.cpucount() # Number of CPU cores
Conservative approach
cpuworkers = os.cpucount() - 1 # Leave one core free
3. Handle Errors Gracefully
from concurrent.futures import ThreadPoolExecutor, ascompleted
def risky
task(n):
if n == 5:
raise ValueError("Number 5 is not allowed!")
return n 2
numbers = range(10)
with ThreadPoolExecutor(maxworkers=3) as executor:
futuretonum = {executor.submit(riskytask, n): n for n in numbers}
for future in ascompleted(futuretonum):
num = futuretonum[future]
try:
result = future.result()
print(f"{num} -> {result}")
except Exception as e:
print(f"{num} generated exception: {e}")
Common Pitfalls
1. Race Conditions
import threading
❌ Bad: Race condition
sharedlist = []
def appenditems():
for i in range(1000):
sharedlist.append(i)
threads = [threading.Thread(target=appenditems) for in range(10)]
for t in threads:
t.start()
for t in threads:
t.join()
print(len(sharedlist)) # May not be 10000!
✅ Good: Use lock or thread-safe structure
from queue import Queue
sharedqueue = Queue()
def appenditemssafe():
for i in range(1000):
sharedqueue.put(i)
threads = [threading.Thread(target=appenditemssafe) for in range(10)]
for t in threads:
t.start()
for t in threads:
t.join()
print(sharedqueue.qsize()) # Exactly 10000
2. Deadlocks
import threading
lock1 = threading.Lock()
lock2 = threading.Lock()
❌ Bad: Potential deadlock
def thread1():
lock1.acquire()
lock2.acquire() # May wait forever
lock2.release()
lock1.release()
def thread2():
lock2.acquire()
lock1.acquire() # May wait forever
lock1.release()
lock2.release()
✅ Good: Consistent lock ordering
def thread1safe():
lock1.acquire()
lock2.acquire()
lock2.release()
lock1.release()
def thread2_safe():
lock1.acquire() # Same order!
lock2.acquire()
lock2.release()
lock1.release()
Conclusion
Quick Reference
| Aspect | Threading | Multiprocessing |
|--------|-----------|-----------------|
| Best for | I/O-bound tasks | CPU-bound tasks |
| GIL Impact | Limited by GIL | Bypasses GIL |
| Memory | Shared | Separate |
| Overhead | Low | High |
| Communication | Easy (shared memory) | Complex (IPC) |
| Debugging | Easier | Harder |
Decision Matrix
Use Threading when:- Network I/O (API calls, web scraping)
- File I/O
- Database queries
- Waiting for external resources
- Heavy computation
- Data processing
- Image/video processing
- Scientific computing
- Embarrassingly parallel problems
- High concurrency I/O
- Web servers
- Real-time applications
Key Takeaways
Happy concurrent programming in Python! 🚀