Skip to Content
Course content

207: Process Pools with concurrent.futures

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

You've probably had that moment where you're staring at your activity monitor and you see one CPU core pinned at 100% while the other seven just sit there, idling. It's frustrating. You know your machine has the horsepower to handle a task faster, but Python seems to be stubbornly refusing to use it. This usually happens when you're dealing with CPU-bound tasks—things like heavy mathematical computations, image processing, or parsing massive JSON files.

The bottleneck of the sequential loop

The most intuitive way to handle a batch of heavy tasks is a simple for loop. I'll show you a quick example: imagine we need to check if a list of very large numbers are primes. In a naive implementation, it looks like this:

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

numbers = [1000000007, 1000000009, 1000000021, 1000000033] # and many more...
results = [is_prime(n) for n in numbers]

This is "safe" and easy to debug, but it's slow. It processes one number, waits for it to finish, and then moves to the next. You're effectively ignoring the multi-core architecture of your processor. Now, you might be tempted to throw a ThreadPoolExecutor at this, thinking that concurrency is the magic bullet. But here is where Python's Global Interpreter Lock (GIL) trips you up.

Why threads are a lie for CPU-bound work

If you replace that loop with a ThreadPoolExecutor, you'll notice something strange: the code doesn't actually get faster. In some cases, it might even get slower due to the overhead of managing the threads. This is because the GIL ensures that only one thread executes Python bytecode at a time. For I/O-bound tasks (like downloading websites), threads are great because the thread "sleeps" while waiting for the network. But for calculating primes, the thread never sleeps; it just fights for the lock.

I've seen junior devs spend hours wondering why their "multithreaded" math code is still running on a single core. The reality is that for CPU-heavy work, threads are just taking turns on a single lane of traffic. To actually widen the road, we need separate processes.

Breaking the GIL with ProcessPoolExecutor

This is where concurrent.futures.ProcessPoolExecutor comes in. Instead of creating threads within a single process, it spawns entirely new Python instances for each worker. Each single process has its own memory space and, crucially, its own GIL. Now, your OS can actually schedule those processes across different physical CPU cores.

Here is how I would rewrite that prime checker to actually use the hardware you paid for:

from concurrent.futures import ProcessPoolExecutor

numbers = [1000000007, 1000000009, 1000000021, 1000000033]

with ProcessPoolExecutor() as executor:
    # .map() is the easiest way to replace a list comprehension
    results = list(executor.map(is_prime, numbers))

By using executor.map, we distribute the numbers list across the available cores. If you have an 8-core CPU, Python will spin up workers and process chunks of that list in parallel. The speedup is often nearly linear—meaning it'll finish in a fraction of the time.

The hidden cost of process spawning

Before you go replacing every loop in your codebase with a process pool, there's a trade-off you need to understand: serialization. Because each process has its own memory, Python can't just "share" a variable. It has to pickle (serialize) the data, send it to the worker process, and then pickle the result to send it back to the main process.

If your function is very "light"—say, it just adds two numbers—the time it takes to pickle the data and spin up the process will actually be longer than the time it takes to just run the loop sequentially. I always tell my colleagues: only reach for ProcessPoolExecutor when the "work" inside the function significantly outweighs the "overhead" of moving the data. If you're doing a million tiny additions, stick to a loop or use NumPy. If you're doing a thousand heavy computations, use a process pool.




📋 Practical Task

Parallelizing a Heavy Data Hash Generator

You have been given a list of large strings that represent simulated data chunks. Your task is to calculate a SHA-256 hash for each string, but since the strings are massive, this is simulating a CPU-intensive operation. Currently, the code is running sequentially and is far too slow.

Requirements:

  • Modify the provided script to use ProcessPoolExecutor from the concurrent.futures module.
  • Ensure the hash_data function is called in parallel across the data_chunks list.
  • Use a with` statement to manage the executor to ensure resources are cleaned up properly.
  • Compare the execution time of your parallel version against the sequential version to verify the speedup.
import hashlib
import time
from concurrent.futures import ProcessPoolExecutor

def hash_data(text):
    # Simulating a heavy CPU load by hashing the text multiple times
    result = text
    for _ in range(10000):
        result = hashlib.sha256(result.encode()).hexdigest()
    return result

if __name__ == "__main__":
    data_chunks = ["Chunk a" * 1000, "Chunk b" * 1000, "Chunk c" * 1000, "Chunk d" * 1000]
    
    # TODO: Implement the ProcessPoolExecutor here to replace 
    # the sequential list comprehension below.
    
    start = time.time()
    # Sequential version (Replace this!)
    # results = [hash_data(chunk) for chunk in data_chunks] 
    end = time.time()
    
    print(f"Processed {len(data_chunks)} chunks in {end - start:.2f} seconds")
Rating
0 0

There are no comments for now.

to be the first to leave a comment.