Python
Completed
-
Section 1: Getting Started
-
Section 2: Core Syntax and Data Types
-
Section 3: Collections
-
39: Set Operations: Union, Intersection, Difference
-
Section 4: Control Flow
-
Section 5: Functions
-
Section 6: Turtle Graphics and Early Practice Projects
-
Section 7: Working with Files and I/O
-
Section 8: Regular Expressions
-
Section 9: Object-Oriented Python
-
Section 10: Error Handling
-
Section 11: Modules and Packages
-
Section 12: Iterators, Generators, and Functional Tools
-
Section 13: Decorators and Metaprogramming
-
Section 14: Concurrency and Parallelism
-
Section 15: Working with Dates, Times, and Numbers
-
Section 16: Standard Library Deep Dive I: Data Structures
-
Section 17: Standard Library Deep Dive II: System and Introspection
-
Section 18: Standard Library Deep Dive III: Security and Encoding
-
Section 19: Standard Library Deep Dive IV: Text and Data Utilities
-
Section 20: Networking and Web Basics
-
Section 21: Working with Databases
-
Section 22: Testing and Quality
-
Section 23: Advanced Typing
-
Section 24: Context Managers and Resource Handling
-
Section 25: Text, Unicode, and Binary Data
-
Section 26: More Functional and Iteration Tools
-
Section 27: Data Validation and Configuration
-
Section 28: Working with Images and Media
-
Section 29: Property-Based and Documentation Testing
-
Section 30: Packaging and Deployment
-
Section 31: Performance and Internals
-
Section 32: Design Patterns in Python
-
Section 33: GUI Programming
-
Section 34: Security Basics
-
Section 35: Data Structures and Algorithms
-
Section 36: Practical Projects
-
Section 37: Capstone Projects
-
Section 38: Interview and Algorithm Practice
-
Section 39: Writing Idiomatic Python
207: Process Pools with concurrent.futures
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
ProcessPoolExecutorfrom theconcurrent.futuresmodule. - Ensure the
hash_datafunction is called in parallel across thedata_chunkslist. - 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")
There are no comments for now.