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
205: Multiprocessing for CPU-Bound Work
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 returnsTrueif a number is prime andFalseotherwise. - 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
ProcessPoolExecutorto 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.
There are no comments for now.