Threading vs Multiprocessing

RoadmapsPython Backend

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

Recall questions

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)

Related: The GIL (Global Interpreter Lock)

Return to Python Backend Roadmap