Skip to Content
Course content

205: Multiprocessing for CPU-Bound Work

Click on the "Edit" button in the top corner of the screen to edit your slide content.

I was working on a script the other day that needed to do some heavy number crunching—specifically, calculating the sum of squares for a massive range of numbers multiple times. As I ran it, I noticed my laptop fans started screaming, but only one of my CPU cores was actually doing the work. It felt like a waste of hardware.

The Bottleneck

Here is the basic function I wrote. It's not doing anything fancy, just some raw math that keeps the CPU busy. I'll run this four times to simulate processing four different large datasets.

import time

def heavy_computation(n):
    print(f"Starting computation for {n}...")
    result = sum(i * i for i in range(n))
    return result

if __name__ == "__main__":
    numbers = [10_000_000, 10_000_000, 10_000_000, 10_000_000]
    start = time.perf_counter()
    
    for num in numbers:
        heavy_computation(num)
        
    end = time.perf_counter()
    print(f"Total time taken: {end - start:.2f} seconds")

Running this sequentially is slow because it's "CPU-bound." The program is spending all its time executing instructions on the processor, not waiting for a network response or a file to load. Naturally, my first instinct was to throw some threads at it.

Why Threading Fails Here

I swapped the loop for a ThreadPoolExecutor from the concurrent.futures module. In my head, I figured, "I have four tasks and multiple cores; this should be a 4x speedup."

from concurrent.futures import ThreadPoolExecutor
import time

# ... heavy_computation function as defined above ...

if __name__ == "__main__":
    numbers = [10_000_000, 10_000_000, 10_000_000, 10_000_000]
    start = time.perf_counter()
    
    with ThreadPoolExecutor() as executor:
        executor.map(heavy_computation, numbers)
        
    end = time.perf_counter()
    print(f"Threaded total time: {end - start:.2f} seconds")

The result? It took almost exactly the same amount of time as the sequential version. In some cases, it was actually a tiny bit slower. This is the "aha!" moment where you have to remember the Global Interpreter Lock (GIL). In Python, the GIL ensures that only one thread executes Python bytecode at a time. For I/O tasks (like downloading images), threading is great because the thread "releases" the lock while waiting for the web server. But for math? The thread holds onto the lock tight, and the other threads just sit there waiting their turn. We aren't actually running in parallel; we're just switching between tasks very quickly.

Breaking the GIL

To actually use those other CPU cores, we need separate Python instances, each with its own GIL. That's where multiprocessing comes in. I replaced the ThreadPoolExecutor with a ProcessPoolExecutor. The API is almost identical, but the behavior is fundamentally different.

from concurrent.futures import ProcessPoolExecutor
import time

# ... heavy_computation function as defined above ...

if __name__ == "__main__":
    numbers = [10_000_000, 10_000_000, 10_000_000, 10_000_000]
    start = time.perf_counter()
    
    with ProcessPoolExecutor() as executor:
        executor.map(heavy_computation, numbers)
        
    end = time.perf_counter()
    print(f"Multiprocessing total time: {end - start:.2f} seconds")

Now we're talking. On my quad-core machine, the time dropped significantly. Python spawned four separate processes, and the OS scheduled each one on a different core. They were truly running at the same time.

A Critical Detail on Windows and macOS

You might have noticed I wrapped the execution in if __name__ == "__main__":. When using multiprocessing, this isn't just a "best practice"—it's mandatory on Windows and macOS. Because the ProcessPoolExecutor starts new Python processes, it essentially "imports" your script into the new process. If you don't have that if guard, the new process will try to create another pool of processes, which creates another pool, and so on, until your computer crashes in a recursive loop of process creation. Always use the guard.

  • Threading: Use for I/O-bound work (API calls, DB queries, reading files).
  • Multiprocessing: Use for CPU-bound work (Heavy math, image processing, data parsing).



📋 Practical Task

Exercise: Building a Parallel Prime Range Scanner

You need to create a script that finds all prime numbers within several different large ranges. Because primality testing is CPU-intensive, this is the perfect candidate for multiprocessing.

Requirements:

  • Write a function is_prime(n) that returns True if a number is prime and False otherwise.
  • Create a function count_primes_in_range(range_tuple) that takes a tuple of (start, end) and returns the count of prime numbers found in that range.
  • Define a list of four large ranges (e.g., (1_000_000, 2_000_000), (2_000_000, 3_000_000), etc.).
  • Implement a ProcessPoolExecutor to process these ranges in parallel.
  • Print the total time taken and the total number of primes found across all ranges.
  • Ensure you use the if __name__ == "__main__": guard to prevent recursive process spawning.
Rating
0 0

There are no comments for now.

to be the first to leave a comment.