Skip to content
Echos 📣
Go back

Threading limitations in Python

Edit page

Table of Contents

Open Table of Contents

The Illusion of Parallelism: Python Threading 🧵

On paper, a 4-core CPU should let us split the 3 million numbers into four chunks and process them simultaneously. Here’s what that looks like with Python’s ThreadPoolExecutor:

import time
from concurrent.futures import ThreadPoolExecutor

if __name__ == "__main__":
    start_time = time.time()
    
    # Split the workload into 4 chunks
    ranges = [
        (1, 750_000), 
        (750_000, 1_500_000), 
        (1_500_000, 2_250_000), 
        (2_250_000, 3_000_000)
    ]
    
    total_primes = 0
    with ThreadPoolExecutor(max_workers=4) as executor:
        results = executor.map(lambda r: count_primes(*r), ranges)
        total_primes = sum(results)

    end_time = time.time()
    print(f"Number of primes between 1 and 3 million: {total_primes}")
    print(f"Threaded Time taken: {end_time - start_time:.2f} seconds")

The Result?

Number of primes between 1 and 3 million: 216816
Threaded Time taken: 8.67 seconds

Why This Happens: A Closer Look at the GIL

The GIL isn’t an arbitrary restriction — it exists because CPython’s memory management relies on reference counting. Every object carries a counter of how many references point to it, incremented and decremented constantly as objects are created, passed around, and discarded. Without a lock serializing access to that counter, two threads updating it at the same time could corrupt it, leaking memory or freeing an object still in use. The GIL is the trade-off CPython made to keep that bookkeeping simple and fast in the single-threaded case, at the cost of parallelism in the multi-threaded one.

A few details that are easy to miss:

The Python Alternative: Multiprocessing

If you have a CPU-bound task (like mathematical computations, image processing, or heavy loops), Python threads will not help you. You need Multiprocessing.

Instead of creating new threads within the same program, the multiprocessing module creates entirely new, separate OS processes.

import time
import math
from concurrent.futures import ProcessPoolExecutor

def is_prime(n):
    if n < 2:
        return False
    for i in range(2, int(math.sqrt(n)) + 1):
        if n % i == 0:
            return False
    return True

# A small helper to unpack the tuple, since mapping works best with one argument
def count_primes_range(args):
    start, end = args
    return sum(1 for n in range(start, end) if is_prime(n))

if __name__ == "__main__":
    start_time = time.time()
    
    # Split the workload into 4 chunks
    ranges = [
        (1, 750_000), 
        (750_000, 1_500_000), 
        (1_500_000, 2_250_000), 
        (2_250_000, 3_000_000)
    ]
    
    total_primes = 0
    
    # The magic swap: We use ProcessPoolExecutor instead of ThreadPoolExecutor
    with ProcessPoolExecutor(max_workers=4) as executor:
        # The main process serializes 'ranges', sends them to the 4 worker processes,
        # they do the math simultaneously, and send the answers back.
        results = executor.map(count_primes_range, ranges)
        total_primes = sum(results)
        
    print(f"Found {total_primes} primes.")
    print(f"Multiprocessing Time: {time.time() - start_time:.2f} seconds")
Found 216816 primes.
Multiprocessing Time: 3.09 seconds

The Hidden Cost: Overhead

If multiprocessing is so great, why doesn’t Python just use it for everything?

Because spawning a brand new OS process is incredibly heavy compared to spawning a thread.

MultithreadingMultiprocessing
Memory FootprintTiny. All threads share the same RAM.Huge. Each process copies the entire Python runtime into RAM.
Startup TimeInstantaneous.Slow. The OS has to allocate new resources for every worker.
Data SharingEasy (but dangerous). Variables are shared instantly.Hard. Data must be “pickled” (serialized), copied across memory boundaries, and “unpickled”.
Takeway

Multiprocessing is perfect for CPU-bound tasks where the computation takes a long time (like our prime number crunching).

But if you try to use multiprocessing to run thousands of small, quick tasks, the time it takes Python to serialize the data, boot up the new processes, and copy the memory will actually take longer than the math itself!

Other Multiprocessing Gotchas

The memory/startup/data-sharing table above is the headline cost, but a few other sharp edges are worth knowing about before reaching for multiprocessing:


True Parallelism with Rust 🦀

Rust does not have a GIL. It achieves memory safety at compile time, meaning threads are free to run truly in parallel without needing a giant lock to protect the interpreter.

To make this dead simple in Rust, we use a massively popular crate (library) called Rayon. Rayon introduces data parallelism. By changing a call from iter() to par_iter() (parallel iterator) will automatically divide the work and distribute it across all available CPU cores.

Here is the equivalent Rust code:

use std::time::Instant;

use rayon::iter::{IntoParallelIterator, ParallelIterator};

fn is_prime(n: u32) -> bool {
    if n < 2 {
        return false;
    }

    let limit = (n as f32).sqrt() as u32;
    for i in 2..=limit {
        if n % i == 0 {
            return false;
        }
    }
    true
}

fn main() {
    let start_time = Instant::now();

    // into_par_iter() will split the range across all available CPU threads safely
    let primes: usize = (1..3_000_000)
        .into_par_iter()
        .filter(|&n| is_prime(n))
        .count();

    let duration = start_time.elapsed();

    println!("Found {} primes.", primes);
    println!("🦀 Time taken {:.2?}", duration);
}
Found 216816 primes.
🦀 Time taken 764.18ms

Rust finishes in under a second — roughly 11× faster than sequential Python and 4× faster than Python’s multiprocessing. It utilizes 100% of the available hardware, bypassing the limitations you hit in Python.

Rust’s compiler guarantees that because is_prime doesn’t mutate any shared state outside of its local scope, it is perfectly safe to run in parallel, avoiding race conditions entirely without a GIL.

Summary

Here is a quick recap of the results across all three approaches, counting primes from 1 to 3 million on a 4-core machine:

ApproachTimeSpeedup
Python — Sequential8.69s1× (baseline)
Python — ThreadPoolExecutor (4 threads)8.67s~1× (no gain)
Python — ProcessPoolExecutor (4 processes)3.09s~2.8×
Rust — Rayon par_iter()0.76s~11.4×

The key takeaways:


Edit page
Share this post:

Next Post
Managing internal Python packages with Poetry