Threading vs Multiprocessing
Scenario
You need to scale a Python script. Should you use `threading` or `multiprocessing`? Pick wrong, and your script might crash your server with out-of-memory errors, or run slower than it did on one core.
Mental model
Threads are workers sharing the same desk and tools. Processes are workers in completely separate rooms, each with their own copy of the tools.
Threads run in the same memory space, making them lightweight and easy to share data, but they are limited by the GIL. Processes spawn an entirely new Python interpreter with its own memory space and its own GIL, allowing true parallelism across CPU cores.
Deep dive
Threading is lightweight. Because all threads share the same memory, starting a thread is fast, and they can easily read the same variables. The massive downside is the GIL: only one thread can execute Python code at a time. This makes threads perfect for I/O-bound tasks (waiting on the network or DB), because they drop the GIL while waiting.
Threading: Shared memory, blocked by the GIL, perfect for I/O-bound work.
Multiprocessing bypasses the GIL entirely. It spawns a brand new OS process, which gets its own memory and its own Python interpreter (meaning its own GIL). If you have 4 cores, 4 processes can run Python code at the exact same time. The downside is heavy overhead: creating processes is slow, they use 4x the RAM, and sharing data between them requires serialising it (Pickle) and sending it over pipes/queues.
Multiprocessing: Separate memory, bypasses the GIL, required for CPU-bound work.
Code examples
Threading (I/O-Bound)
import threading
import requests
def fetch_url(url):
# Drops the GIL while waiting for the network
response = requests.get(url)
print(f"Fetched {url}")
urls = ["https://example.com"] * 10
threads = []
for url in urls:
t = threading.Thread(target=fetch_url, args=(url,))
threads.append(t)
t.start()
for t in threads:
t.join()
Since `requests.get` is I/O-bound, the GIL is released. All 10 threads can wait for their HTTP responses concurrently in the same memory space. Fast and memory-efficient.
Multiprocessing (CPU-Bound)
from multiprocessing import Pool
import math
def heavy_computation(num):
# CPU-bound: pure math
return math.factorial(num)
if __name__ == '__main__':
numbers = [50000, 50001, 50002, 50003]
# Spawns 4 separate Python processes, utilising 4 CPU cores
with Pool(processes=4) as pool:
results = pool.map(heavy_computation, numbers)
print("Done")
If we used threads here, the GIL would force them to run sequentially. By using a Process Pool, the OS distributes the 4 isolated processes across 4 physical cores, achieving true parallelism.
Common mistakes
- Sharing complex objects in multiprocessing: In threads, modifying a global list is easy (though you need locks to prevent race conditions). In multiprocessing, variables aren't shared. If Process A modifies a list, Process B won't see the change. You must use `multiprocessing.Queue` or shared memory arrays to communicate between processes, which has serialization overhead.
Recall questions
- Which approach (threading or multiprocessing) shares the same memory space?
- Which approach is required to bypass the GIL in Python?
- Why shouldn't you use multiprocessing for a simple script that downloads 100 images?
Questions & answers
Threading vs multiprocessing — which for CPU-bound work?
Multiprocessing. CPU-bound work (like data processing or image resizing) requires the CPU to constantly execute instructions. In Python, the GIL prevents threads from running in parallel, meaning threads would just fight for execution time and run slower. Multiprocessing bypasses the GIL by creating isolated processes, allowing true parallel execution across multiple cores.
Approach: Always link CPU-bound to Multiprocessing, and explicitly mention the GIL as the reason why Threading fails for this use case.
Why is threading often preferred for web scraping or making thousands of API calls?
Web scraping is heavily I/O-bound. The script spends 99% of its time waiting for the network to respond. Threads drop the GIL during I/O, allowing concurrent execution. Since threads share memory, they are much lighter to spawn and use vastly less RAM than spawning thousands of full OS processes.
Approach: Contrast the low memory footprint of threads with the heavy footprint of processes, and explain that the GIL isn't a bottleneck for I/O.
Continue learning
Previous: The GIL (Global Interpreter Lock)
Next: Multiprocessing for CPU-bound work
Related: The GIL (Global Interpreter Lock)